1. 什么是“划词高亮”?
有些同学或许不太清楚“划词高亮”是指什么,下面便是一个典型的“划词高亮”:
用户挑选一段文本(即划词),即会主动将这段选取的文本增加高亮布景,用户能够很便利地为网页增加在线笔记。
笔者前段时刻为线上事务完结了一个与内容结构非耦合的文本高亮在线笔记功用。非耦合是指不需求为高亮功用树立特别的页面 DOM 结构,而高亮功用对事务近乎通明。该功用中心部分具有较强的通用性与移植性,故拿出来和咱们共享沟通一下。
本文具体的中心代码已封装成独立库 web-highlighter,阅览中如有疑问可参阅其间代码↓↓。
2. 完结“划词高亮”需求处理哪些问题?
完结一个“划词高亮”的在线笔记功用需求处理的中心问题有两个:
加高亮布景。即怎样依据用户在网页上的选取,为相应的文本增加高亮布景;
高亮区域的耐久化与复原。即怎样保存用户高亮信息,并在下次阅览时精确复原,不然下次翻开页面用户高亮的信息就丢掉了。
一般来说,划词高亮的事务需求方主要是针对自己产出的内容,你能够比较简略对内容在网页上的排版、HTML 标签等方面进行操控。这种状况下,处理高亮需求会更便利一些,究竟自己能够依据高亮需求调整现有内容的 HTML。
而笔者面临的状况是,页面 HTML 排版结构杂乱,且无法依据高亮需求来推进事务改动 HTML。这也催生出了对处理计划更通用化的要求,方针便是:针对恣意内容均可“划词高亮”并支撑后续拜访时复原高亮状况,而不必去关怀内容的安排结构。
下面就来具体说说,怎样处理上面的两个中心问题。
3. 怎样“加高亮布景”?
依据动图演示咱们能够知道,用户挑选某一段文本(下文称为“用户选区”)后,咱们会给这段文本加一个高亮布景。
例如用户挑选了上图中的文本(即蓝色部分)。为其加高亮的根本思路如下:
获取选中的文本节点:经过用户挑选的区域信息,获取一切被选中的一切文本节点;
为文本节点增加布景色:给这些文本节点包裹一层新的元素,该元素具有指定的布景色彩。
3.1. 怎样获取选中的文本节点?
1)Selection API
需求依据阅览器为咱们供给的 Selection API 。它的兼容性还不错。假如要支撑更低版别的阅览器则需求用 polyfill。
Selection API 能够回来一系列关于用户选区的信息。那么是不是能够经过它直接获取选取中的一切 DOM 元素呢?
很惋惜并不能。但好在它能够回来选区的首尾节点信息:
- const range = window.getSelection().getRangeAt(0);
- const start = {
- node: range.startContainer,
- offset: range.startOffset
- };
- const end = {
- node: range.endContainer,
- offset: range.endOffset
- };
Range 方针包含了选区的开端与完毕信息,其间包含节点(node)与文本偏移量(offset)。节点信息不必多说,这儿解说一下 offset 是指什么:例如,标签
这是一段文本的示例
,用户选取的部分是“一段文本”这四个字,这时首尾的 node 均为 p 元素内的文本节点(Text Node),而 startOffset 和 endOffset 别离为 2 和 6。
2)首尾文本节点拆分
了解了 offset 的概念后,天然就发现有个问题需求处理。因为用户选区(selection)或许只包含一个文本节点的一部分(即 offset 不为 0),所以咱们最终得到的用户选区所包含的节点里,也只期望有首尾文本节点的这“一部分”。对此,咱们能够运用 .splitText() 拆分文本节点:
- // 首节点
- if (curNode === $startNode) {
- if (curNode.nodeType === 3) {
- curNode.splitText(startOffset);
- const node = curNode.nextSibling;
- selectedNodes.push(node);
- }
- }
- // 尾节点
- if (curNode === $endNode) {
- if (curNode.nodeType === 3) {
- const node = curNode;
- node.splitText(endOffset);
- selectedNodes.push(node);
- }
- }
以上代码会依据 offset 对文本节点进行拆分。关于开端节点,只需求搜集它的后半部分;而关于完毕节点则是前半部分。
3)遍历 DOM 树
到现在为止,咱们精确找到了首尾节点,所以下一步便是找出“中心”一切的文本节点。这就需求遍历 DOM 树。
“中心”加上引号是因为,在视觉上这些节点是坐落首尾之间的,但因为 DOM 不是线性结构而是树形结构,所以这个“中心”换成程序语言,便是指深度优先遍历时,坐落首尾两节点之间的一切文本节点。DFS 的办法有许多,能够递归,也能够用栈+循环,这儿就不赘述了。
需求提一下的是,因为咱们是要为文本节点增加高亮布景,因而在遍历时只会搜集文本节点。
- if (curNode.nodeType === 3) {
- selectedNodes.push(curNode);
- }
3.2. 怎样为文本节点增加布景色?
这一步自身并不困难。在上一步的基础上,咱们现已选出了一切被用户选中的 文本节点(包含拆分后的首尾节点)。对此,一个最直接的办法便是为其“包裹上”一个带布景款式的元素。
具体的,咱们能够给每个文本节点外加上一个 class 为 highlight 的 元素;而布景款式则经过 CSS .highlight 挑选器设置。
- // 运用上一步中封装的办法获取选区内的文本节点
- const nodes = getSelectedNodes(start, end);
- nodes.forEach(node => {
- const wrap = document.createElement('span');
- wrap.setAttribute('class', 'highlight');
- wrap.appendChild(node.cloneNode(false));
- node.parentNode.replaceChild(wrap);
- });
- .highlight {
- background: #ff9;
- }
这样就能够给被选中的文字增加一个“永久”的高亮布景了。
p.s. 选区的重合问题
可是,文本高亮里还有一个比较扎手的需求 —— 高亮区域的重合。举个比如,最开端的演示图(下图)里,第一个高亮区域和第二个高亮区域之间存在堆叠部分,即“本区域高”四个字。
这个问题现在来看好像还不是问题,但在结合下面要说到的一些功用与需求时,就会变成十分费事,乃至无法正常运转(一些开源库这块处理也不尽善尽美,这也是没有挑选它们的一个原因)。这儿简略提一下,具体的状况我会放到后续对应的当地再具体说。
4. 怎样完结高亮选区的耐久化与复原?
到现在咱们现已能够给选中的文本增加高亮布景了。但还有一个大问题:
幻想一下,用户辛辛苦苦划了许多要点(高亮),开心肠退出页面后,下次拜访时发现这些都不能保存时,该有多么得懊丧。因而,假如只是在页面上做“一次性”的文本高亮,那它的运用价值会大大下降。这也就促进咱们的“划词高亮”功用要能够保存(耐久化)这些高亮选区并正确复原。
耐久化高亮选区的中心是找到一种适宜的 DOM 节点序列化办法。
经过第三部分能够知道,当确认了首尾节点与文本偏移(offset)信息后,即可为其间文本节点增加布景色。其间,offset 是数值类型,要在服务器保存它天然没有问题;可是 DOM 节点不同,在阅览器中保存它只需求赋值给一个变量,但想在后端保存所谓的 DOM 则不那么直接了。
4.1 序列化 DOM 节点标识
所以这儿的中心点便是找到一种办法,能够定位 DOM 节点,一起能够被保存成一般的 JSON Object,用以传给后端保存,这个进程在本文中被称为 DOM 标识 的“序列化”。而下次用户拜访时,又能够从后端取回,然后“反序列化”为对应的 DOM 节点。
有几种常见的办法来标识 DOM 节点:
运用 xPath
运用 CSS Selector 语法
运用 tagName + index
这儿挑选了运用第三种办法来快速完结。需求留意一点,咱们经过 Selection API 取到的首尾节点一般是文本节点,而这儿要记载的 tagName 和 index 都是该文本节点的父元素节点(Element Node)的,而 childIndex 表明该文本节点是其父亲的第几个儿子:
- function serialize(textNode, root = document) {
- const node = textNode.parentElement;
- let childIndex = -1;
- for (let i = 0; i < node.childNodes.length; i++) {
- if (textNode === node.childNodes[i]) {
- childIndex = i;
- break;
- }
- }
- const tagName = node.tagName;
- const list = root.getElementsByTagName(tagName);
- for (let index = 0; index < list.length; index++) {
- if (node === list[index]) {
- return {tagName, index, childIndex};
- }
- }
- return {tagName, index: -1, childIndex};
- }
经过该办法回来的信息,再加上 offset 信息,即定位选取的开端方位,一起也彻底可发送给后端进行保存了。
4.2 反序列化 DOM 节点
依据上一节的序列化办法,从后端获取到数据后,能够很简略反序列化为 DOM 节点:
- function deSerialize(meta, root = document) {
- const {tagName, index, childIndex} = meta;
- const parent = root.getElementsByTagName(tagName)[index];
- return parent.childNodes[childIndex];
- }
至此,咱们大体现已处理了两个中心问题,这好像现已是一个可用版别了。但其实不然,依据实践经历,假如仅仅是上面这些处理,往往是无法应对实践需求的,存在一些“丧命问题”。
但不必悲观,下面会具体来说说所谓的“丧命问题”是什么,而又是怎样处理并完结一个线上事务可用的通用“划词高亮”功用的。
5. 怎样完结一个出产环境可用的“划词高亮”?
1)上面的计划有什么问题?
首要来看看上面的计划会有什么问题。
当咱们需求高亮文本时,会为文本节点包裹span元素,这就改动了页面的 DOM 结构。它或许会导致后续高亮的首尾节点与其 offset 信息其实是依据被改动后的 DOM 结构的。带来的成果有两个:
下次拜访时,程序有必要按前次用户高亮的次序复原。
用户不能随意撤销(删去)高亮区域,只能按增加次序从后往前删。
不然,就会有部分的高亮选区在复原时无法定位到正确的元素。
文字或许欠好了解,下面我举个比如来直观解说下这个问题。
- <p>
- 十分快乐今日能够在这儿和咱们共享一下文本高亮(在线笔记)的完结办法。
- </p>
关于上面这段 HTML,用户别离按次序高亮了两个部分:“快乐”和“文本高亮”。那么依照上面的完结办法,这段 HTML 变成了下面这样:
- <p>
- 十分
- <span class="highlight">快乐</span>
- 今日能够在这儿和咱们共享一下
- <span class="highlight">文本高亮</span>
- (在线笔记)的完结办法。
- </p>
对应的两个序列化数据别离为:
- // “快乐”两个字被高亮时获取的序列化信息
- {
- start: {
- tagName: 'p',
- index: 0,
- childIndex: 0,
- offset: 2
- },
- end: {
- tagName: 'p',
- index: 0,
- childIndex: 0,
- offset: 4
- }
- }
- // “文本高亮”四个字被高亮时获取的序列化信息。
- // 这时候因为p下面现已存在了一个高亮信息(即“快乐”)。
- // 所以其内部 HTML 结构已被修正,直观来说便是 childNodes 改变了。
- // 然后,childIndex特点因为前一个 span 元素的参加,变为了 2。
- {
- start: {
- tagName: 'p',
- index: 0,
- childIndex: 2,
- offset: 14
- },
- end: {
- tagName: 'p',
- index: 0,
- childIndex: 2,
- offset: 18
- }
- }
能够看到,“文本高亮”这四个字的首尾节点的 childIndex 都被记为 2,这是因为前一个高亮区域改变了
元素下的DOM结构。假如此刻“快乐”选区的高亮被用户撤销,那么下次再拜访页面就无法复原高亮了 —— “快乐”选区的高亮被撤销了,
下天然就不会呈现第三个 childNode,那么 childIndex 为 2 就找不到对应的节点了。这就导致存储的数据在复原高亮选区时呈现问题。
此外,还记得在第三部分结尾说到的高亮选取重合问题么?支撑选取重合很简略呈现如下的包裹元素嵌套状况:
- <p>
- 十分
- <span class="highlight">快乐</span>
- 今日能够在这儿和咱们共享一下
- <span class="highlight">
- 文本
- <span class="highlight">高亮</span>
- </span>
- (在线笔记)的完结办法。
- </p>
这也使得某个文本区域经过屡次高亮、撤销高亮后,会呈现与原 HTML 页面不同的杂乱嵌套结构。能够预见,当咱们运用 xpath 或 CSS selector 作为 DOM 标识时,上面说到的问题也会呈现,一起也使其他需求的完结愈加杂乱。
到这儿能够提一下其他开源库或产品是怎样处理选区重合问题的:
开源库 Rangy 有一个 Highlighter 模块能够完结文本高亮,但其关于选区重合的状况是将两个选区直接兼并了,这是不合符咱们事务需求的。
付费产品 Diigo 直接不允许选区的重合。
Medium.com 是支撑选区重合的,体会十分不错,这也是咱们产品的方针。但它页面的内容区结构相较我面临的状况会更简略与更可控。
所以怎样处理这些问题呢?
2)另一种序列化 / 反序列化办法
我会对第四部分说到的序列化办法进行改善。依然记载文本节点的父节点 tagName 与 index,但不再记载文本节点在 childNodes 中的 index 与 offset,而是记载开端(完毕)方位在整个父元素节点中的文本偏移量。
例如下面这段 HTML:
- <p>
- 十分
- <span class="highlight">快乐</span>
- 今日能够在这儿和咱们共享一下
- <span class="highlight">文本高亮</span>
- (在线笔记)的完结办法。
- </p>
关于“文本高亮”这个高亮选区,之前用于标识文本开端方位的信息为childIndex = 2, offset = 14。而现在变为offset = 18(从
元素下第一个文本“非”开端核算,经过18个字符后是“文”)。能够看出,这样表明的长处是,不论
内部原有的文本节点被(包裹)节点怎样切割,都不会影响高亮选区复原时的节点定位。
据此,在序列化时,咱们需求一个办法来将文本节点内偏移量“翻译”为其对应的父节点内部的整体文本偏移量:
- 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. 关于BETWAY登录生成的动态页面怎样办?
不难发现,这种非耦合的文本高亮功用很依赖于页面的 DOM 结构,需求确保做高亮时的 DOM 结构和复原时的共同,不然无法正确复原出选区的开端节点方位。据此,对“划词”高亮最友爱的应该是纯后端烘托的页面,在onload监听中触发高亮选区复原的办法即可。但现在越来越多的页面(或页面的一部分)是BETWAY登录动态生成的,针对这个问题该怎样处理呢?
我在实践作业中也遇到了类似问题 —— 页面的许多区域是 ajax 恳求后BETWAY登录烘托的。我的处理办法包含如下:
阻隔改变规模。将上述代码中的“根节点”从documentElement换为另一个更具体的容器元素。例如我面临的事务会在 id 为 article-container 的
内加载动态内容,那么我就会指定这个 article-container 为“根节点”。这样能够最大程度避免外部的 DOM 改变影响到高亮方位的定位,尤其是页面改版。
确认高亮选区的复原机遇。因为内容或许是动态生成,所以需求比及该部分的 DOM 烘托完结后再调用复原办法。假如有露出的监听事情能够在监听内处理;或许经过 MutationObserver 监听标志性元从来判别该部分是否加载完结。
记载事务内容信息,应对内容区改版。内容区的 DOM 结构更改算是“毁灭性冲击”。怎样的确有该类状况,能够测验让事务内容展现方将阶段信息等具体的内容信息绑定在 DOM 元素上,而我在高亮时取出这些信息来冗余存储,改版后能够经过这些内容信息“刷”一遍存储的数据。
6.4. 其他
篇幅问题,还有其他细节的问题就不在这篇文章里共享了。具体内容能够参阅 web-highlighter 这个仓库里的完结。
7. 总结
本文先从“划词高亮”功用的两个中心问题(怎样高亮用户选区的文本、怎样将高亮选区复原)切入,依据 Selection API、深度优先遍历和 DOM 节点标识的序列化这些手法完结了“划词高亮”的中心功用。可是,该计划依然存在一些实践问题,因而在第 5 部分进一步给出了相应的处理计划。
依据实践开发的经历,我发现处理上述几个“划词高亮”中心问题的代码具有必定通用性,因而把中心部分的源码封装成了独立的库 web-highlighter,保管在 github,也能够经过 npm 装置。
其已服务于线上产品事务,根本的高亮功用一行代码即可敞开:
- (new Highlighter()).run();
兼容IE 10/11、Edge、Firefox 52+、Chrome 15+、Safari 5.1+、Opera 15+。
转载请注明: 文章转载自:BETWAY官网网 https://www.nucmc.com/show-12-1126-1.html