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 是指什么:例如,标签<p>这是一段文本的示例</p>,用户选取的部分是“一段文本”这四个字,这时首尾的 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 树。