如何用JS实现“划词高亮标记”的在线笔记功能?(1)

p.s. 选区的重合问题

然而,文本高亮里还有一个比较棘手的需求 —— 高亮区域的重合。举个例子,最开始的演示图(下图)里,第一个高亮区域和第二个高亮区域之间存在重叠部分,即“本区域高”四个字。

js划词高亮笔记功能

这个问题目前来看似乎还不是问题,但在结合下面要提到的一些功能与需求时,就会变成非常麻烦,甚至无法正常运行(一些开源库这块处理也不尽如人意,这也是没有选择它们的一个原因)。这里简单提一下,具体的情况我会放到后续对应的地方再详细说。

  1. 如何实现高亮选区的持久化与还原?

到目前我们已经可以给选中的文本添加高亮背景了。但还有一个大问题:

想象一下,用户辛辛苦苦划了很多重点(高亮),开心地退出页面后,下次访问时发现这些都不能保存时,该有多么得沮丧。因此,如果只是在页面上做“一次性”的文本高亮,那它的使用价值会大大降低。这也就促使我们的“划词高亮”功能要能够保存(持久化)这些高亮选区并正确还原。

持久化高亮选区的核心是找到一种合适的 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 表示该文本节点是其父亲的第几个儿子:

  1. function serialize(textNode, root = document) {
  1. const node = textNode.parentElement;
  1. let childIndex = -1;
  1. for (let i = 0; i < node.childNodes.length; i++) {
  1. if (textNode === node.childNodes[i]) {
  1. childIndex = i;
  1. break;
  1. }
  1. }
  1. const tagName = node.tagName;
  1. const list = root.getElementsByTagName(tagName);
  1. for (let index = 0; index < list.length; index++) {
  1. if (node === list[index]) {
  1. return {tagName, index, childIndex};
  1. }
  1. }
  1. return {tagName, index: -1, childIndex};
  1. }

通过该方法返回的信息,再加上 offset 信息,即定位选取的起始位置,同时也完全可发送给后端进行保存了。

4.2 反序列化 DOM 节点

基于上一节的序列化方法,从后端获取到数据后,可以很容易反序列化为 DOM 节点:

  1. function deSerialize(meta, root = document) {
  1. const {tagName, index, childIndex} = meta;
  1. const parent = root.getElementsByTagName(tagName)[index];
  1. return parent.childNodes[childIndex];
  1. }

至此,我们大体已经解决了两个核心问题,这似乎已经是一个可用版本了。但其实不然,根据实践经验,如果仅仅是上面这些处理,往往是无法应对实际需求的,存在一些“致命问题”。新建一个前端学习qun438905713,在群里大多数都是零基础学习者,大家相互帮助,相互解答,并且还准备很多学习资料,欢迎零基础的小伙伴来一起交流。

但不用灰心,下面会具体来说说所谓的“致命问题”是什么,而又是如何解决并实现一个线上业务可用的通用“划词高亮”功能的。

  1. 如何实现一个生产环境可用的“划词高亮”?

1)上面的方案有什么问题?

首先来看看上面的方案会有什么问题。

当我们需要高亮文本时,会为文本节点包裹span元素,这就改动了页面的 DOM 结构。它可能会导致后续高亮的首尾节点与其 offset 信息其实是基于被改动后的 DOM 结构的。带来的结果有两个:

下次访问时,程序必须按上次用户高亮的顺序还原。

用户不能随意取消(删除)高亮区域,只能按添加顺序从后往前删。

否则,就会有部分的高亮选区在还原时无法定位到正确的元素。

文字可能不好理解,下面我举个例子来直观解释下这个问题。

  1.  

  1. 非常高兴今天能够在这里和大家分享一下文本高亮(在线笔记)的实现方式。
  1.  

对于上面这段 HTML,用户分别按顺序高亮了两个部分:“高兴”和“文本高亮”。那么按照上面的实现方式,这段 HTML 变成了下面这样:

  1.  

  1. 非常
  1. 高兴
  1. 今天能够在这里和大家分享一下
  1. 文本高亮
  1. (在线笔记)的实现方式。
  1.  

对应的两个序列化数据分别为:

  1. // “高兴”两个字被高亮时获取的序列化信息
  1. {
  1. start: {
  1. tagName: ‘p’,
  1. index: 0,
  1. childIndex: 0,
  1. offset: 2
  1. },
  1. end: {
  1. tagName: ‘p’,
  1. index: 0,
  1. childIndex: 0,
  1. offset: 4
  1. }
  1. }
  1. // “文本高亮”四个字被高亮时获取的序列化信息。
  1. // 这时候由于p下面已经存在了一个高亮信息(即“高兴”)。
  1. // 所以其内部 HTML 结构已被修改,直观来说就是 childNodes 改变了。
  1. // 进而,childIndex属性由于前一个 span 元素的加入,变为了 2。
  1. {
  1. start: {
  1. tagName: ‘p’,
  1. index: 0,
  1. childIndex: 2,
  1. offset: 14
  1. },
  1. end: {
  1. tagName: ‘p’,
  1. index: 0,
  1. childIndex: 2,
  1. offset: 18
  1. }
  1. }

可以看到,“文本高亮”这四个字的首尾节点的 childIndex 都被记为 2,这是由于前一个高亮区域改变了

元素下的DOM结构。如果此时“高兴”选区的高亮被用户取消,那么下次再访问页面就无法还原高亮了 —— “高兴”选区的高亮被取消了,

下自然就不会出现第三个 childNode,那么 childIndex 为 2 就找不到对应的节点了。这就导致存储的数据在还原高亮选区时出现问题。

此外,还记得在第三部分末尾提到的高亮选取重合问题么?支持选取重合很容易出现如下的包裹元素嵌套情况:

  1.  

  1. 非常
  1. 高兴
  1. 今天能够在这里和大家分享一下
  1. 文本
  1. 高亮
  1. (在线笔记)的实现方式。
  1.  

这也使得某个文本区域经过多次高亮、取消高亮后,会出现与原 HTML 页面不同的复杂嵌套结构。可以预见,当我们使用 xpath 或 CSS selector 作为 DOM 标识时,上面提到的问题也会出现,同时也使其他需求的实现更加复杂。

到这里可以提一下其他开源库或产品是如何处理选区重合问题的:

开源库 Rangy 有一个 Highlighter 模块可以实现文本高亮,但其对于选区重合的情况是将两个选区直接合并了,这是不合符我们业务需求的。

付费产品 Diigo 直接不允许选区的重合。

Medium.com 是支持选区重合的,体验非常不错,这也是我们产品的目标。但它页面的内容区结构相较我面对的情况会更简单与更可控。

所以如何解决这些问题呢?

2)另一种序列化 / 反序列化方式

我会对第四部分提到的序列化方式进行改进。仍然记录文本节点的父节点 tagName 与 index,但不再记录文本节点在 childNodes 中的 index 与 offset,而是记录开始(结束)位置在整个父元素节点中的文本偏移量。

例如下面这段 HTML:

  1.  

  1. 非常
  1. 高兴
  1. 今天能够在这里和大家分享一下
  1. 文本高亮
  1. (在线笔记)的实现方式。
  1.  

对于“文本高亮”这个高亮选区,之前用于标识文本起始位置的信息为childIndex = 2, offset = 14。而现在变为offset = 18(从

元素下第一个文本“非”开始计算,经过18个字符后是“文”)。可以看出,这样表示的优点是,不管

内部原有的文本节点被(包裹)节点如何分割,都不会影响高亮选区还原时的节点定位。

据此,在序列化时,我们需要一个方法来将文本节点内偏移量“翻译”为其对应的父节点内部的总体文本偏移量:

  1. function getTextPreOffset(root, text) {
  1. const nodeStack = [root];
  1. let curNode = null;
  1. let offset = 0;
  1. while (curNode = nodeStack.pop()) {
  1. const children = curNode.childNodes;
  1. for (let i = children.length - 1; i >= 0; i–) {
  1. nodeStack.push(children[i]);
  1. }
  1. if (curNode.nodeType === 3 && curNode !== text) {
  1. offset += curNode.textContent.length;
  1. }
  1. else if (curNode.nodeType === 3) {
  1. break;
  1. }
  1. }
  1. return offset;
  1. }

而还原高亮选区时,需要一个对应的逆过程:

  1. function getTextChildByOffset(parent, offset) {
  1. const nodeStack = [parent];
  1. let curNode = null;
  1. let curOffset = 0;
  1. let startOffset = 0;
  1. while (curNode = nodeStack.pop()) {
  1. const children = curNode.childNodes;
  1. for (let i = children.length - 1; i >= 0; i–) {
  1. nodeStack.push(children[i]);
  1. }
  1. if (curNode.nodeType === 3) {
  1. startOffset = offset - curOffset;
  1. curOffset += curNode.textContent.length;
  1. if (curOffset >= offset) {
  1. break;
  1. }
  1. }
  1. }
  1. if (!curNode) {
  1. curNode = parent;
  1. }
  1. return {node: curNode, offset: startOffset};
  1. }

3)支持高亮选区的重合

重合的高亮选区带来的一个问题就是高亮包裹元素的嵌套,从而使得 DOM 结构会有较复杂的变动,增加了其他功能(交互)实现与问题排查的复杂度。因此,我在 3.2. 节提到的包裹高亮元素时,会再进行一些稍复杂的处理(尤其是重合选区),以保证尽量复用已有的包裹元素,避免元素的嵌套。

在处理时,将需要包裹的各个文本片段(Text Node)分为三类情况:

完全未被包裹,则直接包裹该部分。

属于被包裹过的文本节点的一部分,则使用.splitText()将其拆分。

是一段完全被包裹的文本段,不需要对节点进行处理。

于此同时,为每个选区生成唯一 ID,将该段文本几点多对应的 ID、以及其由于选区重合所涉及到的其他 ID,都附加包裹元素上。因此像上面的第三种情况,不需要变更 DOM 结构,只用更新包裹元素两类 ID 所对应的 dataset 属性即可。

  1. 其他问题

解决以上的一些问题后,“文本划词高亮”就基本可用了。还剩下一些“小修补”,简单提一下。

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 的

内加载动态内容,那么我就会指定这个 article-container 为“根节点”。这样可以最大程度防止外部的 DOM 变动影响到高亮位置的定位,尤其是页面改版。

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

最后:

总结来说,面试成功=基础知识+项目经验+表达技巧+运气。我们无法控制运气,但是我们可以在别的地方花更多时间,每个环节都提前做好准备。

面试一方面是为了找到工作,升职加薪,另一方面也是对于自我能力的考察。能够面试成功不仅仅是来自面试前的临时抱佛脚,更重要的是在平时学习和工作中不断积累和坚持,把每个知识点、每一次项目开发、每次遇到的难点知识,做好积累,实践和总结。

点击这里领取Web前端开发经典面试题

6745320)]

[外链图片转存中…(img-CAM8XapH-1713766745321)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

[外链图片转存中…(img-7cuYmKjz-1713766745321)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

[外链图片转存中…(img-RhE8xZQK-1713766745322)]

最后:

总结来说,面试成功=基础知识+项目经验+表达技巧+运气。我们无法控制运气,但是我们可以在别的地方花更多时间,每个环节都提前做好准备。

面试一方面是为了找到工作,升职加薪,另一方面也是对于自我能力的考察。能够面试成功不仅仅是来自面试前的临时抱佛脚,更重要的是在平时学习和工作中不断积累和坚持,把每个知识点、每一次项目开发、每次遇到的难点知识,做好积累,实践和总结。

点击这里领取Web前端开发经典面试题

  • 5
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值