背景

一天产品大大向 boss 汇报完研发成绩和产品业绩产出,若有所思的走进去,劲直向我走过去,嘴角微微上扬。
产品大大:boss 对咱们的研发成绩挺称心的,balabala...(心田 OS:不听,讲重点)
产品大大:咱们的客服 IM 桌面客户端当初曾经有能力做到 1 对多接待在线访客了,前面咱们想要将专项问题访客在以后接待客服不能解决的状况下,能够拉专项客服发动群聊。
我:嗯,这个想法不错,专项客服还能一眼看到之前对话上下文,访客还毋庸从新发动聊天。
哒哒哒,性能做完了,两周后上线验收,客户很称心,boss 在群里也点了个 。
产品大大又来了:嗯,这个性能不错,咱们也反对给客服本人群聊吧,有助于客服群体沟通和问题反馈。
我:嗯,这个想法也不错,群聊的内容也有助于前期的用户声音数据挖掘。
哐哐哐,做好了,上线,调整好心态,静等被各路大佬 吞没。
客服 A:搞毛啊,你们群聊都不给个@性能,我怎么 diao 你们研发啊。
客服 B/C/D:➕10086。
我:。。。
哈哈哈,下面的虚构场景纯属好玩,上面就怎么完满的实现一个@性能开展,包教包会。
在进入正题之前,咱们先准备一些对光标选区的“基操”常识。而对光标选区的操作在页面上的元素大抵分为两大类:text/textarea 文本输入框元素和富文本元素。上面别离对一些罕用 API 进行简略介绍。

text/textarea 文本输入框中操作光标选区

首先看这类操作形式,简直能够不必 SelectionRange相干 API,应用的是文本输入框元素原生办法。

被动选中某一区域

被动选中在文本输入框元素某一区域能够应用 setSelectionRange 。

element.setSelectionRange(selectionStart, selectionEnd [, selectionDirection]);
  • selectionStart 被选中的第一个字符的地位索引,从 0 开始。
  • selectionEnd 被选中的最初一个字符的 下一个 地位索引。
  • selectionDirection 抉择方向的字符串。
$input.setSelectionRange(0, 6);// $input.select() // 全副选中$input.focus();

聚焦到某一地位

如果咱们想把光标挪动到 前面,其实是将选区起始地位设置雷同的成果

$input.setSelectionRange(6, 6);$input.focus();

还原之前的选区

有些场景,咱们抉择了以后input文本框选区,要去做别的操作,回来后要复原选区,这时在做别的操作之前就要将选区地位存下来,会用到selectionStartselectionEnd两个属性。

$input.addEventListener('mouseup', () => {  this.pos = {    start: $input.selectionStart,    end: $input.selectionEnd,  };});const { start, end } = this.pos;$input.setSelectionRange(start, end);$input.focus();

在指定选区插入(替换)内容

指定文本输入框的某个地位插入内容或者替换选区的内容,能够应用 setRangeText 实现。

setRangeText(replacement)
setRangeText(replacement, start, end, selectMode)

这个办法有 2 种模式,第二种模式有 4 个参数:

  • 第一个参数replacement是替换文本;
  • startend是起始地位,默认值该元素以后选中的区域的地位;
  • 最初一个参数selectMode是替换后选区的状态,它有 4 个状态:select(替换后选中)、start(替换后光标位于替换词之前)、end(替换后光标位于替换词之后)和 preserve(默认值,尝试保留选区);

咱们将光标或者选区地位插入或者替换文本 ,能够这样

$input.setRangeText('');$input.focus();


上面再来看看第二种有 4 个参数模式,发现会在咱们指定的起始地位插入内容后,以后的选区和光标尝试保留。替换后选区的状态另外 3 种场景大家能够自行测试。

$input.setRangeText('', 8, 8, 'preserve');$input.focus();


对于选区的操作在 input/textarea 输入框中的操作罕用的差不多就这些,上面简略总结一下

富文本中操作光标选区——Selection & Range

设置富文本

首先富文本元素是可编辑元素,能够在元素上看到光标,除了表单元素,还有给一般元素增加属性能够转换文富文本元素,能够通过一下三种形式转换。

  • 给元素增加属性 contenteditable="true"
  • 增加 CSS 属性 -webkit-user-modify: "read-write"
  • 通过 js 的 document.designMode="on"形式设置;

Selection & Range

在富文本中对光标及选区的操作,不得不提 JavaScript 的两个原生对象: Selection 和 Range 。

  • Selection 对象示意用户抉择的文本范畴或插入符号的以后地位。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标通过文字而产生。要获取用于查看或批改的 Selection 对象,请调用 window.getSelection()。
  • Range 对象示意蕴含节点和局部文本节点的文档片段。通过 selection对象取得的 range对象才是咱们操作光标的重点。


大家可能留神到 getRangeAt(0),选区可能会有多个 range? 还真是,在 Firefox 反对多个选区,通过 cmd键(windows上是 ctrl键)能够实现多选区。

再看一个 range 返回的一个属性,collapsed,示意选区的终点与起点是否重叠。当collapsedtrue时,选中区域被压缩成一个点,对于一般的元素,可能什么都看不到,如果是在可编辑元素上,那这个被压缩的点就变成了能够闪动的光标。

被动选中富文本的某个节点

选中富文本中的某个独立标签,能够用到两个 API,Range.selectNode() 和 Range.selectNodeContent(),两者不同的是前者包含节点本身,后者不包含本身。比方咱们想独立选中富文本的第二个子元素<span style="color: #00965e;">Selection</span> ,应用 Range.selectNode() API 选中元素,删除的是整个元素,再输出内容是没有款式的。

const $span = $input.childNodes[1];range.selectNode($span);selection.removeAllRanges();selection.addRange(range);


当然局部浏览器(比方 Chrome104)是有差别的,在删除整个节点后,新输出的内容时会插入一个标签(font)并集成之前的款式。

应用 Range.selectNodeContents() API 选中的是节点外部内容,当删除选中内容后,节点自身还在。

const $span = $input.childNodes[1];range.selectNodeContents($span);selection.removeAllRanges();selection.addRange(range);


以上是对富文本中单个节点的选中操作,然而,实际上富文本中的节点可能是嵌套或者平行的,怎么跨多个元素去选中操作呢?

被动选中富文本的某一区域

表单输入框只有繁多文本,而富文本元素或者一般元素蕴含多个元素。当被动选中页面某一区域,先要创立一个Range对象,可能会跨多个元素,所以要设置选区的起始节点,别离是 Range.setStart() 和 Range.setEnd() 办法。

range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
  • startNode 开始节点
  • startOffset 从 startNode 的开始地位算起的偏移量
  • endNode 完结节点
  • endOffset 从 endNode 的完结地位算起的偏移量

下面是设置选区范畴,而后将其增加到选区并选中 Selection.addRange()。不过,个别在增加前,会革除之前的选区,这时就要用到 Selection.removeAllRanges() 。

const selection = document.getSelection();const range = document.createRange();range.setStart($input.firstChild, 0);range.setEnd($input.firstChild, 4);selection.removeAllRanges();selection.addRange(range);


留神这里取富文本元素的第一个子节点(文本节点),而不是元素自身,否则就会超出偏移量,呈现报错。因为在计算元素节点和文本节点偏移量是有差异的:

如果起始节点类型是 Text、Comment 或 CDATASection 之一,那么 startOffset 指的是从起始节点算起字符的偏移量。 对于其余 Node 类型节点,startOffset 是指从起始结点开始算起子节点的偏移量。

那么问题来了,如果我想选中富文本结尾这一段 <span style="color: #00965e;">Selection</span> 对象,怎么实现呢?
对于比较复杂的富文本元素构造,要实现任意区间选中,要害要找到起始节点和完结节点,以及它们各自的偏移量。

如同没有更好的原生办法能够借助,这里有一种计划,首先遍历出富文本蕴含所有的文本节点,而后记录每个节点边界的偏移量,查找出满足区间条件的起始和完结节点,最初从起始和完结节点中计算各自须要的文本偏移量。

/** * 获取所有文本节点及其偏移量 * @param {HTMLElement} wrapDom 最外层节点 * @param {Number} start 开始地位 * @param {Number} end 完结地位 * @returns */function getNodeAndOffset(wrapDom, start = 0, end = 0) {  const txtList = [];  const map = function (children) {    [...children].forEach((el) => {      if (el.nodeName === '#text') {        txtList.push(el);      } else {        map(el.childNodes);      }    });  };  // 递归遍历,提取出所有 #text  map(wrapDom.childNodes);  // 计算文本的地位区间  const clips = txtList.reduce((arr, item, index) => {    const end =      item.textContent.length + (arr[index - 1] ? arr[index - 1][2] : 0);    arr.push([item, end - item.textContent.length, end]);    return arr;  }, []);  // 查找满足条件的范畴区间  const startNode = clips.find((el) => start >= el[1] && start < el[2]);  const endNode = clips.find((el) => end >= el[1] && end < el[2]);  return [startNode[0], start - startNode[1], endNode[0], end - endNode[1]];}

有了这个咱们本人封装的工具办法,就能够选中任意文字区间,而不必思考富文本的元素构造。

const selection = document.getSelection();const range = document.createRange();const nodes = this.getNodeAndOffset($input, 4, 17);range.setStart(nodes[0], nodes[1]);range.setEnd(nodes[2], nodes[3]);selection.removeAllRanges();selection.addRange(range);

聚焦到某一地位

通过下面已实现选中任意区间文字,再来看将光标聚焦到某一地位,就简略多了,只选将起始和完结范畴地位设置雷同即可。

const selection = document.getSelection();const range = document.createRange();const nodes = this.getNodeAndOffset($input, 7, 7);range.setStart(nodes[0], nodes[1]);range.setEnd(nodes[2], nodes[3]);selection.removeAllRanges();selection.addRange(range);

还原之前的选区

同文本输入框保留以后的光标地位,在富文本里,能够将整个选区都保留下来,而后在前面还原选区。

// 保留光标$input.addEventListener('mouseup', () => {  const selection = document.getSelection();  const range = selection.getRangeAt(0);  this.lastRange = range;});// 还原光标const selection = document.getSelection();const range = document.createRange();selection.removeAllRanges();selection.addRange(this.lastRange);

在指定选区插入(替换)内容

在选区插入内容,能够应用 Range.insertNode() 办法,它示意在选区的起点处插入一个节点,并且不会替换以后曾经选中的,如果须要替换,能够先删除,删除选区能够用 Range.deleteContent() 办法。

const $text = document.createTextNode('');this.lastRange.deleteContents();this.lastRange.insertNode($text);


从下面客服发现,插入内容后,内容是被选区选中状态,如果心愿光标在插入的内容前面,能够应用 Range.setStartAfter() 设置选区的终点为元素的前面,默认选区的起点在元素的前面 Range.setEndAfter(),毋庸设置。

this.lastRange.setStartAfter($text);$input.focus();


同样,也能够应用 Range.setEndBefore 和 setStartBefore将光标设置到内容的后面。

给指定选区包裹标签

还有一些比拟高级的用法利用,比方给某句话加背景标记成果,相似 word 文档文字选中加背景色。能够通过 Range.surroundContents() 办法实现。


不过,入选区蕴含多个元素,也就是断开了一个非 text 节点,只蕴含了节点的其中一个边界,就会抛出异样。
那么怎么能够躲避这个问题,实现跨多个节点选中标记呢?答案是 extractContents(),它会将咱们的选区多节点挪动到 DocumentFragment 对象,须要留神的是

应用 DOM 事件增加的事件监听器在提取期间不会保留。HMTL 属性事件将按 Node.cloneNode() 办法原样保留和复制。HTML id 属性也会被克隆,如果提取了局部选定的节点并将其附加到文档中,则可能导致有效的文档。

用标签包裹该文档片段,而后插入。

const $mark = document.createElement('mark');// this.lastRange.surroundContents($mark)const fragments = this.lastRange.extractContents();$mark.append(fragments);this.lastRange.insertNode($mark);

光标/选区的地位坐标

有时咱们想确定文本区域中被选中的局部或者光标的视窗坐标,以此能够将相似备注或者悬浮框定位到左近。这里能够应用 Range.getBoundingClientRect() API,它返回一个 DOMRect 对象,该对象将范畴中的内容包围起来;即该对象是一个将范畴内所有
元素包围起来的矩形。

const pos = this.lastRange.getBoundingClientRect();const highlight = document.getElementById('highlight');highlight.style.left = `${pos.x}px`;highlight.style.top = `${pos.y}px`;highlight.style.width = `${pos.width}px`;highlight.style.height = `${pos.height}px`;
#highlight {  position: absolute;  background-color: aqua;}


选中了 3 行,但不是残缺的 3 行,给回的信息是一个最小包裹选区的矩形地位坐标,如果还须要晓得选取中每个元素更具体的地位坐标,能够应用 Range.getClientRects(),它返回的是一个 DOMRect 对象列表,示意 Range 在屏幕上所占的区域。这个列表相当于会集了范畴中所有元素调用 Element.getClientRects()办法所失去的后果。

this.lastRange.getClientRects();



Selection&Range 的 API 很多,罕用的大抵以上列举的,同样简略总结一下

@的性能实现

如果你曾经相熟了下面的「基操」,那么对于实现一个@性能曾经胜利了一半,剩下的一半就是思路了,大抵分为如下几步:

  1. 监听用户输出了@字符,展现用户列表;
  2. 点击用户,导致输入框失焦,及时保留光标信息;
  3. 点击实现,用户列表暗藏,复原输入框的光标,而后在光标后插入用户名;

首先咱们写好 HTMLCSS 局部在这里省略

<!-- 富文本聊天音讯输入框 --><div  class="chat-input"  ref="chatInput"  contenteditable="true"  placeholder="请输出内容"  @input="inputChatContent"  @blur="chatContentBlur"  @mouseup="chatContentMouseup"></div><!-- 用户列表浮窗 --><ul  class="popper"  v-show="isShowUserList"  ref="popper"  :style="popperStyle"  v-click-out-hide>  <li v-for="(item, index) in userList" :key="index" @click="selectUser(item)">    <el-row>{{item.name}}</el-row>  </li></ul>

接着咱们监听富文本的input事件,当监听到 @ 字符,展现用户列表,否则暗藏。于此同时,应用 Range.getBoundingClientRect() 获取光标的地位,这样能力将用户列表浮框定位到光标左近。

/** * 输出聊天内容 * @param {*} ev */inputChatContent(ev) {  if (ev.data === '@') {    const pos = this.getCaretPos()    this.showUserList()    this.$nextTick(() => {      this.setUserListPos(pos)    })  } else {    this.hideUserList()  }},/** * 获取光标地位 * @returns */getCaretPos() {  const range = this.getRange()  const pos = range.getBoundingClientRect()  return pos},/** * 设置用户列表的地位 * @param {*} pos */setUserListPos(pos) {  const $popper = this.$refs.popper  const panelWidth = $popper.offsetWidth  const panelHeight = $popper.offsetHeight  const { x, y } = pos  this.popperStyle = {    top: y - panelHeight - 20 + 'px',    left: x - panelWidth / 2 + 'px'  }},hideUserList() {  this.isShowUserList = false},showUserList() {  this.isShowUserList = true},

当点击用户列表时,输入框会失焦,此时先将光标保存起来,同时创立将要插入的用户名文本节点,而后复原光标,再 Range.insetNode() 将用户名文本节点 Range.insetNode() 插入到选区,最初 Selection.removeAllRanges() 移除所有页面选区,Selection.addRange() 插入以后选区。

 /** * 抉择用户 */selectUser(user) {  // 让失焦事件先执行  setTimeout(() => {    this.hideUserList()    this.insertContent(user)  })},/** * 复原光标 */restoreCaret() {  if (this.lastRange) {    const selection = window.getSelection()    selection.removeAllRanges()    selection.addRange(this.lastRange)  }},/** * 插入内容 * @param {*} data */insertContent(data) {  this.restoreCaret() // 还原光标  const selection = window.getSelection()  const range = selection.getRangeAt(0)  range.collapse(false) // 折叠选区,光标移到最初  range.insertNode(data.content)  range.collapse(false)  selection.removeAllRanges()  selection.addRange(range)}

@性能加强版

以上是简略实现了@ 性能,在理论利用中还有更多晋升用户体验的中央

  • 以后通过退回键删除用户名@xxx,发现删除的是一一字符,而用户更多想要的是按一下退回键,删除整个用户名;
  • 发送进来怎么解析成后端能辨认的特定的数据结构,并且特定的数据结构怎么转换回富文本呢?


暂且带这两个问题,接着怎么解决呢?

实现整体删除用户名

首先想到的是用户名插入富文本是一个标签,按一下退回键,就能删除整个标签?答案是不能,富文本标签里的文字在没有选中状况下都是一一删除。除非该元素是不可编辑元素 span.contentEditable = false

这里要将 @xxx放入不可编辑标签中,删除的时候能力一起删。此时会有一个问题,每次插入用户标签后,多出一个@ 字符,所以在之前曾经输出的 @ 字符就须要在插入标签前须要删除掉。具体实现是找到选区所在的开始节点 Range.startContainer,再找到选区完结地位的偏移量 Range.endOffset ,失常状况下选区完结地位的前一个就是 @字符,选中删除即可。

/** * 删除输入框中光标地位现有的@字符 */deleteCaretAtCode() {  const range = this.getRange()  // 光标开始节点和光标在节点上的偏移量,找到光标精确地位,选中光标地位前一个字符范畴并删除,  const node = range.startContainer  const end = range.endOffset  // 开始节点内容最初一个字符是@,删除,否则不删除  if (node.textContent[end - 1] === '@') {    range.setStart(node, end ? end - 1 : 0)    range.deleteContents()  }},/** * 转换要插入光标地位的内容 * @param {*} data */parseContent(data) {  const { type = 'text', name } = data  let content = null  // type 是插入内容类型,可能是文本、@标签、图片、表情等  if (type === 'text') {    content = document.createTextNode(name)  } else if (type === 'at') {    // 删除输入框中光标地位现有的@字符    this.deleteCaretAtCode()    const $span = document.createElement('span')    $span.contentEditable = false    $span.classList.add('tag')    $span.innerHTML = `@${name}`    // 插入一个空格字符(\u0010)到@标签前面,能够解决局部浏览器上光标在聊天输入框前面    const $space = document.createTextNode('\u0010')    const frag = document.createDocumentFragment()    frag.appendChild($span)    frag.appendChild($space)    content = frag  }  return content}, /** * 插入内容 * @param {*} data */insertContent(data) {  this.restoreCaret() // 还原光标  const selection = window.getSelection()  const range = selection.getRangeAt(0)  range.collapse(false) // 折叠选区,光标移到最初  const pc = this.parseContent(data)  range.insertNode(pc)  range.collapse(false)  selection.removeAllRanges()  selection.addRange(range)}

一些兼容解决

可能你曾经留神到了,在用户标签@xxx前面追加了一个空格字符,这样光标能够显示进去,不过在用退格键须要按两次删除标签,这里具体的要看业务须要了。
留神的是在 Mac Chrome(V104)上,如果在标签前面不追加字符,光标会在外层富文本标签前面,在非编辑标签后加任意字符是失常的。
通过测试 Firefox 浏览器有这样的 bug:用户标签@xxx没有正确的插入到富文本输入框的地位,而是插入到了用户列表点击的那一项上了。
大胆揣测 Firefox 将一般元素也能够设置光标,这样点击用户列表某一项元素时,该元素取得光标,再获取新选区 selection.getRangeAt(0)其实是在点击的这个元素上,非复原的富文本输入框上,而后插入用户标签,也就是下面的情景。
这里我的解决办法是将用户列表项设置为不可选中的,天然就无奈获取光标。

.popper li {  /* 用户不能选中文本 firfox 非编辑编辑元素也可选中 */  user-select: none;  -webkit-user-select: none;  list-style: none;  padding: 10px;}


咱们再看看再 Safari 上的体现

发现是没有依照料想的删除掉前置的@字符的,查看控制台有报错

大抵意思是 selection.getRangeAt(0)中第 0 位是超出容许的范畴的,难道在输入框失去焦点的情景下,Safari 默认将选区给清空了?通过试验发现的确如此

// 输入框失去焦点和获取焦点时打印选区个数const selection = window.getSelection();console.log('selection.rangeCount: ', selection.rangeCount);


而失常其余浏览器选区个数放弃不变

那怎么解决这种情景呢?咱们是在输入框失焦的时候保留选区的,此时 Safari 曾经清空了选区,导致这个时候保留的选区是空的,因而咱们要将选区保留前置一下,在输出@字符时候就保留一下选区(光标),能够这样做

/** * 输出聊天内容 * @param {*} ev */inputChatContent(ev) {  if (ev.data === '@') {    // 在输出@字符时候就保留一下光标    this.saveCaret()    const pos = this.getCaretPos()    this.showUserList()    this.$nextTick(() => {      this.setUserListPos(pos)    })  } else {    this.hideUserList()  }},

总结

本文以“完满”实现一个@ 性能为引子,介绍了 input/textarea 文本输入框和富文本的选区操作。并在此基础上联合@ 性能的实现思路,边实际边改良,不乏有很多中央没有思考周全的,仅当作学习的 demo 就好,欢送斧正。本文在线案例能够点这里,完~