网页标注工具的技术原理与实现

发布于 | 分类于 杂项|本文包含AIGC内容

阅读网页时,经常需要给某段文字划线、圈出某个区域,或者留下一条笔记。最近整理了一个网页标注项目 annotate,将文本定位、覆盖层渲染和绘图交互提取成独立的 TypeScript 核心包,再通过原生 JavaScript、Vue 示例和 Chrome 扩展接入。

这篇文章主要记录其中的技术原理:一次选区如何变成可以保存的数据,刷新页面后如何找回原文,以及滚动、缩放和布局变化时如何重新绘制标注。

整个工具的预览图

项目结构与数据流 ​

项目采用 pnpm workspace 管理,主要分为四个部分:

目录职责
packages/core文本锚点、覆盖层渲染、绘图手势、可选的评论存储及序列化
packages/chrome-extension向网页注入标注能力,提供工具栏、笔记编辑、列表和本地导出

核心包 @annotate/core 没有运行时依赖,提供 ESM、CJS 和类型声明。它的主要对象包括:

  • TextAnchorResolver:将文本选区转换为锚点,并将锚点恢复为 Range。
  • OverlayRenderer:根据文本锚点或图形数据绘制标注。
  • DrawingController:处理选区和指针手势,向上层提供草稿。
  • CommentStore:管理评论记录,通过同步存储适配器保存数据。

一次标注的处理过程可以概括为:

text
原生文本选区 / 指针轨迹
        ↓
文本锚点 / 图形几何数据
        ↓
草稿与样式编辑
        ↓
上层确认并保存标注记录
        ↓
覆盖层渲染、列表更新

渲染器只需要标注 ID、类型、颜色,以及对应的文本锚点或几何数据。作者、回复、账户权限和服务端保存由消费方处理。因此,在已有系统中使用引擎时,可以将业务数据投影成 IAnnotation[],直接传给渲染器。

项目以 reviewjs/annotate 为初始实现基础,保留并改编了部分工具函数、元素定位和评论数据处理代码,之后进行了 TypeScript 模块化、架构重构及功能扩展。项目采用 MIT 许可,保留上游版权声明。

文本选区如何持久化 ​

浏览器中的文本选择可以通过 Selection 和 Range 获取。Range 记录起止 DOM 节点及节点内偏移,适合处理当前页面中的选区。

刷新页面后,DOM 节点会重新创建,所以持久化数据需要能够在新 DOM 中重新定位文本。

建立文本索引 ​

项目使用 TreeWalker 遍历指定内容根中的文本节点,按 DOM 顺序拼接字符串,同时记录每个文本节点在字符串中的起始位置。

例如:

html
<p data-annotate-block="paragraph-42">学习<strong>网页标注</strong>原理</p>

对应的索引可以理解为:

text
文本节点       起始偏移
学习           0
网页标注       2
原理           6

完整文本:学习网页标注原理

选择“网页标注”后,生成的锚点大致如下:

js
const anchor = {
  blockId: 'paragraph-42',
  start: 2,
  end: 6,
  exact: '网页标注',
  prefix: '学习',
  suffix: '原理',
};

其中,blockId 指定内容块,start 和 end 记录位置,exact 用于校验原文,prefix 和 suffix 提供上下文。当前实现最多捕获前后各 48 个字符作为上下文。

偏移使用 JavaScript 字符串的 UTF-16 单位。索引保留空白文本节点,不主动 trim,也不为 <br> 或块边界插入虚拟换行。这样可以让捕获和恢复使用相同的文本规则。部分 emoji 占两个 UTF-16 单位,需要避免在外部系统中用另一套字符计数规则重算偏移。

工具栏、输入框、可编辑区域以及 script、style 等内容会被排除。跨越多个内容块的选区则保存为有序的 segments,恢复时得到多个 Range。

恢复时校验位置和内容 ​

恢复文本标注的步骤是:

  1. 根据 blockId 找到唯一的内容块;没有块 ID 时使用配置的文本根。
  2. 建立或复用该区域的文本索引。
  3. 检查 start、end 是否有效,以及对应字符串是否等于 exact。
  4. 将字符串偏移映射回具体 Text 节点,构建 Range。

如果位置已经失效,可以通过 allowQuoteFallback: true 开启文本回退查找。Chrome 扩展采用了这个配置。

回退时先查找 exact 的所有出现位置,再用前后文筛选候选。只有一个候选时才能恢复;存在多个候选时返回 ambiguous。如果内容块被删除,则返回 block-missing,不会越过原来的内容块去全页寻找相似文字。

例如,页面里多次出现“点击查看详情”,仅保存这几个字无法明确对应哪个位置。上下文与稳定块 ID 能提高定位可靠性,仍无法消除内容删除、重复和大幅改写带来的歧义。

在自己维护的页面中,适合为段落提供稳定的 data-annotate-block。这个 ID 应来自业务内容身份;使用排序下标时,插入和排序会改变原有映射。

文本标注如何绘制 ​

项目中的 OverlayRenderer 在独立覆盖层中绘制文本装饰,不给原文插入 <mark> 包裹节点。这能减少标注操作对原文 DOM 结构和框架渲染过程的影响。

核心包保留了单独的 paintRange 工具,它会修改 DOM;覆盖层渲染器没有调用该工具。

从 Range 获取布局矩形 ​

一段选区可能跨行、跨节点,也可能包含不同字号。因此,整段选区的外接矩形不足以描述文字的实际位置。

渲染器会遍历 Range 涉及的文本节点,为各节点创建局部 Range,然后调用 getClientRects() 获取布局矩形。

text
锚点恢复为 Range
        ↓
提取各文本节点的选中部分
        ↓
获取布局矩形
        ↓
合并同一行相接或重叠的矩形
        ↓
绘制色带、下划线和波浪线

合并矩形时还要比较高度、纵向位置和裁剪区域。不同字号或基线的片段需要保留各自的矩形,避免将两行内容错误合并。

高亮色带使用绝对定位元素绘制。下划线支持直线、虚线和波浪线,也可以与背景高亮组合。

坐标转换与裁剪 ​

Range 测量得到视口坐标,而覆盖层挂载在指定容器中。渲染器需要根据容器的位置、滚动和缩放,将测量结果转换到覆盖层的局部坐标。

在嵌套滚动区域中,还需要计算可见范围。项目将文本根、配置的滚动容器、文本祖先的 overflow 裁剪范围,以及 visual viewport 的可见区域取交集,再裁剪标注。

例如,一段文字滚出阅读区域后,它的高亮也应被该区域裁剪。图形标注使用同样的可见区域计算,并通过 SVG 的 clipPath 限制显示范围。

布局变化后的刷新 ​

滚动通常改变屏幕位置;文本修改可能改变锚点位置。项目为这两种变化提供了不同的处理入口:

  • refresh():安排下一帧重新测量并绘制,保留可复用的锚点解析缓存。
  • invalidate():清除文本索引和解析缓存,再安排绘制。

滚动、窗口尺寸变化、字体加载和元素尺寸变化会触发刷新;相关 DOM mutation 会使索引失效。多次刷新请求通过 requestAnimationFrame 合并到下一帧,减少同一帧中的重复绘制。

这套机制适用于常规横排富文本。旋转、倾斜变换、竖排文字和非矩形裁剪仍有适配边界。持续 transform 动画也可能需要业务侧主动调用 refresh()。

图形标注如何保存坐标 ​

矩形、椭圆、图钉和自由笔使用 SVG 绘制。持久化时,几何数据记录相对于锚定元素边界框的归一化坐标。

假设锚定元素的位置是 (left, top),尺寸是 (width, height),指针位置是 (clientX, clientY),则:

js
const x = (clientX - left) / width;
const y = (clientY - top) / height;

恢复时,重新测量锚定元素,再反向换算:

js
const clientX = left + x * width;
const clientY = top + y * height;

矩形和椭圆还会保存归一化宽高;自由笔保存一组归一化点。这里的“归一化”表示相对比例,绘制越出锚定元素时,比例值可能超出 0 到 1。

元素通过 CSS selector 定位。生成路径时优先利用元素 ID,没有 ID 时逐层生成带 nth-of-type 的路径。

归一化坐标可以随元素尺寸变化按比例调整,但它仍然依赖元素身份和布局。CSS 路径可能因 DOM 重排而失效;段落重新换行后,原来圈住的文字也可能离开原有比例位置。对于文字本身,文本锚点能提供更明确的定位信息。

手势与草稿生命周期 ​

DrawingController 使用 Pointer Events 统一处理鼠标、触摸和笔输入,通过 pointerId 跟踪当前手势,并尝试使用 pointer capture 持续接收指针事件。

图形绘制过程包括:

text
pointerdown:确定锚定元素和起点
pointermove:更新轨迹,绘制临时预览
pointerup:生成草稿,交给上层确认
pointercancel / 丢失 capture:取消未完成笔画

文本标注则读取原生 Selection。移动端选区可以继续通过手柄调整,自动捕获模式会等待选区稳定;业务也可以使用显式模式,让用户调整完选区后点击按钮确认。

草稿与已保存记录分别管理。onDraft 通知上层创建草稿,选区调整可以通过 onDraftUpdate 更新同一份草稿。上层保存完成后调用 completeDraft();用户取消时调用 cancelDraft(),清理预览和待执行的选区捕获。

触摸绘制还涉及浏览器滚动和缩放。默认绘制模式使用 touch-action: pinch-zoom,第二个触点出现时取消当前触摸笔画,将缩放交还浏览器。工具或启用状态切换后,需要调用 syncTouchAction(),在下一次手势开始前同步配置。

这些逻辑减少了滚动误落点、取消事件误提交和拖动后兼容 click 的影响。笔输入和掌触识别仍依赖设备及浏览器实际报告的事件。

Chrome 扩展如何接入 ​

扩展采用 Manifest V3,使用 content script 在普通 HTTP、HTTPS 页面以及配置允许的本地文件页面中运行。当前配置只注入顶层页面,使用 storage 权限保存数据。

扩展的 popup 负责开关和偏好配置,向 content script 发送消息;页面内的工具栏、笔记编辑器和标注列表由 content script 创建。

用 Shadow DOM 隔离界面 ​

页面内控件挂载到一个宿主节点的 closed Shadow Root 中,样式一同放入 Shadow DOM。宿主使用固定定位和较高层级,默认让指针事件穿透;具体交互控件重新启用指针事件。

这能减少宿主网页 CSS 对扩展控件的影响。宿主和引擎覆盖层还带有 data-annotate-ui 标记,文本索引和相关交互会排除这些区域,避免将笔记编辑器中的文字再次捕获为原文。

closed 控制的是 Shadow Root 的访问方式,不应据此推导出安全隔离保证。

同步状态与异步保存 ​

核心包的 StorageAdapter 是同步接口,支持内存和 localStorage 等实现。CommentStore 在同步写入成功后更新内存,并通知订阅者;同步写入失败时保留原来的列表。

chrome.storage.local 是异步接口,因此扩展使用 MemoryAdapter 管理当前会话,再在上层安排异步保存。

每条标注独立保存,存储键形如:

text
annotate:item:<编码后的页面 URL>:<标注 ID>

写入按页面会话串行排队,界面显示“正在保存”“已保存”或“部分修改未保存”。保存失败时保留页面中的修改,允许用户重试或导出。

异步恢复期间,用户可能已经新增或修改了标注。扩展会先读取已保存记录,再合并当前会话中尚待协调的变更,避免读取结果覆盖刚刚发生的编辑。

删除使用带 deleted: true 的记录。读取时它会覆盖旧页面桶中的同 ID 标注,兼容旧存储格式,避免已删除数据再次出现。

当前页面身份使用完整的 location.href。因此,查询参数和 hash 的变化会形成不同的页面会话。扩展通过定时检查地址变化切换会话,同时监听存储变更并重新读取对应页面的数据。

串行队列和逐条存储可以降低覆盖风险,但当前实现没有跨标签页的版本冲突合并。多个标签页同时编辑同一条记录时,仍可能出现后续写入覆盖前一次写入的情况。

标注列表的数据量处理 ​

“全部标注”面板需要同时处理可见高度和渲染数量。当前面板最大高度为 calc(100vh - 32px),标题、导出栏、保存状态和分页栏不收缩,列表区域独立滚动。

css
.list {
  flex: 1 1 auto;
  min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
}

列表采用每页 20 条的本地分页,按新到旧的顺序展示。切页后滚动到顶部,删除末页最后一条时回退页码;定位标注时切到对应页。面板隐藏时跳过卡片渲染,导出仍读取全部记录。

分页限制的是卡片 DOM 数量。它不会减少内存中的标注记录,也不会限制页面覆盖层处理的标注数量。

存储读取目前还会获取扩展本地存储中的全部键,再筛选当前页面的记录。如果后续积累了大量页面数据,可以进一步考虑页面索引、按键读取、删除标记清理和存储迁移。覆盖层的视口筛选和增量更新也需要单独评估。

导出与能力边界 ​

扩展支持 JSON 和 Markdown 本地导出。JSON 保存结构化记录,Markdown 整理原文摘录、笔记、来源及标注信息,通过 Blob 和临时对象 URL 触发下载。

笔记在界面中通过文本节点显示,Markdown 导出对元字符进行转义。导出不会因为列表当前停留在某一页而遗漏其他记录。

整个项目的实现涉及三个持续变化的对象:内容、布局和用户输入。文本锚点负责恢复内容位置,覆盖层根据当前布局重新测量,手势控制器维护草稿状态,上层则负责界面和持久化。

当前实现适合常规网页文本和基于元素的图形标注。跨域 iframe、Canvas 内部文字、虚拟列表中未挂载的内容以及网页自身 Shadow DOM 中的文字,不在默认文本遍历的覆盖范围内。内容被删除或定位存在歧义时,需要保留笔记并向用户展示定位失败。

以上是结合当前源码整理的实现说明。具体设备兼容性和大量数据下的性能,需要在对应页面和设备上测量。

相关链接 ​

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。