背景介绍
模拟实现 Textarea 的功能,当用户输入 @ 的时候弹出选择框选择人员,同时,将 @人员 变成蓝色插入到编辑框中。这样的需求想到的解决方案就是利用 DIV 的 contenteditable="true"
让 DIV 具有输入的功能,同时监听 DIV 内容的变化,输入 @ 的时候弹出人员选择框,利用 Selection 对象的属性,找到鼠标 @ 的具体位置,进行页面内容的插入。具体效果如下:
本文把其中遇到的问题记录下来,思路供大家参考:
预备知识
Selection 对象
getSelection()
方法暴露在 document 和 window 对象上,返回当前选中文本的 Selection 对象。
// 获取 Selection 对象
const selection = window.getSelection()
// 或者
const selection = document.getSelection()
getSelection()
在 HTML5 中进行了标准化,IE9 以及 Firefox、Safari、Chrome 和 Opera 的所有现代版本中都实现了这个方法(简而言之,IE8 及更早不兼容。)
Selection 对象用到的属性:
anchorNode
: 选区开始的节点
anchorOffset
: 在 anchorNode 中,从开头到选取开始跳过的字符数
focusNode
: 选区结束的节点
focusOffset
: focusNode
中包含在选区内的字符数
更多属性:developer.mozilla.org/zh-CN/docs/…
当文档没有选中区域的时候,anchorNode 和 focusNode 是同一个节点并且 anchorOffset 和 focusOffset 也是一样的,表示鼠标在 anchorNode 节点的位置。利用这一点,当用户输入 @ 的时候使用当前 anchorNodeGlobal 记录当前鼠标所在的 node 节点,focusOffsetGlobal 记录当前鼠标所在节点的具体位置。这样在插入蓝色 <span>
标签的时候就可以知道具体插入在哪个节点了。
Range 对象
// 通过 document 创建
const range = document.createRange()
// 通过 Selection 获取
const range = Selection.getRangeAt(0)
//实验中的功能:Range()构造函数创建,新创建的对象属于全局 Document 对象
const range = new Range()
更多属性:developer.mozilla.org/zh-CN/docs/…
光标到最后
利用上面的 Selection 和 Range 实现光标移动到最后的功能。在 Vue 中,在 nextTick
回调中重置光标位置,避免修改 Node 节点不起作用。
方法一:
function keepLastIndex(curDom: HTMLElement) {
curDom.focus() // 解决ff不获取焦点无法定位问题
const curSelection = window.getSelection() // 创建 selection
curSelection?.selectAllChildren(curDom) // selection 选择 curDom 下所有子内容
curSelection?.collapseToEnd() // 光标移至最后
}
方法二:
function keepLastIndex(curDom: HTMLElement) {
curDom.focus() // 解决ff不获取焦点无法定位问题
// 当前鼠标指针
const curSelection = window.getSelection()
// 创建空的 range
const rangeTmp = document.createRange()
rangeTmp.selectNodeContents(curDom)
// false 折叠到 end 节点,这样就可以使光标不选中
rangeTmp.collapse(false)
curSelection?.removeAllRanges()
// 添加到最后
curSelection?.addRange(rangeTmp)
}
容器 DIV
contenteditable
<div contenteditable="true" class="edit-wrap"></div>
可以让 DIV 容器可以输入内容。
Safari 光标
Safari 浏览器 user-select
的默认值是 none,所以需要重新设置一下,使得 contenteditable
的容器可以编辑。
[contenteditable] {
-webkit-user-select: text;
user-select: text;
}
出现光标
DIV 容器只有有内容的时候才会出现编辑的光标,所以可以设置 DIV 容器的 padding: 1px;
让 DIV 容器初始化的时候也出现编辑的光标。
模拟 placeholder
采用一个 div (内容是 placeholder 的值)浮在可编辑 DIV 的里面,当可编辑 DIV 的内容为空时,显示该 div,当可编辑 DIV 鼠标 focus 的时候,隐藏该 div。
<div class="o-input">
<div
ref="editorRef"
contenteditable="true"
class="edit-wrap van-field__control"
/>
<div
v-if="!editVal && showPlaceholder"
contenteditable="false"
class="edit-wrap-placeholder-o"
>{{ placeholder }}</div>
</div>
去掉 DIV 的 outline
.edit-wrap
&:focus
outline none
获取 DIV 的内容
有了可以编辑的 DIV 后,一个关键的问题就是获取 DIV 中的内容,类似 Textarea 的 value 属性,去获取到用户在 DIV 中输入的问题。如果直接使用 Node 节点的 textContent
的话,在换行的时候会有问题,多出 \n
导致换行显示不正确。主要原因是在可编辑的 DIV 中回车换行会出现 <br>
标签,在删除的时候有时候 <br>
标签可能并不会被删除掉,这就导致直接使用 textContent
作为 DIV 的值不正确。
正确解法是:去循环遍历 DIV 的各个子元素,如果是 DIV 的话,内容追加 \n
;如果是 BR 元素的话,判断该元素是否可以追加 \n
;
if (cur.tagName === 'BR'
&& (cur.nextSibling?.nodeName !== 'DIV' && cur.nextSibling?.nodeName !== 'P')
&& (cur.parentNode?.nodeName === 'DIV' || cur.parentNode?.nodeName === 'P')
&& (cur.parentNode?.nextSibling?.nodeName !== 'DIV' && cur.parentNode?.nextSibling?.nodeName !== 'P')
) {
result += '\n'
}
如果该元素类型是 nodeType = 1 并且有子元素,则循环遍历其子元素。
全部代码:
// 全局变量
let result = ''
const getNodeText = (curNode: Node) => {
if (curNode.nodeType === 1 && curNode.nodeName !== 'BR') {
if (curNode.nodeName === 'DIV' || curNode.nodeName === 'P') {
result += '\n'
}
const len = curNode.childNodes.length
for (let i = 0; i < len; i++) {
const nodeItem = curNode.childNodes[i]
getNodeText(nodeItem)
}
} else if (curNode.nodeType === 1 && curNode.nodeName === 'BR') {
const cur = curNode as HTMLElement
if (cur.tagName === 'BR'
&& (cur.nextSibling?.nodeName !== 'DIV' && cur.nextSibling?.nodeName !== 'P')
&& (cur.parentNode?.nodeName === 'DIV' || cur.parentNode?.nodeName === 'P')
&& (cur.parentNode?.nextSibling?.nodeName !== 'DIV' && cur.parentNode?.nextSibling?.nodeName !== 'P')
) {
result += '\n'
} else {
result += cur.innerHTML
}
} else if (curNode.nodeType === 3) {
result += curNode.nodeValue
}
}
// div 里面的 childNodes
export const getEditVal = (editDom: Node|null): string => {
if (editDom?.childNodes) {
result = ''
const len = editDom.childNodes.length
for (let i = 0; i < len; i++) {
const nodeItem = editDom.childNodes[i]
getNodeText(nodeItem)
}
return result
}
return ''
}
内容转义
因为有 @人员 蓝色显示的需要,可编辑的 DIV 的文本内容在显示的时候会使用 v-html
,所以要对用户输入的 html 标签的 <
和 >
进行转义。
// 编码
export const funEncodeHTML = function (str: string) {
if (typeof str === 'string') {
return str.replace(/<|&|>/g, (matches: string): string => ({
'<': '<',
'>': '>',
'&': '&',
})[matches] || '')
}
return ''
}
// 解码
export const funDecodeHTML = function (str: string) {
if (typeof str === 'string') {
return str.replace(/<|>|&/g, (matches: string): string => ({
'<': '<',
'>': '>',
'&': '&',
})[matches] || '')
}
return ''
}
监听 DIV 内容变化
主要是两个事件:在可编辑 DIV 容器上监听 keydown
和 input
事件,事件顺序:keydown
=> input
即先 keydown
然后 input
事件。
<div
ref="editorRef"
contenteditable="true"
class="edit-wrap van-field__control"
@input="editorInput"
@keydown="handleKeyDown"
/>
踩坑:
1.在 Chrome 53 版本,input 事件中不会返回 event.data
并不会返回当前输入的值。可以在 keydown 事件中记录下当前输入时哪个键被按下:curCodeKey.value = e.key
2.在 iPhone 8 plus 中,中文输入时输入 @ 并不会触发 keydown
事件,可以在 input 事件做下兼容:
const editorInput = (e: InputEvent) => {
// iphone8 plus 中文输入 @ 不会触发 handleKeyDown
if (e.data && e.data === '@') {
curCodeKey.value = e.data
}
...
}
监听输入法
主要是两个事件:compositionstart
和 compositionend
;compositionstart
表示中文输入开始;compositionend
表示中文输入结束。
<div
ref="editorRef"
contenteditable="true"
@compositionstart="handleStart"
@compositionend="handleEnd"
/>
踩坑:
在 Android 中 compositionstart
和 compositionend
事件不会被触发,只能在 input
事件中统一处理数量限制以及输入 @ 的时候弹出选择框选择人员。
粘贴限制
如果粘贴的文字 + DIV 本身文字超过本身限制的最大值的时候是有问题的,所以需要对粘贴事件进行监控,当字数过多时进行截取,只粘贴最大字数限制的文字:
<div
ref="editorRef"
contenteditable="true"
@paste="handlePast"
/>
const handlePast = (event: ClipboardEvent) => {
// 解码
const realEditVal = funDecodeHTML(editVal.value)
if (props.max <= realEditVal.length) {
Toast('字数超过限制,保留前0个字')
return
}
// 粘贴的文本
const paste = event.clipboardData?.getData('text')
const toastNum = props.max - realEditVal.length
const contentPast = paste?.substr(0, toastNum) || ''
// 当前光标位置
const selection = window.getSelection()
if (!selection?.rangeCount) return
// 删除已经选中的文本
selection.deleteFromDocument()
const insertNodeTmp = document.createTextNode(contentPast)
selection.getRangeAt(0).insertNode(insertNodeTmp)
// 让光标移动到复制完文本后
selection.collapse(insertNodeTmp, contentPast.length)
// 保存
saveDivEnglish()
if (paste && toastNum < paste?.length) {
const tNum = Math.max(0, toastNum)
Toast(`字数超过限制,保留前${tNum}个字`)
}
// 阻止默认粘贴事件
event.preventDefault()
}
编辑定位
前面提到了 selection.anchorNode
, 循环容器 DIV 里面的 node 节点,当与记录的 anchorNode
是同一个节点(===)的时候,即可以找到光标的位置了。
踩坑:
在 Safari 浏览器中,const selection = window.getSelection()
会随着光标的移动自动被更新掉,所以在 @ 之后,要先将 selection.anchorNode
存一下。
算法部分
主要是两个方面:一方面监控输入,输入 @ 符号的时候,弹出选择框,将蓝色 span 标签插入到 @ 的位置;另一方面控制字数,当字数超过最大字数的时候就不能输入了,但是对于中文输入是插入的过程,如果文本数量超出,则从文本后面删除多余的文字,效果同有字数限制的 Textarea 类似。