【问题标题】:Vue 2 contentEditable with v-modelVue 2 contentEditable 与 v-model
【发布时间】:2019-05-22 19:19:15
【问题描述】:

我正在尝试制作一个类似于 Medium 的文本编辑器。我正在使用内容可编辑的段落标签并将每个项目存储在一个数组中,并使用 v-for 渲染每个项目。但是,我在使用 v-model 将文本与数组绑定时遇到了问题。似乎与 v-model 和 contenteditable 属性有冲突。这是我的代码:

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

在我的脚本中:

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);
         }
      }
   }
}

我没有在网上找到任何答案。

【问题讨论】:

  • 通过修改vue的源码,我们可以添加高效的v-model contentEditable。这是代码codepen.io/muthu32/pen/oNvGyQX
  • @MuthuKumar。很好,而且有效!但它确实应该集成到 Vue 中。修改源码有点脆弱。
  • @marlar 这是更新后的代码,无需修改源代码。 codepen.io/muthu32/full/qBWvaYq
  • @Soubriquet,你有没有尝试将组件(例如,图像)插入到 contenteditable 中?

标签: javascript vue.js contenteditable


【解决方案1】:

我尝试了一个示例,eslint-plugin-vue 报告v-model 不支持p 元素。请参阅valid-v-model 规则。

在撰写本文时,Vue 似乎并不直接支持您想要的。我将介绍两种通用解决方案:

直接在可编辑元素上使用输入事件

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

针对您的特定问题的定制解决方案

经过多次迭代,我发现对于您的用例,使用单独的组件更容易获得可行的解决方案。 contenteditable 元素似乎非常棘手——尤其是在列表中呈现时。我发现我必须在删除后手动更新每个pinnerText 才能使其正常工作。我还发现使用 ids 有效,但使用 refs 没有。

可能有一种方法可以在模型和内容之间实现完整的双向绑定,但我认为这需要在每次更改后操作光标位置。

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

【讨论】:

  • 嗯,我明白了。我用更多代码更新了我的帖子。我需要 v-model 以便文本可以存储在 content 数组中,并在我删除段落时重新呈现。
  • @Soubriquet 您可以v-model 与自定义组件一起使用(请参阅此答案中的第二个解决方案),将&lt;p&gt; 替换为v-for 中的自定义组件循环。
  • @tony19 我刚刚尝试了第二个建议,但现在我的v-on 都没有触发。我制作了一个新组件并在其中放置了 contenteditable 标记。 &lt;DynamicInput v-model="content[index].value" v-bind:id="'content-'+index" v-bind:ref="'content-'+index" v-on:keydown.enter="prevent_nl($event)" v-on:keyup="emit_content($event)" v-on:keyup.enter="add_content(index)" v-on:keyup.delete="remove_content(index)"&gt;&lt;/DynamicInput&gt;
  • .native 修饰符与v-on 绑定一起使用。
  • @Soubriquet 我针对您的具体情况更新了第三部分的答案。请再看一下,看看这是否适合你。
【解决方案2】:

我昨天想通了!确定了这个解决方案。我基本上只是手动跟踪我的content数组中的innerHTML,方法是更新任何可能的事件并通过手动分配具有动态引用的相应元素来重新渲染,例如content-0, content-1,... 效果很好:

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

【讨论】:

    【解决方案3】:

    我想我可能想出了一个更简单的解决方案。见下面的 sn-p:

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

    解释:

    您可以编辑跨度,因为我添加了标签contenteditable。请注意,在input 上,我将调用handleInput 函数,它将内容的innerHtml 设置为您插入到可编辑范围中的任何内容。然后,要添加粗体功能,您只需选择要加粗的内容并单击粗体按钮即可。

    增加了奖励!它也适用于 cmd+b ;)

    希望这对某人有所帮助!

    愉快的编码

    请注意,我通过 CDN 引入了用于样式和 vue 的引导 css,以便它可以在 sn-p 中运行。

    【讨论】:

    • 这很好很简单。非常适合我。
    • 咳咳,毕竟不是那么完美。当内容是静态的时它可以工作,但是一旦从模型中获取内容,插入符号就会在您输入任何内容后立即转到文本的开头。
    • 但是,将事件从输入更改为模糊对我来说解决了这个问题,因为模型的更新仅在您离开现场时发生。
    • 我正在重置插入符号的位置,它运行良好:handleInput: (e) => { const sel = document.getSelection();常量偏移 = sel.anchorOffset; this.content = e.target.textContent; app.$nextTick(() => { sel.collapse(sel.anchorNode, offset); }); }
    • @roberto 你是救世主!我被困在插入符号重置问题上,并尝试了许多不同的方法。您的解决方案完美运行。
    【解决方案4】:

    你可以使用组件 v-model 在 Vue 中创建 contentEditable。

    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>
      <button class="toolbar" v-on:click.prevent="stylize('italic')">ITALIC</button>
      <button class="toolbar" v-on:click.prevent="stylize('justifyLeft')">LEFT ALIGN</button>
      <button class="toolbar" v-on:click.prevent="stylize('justifyCenter')">CENTER</button>
      <button class="toolbar" v-on:click.prevent="stylize('justifyRight')">RIGHT ALIGN</button>
      <button class="toolbar" v-on:click.prevent="stylize('insertOrderedList')">ORDERED LIST</button>
      <button class="toolbar" v-on:click.prevent="stylize('insertUnorderedList')">UNORDERED LIST</button>
      <button class="toolbar" v-on:click.prevent="stylize('backColor',false,'#FFFF66')">HEIGHLIGHT</button>
      <button class="toolbar" v-on:click.prevent="stylize('foreColor',false,'red')">RED TEXT</button>
      <button class="toolbar" v-on:click.prevent="createLink()">CREATE LINK</button>
      <button class="toolbar" v-on:click.prevent="stylize('unlink')">REMOVE LINK</button>
      <button class="toolbar" v-on:click.prevent="stylize('formatBlock',false,'H1')">H1</button>
      <button class="toolbar" v-on:click.prevent="stylize('underline')">UNDERLINE</button>
      <button class="toolbar" v-on:click.prevent="stylize('strikeThrough')">STRIKETHROUGH</button>
      <button class="toolbar" v-on:click.prevent="stylize('superscript')">SUPERSCRIPT</button>
      <button class="toolbar" v-on:click.prevent="stylize('subscript')">SUBSCRIPT</button>
      <button class="toolbar" v-on:click.prevent="stylize('indent')">INDENT</button>
      <button class="toolbar" v-on:click.prevent="stylize('outdent')">OUTDENT</button>
      <button class="toolbar" v-on:click.prevent="stylize('insertHorizontalRule')">HORIZONTAL LINE</button>
      <button class="toolbar" v-on:click.prevent="stylize('insertParagraph')">INSERT PARAGRAPH</button>
      <button class="toolbar" v-on:click.prevent="stylize('formatBlock',false,'BLOCKQUOTE')">BLOCK QUOTE</button>
      <button class="toolbar" v-on:click.prevent="stylize('selectAll')">SELECT ALL</button>
      <button class="toolbar" v-on:click.prevent="stylize('removeFormat')">REMOVE FORMAT</button>
      <button class="toolbar" v-on:click.prevent="stylize('undo')">UNDO</button>
      <button class="toolbar" v-on:click.prevent="stylize('redo')">REDO</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>

    【讨论】:

    【解决方案5】:

    您可以使用 watch 方法创建两种方式绑定 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>
      <button class="toolbar" v-on:click.prevent="stylize('italic')">ITALIC</button>
      <button class="toolbar" v-on:click.prevent="stylize('justifyLeft')">LEFT ALIGN</button>
      <button class="toolbar" v-on:click.prevent="stylize('justifyCenter')">CENTER</button>
      <button class="toolbar" v-on:click.prevent="stylize('justifyRight')">RIGHT ALIGN</button>
      <button class="toolbar" v-on:click.prevent="stylize('insertOrderedList')">ORDERED LIST</button>
      <button class="toolbar" v-on:click.prevent="stylize('insertUnorderedList')">UNORDERED LIST</button>
      <button class="toolbar" v-on:click.prevent="stylize('backColor',false,'#FFFF66')">HEIGHLIGHT</button>
      <button class="toolbar" v-on:click.prevent="stylize('foreColor',false,'red')">RED TEXT</button>
      <button class="toolbar" v-on:click.prevent="createLink()">CREATE LINK</button>
      <button class="toolbar" v-on:click.prevent="stylize('unlink')">REMOVE LINK</button>
      <button class="toolbar" v-on:click.prevent="stylize('formatBlock',false,'H1')">H1</button>
      <button class="toolbar" v-on:click.prevent="stylize('formatBlock',false,'BLOCKQUOTE')">BLOCK QUOTE</button>
      <button class="toolbar" v-on:click.prevent="stylize('underline')">UNDERLINE</button>
      <button class="toolbar" v-on:click.prevent="stylize('strikeThrough')">STRIKETHROUGH</button>
      <button class="toolbar" v-on:click.prevent="stylize('superscript')">SUPERSCRIPT</button>
      <button class="toolbar" v-on:click.prevent="stylize('subscript')">SUBSCRIPT</button>
      <button class="toolbar" v-on:click.prevent="stylize('indent')">INDENT</button>
      <button class="toolbar" v-on:click.prevent="stylize('outdent')">OUTDENT</button>
      <button class="toolbar" v-on:click.prevent="stylize('insertHorizontalRule')">HORIZONTAL LINE</button>
      <button class="toolbar" v-on:click.prevent="stylize('insertParagraph')">INSERT PARAGRAPH</button>
      <button class="toolbar" v-on:click.prevent="stylize('selectAll')">SELECT ALL</button>
      <button class="toolbar" v-on:click.prevent="stylize('removeFormat')">REMOVE FORMAT</button>
      <button class="toolbar" v-on:click.prevent="stylize('undo')">UNDO</button>
      <button class="toolbar" v-on:click.prevent="stylize('redo')">REDO</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>

    【讨论】:

      【解决方案6】:

      我想我可能会做出贡献,因为我不认为给定的解决方案是最优雅或最简洁的,可以清楚地回答需要什么,或者它们没有提供 Vue 的最佳使用。有些接近,但最终需要一些调整才能真正有效。 首先注意,&lt;p&gt; 段落不支持 v-model。内容在innerHTML 中,并且只能在元素槽内使用{{content}} 添加。插入后不会编辑该内容。您可以为其提供初始内容,但每次刷新内容时,内容编辑光标都会重置到前面(不是自然的打字体验)。这导致了我的最终解决方案:

      ...
      <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, '');
          },
      ...
      
      

      我的建议是,将初始静态内容值放入 &lt;p&gt; 插槽,然后使用 @input 触发器更新第二个 active 内容变量,其中包含来自contenteditable 动作。您还需要修剪由 &lt;p&gt; 元素创建的结尾 HTML 格式空白,否则如果您有空格,您将在结尾处得到一个粗略的字符串。

      如果有其他更有效的解决方案,我不知道,但欢迎提出建议。这是我用于我的代码的内容,我相信它会表现出色并满足我的需求。

      【讨论】:

        猜你喜欢
        • 2019-02-04
        • 2023-02-03
        • 2020-08-19
        • 2020-11-11
        • 1970-01-01
        • 2020-01-23
        • 2020-05-05
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多