Reputation: 3330
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.
Upvotes: 39
Views: 66631
Reputation: 49
Vue 3
<template>
<p contenteditable @input="onInput" ref="p"></p>
{{ msg }}
</template>
<style scoped>
[contenteditable] {
border: 2px solid orange;
padding: 1rem;
font-size: 1.5rem;
}
[contenteditable]:focus {
background-color: bisque;
}
</style>
<script setup>
import { onMounted, ref } from "vue";
const msg = ref("hello world");
const p = ref(null);
onMounted(() => {
p.value.innerText = msg.value;
});
function onInput(e) {
msg.value = e.target.innerText;
}
</script>
Upvotes: 0
Reputation: 480
You can Use component v-model to create contentEditable in Vue.
Vue.component('editable', {
template: `<p
v-bind:innerHTML.prop="value"
contentEditable="true"
@input="updateCode"
@keyup.ctrl.delete="$emit('delete-row')"
></p>`,
props: ['value'],
methods: {
updateCode: function($event) {
//below code is a hack to prevent updateDomProps
this.$vnode.child._vnode.data.domProps['innerHTML'] = $event.target.innerHTML;
this.$emit('input', $event.target.innerHTML);
}
}
});
new Vue({
el: '#app',
data: {
len: 3,
content: [{
value: 'paragraph 1'
},
{
value: 'paragraph 2'
},
{
value: 'paragraph 3'
},
]
},
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);
},
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>
<editable v-for="(item, index) in content" :key="index" v-on:delete-row="deleteThisRow(index)" v-model="item.value"></editable>
<pre>
{{content}}
</pre>
</div>
Upvotes: 4
Reputation: 480
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>
Upvotes: 8
Reputation: 81
I thought I might contribute because I don't feel that the given solutions are the most elegant or concise to clearly answer what is needed or they don't provide the best use of Vue. Some get close, but ultimately need a bit of tweaking to really be effective.
First note, the <p>
paragraph does not support v-model. The content is in the innerHTML and is only added using {{content}}
inside the element slot. That content is not edited after inserting. You can give it initial content but every time you refresh the content, the content editing cursor gets reset to the front (not a natural typing experience). This leads to my final solution:
...
<p class="m-0 p-3" :contenteditable="manage" @input="handleInput">
{{ content }}
</p>
...
props: {
content: {type:String,defalut:"fill content"},
manage: { type: Boolean, default: false },
...
data: function() {
return {
bioContent: this.content
...
methods: {
handleInput: function(e) {
this.bioContent = e.target.innerHTML.replace(/(?:^(?: )+)|(?:(?: )+$)/g, '');
},
...
My suggestion is, put in an initial static content value into the <p>
slot, then have a @input
trigger to update a second active content variable with what is put into the innerHTML from the contenteditable action. You will also want to trim off the end HTML format whitespace created by the <p>
element, otherwise you will get a gross string at the end if you have a space.
If there is another, more effective solution, I am not aware of it but I am welcome to suggestions. This is what I have used for my code and I am confident that it will be performant and suit my needs.
Upvotes: 2
Reputation: 5168
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.
Upvotes: 13
Reputation: 3330
I figured it out yesterday! Settled on this solution. I basically just manually keep track of the innerHTML in my content
array by updating on any possible event and re-rendering by manually assigning the corresponding elements with dynamic refs e.g. content-0
, content-1
,... Works beautifully:
<template>
<div id="editbar">
<button class="toolbar" v-on:click.prevent="stylize('bold')">Bold</button>
</div>
<div>
<div v-for="(value, index) in content">
<p v-bind:id="'content-'+index" class="content" v-bind:ref="'content-'+index" v-on:keydown.enter="prevent_nl($event)" v-on:keyup.enter="add_content(index)" v-on:keyup.delete="remove_content(index)" contenteditable></p>
</div>
</div>
</template>
<script>
export default {
data() {
return {
content: [{
html: ''
}]
}
},
methods: {
add_content(index) {
//append to array
},
remove_content(index) {
//first, check some edge conditions and remove from array
//then, update innerHTML of each element by ref
for(var i = 0; i < this.content.length; i++) {
this.$refs['content-'+i][0].innerHTML = this.content[i].html;
}
},
stylize(style){
document.execCommand(style, false, null);
for(var i = 0; i < this.content.length; i++) {
this.content[i].html = this.$refs['content-'+i][0].innerHTML;
}
}
}
}
</script>
Upvotes: 2
Reputation: 64312
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>
Upvotes: 59