vue使用contentEditable实现输入框中添加 emoji 表情

项目上有个需求,需要在textarea中输入 emoji 表情时可以显示,emoji 表情非原生表情,是第三方库,每个 emoji相当于一个图片。

于是就相当于在textarea中插入img,最后以divcontentEditable属性实现,记录一下实现方式,以及遇到的问题和解决。

{{ $t('screeningVisibleTip') }}
  1. 数据获取
    以原生 dom 的 innerHTML 为主,最后获取数据也是从 innerHTML 获取。
this.$refs.textareaRef.innerHTML
  1. placeholder
    placeholder 以绝对定位悬浮于 div 之上, focus 时无脑隐藏,blur 时如果innerHTML有值则显示,反之隐藏。input 触发时如果输入被清空,则显示,反之隐藏。
onBlur(e) {
   this.showPlaceHolder = !this.$refs.textareaRef.innerHTML;
},

onInput(){
   ...
   // placholder 控制
   if(e.target.innerHTML) {
     this.showPlaceHolder = false;
   } else {
     this.showPlaceHolder = true;
   }
}
  1. 插入表情
    插入表情时需要在光标位置插入,使用 range.insertNode()插入到当前光标处,然后再使用 range.collapse()折叠光标
const { range, selection } = this.getRange();
// this.createIconEle()返回一个创建的 img dom 节点
range.insertNode(this.createIconEle(code)); 

range取法考虑兼容性:

getRange() {
   const selection = getSelection();
   if(selection.getRangeAt){
      console.log('取法一')
      return { range: selection.getRangeAt(0), selection };
   } else {
      console.log('取法二');
      const range = document.createRange();
      range.setStart(selection.anchorNode, selection.anchorOffset);
      range.setEnd(selection.focusNode, selection.focusOffset);
      return { range, selection };
   }
}

插入后,折叠光标的实现,实际上我一开始range.collapse()的写法是:

range.collapse(false);
this.$refs.textareaRef.focus();

发现在谷歌浏览器上没有什么问题,但是在safari以及手机浏览器中插入表情后,光标总是在表情左侧(目标是在表情右侧),经过反复测试,考虑是否因为调用insertNode()后使得原有的 range 发生了某些改变,导致折叠时错位,因此,尝试清除当前的 range,重新生成一个 range,问题解决。代码如下:

const { startContainer, startOffset, endContainer, endOffset } = range;
selection.removeAllRanges(); // 清空所有 range
const newRange = document.createRange();
newRange.setStart(startContainer, startOffset); // 将之前记录的起始节点及位置重新 set 给新的 range
newRange.setEnd(endContainer, endOffset); // 同上,重新 set 结束节点
selection.addRange(newRange);  // 增加新 range
newRange.collapse(false);
selection.collapseToEnd();   // selection 也需要折叠一下
this.$refs.textareaRef.focus();

4.输入最大值限制
使用原生 dom 的innerHTML最大的问题是限制输入最大值比较麻烦。
目前的解决思路是当 innerHTML 没有超过最大值且改变时记录一份备份数据,当触发 oninput 时判断字数是否超出最大值(表情算作了一个字),如果超过则使用备份数据对innerHTML进行重新赋值,并且设置好光标位置。插入表情时进行的校验则比较简单,如果超出就直接返回,不做任何处理。

oninput 的触发事件中比较难处理的还是处理光标的问题,目前的处理方法是,在替换innerHTML之前判断当前光标所在的节点index,判断所在当前节点中的位移endOffset,记录当前 range 所在的节点endContainer,并且判断endContainer是否为一个长度,如果为一个长度,则光标应该在前一个元素的末尾,如果前一个元素为表情,则应该设置:

newRange.setEnd(this.$refs.textareaRef, insertNodeIndex);

如果前一个元素为文本,则应该设置:

newRange.setEnd(targetNode, 0);

如果endContainer的长度多于 1 ,则仍然在目标节点,只是往前移一个位移。

newRange.setEnd(targetNode, rangeIndex-1);

设置好光标末位之后,就可以折叠光标。
onInput 的触发方法整体内容如下:

onInput(e) {
   const vm = this;
   const [tl, el] = this.getContentLength();
   // 超过最大可输入值时做一些处理
   if(tl + el > MAX_LENGTH) {
      // 超出范围
      const { range, selection } = this.getRange();
      var insertNodeIndex = 0; // 光标所在节点
      var rangeIndex = range.endOffset; // 光标在节点中位置
      var deleteFlag = false; // 是否删除了整个节点
      const beforeChildNodes = this.$refs.textareaRef.childNodes; // 替换前 child 节点
      beforeChildNodes.forEach((n, index) => {
        if(n === range.endContainer){
           insertNodeIndex = index;
           deleteFlag = n && n.nodeValue && n.nodeValue.length === 1;
        }
      })

      // 替换
      this.$refs.textareaRef.innerHTML = this.htmlContent;
      const afterChildNodes = this.$refs.textareaRef.childNodes;
      const targetNode = afterChildNodes[insertNodeIndex];
      selection.removeAllRanges();
      const newRange = document.createRange();
      newRange.setStart(this.$refs.textareaRef, 0);
      // 确定末位 
      if(deleteFlag) {
          if(!targetNode) {
            newRange.setEnd(this.$refs.textareaRef,afterChildNodes.length);
          } else {
            if(targetNode.nodeName === 'IMG') {
              newRange.setEnd(this.$refs.textareaRef, insertNodeIndex);
            } else {
              newRange.setEnd(targetNode, 0);
            }
          }
       } else {
          newRange.setEnd(targetNode, rangeIndex-1);
      }
      selection.addRange(newRange);
      newRange.collapse(false);
      selection.collapseToEnd();
      this.$refs.textareaRef.focus();
    } else {
      this.htmlContent = e.target.innerHTML;
    }
    // placholder 控制
    if(e.target.innerHTML) {
      this.showPlaceHolder = false;
    } else {
      this.showPlaceHolder = true;
    }
}

这种方法有点麻烦,当时是因为没有找到删除光标前一位的方法,现在搞清楚 rangeselection 之后,感觉可能还有更好的解决办法:比如可以移动range的开始位到前一位,然后删除这个 range 的内容,如果这样的话就可以不用判断各种情况下的 range 末尾。

  1. xxs 问题
    需要处理好 <>, 以防有脚本注入的问题,这里因为没有插入其他 html元素的必要,因此在过滤了 img 标签,替换为 emoji code 之后,直接替换了所有的<><>,后端接口也需要增加过滤。

你可能感兴趣的:(vue使用contentEditable实现输入框中添加 emoji 表情)