项目上有个需求,需要在textarea
中输入 emoji 表情时可以显示,emoji 表情非原生表情,是第三方库,每个 emoji相当于一个图片。
于是就相当于在textarea
中插入img
,最后以div
加contentEditable
属性实现,记录一下实现方式,以及遇到的问题和解决。
<div>
<div
id="textarea"
contentEditable="true"
ref="textareaRef"
@input="onInput"
@focus="showPlaceHolder = false"
@blur="onBlur"
/>
<span v-if="showPlaceHolder">{{ $t('screeningVisibleTip') }}</span>
</div>
- 数据获取
以原生 dom 的 innerHTML 为主,最后获取数据也是从 innerHTML 获取。
this.$refs.textareaRef.innerHTML
- 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;
}
}
- 插入表情
插入表情时需要在光标位置插入,使用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;
}
}
这种方法有点麻烦,当时是因为没有找到删除光标前一位的方法,现在搞清楚 range
和 selection
之后,感觉可能还有更好的解决办法:比如可以移动range
的开始位到前一位,然后删除这个 range
的内容,如果这样的话就可以不用判断各种情况下的 range
末尾。
- xxs 问题
需要处理好<
及>
, 以防有脚本注入的问题,这里因为没有插入其他 html元素的必要,因此在过滤了 img 标签,替换为 emoji code 之后,直接替换了所有的<
及>
为<
和>
,后端接口也需要增加过滤。
网友评论