I'm trying to make a text editor similar to Medium. I'm using a content editable paragraph tag and store each item in an array and render each with v-for. However, I'm having problems with binding the text with the array using v-model. Seems like there's a conflict with v-model and the contenteditable property. Here's my code:
<div id="editbar">
<button class="toolbar" v-on:click.prevent="stylize('bold')">Bold</button>
</div>
<div v-for="(value, index) in content">
<p v-bind:id="'content-'+index" v-bind:ref="'content-'+index" v-model="content[index].value" v-on:keyup="emit_content($event)" v-on:keyup.delete="remove_content(index)" contenteditable></p>
</div>
and in my script:
export default {
data() {
return {
content: [{ value: ''}]
}
},
methods: {
stylize(style) {
document.execCommand(style, false, null);
},
remove_content(index) {
if(this.content.length > 1 && this.content[index].value.length == 0) {
this.content.splice(index, 1);
}
}
}
}
I haven't found any answers online for this.
Using v-model on a custom component allows us to do both of these stops with one directive. value acts as our source of truth. We bind it to our child component to set the value of our input, and when our input changes - we update with value = $event - which sets value to the new value of our input.
You can use v-model with a custom Vue component by accepting a prop named 'value' and emitting an event named 'input'. For example, the below custom component is a fake select using div elements. Clicking on a div selects it.
V-model in vue. js is defined to make a two-way binding process to speed up the development of web application by bundled with Vue. js. This v-model directive helps to bind a value to the component data also triggers the event whenever a user presses a key or paste option.
The v-model gives the flexibility to use multiple v-model directives on a single component instance. It is possible to rename modelValue to whatever we want, and therefore we can use multiple v-model directives.
I tried an example, and eslint-plugin-vue reported that v-model
isn't supported on p
elements. See the valid-v-model rule.
As of this writing, it doesn't look like what you want is supported in Vue directly. I'll present two generic solutions:
<template>
<p
contenteditable
@input="onInput"
>
{{ content }}
</p>
</template>
<script>
export default {
data() {
return { content: 'hello world' };
},
methods: {
onInput(e) {
console.log(e.target.innerText);
},
},
};
</script>
Editable.vue
<template>
<p
ref="editable"
contenteditable
v-on="listeners"
/>
</template>
<script>
export default {
props: {
value: {
type: String,
default: '',
},
},
computed: {
listeners() {
return { ...this.$listeners, input: this.onInput };
},
},
mounted() {
this.$refs.editable.innerText = this.value;
},
methods: {
onInput(e) {
this.$emit('input', e.target.innerText);
},
},
};
</script>
index.vue
<template>
<Editable v-model="content" />
</template>
<script>
import Editable from '~/components/Editable';
export default {
components: { Editable },
data() {
return { content: 'hello world' };
},
};
</script>
After a lot of iterations, I found that for your use case it was easier to get a working solution by not using a separate component. It seems that contenteditable
elements are extremely tricky - especially when rendered in a list. I found I had to manually update the innerText
of each p
after a removal in order for it to work correctly. I also found that using ids worked, but using refs didn't.
There's probably a way to get a full two-way binding between the model and the content, but I think that would require manipulating the cursor location after each change.
<template>
<div>
<p
v-for="(value, index) in content"
:id="`content-${index}`"
:key="index"
contenteditable
@input="event => onInput(event, index)"
@keyup.delete="onRemove(index)"
/>
</div>
</template>
<script>
export default {
data() {
return {
content: [
{ value: 'paragraph 1' },
{ value: 'paragraph 2' },
{ value: 'paragraph 3' },
],
};
},
mounted() {
this.updateAllContent();
},
methods: {
onInput(event, index) {
const value = event.target.innerText;
this.content[index].value = value;
},
onRemove(index) {
if (this.content.length > 1 && this.content[index].value.length === 0) {
this.$delete(this.content, index);
this.updateAllContent();
}
},
updateAllContent() {
this.content.forEach((c, index) => {
const el = document.getElementById(`content-${index}`);
el.innerText = c.value;
});
},
},
};
</script>
I think I may have come up with an even easier solution. See snippet below:
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
</head>
<body>
<main id="app">
<div class="container-fluid">
<div class="row">
<div class="col-8 bg-light visual">
<span class="text-dark m-0" v-html="content"></span>
</div>
<div class="col-4 bg-dark form">
<button v-on:click="bold_text">Bold</button>
<span class="bg-light p-2" contenteditable @input="handleInput">Change me!</span>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
new Vue({
el: '#app',
data: {
content: 'Change me!',
},
methods: {
handleInput: function(e){
this.content = e.target.innerHTML
},
bold_text: function(){
document.execCommand('bold')
}
}
})
</script>
</body>
</html>
Explanation:
You can edit the span as I have added the tag contenteditable
. Notice that on input
, I will call the handleInput function, which sets the innerHtml of the content to whatever you have inserted into the editable span. Then, to add the bold functionality, you simply select what you want to be bold and click on the bold button.
Added bonus! It also works with cmd+b ;)
Hopefully this helps someone!
Happy coding
Note that I brought in bootstrap css for styling and vue via CDN so that it will function in the snippet.
You can use watch method to create two way binding contentEditable.
Vue.component('contenteditable', {
template: `<p
contenteditable="true"
@input="update"
@focus="focus"
@blur="blur"
v-html="valueText"
@keyup.ctrl.delete="$emit('delete-row')"
></p>`,
props: {
value: {
type: String,
default: ''
},
},
data() {
return {
focusIn: false,
valueText: ''
}
},
computed: {
localValue: {
get: function() {
return this.value
},
set: function(newValue) {
this.$emit('update:value', newValue)
}
}
},
watch: {
localValue(newVal) {
if (!this.focusIn) {
this.valueText = newVal
}
}
},
created() {
this.valueText = this.value
},
methods: {
update(e) {
this.localValue = e.target.innerHTML
},
focus() {
this.focusIn = true
},
blur() {
this.focusIn = false
}
}
});
new Vue({
el: '#app',
data: {
len: 4,
val: "Test",
content: [{
"value": "<h1>Heading</h1><div><hr id=\"null\"></div>"
},
{
"value": "<span style=\"background-color: rgb(255, 255, 102);\">paragraph 1</span>"
},
{
"value": "<font color=\"#ff0000\">paragraph 2</font>"
},
{
"value": "<i><b>paragraph 3</b></i>"
},
{
"value": "<blockquote style=\"margin: 0 0 0 40px; border: none; padding: 0px;\"><b>paragraph 4</b></blockquote>"
}
]
},
methods: {
stylize: function(style, ui, value) {
var inui = false;
var ivalue = null;
if (arguments[1]) {
inui = ui;
}
if (arguments[2]) {
ivalue = value;
}
document.execCommand(style, inui, ivalue);
},
createLink: function() {
var link = prompt("Enter URL", "https://codepen.io");
document.execCommand('createLink', false, link);
},
deleteThisRow: function(index) {
this.content.splice(index, 1);
if (this.content[index]) {
this.$refs.con[index].$el.innerHTML = this.content[index].value;
}
},
add: function() {
++this.len;
this.content.push({
value: 'paragraph ' + this.len
});
},
}
});
<script src="https://unpkg.com/[email protected]/dist/vue.min.js"></script>
<div id="app">
<button class="toolbar" v-on:click.prevent="add()">ADD PARAGRAPH</button>
<button class="toolbar" v-on:click.prevent="stylize('bold')">BOLD</button>
<contenteditable ref="con" :key="index" v-on:delete-row="deleteThisRow(index)" v-for="(item, index) in content" :value.sync="item.value"></contenteditable>
<pre>
{{content}}
</pre>
</div>
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With