可以编辑的 DIV

5,427 阅读6分钟

背景介绍

模拟实现 Textarea 的功能,当用户输入 @ 的时候弹出选择框选择人员,同时,将 @人员 变成蓝色插入到编辑框中。这样的需求想到的解决方案就是利用 DIV 的 contenteditable="true" 让 DIV 具有输入的功能,同时监听 DIV 内容的变化,输入 @ 的时候弹出人员选择框,利用 Selection 对象的属性,找到鼠标 @ 的具体位置,进行页面内容的插入。具体效果如下:

QQ20210802-215852-HD.gif

本文把其中遇到的问题记录下来,思路供大家参考:

预备知识

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 的值不正确。

image.png

正确解法是:去循环遍历 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 => ({
      '<': '&lt;',
      '>': '&gt;',
      '&': '&amp;',
    })[matches] || '')
  }
  return ''
}
// 解码
export const funDecodeHTML = function (str: string) {
  if (typeof str === 'string') {
    return str.replace(/&lt;|&gt;|&amp;/g, (matches: string): string => ({
      '&lt;': '<',
      '&gt;': '>',
      '&amp;': '&',
    })[matches] || '')
  }
  return ''
}

监听 DIV 内容变化

主要是两个事件:在可编辑 DIV 容器上监听 keydowninput 事件,事件顺序: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
  }
    ...
}

监听输入法

主要是两个事件:compositionstartcompositionendcompositionstart 表示中文输入开始;compositionend 表示中文输入结束。

 <div
    ref="editorRef"
    contenteditable="true"
    @compositionstart="handleStart"
    @compositionend="handleEnd"
/>

踩坑:
在 Android 中 compositionstartcompositionend 事件不会被触发,只能在 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 类似。

可以编辑的 DIV(editVal).png