对于“文本高亮”这个高亮选区,之前用于标识文本起始位置的信息为childIndex = 2, offset = 14。而现在变为offset = 18(从<p>元素下第一个文本“非”开始计算,经过18个字符后是“文”)。可以看出,这样表示的优点是,不管<p>内部原有的文本节点被<span>(包裹)节点如何分割,都不会影响高亮选区还原时的节点定位。
据此,在序列化时,我们需要一个方法来将文本节点内偏移量“翻译”为其对应的父节点内部的总体文本偏移量:
function getTextPreOffset(root, text) { const nodeStack = [root]; let curNode = null; let offset = 0; while (curNode = nodeStack.pop()) { const children = curNode.childNodes; for (let i = children.length - 1; i >= 0; i--) { nodeStack.push(children[i]); } if (curNode.nodeType === 3 && curNode !== text) { offset += curNode.textContent.length; } else if (curNode.nodeType === 3) { break; } } return offset; }
而还原高亮选区时,需要一个对应的逆过程:
function getTextChildByOffset(parent, offset) { const nodeStack = [parent]; let curNode = null; let curOffset = 0; let startOffset = 0; while (curNode = nodeStack.pop()) { const children = curNode.childNodes; for (let i = children.length - 1; i >= 0; i--) { nodeStack.push(children[i]); } if (curNode.nodeType === 3) { startOffset = offset - curOffset; curOffset += curNode.textContent.length; if (curOffset >= offset) { break; } } } if (!curNode) { curNode = parent; } return {node: curNode, offset: startOffset}; }
3)支持高亮选区的重合
重合的高亮选区带来的一个问题就是高亮包裹元素的嵌套,从而使得 DOM 结构会有较复杂的变动,增加了其他功能(交互)实现与问题排查的复杂度。因此,我在 3.2. 节提到的包裹高亮元素时,会再进行一些稍复杂的处理(尤其是重合选区),以保证尽量复用已有的包裹元素,避免元素的嵌套。
在处理时,将需要包裹的各个文本片段(Text Node)分为三类情况:
完全未被包裹,则直接包裹该部分。
属于被包裹过的文本节点的一部分,则使用.splitText()将其拆分。
是一段完全被包裹的文本段,不需要对节点进行处理。
于此同时,为每个选区生成唯一 ID,将该段文本几点多对应的 ID、以及其由于选区重合所涉及到的其他 ID,都附加包裹元素上。因此像上面的第三种情况,不需要变更 DOM 结构,只用更新包裹元素两类 ID 所对应的 dataset 属性即可。
6. 其他问题
解决以上的一些问题后,“文本划词高亮”就基本可用了。还剩下一些“小修补”,简单提一下。
6.1. 高亮选区的交互事件,例如 click、hover
首先,可以为每个高亮选区生成一个唯一 ID,然后在该选区内所有的包裹元素上记录该 ID 信息,例如用data-highlight-id属性。而对于选取重合的部分可以在data-highlight-extra-id属性中记录重合的其他选区的 ID。
而监听到包裹元素的 click、hover 后,则触发 highlighter 的相应事件,并带上高亮 ID。
6.2. 取消高亮(高亮背景的删除)
由于在包裹时支持选区重合(对应会有上面提到的三种情况需要处理),因此,在删除选取高亮时,也会有三种情况需要分别处理:
直接删除包裹元素。即不存在选区重合。
更新data-highlight-id属性和data-highlight-extra-id属性。即删除的高亮 ID 与 data-highlight-id 相同。
只更新data-highlight-extra-id属性。即删除的高亮 ID 只在 data-highlight-extra-id 中。
6.3. 对于前端生成的动态页面怎么办?
不难发现,这种非耦合的文本高亮功能很依赖于页面的 DOM 结构,需要保证做高亮时的 DOM 结构和还原时的一致,否则无法正确还原出选区的起始节点位置。据此,对“划词”高亮最友好的应该是纯后端渲染的页面,在onload监听中触发高亮选区还原的方法即可。但目前越来越多的页面(或页面的一部分)是前端动态生成的,针对这个问题该怎么处理呢?
我在实际工作中也遇到了类似问题 —— 页面的很多区域是 ajax 请求后前端渲染的。我的处理方式包括如下:
隔离变化范围。将上述代码中的“根节点”从documentElement换为另一个更具体的容器元素。例如我面对的业务会在 id 为 article-container 的<div>内加载动态内容,那么我就会指定这个 article-container 为“根节点”。这样可以最大程度防止外部的 DOM 变动影响到高亮位置的定位,尤其是页面改版。