原文链接:https://www.hezebin.com/article/6683c5d402a8ac30292a18f7
前言
最近负责了一个对文本划选批注和高亮的功能开发,大致类似钉钉文档的划选评论功能:
第一眼看到这个需求时瞬间压力就拉满了😭,这种程度的交互,对于我这半道出家前端的来说,显得有点复杂 …
一开始,我的重点主要关注在如何获取到划选的文本,以及如何操作 dom 元素去包裹划选的内容,使其呈现出高亮和下划线的样式,好在 JS 本身就有相关的鼠标划选事件和函数支持,很容易查到相关的资料:
// 获取选中的文本
var selectedText = window.getSelection().toString().trim();
进一步还了解到 Selection
下的 Range
本身就支持使用节点元素包裹选中内容:
document.addEventListener('mouseup', function(event) {
// 获取选中的文本
var selectedText = window.getSelection().toString().trim();
// 检查是否有文本被选中
if (selectedText.length > 0) {
// 创建一个span元素并设置高亮样式
var highlightSpan = document.createElement('span');
highlightSpan.className = 'highlight';
// 将选中的文本包装在span元素中
var range = window.getSelection().getRangeAt(0);
range.surroundContents(highlightSpan);
// 清除选中状态
window.getSelection().removeAllRanges();
}
});
至此至少划选改变样式的功能就能实现了,当我欣喜于这功能也没想象中的那么难时,就发现了事情似乎并不简单。若新划选的文本有一部分非高亮,有一部分高亮的情况下,surroundContents
的这种方式会导致html标签的开闭混乱,如下图的情况:
大概就是会使新的 span标签组包裹了一个单独的 span 开始标签,页面就崩溃了:
<span>多服务<span>之间的</span>
且这里包裹一个其他 span 标签元素只是最简单的一种情况,被划选的内容完全可能被多种 html 元素包裹,划选的内容就是一个富文本,而不是单纯的纯文字。所以 surroundContents
没法用了…
至此,一切似乎又回到了原点,目前已知手段仅是获取到划选的 Selection 和 Range 对象,进而获取到划选文本或节点信息。
技术难点
经过上文的一些思考和尝试,罗列出了下述的一些需要解决的关键问题:
- 划选的内容中可能包含部分富文本,高亮或下划线后需要保留原富文本的样式,即不能只取文本内容,需要包裹和梳理出正确的 html 元素关系;
- 划选内容高亮或下划线后,若要取消,想要定位到目标高亮元素,需要给 span 标签带上 id 等唯一标识的属性,然后span 下包裹的绝不仅是单纯的文本,依旧可能有复杂的其他标签节点关系,删除 span 后这些关系不能乱;
- 划选后的按钮要在划选内容附近,批注的输入框要在等高度的右侧;
- 批注(评论)的卡片要和划选内容等高对齐,特别内容长有滚动条的时候不能左边是内容,右边找不到评论内容;若一行中有多段内容被划选评论时,右侧的评论卡片如何定位;
- 划选的内容和评论、高亮这些关系和数据如何存储;
实现思路
1.划选高亮
const selection = window.getSelection();
rangeRef.current = selection.getRangeAt(0).cloneRange();
const span = document.createElement('span');
span.className = 'bjyh-annotate-highlight';
span.setAttribute('id', dayjs().unix().toString());
span.appendChild(rangeRef.current.extractContents()); // 插入选区内容
rangeRef.current.insertNode(span);
关键代码解读:
const selection = window.getSelection();
获取当前用户选择的文本范围。window.getSelection()
返回一个Selection
对象,表示用户当前的文本选择。rangeRef.current = selection.getRangeAt(0).cloneRange();
获取Selection
对象中的第一个Range
对象,并将其克隆存储到rangeRef.current
中。getRangeAt(0)
获取第一个选区。cloneRange()
创建一个与当前选区相同的新选区。const span = document.createElement('span');
创建一个新的span
元素,用来包裹高亮文本。span.className = 'bjyh-annotate-highlight';
为新创建的span
元素设置类名,用于添加样式。span.setAttribute('id', dayjs().unix().toString());
为 span 元素设置唯一的 id 属性。- 使用
dayjs().unix().toString()
获取当前时间的 Unix 时间戳并转换为字符串,确保 id 的唯一性(替换为 uuid 或存储完评论后生成的数据库 id)。 span.appendChild(document.createTextNode(rangeRef.current.toString()));
创建一个新的文本节点,内容为选区的文本,并将其添加到 span 元素中。rangeRef.current.toString()
返回选区中的文本内容。rangeRef.current.deleteContents();
删除选区中的内容。这一步会将选中的文本从原位置删除。rangeRef.current.insertNode(span);
在选区的起始位置插入新的span
元素。由于选区内容已经被删除,因此插入 span 元素后,它会包含之前选中的文本。
上述包含了很多 dom 元素操作,想要完全了解甚至熟悉这些函数没点功底可不行,好在现在有大模型,确实是降低了知识获取的途径和学习门槛,着重要提一下的是当时我比较有疑问的:“appendChild
后为什么原节点会被删除呢?”
appendChild 方法在将子节点添加到新父节点时,会将该子节点从原来的父节点中移除。也就是说,如果你有一个节点 child,它原本是 parent 的子节点,当你执行 newParent.appendChild(child) 时,child 会被从 parent 中移除,并添加到 newParent 中。这是 DOM 操作的内置行为。
这也是能循环处理子节点关系的原因。
2.定位按钮和高亮元素块
划选时通过 Range 对象很容易拿到划选的元素节点,通过节点的 getBoundingClientRect()
方法可以拿到当前节点相较于视窗的定位信息,然后将按钮全局定位到划选内容附近即可。
难点还是在于鼠标 hover 或者 click 已经高亮的元素时,需要显示功能按钮,然后点击按钮后需要精确定位到该元素,以便后续处理取消高亮的操作:
const highlights = element.getElementsByClassName('bjyh-annotate-highlight');
if (highlights) {
for (let i = 0; i < highlights.length; i++) {
const highlight = highlights[i] as HTMLElement;
highlight.addEventListener('click', (e) => {
e.stopPropagation();
// 记录当前操作的高亮块
currentClickHighlight.current = highlight;
annotateBtnWrapperRef.current.style.top = `${e.clientY + 6}px`;
annotateBtnWrapperRef.current.style.left = `${e.clientX + 6}px`;
annotateBtnWrapperRef.current.style.display = 'flex';
});
}
}
这里主要还是通过统一的高亮块类名,来为所有的高亮块添加鼠标事件。
3.取消高亮
const parent = currentClickHighlight.current.parentNode;
const fragment = document.createDocumentFragment();
// 将 span 的所有子元素移动到文档片段中
while (currentClickHighlight.current.firstChild) {
fragment.appendChild(currentClickHighlight.current.firstChild);
}
// 替换 span 元素
parent.replaceChild(fragment, currentClickHighlight.current);
代码解读:
const parent = currentClickHighlight.current.parentNode;
取得currentClickHighlight.current
的父节点。currentClickHighlight.current
是当前被高亮的 span 元素。parentNode
是该 span 元素的父节点。const fragment = document.createDocumentFragment();
创建一个空的文档片段,用来临时存放 span 元素的子节点。文档片段是一个轻量级的 DOM 节点,它不会出现在 DOM 树中,但可以用作 DOM 操作的容器。while (currentClickHighlight.current.firstChild) { fragment.appendChild(currentClickHighlight.current.firstChild); }
使用 while 循环,将currentClickHighlight.current
的所有子节点依次移动到 fragment 中。firstChild
属性获取 currentClickHighlight.current 的第一个子节点。appendChild
方法将子节点添加到fragment
中,并且会将该子节点从原位置移除。parent.replaceChild(fragment, currentClickHighlight.current);
将currentClickHighlight.current
替换为fragment
。replaceChild
方法的第一个参数是要插入的节点,第二个参数是要被替换的节点。- 由于
fragment
中包含了currentClickHighlight.current
的所有子节点,替换后效果等同于将span
元素移除,只保留其子节点。
4.评论卡片定位
富文本内容和评论区左右布局,评论区的高度保持和文档富文本区等高,滚动条出现在两者的父容器上。
每个评论的卡片获取其 id 对应 span 标签的 offsetTop 值,然后在评论区容器内通过绝对定位absolute
来定位。
由于可能存在多个卡片挤在一堆、重叠的问题,在渲染评论区中的卡片列表时,需要记录一个 maxTop 值,若新卡片的 top 值已经小于了 maxTop + 卡片高度
了,那么此时再将新卡片定位在该值处一定会出现卡片覆盖。
最后需要注意评论卡片的展示顺序应该基于评论对应的划选内容的 offsetTop 来排序!
显示评论输入框时,此时右侧可能已经有评论卡片了,此时又出现了输入框覆盖卡片很丑的情况,目前我看钉钉文档的解决方案是将所有的卡片位置都向上下挪动,给输入框腾出空间,但我觉得有点复杂,要多好多处理逻辑,不如干脆显示输入框时,将评论区的卡片列表隐藏了,评论完后再重新渲染显示。
5.数据存储
一开始的数据库表和接口设计时,觉得只存储评论内容和划选文本内容就行了,但发现如此很难将评论和划选的内容关联起来,若根据划选的文本来定位,一方面是将富文本转成纯文本麻烦,另外也没法定位唯一的文本,划选的纯文本完全可能重复。高亮同理。
最终方案是存储了一份整个文档内容的富文本(定开的特殊场景不能改变原富文本内容),该内容就包含了带 id 和 class 的各种高亮、下划线 span 标签,不管是根据标签找到评论,还是根据评论 id 找到标签都方便。