38

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.

Soubriquet
  • 3,100
  • 10
  • 37
  • 52
  • 2
    By modifying source code for vue, we can add efficient v-model contentEditable. Here is the code https://codepen.io/muthu32/pen/oNvGyQX – Muthu Kumar Sep 03 '19 at 10:18
  • @MuthuKumar. Nice and it works! But it really should be integrated into Vue. Modifying source is a bit fragile. – marlar Sep 20 '19 at 07:37
  • 1
    @marlar Here is updated code without modifying the source code. https://codepen.io/muthu32/full/qBWvaYq – Muthu Kumar Sep 25 '19 at 06:20
  • @Soubriquet, have you tried to insert components (for example, images) into contenteditable? – webprogrammer Jan 25 '20 at 12:28

7 Answers7

58

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:

Use input events directly on the editable element

<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>

Create a reusable editable component

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>

Custom solution for your specific problem

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>
Community
  • 1
  • 1
David Weldon
  • 63,632
  • 11
  • 148
  • 146
  • Hmm I see. I updated my post with more code. I need v-model to so that the text can be stored in the `content` array and re-rendered when I delete a paragraph. – Soubriquet Dec 23 '18 at 00:56
  • @Soubriquet You *can* use `v-model` with the custom component (see the second solution in this answer), replacing `

    ` with the custom component inside your `v-for` loop.

    – tony19 Dec 23 '18 at 01:05
  • @tony19 I just tried the second suggestion, but now none of my `v-on` are triggering. I made a new component and placed the contenteditable tag in it. `` – Soubriquet Dec 23 '18 at 01:18
  • Use [`.native`](https://vuejs.org/v2/guide/components-custom-events.html#Binding-Native-Events-to-Components) modifier with those `v-on` bindings. – tony19 Dec 23 '18 at 04:13
  • @Soubriquet I updated the answer with a third section for your specific case. Please have another look and see if that works for you. – David Weldon Dec 24 '18 at 16:31
  • @DavidWeldon, what if I want to have within `content` a few vue-components? How can I store such string to database and get it later to insert into editable again? – webprogrammer Jan 23 '20 at 10:30
  • I recommend asking a separate question about that. – David Weldon Jan 23 '20 at 15:53
  • @DavidWeldon , I have already asked here https://stackoverflow.com/questions/59875992/parse-string-with-components-inside-to-template-in-vue-js and the general question here https://stackoverflow.com/questions/59849554/how-to-insert-vue-component-into-contenteditable-div – webprogrammer Jan 23 '20 at 19:23
13

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.

Brad Ahrens
  • 4,864
  • 5
  • 36
  • 47
  • 1
    That was nice and simple. Worked perfectly for me. – marlar Sep 20 '19 at 07:35
  • 4
    Ahem, not so perfectly after all. It works when the content is static, but as soon as the content is taken from the model, the caret goes to the start of text as soon as your enter anything. – marlar Sep 20 '19 at 07:49
  • However, changing the event from input to blur solves it for me because the update of the model only happens when you leave the field. – marlar Sep 20 '19 at 07:56
  • 2
    I'm resetting the caret position and it works nicely: handleInput: (e) => { const sel = document.getSelection(); const offset = sel.anchorOffset; this.content = e.target.textContent; app.$nextTick(() => { sel.collapse(sel.anchorNode, offset); }); } – roberto Apr 24 '20 at 11:15
  • @roberto you are a saviour! I was stuck on the caret resetting problem and tried many different approaches. Your solution works perfectly. – Ketan Patil Oct 14 '20 at 14:37
8

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/vue@2.6.10/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>
Muthu Kumar
  • 420
  • 5
  • 7
  • Can your solution cause Cross Site Scripting (XSS) attacks? I'd suggest changing `v-html` to `v-text` – Seangle Jan 09 '23 at 12:56
4

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/vue@2.6.10/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>
Muthu Kumar
  • 420
  • 5
  • 7
  • 3
    Beware that `Document.execCommand()` is obsolete, https://developer.mozilla.org/en-US/docs/Web/API/document/execCommand. – Brian Zelip Dec 18 '20 at 11:52
2

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>
Soubriquet
  • 3,100
  • 10
  • 37
  • 52
2

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(/(?:^(?:&nbsp;)+)|(?:(?:&nbsp;)+$)/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.

-1

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>
Darryl Noakes
  • 2,207
  • 1
  • 9
  • 27
anass Sanba
  • 19
  • 1
  • 4