項目上有個需求,需要在textarea中輸入 emoji 表情時可以顯示,emoji 表情非原生表情,是第三方庫,每個 emoji相當(dāng)于一個圖片。
于是就相當(dāng)于在textarea中插入img,最后以div加contentEditable屬性實現(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>
- 數(shù)據(jù)獲取
以原生 dom 的 innerHTML 為主,最后獲取數(shù)據(jù)也是從 innerHTML 獲取。
this.$refs.textareaRef.innerHTML
- 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;
}
}
- 插入表情
插入表情時需要在光標(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)在搞清楚 range 和 selection 之后,感覺可能還有更好的解決辦法:比如可以移動range的開始位到前一位,然后刪除這個 range 的內(nèi)容,如果這樣的話就可以不用判斷各種情況下的 range 末尾。
- xxs 問題
需要處理好<及>, 以防有腳本注入的問題,這里因為沒有插入其他 html元素的必要,因此在過濾了 img 標(biāo)簽,替換為 emoji code 之后,直接替換了所有的<及>為<和>,后端接口也需要增加過濾。