React 划选评论或高亮功能实现

原文链接: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);

关键代码解读:

  1. const selection = window.getSelection(); 获取当前用户选择的文本范围。
  2. window.getSelection() 返回一个 Selection 对象,表示用户当前的文本选择。
  3. rangeRef.current = selection.getRangeAt(0).cloneRange();获取 Selection 对象中的第一个 Range 对象,并将其克隆存储到 rangeRef.current 中。
  4. getRangeAt(0) 获取第一个选区。
  5. cloneRange() 创建一个与当前选区相同的新选区。
  6. const span = document.createElement('span'); 创建一个新的 span 元素,用来包裹高亮文本。
  7. span.className = 'bjyh-annotate-highlight';为新创建的 span 元素设置类名,用于添加样式。
  8. span.setAttribute('id', dayjs().unix().toString());为 span 元素设置唯一的 id 属性。
  9. 使用 dayjs().unix().toString() 获取当前时间的 Unix 时间戳并转换为字符串,确保 id 的唯一性(替换为 uuid 或存储完评论后生成的数据库 id)。
  10. span.appendChild(document.createTextNode(rangeRef.current.toString()));创建一个新的文本节点,内容为选区的文本,并将其添加到 span 元素中。
  11. rangeRef.current.toString() 返回选区中的文本内容。
  12. rangeRef.current.deleteContents(); 删除选区中的内容。这一步会将选中的文本从原位置删除。
  13. 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);

代码解读:

  1. const parent = currentClickHighlight.current.parentNode;取得 currentClickHighlight.current 的父节点。
  2. currentClickHighlight.current 是当前被高亮的 span 元素。
  3. parentNode 是该 span 元素的父节点。
  4. const fragment = document.createDocumentFragment();创建一个空的文档片段,用来临时存放 span 元素的子节点。文档片段是一个轻量级的 DOM 节点,它不会出现在 DOM 树中,但可以用作 DOM 操作的容器。
  5. while (currentClickHighlight.current.firstChild) { fragment.appendChild(currentClickHighlight.current.firstChild); }使用 while 循环,将 currentClickHighlight.current 的所有子节点依次移动到 fragment 中。
  6. firstChild 属性获取 currentClickHighlight.current 的第一个子节点。
  7. appendChild 方法将子节点添加到 fragment 中,并且会将该子节点从原位置移除。
  8. parent.replaceChild(fragment, currentClickHighlight.current);currentClickHighlight.current 替换为 fragment
  9. replaceChild 方法的第一个参数是要插入的节点,第二个参数是要被替换的节点。
  10. 由于 fragment 中包含了 currentClickHighlight.current 的所有子节点,替换后效果等同于将 span 元素移除,只保留其子节点。

4.评论卡片定位

富文本内容和评论区左右布局,评论区的高度保持和文档富文本区等高,滚动条出现在两者的父容器上。

每个评论的卡片获取其 id 对应 span 标签的 offsetTop 值,然后在评论区容器内通过绝对定位absolute 来定位。

由于可能存在多个卡片挤在一堆、重叠的问题,在渲染评论区中的卡片列表时,需要记录一个 maxTop 值,若新卡片的 top 值已经小于了 maxTop + 卡片高度了,那么此时再将新卡片定位在该值处一定会出现卡片覆盖。

最后需要注意评论卡片的展示顺序应该基于评论对应的划选内容的 offsetTop 来排序!

显示评论输入框时,此时右侧可能已经有评论卡片了,此时又出现了输入框覆盖卡片很丑的情况,目前我看钉钉文档的解决方案是将所有的卡片位置都向上下挪动,给输入框腾出空间,但我觉得有点复杂,要多好多处理逻辑,不如干脆显示输入框时,将评论区的卡片列表隐藏了,评论完后再重新渲染显示。

5.数据存储

一开始的数据库表和接口设计时,觉得只存储评论内容和划选文本内容就行了,但发现如此很难将评论和划选的内容关联起来,若根据划选的文本来定位,一方面是将富文本转成纯文本麻烦,另外也没法定位唯一的文本,划选的纯文本完全可能重复。高亮同理。

最终方案是存储了一份整个文档内容的富文本(定开的特殊场景不能改变原富文本内容),该内容就包含了带 id 和 class 的各种高亮、下划线 span 标签,不管是根据标签找到评论,还是根据评论 id 找到标签都方便。

React实现浏览器自带搜索功能高亮,可以使用 JavaScript 的 `window.find()` 方法来进行查找,并使用 `Selection` API 来高亮匹配的文本。以下是一个实现示例: ```jsx import React, { useState, useEffect } from "react"; const SearchHighlight = () => { const [searchText, setSearchText] = useState(""); useEffect(() => { const handleSearch = () => { const selection = window.getSelection(); if (selection.rangeCount) { const range = selection.getRangeAt(0); const searchRange = range.cloneRange(); searchRange.selectNodeContents(document.body); const searchResult = searchRange.toString().indexOf(searchText); if (searchResult !== -1) { selection.removeAllRanges(); range.setStart(searchRange.startContainer, searchResult); range.setEnd( searchRange.startContainer, searchResult + searchText.length ); selection.addRange(range); } } }; window.addEventListener("keydown", (e) => { if (e.ctrlKey && e.key === "f") { e.preventDefault(); setSearchText(""); setTimeout(() => { const searchInput = document.getElementById("search-input"); searchInput.focus(); searchInput.select(); }, 0); } }); window.addEventListener("mouseup", handleSearch); return () => { window.removeEventListener("keydown", handleSearch); window.removeEventListener("mouseup", handleSearch); }; }, [searchText]); return ( <div> <input type="text" id="search-input" value={searchText} onChange={(e) => setSearchText(e.target.value)} /> </div> ); }; export default SearchHighlight; ``` 在上面的代码中,我们监听了 `keydown` 和 `mouseup` 事件,当用户按下 `Ctrl+f` 快捷键时,会弹出浏览器自带的搜索框,并且清空搜索文本框中的内容。当用户选中了文本后,会触发 `mouseup` 事件,我们就可以使用 `window.getSelection()` 方法获取选中的文本对象,然后使用 `Selection` API 对选中的文本进行高亮处理。 需要注意的是,上面的代码只是一个简单的示例,还有很多细节需要处理,例如当用户取消搜索后需要清除高亮等。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值