美文网首页
vue使用contentEditable实现输入框中添加 emo

vue使用contentEditable实现输入框中添加 emo

作者: whelm | 来源:发表于2021-02-04 17:37 被阅读0次

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

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

    <div>
      <div
        id="textarea"
        contentEditable="true"
        ref="textareaRef"
        @input="onInput"
        @focus="showPlaceHolder = false"
        @blur="onBlur"
      />
      <span v-if="showPlaceHolder">{{ $t('screeningVisibleTip') }}</span>
    </div>
    
    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 之后,直接替换了所有的<>&lt;&gt;,后端接口也需要增加过滤。

    相关文章

      网友评论

          本文标题:vue使用contentEditable实现输入框中添加 emo

          本文链接:https://www.haomeiwen.com/subject/zpvbtltx.html