Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Vue 2 contentEditable with v-model

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.

like image 829
Soubriquet Avatar asked Dec 22 '18 22:12

Soubriquet


People also ask

Can I use v-model on a component?

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.

Can I use v-model in a div?

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.

How v-model works Vuejs?

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.

Can you have multiple V models?

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.


3 Answers

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>
like image 152
David Weldon Avatar answered Oct 20 '22 14:10

David Weldon


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.

like image 26
Brad Ahrens Avatar answered Oct 20 '22 14:10

Brad Ahrens


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>
like image 38
Muthu Kumar Avatar answered Oct 20 '22 15:10

Muthu Kumar