模拟实现 Textarea 的功能,当用户输入 @ 的时候弹出选择框选择人员,同时,将 @人员 变成蓝色插入到编辑框中。这样的需求想到的解决方案就是利用 DIV 的
contenteditable="true"
让 DIV 具有输入的功能,同时监听 DIV 内容的变化,输入 @ 的时候弹出人员选择框,利用 Selection 对象的属性,找到鼠标 @ 的具体位置,进行页面内容的插入。具体效果如下:
本文把其中遇到的问题记录下来,思路供大家参考:
Selection 对象
getSelection()
方法暴露在 document 和 window 对象上,返回当前选中文本的 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 对象
const range = document.createRange()
const range = Selection.getRangeAt(0)
const range = new Range()
更多属性:developer.mozilla.org/zh-CN/docs/…
光标到最后
利用上面的 Selection 和 Range 实现光标移动到最后的功能。在 Vue 中,在 nextTick 回调中重置光标位置,避免修改 Node 节点不起作用。
function keepLastIndex(curDom: HTMLElement) {
curDom.focus()
const curSelection = window.getSelection()
curSelection?.selectAllChildren(curDom)
curSelection?.collapseToEnd()
function keepLastIndex(curDom: HTMLElement) {
curDom.focus()
const curSelection = window.getSelection()
const rangeTmp = document.createRange()
rangeTmp.selectNodeContents(curDom)
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">
ref="editorRef"
contenteditable="true"
class="edit-wrap van-field__control"
v-if="!editVal && showPlaceholder"
contenteditable="false"
class="edit-wrap-placeholder-o"
>{{ placeholder }}</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
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
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 事件。
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 表示中文输入结束。
ref="editorRef"
contenteditable="true"
@compositionstart="handleStart"
@compositionend="handleEnd"
在 Android 中 compositionstart 和 compositionend 事件不会被触发,只能在 input 事件中统一处理数量限制以及输入 @ 的时候弹出选择框选择人员。
如果粘贴的文字 + 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 类似。
