vue使用contentEditable實現(xiàn)輸入框中添加 emoji 表情

項目上有個需求,需要在textarea中輸入 emoji 表情時可以顯示,emoji 表情非原生表情,是第三方庫,每個 emoji相當(dāng)于一個圖片。

于是就相當(dāng)于在textarea中插入img,最后以divcontentEditable屬性實現(xiàn),記錄一下實現(xiàn)方式,以及遇到的問題和解決。

<div>
  <div
    id="textarea"
    contentEditable="true"
    ref="textareaRef"
    @input="onInput"
    @focus="showPlaceHolder = false"
    @blur="onBlur"
  />
  <span v-if="showPlaceHolder">{{ $t('screeningVisibleTip') }}</span>
</div>
  1. 數(shù)據(jù)獲取
    以原生 dom 的 innerHTML 為主,最后獲取數(shù)據(jù)也是從 innerHTML 獲取。
this.$refs.textareaRef.innerHTML
  1. placeholder
    placeholder 以絕對定位懸浮于 div 之上, focus 時無腦隱藏,blur 時如果innerHTML有值則顯示,反之隱藏。input 觸發(fā)時如果輸入被清空,則顯示,反之隱藏。
onBlur(e) {
   this.showPlaceHolder = !this.$refs.textareaRef.innerHTML;
},

onInput(){
   ...
   // placholder 控制
   if(e.target.innerHTML) {
     this.showPlaceHolder = false;
   } else {
     this.showPlaceHolder = true;
   }
}
  1. 插入表情
    插入表情時需要在光標(biāo)位置插入,使用 range.insertNode()插入到當(dāng)前光標(biāo)處,然后再使用 range.collapse()折疊光標(biāo)
const { range, selection } = this.getRange();
// this.createIconEle()返回一個創(chuàng)建的 img dom 節(jié)點
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 };
   }
}

插入后,折疊光標(biāo)的實現(xiàn),實際上我一開始range.collapse()的寫法是:

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

發(fā)現(xiàn)在谷歌瀏覽器上沒有什么問題,但是在safari以及手機瀏覽器中插入表情后,光標(biāo)總是在表情左側(cè)(目標(biāo)是在表情右側(cè)),經(jīng)過反復(fù)測試,考慮是否因為調(diào)用insertNode()后使得原有的 range 發(fā)生了某些改變,導(dǎo)致折疊時錯位,因此,嘗試清除當(dāng)前的 range,重新生成一個 range,問題解決。代碼如下:

const { startContainer, startOffset, endContainer, endOffset } = range;
selection.removeAllRanges(); // 清空所有 range
const newRange = document.createRange();
newRange.setStart(startContainer, startOffset); // 將之前記錄的起始節(jié)點及位置重新 set 給新的 range
newRange.setEnd(endContainer, endOffset); // 同上,重新 set 結(jié)束節(jié)點
selection.addRange(newRange);  // 增加新 range
newRange.collapse(false);
selection.collapseToEnd();   // selection 也需要折疊一下
this.$refs.textareaRef.focus();

4.輸入最大值限制
使用原生 dom 的innerHTML最大的問題是限制輸入最大值比較麻煩。
目前的解決思路是當(dāng) innerHTML 沒有超過最大值且改變時記錄一份備份數(shù)據(jù),當(dāng)觸發(fā) oninput 時判斷字數(shù)是否超出最大值(表情算作了一個字),如果超過則使用備份數(shù)據(jù)對innerHTML進行重新賦值,并且設(shè)置好光標(biāo)位置。插入表情時進行的校驗則比較簡單,如果超出就直接返回,不做任何處理。

oninput 的觸發(fā)事件中比較難處理的還是處理光標(biāo)的問題,目前的處理方法是,在替換innerHTML之前判斷當(dāng)前光標(biāo)所在的節(jié)點index,判斷所在當(dāng)前節(jié)點中的位移endOffset,記錄當(dāng)前 range 所在的節(jié)點endContainer,并且判斷endContainer是否為一個長度,如果為一個長度,則光標(biāo)應(yīng)該在前一個元素的末尾,如果前一個元素為表情,則應(yīng)該設(shè)置:

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

如果前一個元素為文本,則應(yīng)該設(shè)置:

newRange.setEnd(targetNode, 0);

如果endContainer的長度多于 1 ,則仍然在目標(biāo)節(jié)點,只是往前移一個位移。

newRange.setEnd(targetNode, rangeIndex-1);

設(shè)置好光標(biāo)末位之后,就可以折疊光標(biāo)。
onInput 的觸發(fā)方法整體內(nèi)容如下:

onInput(e) {
   const vm = this;
   const [tl, el] = this.getContentLength();
   // 超過最大可輸入值時做一些處理
   if(tl + el > MAX_LENGTH) {
      // 超出范圍
      const { range, selection } = this.getRange();
      var insertNodeIndex = 0; // 光標(biāo)所在節(jié)點
      var rangeIndex = range.endOffset; // 光標(biāo)在節(jié)點中位置
      var deleteFlag = false; // 是否刪除了整個節(jié)點
      const beforeChildNodes = this.$refs.textareaRef.childNodes; // 替換前 child 節(jié)點
      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;
    }
}

這種方法有點麻煩,當(dāng)時是因為沒有找到刪除光標(biāo)前一位的方法,現(xiàn)在搞清楚 rangeselection 之后,感覺可能還有更好的解決辦法:比如可以移動range的開始位到前一位,然后刪除這個 range 的內(nèi)容,如果這樣的話就可以不用判斷各種情況下的 range 末尾。

  1. xxs 問題
    需要處理好 <>, 以防有腳本注入的問題,這里因為沒有插入其他 html元素的必要,因此在過濾了 img 標(biāo)簽,替換為 emoji code 之后,直接替換了所有的<>&lt;&gt;,后端接口也需要增加過濾。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容