【Vue组件】从零开始实现一个支持插入自定义表情的评论组件

  近期由于产品迭代,需要新增一个评论功能,且需要支持插入自定义表情。评论功能很多人一开始跟我一样,第一个想到的就是用textarea,但是textarea是不支持的插入图片的,因为我们的表情包是以图片的形式插入文本中的,所以这里是使用HTML5的新特性contenteditable,让div里的内容变成可编辑的。
demo地址

先看下效果图:

在这里插入图片描述

组件功能:

  • 支持插入自定义表情
  • 字符验证,超出部分自动切割(以字符进行计算,不是以长度进行计算,一个中文2个字符,一个字母1个字符,一个表情4个字符),因为我们公司业务是使用字符进行字数统计,所以验证是用字符,需要用length可以自行修改
  • 在光标位置准确插入表情

遇到的问题:(先列出问题,后面再写解决方法)

  • 如果有对显示的内容进行强制更新时(如插入表情包,或者字符切割),光标会跑到文本最前面
  • 无法直接利用vue进行双向绑定,无法用v-model与v-html进行数据双向绑定
  • 定位光标位置,如果是在输入时就点击表情包 应该插入到最光标位置,如果不是在输入时候点击表情应该插入到文本到最后面
  • 进行文本字节计算时对表情的处理,表情占位符如何保证唯一
  • 超出字数限制如何切割 当超出的字符是表情的时候怎么切割
  • 按下回车会插入<div><br></div>标签,或者会用<div>标签包裹住文本
  • 按下空格符会有&nbsp;,以字符进行计算的话空格符&nbsp;会被当成6个字符,实际上空格代表1个
兼容性问题:
  • IE9以及部分Safari createContextualFragment 不兼容该方法
  • window.getSelection , window.getSelection().getRangeAt这两个方法的兼容性,由于我们的业务不需要兼容低版本的IE所以这里我就没做兼容

开始实现

html部分于css部分比较简单就不过多赘述,先不贴代码了,文末看完整代码或者直接下载完整小demo运行看看

参数

既然是封装成组件,那就需要从父组件那里接收一下数据

  submitCmtLoading: {   //  是否正在提交,如果是正在提交就不需要重复提交了
      type: Boolean,
      default: false
    },
    limtText: {    //  限制的字符数,默认是0,0是不需要限制
      type: Number,
      default: 0
    },
    iconWidth: {  //  插入的表情宽度
      type: Number,
      dafault: 24
    },
    iconHeight: {  //  插入的表情高度
      type: Number,
      dafault: 24
    },
    cmt_show: {  // 是否显示组件
      type: Boolean,
      default: true
    },
    iconList: {  // 表情包列表,以数组的形式且是必传项
      type: Array,
      required: true
    },
    placeholder: {  // 提示语
      type: String,
      default: "积极回复可吸引更多人评论"
    },
    info: {  // 信息,这个看业务需求,如果需要对具体项在子组件中进行处理可以传递这个
      type: Object
    }

由于还没贴上html代码,下文中出现的this.$refs.cmt_input都代表输入框,既带属性contenteditable的元素

数据

  data: function() {
    return {
      content: "",  //  评论输入的内容
      widFouce: "",   // 用于定位输入框失焦点前的光标位置
      rangeFouce: "",  // 用于定位输入框失焦点前的光标位置
      iconShow: false,  // 是否显示表情列表
      isSubmit: false  //  能否提交  如果输入内容为空是不给提交的
    };
  },
  watch: {
    cmt_show: {   //  当组件显示时需要将光标定位到文末,不然第一次点击表情包会报错
      handler(value) {
        if (value) {
          this.$nextTick(() => {
            this.toLast(this.$refs.cmt_input);  //  将光标定位到文末
          });
        }
      },
      immediate: true
    }
  },

在表情列表展开的时候,点击其他非表情列表区域要让这个表情列表消失。

 mounted() {
    const self = this;
    document
      .getElementsByClassName("g-doc")[0]
      .addEventListener("click", self.setHideClick);     //  g-doc是根节点,当然也可以换成body啥的,但是在组件销毁的时候要把时间也给移除掉
    self.$once("hook:beforeDestroy", () => {
      document.getElementsByClassName("g-doc")[0] &&
        document
          .getElementsByClassName("g-doc")[0]
          .removeEventListener("click", self.setHideClick);
    });
  },
  //  setHideClick 方法,放在method里的
      setHideClick(event) {
      let target = event.srcElement;
      let nameList = ["icon_item", "icon_list", "icon_box"];  // 这个三个类名是整个表情包列表
      if (
        !nameList.includes(target.className) &&
        !nameList.includes(target.parentElement.className)
      ) { // 如果不是表情包列表区域就关闭表情包列表面板
        this.iconShow = false;
      }
    },

方法

几个简单点的方法,不过多的讲解

**问题:如果有对显示的内容进行强制更新时(如插入表情包,或者字符切割),光标会跑到文本最前面
解决方法: 每次进行更新操作后调一次toLast(obj)方法,让光标回到最后面, 当然了,如果光标不在最后面的就不需要调这个方法了**

    toLast(obj) {
      // 将光标移到最后,obj为输入框节点
      if (window.getSelection) {
        let winSel = window.getSelection(); 
        winSel.selectAllChildren(obj);
        winSel.collapseToEnd();
      }
    },
    blurInput() { // 失焦时触发的方法,失焦的时候保存光标位置
      this.getFouceInput();
    },
    getFouceInput() { // 保存光标位置
      this.widFouce = window.getSelection();
      this.rangeFouce = this.widFouce.getRangeAt(0);
    },
    showIconListPlane() { // 显示或者关闭表情包列表
      // 显示表情包列表
      if (!this.iconShow) { //  如果是打开,要保存一下光标位置
        this.getFouceInput();
      }
      this.iconShow = !this.iconShow;
    },
     submitCmt() {
      // 提交评论
      const self = this;
      let text = this.$refs.cmt_input.innerHTML;
      let length = this.getCharLen(this.paseText(text).text);
      if (self.submitCmtLoading || !self.isSubmit) return;
      if (self.cmt_text == "") {
        self.$emit("submitError", "评论为空");  // 不符合规则时则抛出错误的方法submitError
        return;
      } else if (this.limtText && length > this.limtText) {
        self.$emit("submitError", "评论超出字数"); // 不符合规则时则抛出错误的方法submitError
        return;
      }
      self.$emit("submitSuccess", text, self.info); // 验证通过则抛出正确的方法,且将文本与信息同时传递给父组件
    }

字符验证

接下来讲一下字符验证的函数,因为光标相关的逻辑里有用到部分字符验证,所以先讲一下字符相关的方法。
判断文本占了多少个字符,这里传进来的文本是经过处理的,因为如果没处理过的文本里如果带表情包,而表情包是以img标签存在文本里的这样会占据很多个字符,所以会对表情包做一个占位符处理,让一个表情包只占4个字符,后面会讲这个。现在先看下判断字符个数的方法,因为如果是输入空格会变成&nbsp;所以在这个方法里也会对这个进行处理

    getCharLen(sSource) {
      // 空格&nbsp; 要当1个字符算,所以最后要给每个空格减去5
      // 获取字符长度
      var l = 0;
      var schar;
      for (var i = 0; (schar = sSource.charAt(i)); i++) {
        l += schar.match(/[^\x00-\xff]/) != null ? 2 : 1;
      }
      let nbsp = sSource.match(/&nbsp;/gi);
      if (nbsp) {
        let len = nbsp.length;
        l = l - len * 5;
      }

      return l;
    },

使用占位符来替代表情包,因为1个表情包为4个字节,所以用4个数字来占位。这里使用随机获取时间戳的4为数,然后与文本进行比较,如果文本中存在,则递归再次获取,直到文本中不存在这4个数字。
对于回车键会让文本中添加div与br标签,因为我们的评论是不允许手动换行的,所以把div标签与br标签都替换成两个空格“ ”注意不是&nbsp;

这也就解决了上面说的这三个问题

  • 进行文本字节计算时对表情的处理,表情占位符如何保证唯一

    • 按下回车会插入<div><br></div>标签,或者会用<div>标签包裹住文本
    • 按下空格符会有&nbsp;,以字符进行计算的话空格符&nbsp;会被当成6个字符,实际上空格代表1个
    getRandomFour(str) {
      // 在时间戳里取随机4位数作为key,且如果文本中包含key则递归
      let timeStr = new Date().getTime() + "";
      let result = "";
      for (let i = 0; i < 4; i++) {
        let nums = Math.floor(Math.random() * 13);
        result = result + timeStr[nums];
      }
      return str.indexOf(result) == -1 ? result : this.getRandomFour(str);
    },
    paseText(str) {
      // 解析内容,把图片全部用占位替换掉  跟换行相关(div,br)全部改成两个空格
      let str1 = str.replace(/<div>|<\/div>|<br>/gi, "  ");
      let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;
      let imgMatch = str1.match(imgReg);
      let key = this.getRandomFour(str1);
      let isReplace = false;
      if (imgMatch) {
        isReplace = true;
        for (let i = 0; i < imgMatch.length; i++) {
          str1 = str1.replace(imgMatch[i], key);
        }
      }
      return {
        isReplace,  // 这个是有没有进行占位符替换的标示,如果没有后面就不需要进行还原操作
        key,
        text: str1
      };
    },
    reductionStr(sourceText, text, key) {  
      // 解析内容,把图片全部用占位替换掉  sourceText:原文本  text:替换后的文本  key:占位符标示
      let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;
      let imgMatch = sourceText.match(imgReg);
      let result = text;
      if (imgMatch) {
        for (let i = 0; i < imgMatch.length; i++) {
          result = result.replace(key, imgMatch[i]);
        }
      }
      return result;
    },

当超出限制时进行文本切割,注意:如果是图片占位符,需要整个进行切割,不能切割部分,不然就会产生多余的文本 如 超出1个字符,但是最后为表情包,而表情包为4个数字的占位符,要切割的时候要把4个数字一起移除掉,不能只切除一个这样后面就跟key值匹配不上了。

这也就解决上面说的这个问题

  • 超出字数限制如何切割 当超出的字符是表情的时候怎么切割
setCmtTextByLimit(sSource, key) {
      let self = this;
      //  文字切割 如果超出文字限制需要切割
      if (typeof sSource !== "string") return;
      var str = changeLast(sSource, key); // 先判断最后是否为表情包
      if (this.getCharLen(str) <= this.limtText) return str;
      while (this.getCharLen(str) > this.limtText) {
        str =
          4 + str.lastIndexOf(key) == str.length
            ? str.substring(0, str.length - 4)
            : str.substring(0, str.length - 1);
      }
      function changeLast(strl, key) {
        // 一直切割到最后一个不为表情包或不超出限制为止
        while (
          4 + strl.lastIndexOf(key) == strl.length &&
          self.getCharLen(strl) > self.limtText
        ) {
          strl = sSource.substring(0, sSource.length - 4);
        }
        return strl;
      }
      return str;
    },

输入与光标

输入时要把文本实时传给父组件,且输入时要进行字符判断,超出时就不能输入。当然先要进行是否验证的判断,如果不需要验证则省略后面的一系列步骤,

    changeText(e) {
      //  表情包插入时不触发该方法,只有输入时会触发
      // 判断字数,要先把自定义表情改成占位符,一个自定义表情按俩2个字符算
      let text = e.srcElement.innerHTML;
      let emitText = text;
      if (this.limtText) {
        let textObj = this.paseText(text);  // 文本替换
        let len = this.getCharLen(textObj.text);  // 获取字符长度
        if (len > this.limtText) {
          let str = this.setCmtTextByLimit(textObj.text, textObj.key);  // 字符切割
          emitText = textObj.isReplace
            ? this.reductionStr(text, str, textObj.key)
            : str;   // 如果有进行替换  要将文本复原
          e.srcElement.innerHTML = emitText;
          this.toLast(e.srcElement);   // 进行强制修改内容后 要把光标定位到最后
        }
      }

      this.isSubmit = emitText.length ? true : false;  // 输入框不为空则可以提交
      this.$emit("changeText", emitText);  
    },

插入表情时,需要先进行判断加上4个字符是否超出字符限制,如果超出了就不给插入。而且这里要判断上一次光标的状态,如果上一次的光标是在输入框里,那么就在光标位置插入表情且将光标位置定位至该表情后方,如果上一次的光标不在输入框里,那么就直接在文末插入表情包。对于创建节点createContextualFragment在IE9跟部分safari浏览器中不兼容,可以改成createElement来进行创建,当然也可以都写成createElement,这里写createContextualFragment只是我懒得改哈哈哈。

解决问题:

  • 定位光标位置,如果是在输入时就点击表情包 应该插入到最光标位置,如果不是在输入时候点击表情应该插入到文本到最后面
  • IE9以及部分Safari createContextualFragment 不兼容该方法
   insertIcon(url) { // 插入表情,url为表情地址
      // 判断是否超出字数,如果超出不给插入
      const self = this;
      this.isSubmit || (this.isSubmit = true);
      const length = this.getCharLen(
        this.paseText(this.$refs.cmt_input.innerHTML).text,
      );
      if (this.limtText && length + 4 > this.limtText) return;

      const img = `<img src='${url}' width=${this.iconWidth} height=${this.iconHeight} />`;
      //  兼容性判断 如果不兼容不往下执行,虽然说不兼容IE9以下,但是还是做一下判断 方便后面灵活控制
      if (window.getSelection && window.getSelection().getRangeAt) {
        const winSn = this.widFouce;
        let range = this.rangeFouce;
        //  要判断的光标状态,如果上一次光标不在输入框里,需要手动定位到输入框里
        if (
          winSn.focusNode.className !== 'content_edit'
          && winSn.focusNode.parentElement.className !== 'content_edit'
        ) {
          winSn.selectAllChildren(self.$refs.cmt_input);  // 选中输入框里的元素
          winSn.collapseToEnd();  // 定位光标至文末
          range = winSn.getRangeAt(0);
        }
        range.collapse(false);
        let node;
        if (range.createContextualFragment) {
          // 兼容IE9跟safari,以下为创建img节点
          node = range.createContextualFragment(img);
        } else {
          const tempDom = document.createElement('div');
          tempDom.innerHTML = img;
          node = tempDom;
        }
        const dom = node.firstChild;
        range.insertNode(dom); // 将表情包节点添加进文本
        const clRang = range.cloneRange();  // 复制range对象,注意这里复制的不是引用,所以在复制的对象上做修改不会影响到原对象
        clRang.setStartAfter(dom);  // 设置光标位于表情节点的后方,到这里文本里的表情还是选中状态,这里设置的是克隆后的range
        winSn.removeAllRanges();  // 移除选中状态
        winSn.addRange(clRang);  // 将克隆的range添加进去
        self.$emit('changeText', self.$refs.cmt_input.innerHTML); // 因为监听input,无法监听到表情包的输入,所以这里要再向父组件再抛出一次方法
      } else {
        console.log('不兼容~');
      }
    },

还有一个问题就是无法直接利用vue进行双向绑定,无法用v-model与v-html进行数据双向绑定:
因为输入框是通过contenteditable来实现,所以没办法用v-model进行文本内容的双向绑定,使用v-html也是没办法进行双向绑定的。所以这里是通过监听输入的input方法与插入表情包的方法来进行实时向上传递最新文本。

html代码

    <!--  父组件 -->
     <Comment
      :info="info"
      :submitCmtLoading="submitCmtLoading"
      :limtText="limtText"
      :iconWidth="iconWidth"
      :iconHeight="iconHeight"
      :cmt_show="cmt_show"
      :iconList="iconList"
      @changeText="changeText"
      @submitError="submitError"
      @submitSuccess="submitSuccess"
    />
    <!-- 子组件 -->
   <div v-if="cmt_show" class="cmt_box">
    <div
      ref="cmt_input"
      class="content_edit"
      contenteditable="true"
      :placeholder="placeholder"
      @focus="changeHandle"
      @input="changeText"
      @blur="blurInput"
      v-html="content"
    ></div>
    <div class="cmt_handle">
      <img
        id="emoticon_icon"
        v-if="iconList.length"
        src="//www1.pconline.com.cn/20200929/pgc/cmt/icon.png"
        @click.stop="showIconListPlane"
      />
      <div
        @click="submitCmt()"
        :class="['btn_submit', isSubmit ? 'btn_submit_y' : '']"
      >
        发布
      </div>
      <div v-show="iconShow" class="icon_list">
        <ul class="icon_box">
          <li
            class="icon_item"
            v-for="(item, index) in iconList"
            :key="`icon${index}`"
            @click="insertIcon(item)"
          >
            <img :src="item" />
          </li>
        </ul>
      </div>
    </div>
  </div>

组件完整代码

<template>
  <div v-if="cmt_show" class="cmt_box">
    <div
      ref="cmt_input"
      class="content_edit"
      contenteditable="true"
      :placeholder="placeholder"
      @focus="changeHandle"
      @input="changeText"
      @blur="blurInput"
      v-html="content"
    ></div>
    <div class="cmt_handle">
      <img
        id="emoticon_icon"
        v-if="iconList.length"
        src="//www1.pconline.com.cn/20200929/pgc/cmt/icon.png"
        @click.stop="showIconListPlane"
      />
      <div
        @click="submitCmt()"
        :class="['btn_submit', isSubmit ? 'btn_submit_y' : '']"
      >
        发布
      </div>
      <div v-show="iconShow" class="icon_list">
        <ul class="icon_box">
          <li
            class="icon_item"
            v-for="(item, index) in iconList"
            :key="`icon${index}`"
            @click="insertIcon(item)"
          >
            <img :src="item" />
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "Comment",
  props: {
    submitCmtLoading: {
      type: Boolean,
      default: false
    },
    limtText: {
      type: Number,
      default: 0
    },
    iconWidth: {
      type: Number,
      dafault: 24
    },
    iconHeight: {
      type: Number,
      dafault: 24
    },
    cmt_show: {
      type: Boolean,
      default: true
    },
    iconList: {
      type: Array,
      required: true
    },
    placeholder: {
      type: String,
      default: "积极回复可吸引更多人评论"
    },
    info: {
      type: Object
    }
  },
  data: function() {
    return {
      content: "",
      widFouce: "",
      rangeFouce: "",
      iconShow: false,
      isSubmit: false
    };
  },
  watch: {
    cmt_show: {
      handler(value) {
        if (value) {
          this.$nextTick(() => {
            console.log("this.$refs.cmt_input", this.$refs.cmt_input);
            this.toLast(this.$refs.cmt_input);
          });
        }
      },
      immediate: true
    }
  },
  mounted() {
    const self = this;
    document.getElementById("app").addEventListener("click", self.setHideClick);
    self.$once("hook:beforeDestroy", () => {
      document.getElementById("app") &&
        document
          .getElementById("app")
          .removeEventListener("click", self.setHideClick);
    });
  },
  methods: {
    setHideClick(event) {
      let target = event.srcElement;
      let nameList = ["icon_item", "icon_list", "icon_box"];
      if (
        !nameList.includes(target.className) &&
        !nameList.includes(target.parentElement.className)
      ) {
        this.iconShow = false;
      }
    },
    changeHandle() {
      console.log("blur");
    },

    changeText(e) {
      //  表情包插入时不触发该方法,只有输入时会触发
      // 判断字数,要先把自定义表情改成占位符,一个自定义表情按俩2个字符算
      let text = e.srcElement.innerHTML;
      let emitText = text;
      if (this.limtText) {
        let textObj = this.paseText(text);
        let len = this.getCharLen(textObj.text);
        if (len > this.limtText) {
          let str = this.setCmtTextByLimit(textObj.text, textObj.key);
          emitText = textObj.isReplace
            ? this.reductionStr(text, str, textObj.key)
            : str;
          e.srcElement.innerHTML = emitText;
          this.toLast(e.srcElement);
        }
      }

      this.isSubmit = emitText.length ? true : false;
      this.$emit("changeText", emitText);
    },
    paseText(str) {
      // 解析内容,把图片全部用占位替换掉  跟换行相关(div,br)全部改成两个空格
      let str1 = str.replace(/<div>|<\/div>|<br>/gi, "  ");
      // eslint-disable-next-line no-useless-escape
      let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;
      let imgMatch = str1.match(imgReg);
      let key = this.getRandomFour(str1);
      let isReplace = false;

      if (imgMatch) {
        isReplace = true;
        for (let i = 0; i < imgMatch.length; i++) {
          str1 = str1.replace(imgMatch[i], key);
        }
      }
      return {
        isReplace,
        key,
        text: str1
      };
    },
    /**
     * @description: 复原评论内容
     */
    reductionStr(sourceText, text, key) {
      // 解析内容,把图片全部用占位替换掉
      // eslint-disable-next-line no-useless-escape
      let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;
      let imgMatch = sourceText.match(imgReg);
      let result = text;
      if (imgMatch) {
        for (let i = 0; i < imgMatch.length; i++) {
          result = result.replace(key, imgMatch[i]);
        }
      }
      return result;
    },
    getRandomFour(str) {
      // 在时间戳里取随机4位数作为key,且如果文本中包含key则递归
      let timeStr = new Date().getTime() + "";
      let result = "";
      for (let i = 0; i < 4; i++) {
        let nums = Math.floor(Math.random() * 13);
        result = result + timeStr[nums];
      }
      return str.indexOf(result) == -1 ? result : this.getRandomFour(str);
    },
    getCharLen(sSource) {
      // 空格&nbsp; 要当1个字符算,所以最后要给每个空格减去5
      // 获取字符长度
      var l = 0;
      var schar;
      for (var i = 0; (schar = sSource.charAt(i)); i++) {
        // eslint-disable-next-line no-control-regex
        l += schar.match(/[^\x00-\xff]/) != null ? 2 : 1;
      }
      let nbsp = sSource.match(/&nbsp;/gi);
      if (nbsp) {
        let len = nbsp.length;
        l = l - len * 5;
      }

      return l;
    },
    setCmtTextByLimit(sSource, key) {
      let self = this;
      //  文字切割 如果超出文字限制需要切割
      if (typeof sSource !== "string") return;
      var str = changeLast(sSource, key);
      if (this.getCharLen(str) <= this.limtText) return str;
      while (this.getCharLen(str) > this.limtText) {
        str =
          4 + str.lastIndexOf(key) == str.length
            ? str.substring(0, str.length - 4)
            : str.substring(0, str.length - 1);
      }
      function changeLast(strl, key) {
        // 一直切割到最后一个不为表情包或不超出限制为止
        while (
          4 + strl.lastIndexOf(key) == strl.length &&
          self.getCharLen(strl) > self.limtText
        ) {
          strl = sSource.substring(0, sSource.length - 4);
        }
        return strl;
      }
      return str;
    },
    showIconListPlane() {
      // 显示表情包列表
      if (!this.iconShow) {
        this.getFouceInput();
      }
      this.iconShow = !this.iconShow;
      //  iconShow
    },
    getFouceInput() {
      this.widFouce = window.getSelection();
      this.rangeFouce = this.widFouce.getRangeAt(0);
    },
    toLast(obj) {
      // 将光标移到最后
      if (window.getSelection) {
        let range = window.getSelection();
        range.selectAllChildren(obj);
        range.collapseToEnd();
      }
    },
    insertIcon(url) {
      // 判断是否超出字数,如果超出不给插入
      const self = this;
      this.isSubmit || (this.isSubmit = true);
      let length = this.getCharLen(
        this.paseText(this.$refs.cmt_input.innerHTML).text
      );
      if (this.limtText && length + 4 > this.limtText) return;

      const img = `<img src='${url}' width=${this.iconWidth} height=${this.iconHeight} />`;
      //  兼容性判断 如果不兼容不往下执行,虽然说不兼容IE9以下,但是还是做一下判断 方便后面灵活控制
      if (window.getSelection && window.getSelection().getRangeAt) {
        let winSn = this.widFouce,
          range = this.rangeFouce;
        //  要判断的光标状态
        if (
          winSn.focusNode.className !== "content_edit" &&
          winSn.focusNode.parentElement.className !== "content_edit"
        ) {
          winSn.selectAllChildren(self.$refs.cmt_input);
          winSn.collapseToEnd();
          range = winSn.getRangeAt(0);
        }
        range.collapse(false);
        let node;
        if (range.createContextualFragment) {
          // 兼容IE9跟safari
          node = range.createContextualFragment(img);
        } else {
          let tempDom = document.createElement("div");
          tempDom.innerHTML = img;
          node = tempDom;
        }
        let dom = node.firstChild;
        range.insertNode(dom);
        let clRang = range.cloneRange();
        clRang.setStartAfter(dom);
        winSn.removeAllRanges();
        winSn.addRange(clRang);

        self.$emit("changeText", self.$refs.cmt_input.innerHTML);
      } else {
        console.log("不兼容~");
      }
    },
    blurInput() {
      this.getFouceInput();
    },
    submitCmt() {
      // 提交评论
      const self = this;
      let text = this.$refs.cmt_input.innerHTML;
      let length = this.getCharLen(this.paseText(text).text);
      if (self.submitCmtLoading || !self.isSubmit) return;
      if (self.cmt_text == "") {
        self.$emit("submitError", "评论为空");
        return;
      } else if (this.limtText && length > this.limtText) {
        self.$emit("submitError", "评论超出字数");
        return;
      }
      self.$emit("submitSuccess", text, self.info);
    }
  }
};
</script>
<style lang="scss" scoped>
.cmt_box {
  width: 510px;
  height: 180px;
  background-color: #ffffff;
  border-radius: 2px;
  border: solid 1px #ececec;
  padding: 14px;
  box-sizing: border-box;
  margin: auto;
  .content_edit {
    width: 100%;
    height: 120px;
    outline: none;
    border: none;
    text-align: left;
    font-size: 14px;

    &:empty::before {
      color: #cccccc;
      content: attr(placeholder);
    }
  }
  .cmt_handle {
    display: flex;
    justify-content: space-between;
    align-items: center;
    position: relative;
    #emoticon_icon {
      width: 21px;
      height: 21px;
      display: block;
      flex-shrink: 0;
      cursor: pointer;
    }
    .btn_submit {
      width: 68px;
      height: 32px;
      background-color: #cccccc;
      border-radius: 16px;
      text-align: center;
      line-height: 32px;
      color: #ffffff;
      font-size: 14px;
      cursor: pointer;

      &.btn_submit_y {
        background-color: #f95354;
      }
    }
    .icon_list {
      position: absolute;
      top: 40px;
      left: -10px;
      width: 280px;
      border-radius: 2px;
      background: #fff;
      box-shadow: 0 5px 18px 0 rgba(0, 0, 0, 0.16);
      padding: 15px;
      &::before {
        content: "";
        width: 0;
        height: 0;
        display: block;
        padding: 0;
        position: absolute;
        top: -10px;
        left: 10px;
        border-left: 10px solid transparent;
        border-right: 10px solid transparent;
        border-bottom: 10px solid #fff;
      }
      .icon_box {
        list-style: none;
        display: flex;
        justify-content: start;
        align-items: center;
        flex-wrap: wrap;
        padding: 0;
        margin: 0;

        .icon_item {
          padding: 5px;
          text-align: center;
          cursor: pointer;
          > img {
            width: 30px;
            height: 30px;
          }
        }
      }
    }
  }
}
</style>

最后再附上demo地址啦
本人前端小学生,欢迎交流指正~

阅读 164

推荐阅读