关于网页文本选中标记的功能

在线体验地址

github

npm

前言

当前功能的来源是有一个功能需要给一个在线的 word,可以增加选中数据或者文本,然后再加上备注内容,并且在第二次打开同一个 word 的时候,要把之前打的标记回显出来。

但是找了一圈并没有找到可以直接在线编辑 word 的插件,所以不得不换一种方式,将 word 文档转成 html 文件,然后在网页上进行文本选中标记的功能。

思考

对于上面的需求,我们可以梳理下需要实现的功能点:

  • 支持跨标签选中
  • 支持对同一内容多次标记
  • 支持可以选中已经标记的内容
  • 支持删除标记
  • 支持标记回显
  • 支持标记数据导出

最开始的时候,是想给选中文本框出来,然后给选中的文本增加一个新的标签,比如下:

<div>这是一个测试文本</div>

<!-- 选中 测试 并替换 -->
<div>这是一个<span class="highlight">测试</span>文本</div>

上面这种方式对单独语句或者标记区域不会重复是大概可以满足的,但是显然是不能满足上面的那个需求。比如下:

<div>
  <div>
    <span>div1-1</span>
    <span>div1-2</span>
  </div>
</div>
<div>
  <div>
    <span>div2-1</span>
    <span>div2-2</span>
  </div>
</div>
<div>
  <div>
    <span>div3-1</span>
    <span>div3-2</span>
  </div>
</div>

如果在上面的 dom 结构中,我们选中的文本是 1-2div2-1div2-2div3,那么这个选中区域包括了第一个大段和第三个大段的一部分,和一整个第二大段,如果我们提取文本建一个新的标签包裹,会造成页面结构破坏,如果单独给每个文本增加一个标签,一是太麻烦,还有就是其他的功能能不能完成还不好说。

canvas 方案

所以上面方案被放弃,后面选用的方案是:

  • 标记的部分的显示采用 canvas 来绘制
  • 页面选中的所有信息使用 Selection 和 Range 来获取

标记

canvas 的操作先不说了,就是对 selection 的区域使用 canvas 填充颜色,不熟悉 selection 和 range 的小伙伴可以先熟悉下:

关于网页文本选中标记的功能

在选中文本后,我们可以使用 window.getSelection() 来获取选中的信息,里面的节点是信息是具体到 Text 节点,和每个文本节点相对于选中文本的偏移量。

关于网页文本选中标记的功能

range 信息是用 selection.getRangeAt API 来获取的,这里是一个具体的区域信息,开始和结束文本节点和偏移量。然后我们还可以使用 Range.getClientRects API 获取每个具体区域,因为如果文本在换行之后,虽然还是同一个文本节点,但是区域却有多个:

关于网页文本选中标记的功能

这个 getClientRects 在跨标签使用的时候,有点小问题,就是跨标签区域的时候会有两个相同的 rect 数据,如果我们直接拿这个区域数据去绘制,是有可能重复绘制的,上图的第 2 个和第 3 个就重复了,这里需要处理下。

Range.getClientRects 和 HTMLElement.getBoundingClientRect 获取的数据结构是一样的,都是相对于可视区域的坐标数据。

当我们拿到选中区域相对出口的位置后,然后我们就要计算相对于整个内容的位置数据,所以这里需要再去获取 parentElement 相对可视区域的位置,然后进行计算。这个的结构相当于下图:

关于网页文本选中标记的功能

所以选中区域相对于父级元素的位置为 y = curTextRange.top – parentElementRect.top,同理 x 的距离也可以这样计算:x = curTextRange.left – parentElementRect.left

const selection = window.getSelection()
const range = selection.getRangeAt(0)
const parentRect = container.getBoundingClientRect()
const clientRects = range.getClientRects()

for (let i = 0; i < clientRects.length; i++) {
  const rect = clientRects[i]
  const x = rect.left - parentRect.left
  const y = rect.top - parentRect.top
  const width = rect.right - rect.left
  const height = rect.bottom - rect.top

  // canvas 绘制
  ctx.fillRect(x, y, width, height)
}

到这里,其实当前选中高亮的功能已经完成了。

存储

因为在获取 Range 的数据是包含了 Text 节点,这个又是标记区域的关键数据,但是又不能存储,所以我们只能根据其他的信息来尽量在回显的时候来能够找到原本的 Text 节点。所以最好的方式是,每个标签都能有一个唯一的 id 或者属性能够匹配到。

显然我们上面的那个需求不能够满足,我们只能牺牲一部分精度,用文本匹配的方式用来第二次查找。

<div>
  <span>testa</span>
  <span>testb</span>
  <span>testc</span>
</div>

我们用 Range 获取到的是 testa / testb / testc 等文本节点,如果我们只使用这个文本节点来进行匹配,那么这个重复的概率太大了。打个比方:如果这里的 html 内容是大量的数据,比如每个季度每个商品的费用等,重复概率会很大,如果是对这些数据标记备注啥的,会造成标记混乱,虽然没有唯一 id 用来匹配,重复不能完全避免,但也尽量降低重复的概率,所以我们把匹配的文本再往上提一级,也就是如果对 testb 进行标记,如果 testa 变成了 testd,那么也认为这个 textb 的匹配失效。

标记回显

我们根据存储的信息找到原 Text 节点后,但是 Range 的信息是没有的,所以我们需要用 document.createRange API 来创建一个新的 Range,然后根据 Range 拿到对应的数据。

const range = document.createRange()
range.setStart(Text, startOffset)
range.setEnd(Text, endOffset)

懒加载

在测试一个比较大的文档(60000px的高度)的时候,如果 canvas 也是同样的高度,会造成 canvas 打标记失效的情况,这个是因为 canvas 太大,在标记和清空画布的时候,会有渲染问题,并且也比较耗时,所以在比较大的文档的时候,canvas 只绘制可视区域,为了在页面滚动的时候,降低用户感知,canvas 的最高高度设置为当前可视区域的三倍,上面和下面各隐藏一屏,并且在滚动的时候使用防抖策略,降低性能消耗。

这样可选区域的高亮标记位置的重新计算:

关于网页文本选中标记的功能

具体计算方式是:y = curTextRange.top – parentElementRect.top – canvas.translateY

const selection = window.getSelection()
const range = selection.getRangeAt(0)
const parentRect = container.getBoundingClientRect()
const clientRects = range.getClientRects()

for (let i = 0; i < clientRects.length; i++) {
  const rect = clientRects[i]
  const x = rect.left - parentRect.left
  const y = rect.top - parentRect.top
  const width = rect.right - rect.left
  const height = rect.bottom - rect.top
  
  const translateY = getCanvasTranslateY(ctx.canvas)
  const ay = y - translateY

  // canvas 绘制
  ctx.fillRect(x, ay, width, height)
}

到这里整个网页文本标记的功能就差不多完成了,不知道还有没有更好的方式,如果有小伙伴有更好的方案,也包括在线 word 编辑的需求,欢迎在评论区交流。

原文链接:https://juejin.cn/post/7239725302015377468 作者:对半

(2)
上一篇 2023年6月2日 上午10:44
下一篇 2023年6月2日 上午10:55

相关推荐

发表回复

登录后才能评论