网页标注工具的技术原理与实现
阅读网页时,经常需要给某段文字划线、圈出某个区域,或者留下一条笔记。最近整理了一个网页标注项目 annotate,将文本定位、覆盖层渲染和绘图交互提取成独立的 TypeScript 核心包,再通过原生 JavaScript、Vue 示例和 Chrome 扩展接入。
这篇文章主要记录其中的技术原理:一次选区如何变成可以保存的数据,刷新页面后如何找回原文,以及滚动、缩放和布局变化时如何重新绘制标注。
整个工具的预览图

项目结构与数据流
项目采用 pnpm workspace 管理,主要分为四个部分:
| 目录 | 职责 |
|---|---|
packages/core | 文本锚点、覆盖层渲染、绘图手势、可选的评论存储及序列化 |
packages/chrome-extension | 向网页注入标注能力,提供工具栏、笔记编辑、列表和本地导出 |
核心包 @annotate/core 没有运行时依赖,提供 ESM、CJS 和类型声明。它的主要对象包括:
TextAnchorResolver:将文本选区转换为锚点,并将锚点恢复为 Range。OverlayRenderer:根据文本锚点或图形数据绘制标注。DrawingController:处理选区和指针手势,向上层提供草稿。CommentStore:管理评论记录,通过同步存储适配器保存数据。
一次标注的处理过程可以概括为:
原生文本选区 / 指针轨迹
↓
文本锚点 / 图形几何数据
↓
草稿与样式编辑
↓
上层确认并保存标注记录
↓
覆盖层渲染、列表更新渲染器只需要标注 ID、类型、颜色,以及对应的文本锚点或几何数据。作者、回复、账户权限和服务端保存由消费方处理。因此,在已有系统中使用引擎时,可以将业务数据投影成 IAnnotation[],直接传给渲染器。
项目以 reviewjs/annotate 为初始实现基础,保留并改编了部分工具函数、元素定位和评论数据处理代码,之后进行了 TypeScript 模块化、架构重构及功能扩展。项目采用 MIT 许可,保留上游版权声明。
文本选区如何持久化
浏览器中的文本选择可以通过 Selection 和 Range 获取。Range 记录起止 DOM 节点及节点内偏移,适合处理当前页面中的选区。
刷新页面后,DOM 节点会重新创建,所以持久化数据需要能够在新 DOM 中重新定位文本。
建立文本索引
项目使用 TreeWalker 遍历指定内容根中的文本节点,按 DOM 顺序拼接字符串,同时记录每个文本节点在字符串中的起始位置。
例如:
<p data-annotate-block="paragraph-42">学习<strong>网页标注</strong>原理</p>对应的索引可以理解为:
文本节点 起始偏移
学习 0
网页标注 2
原理 6
完整文本:学习网页标注原理选择“网页标注”后,生成的锚点大致如下:
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。
恢复时校验位置和内容
恢复文本标注的步骤是:
- 根据
blockId找到唯一的内容块;没有块 ID 时使用配置的文本根。 - 建立或复用该区域的文本索引。
- 检查
start、end是否有效,以及对应字符串是否等于exact。 - 将字符串偏移映射回具体 Text 节点,构建 Range。
如果位置已经失效,可以通过 allowQuoteFallback: true 开启文本回退查找。Chrome 扩展采用了这个配置。
回退时先查找 exact 的所有出现位置,再用前后文筛选候选。只有一个候选时才能恢复;存在多个候选时返回 ambiguous。如果内容块被删除,则返回 block-missing,不会越过原来的内容块去全页寻找相似文字。
例如,页面里多次出现“点击查看详情”,仅保存这几个字无法明确对应哪个位置。上下文与稳定块 ID 能提高定位可靠性,仍无法消除内容删除、重复和大幅改写带来的歧义。
在自己维护的页面中,适合为段落提供稳定的 data-annotate-block。这个 ID 应来自业务内容身份;使用排序下标时,插入和排序会改变原有映射。
文本标注如何绘制
项目中的 OverlayRenderer 在独立覆盖层中绘制文本装饰,不给原文插入 <mark> 包裹节点。这能减少标注操作对原文 DOM 结构和框架渲染过程的影响。
核心包保留了单独的 paintRange 工具,它会修改 DOM;覆盖层渲染器没有调用该工具。
从 Range 获取布局矩形
一段选区可能跨行、跨节点,也可能包含不同字号。因此,整段选区的外接矩形不足以描述文字的实际位置。
渲染器会遍历 Range 涉及的文本节点,为各节点创建局部 Range,然后调用 getClientRects() 获取布局矩形。
锚点恢复为 Range
↓
提取各文本节点的选中部分
↓
获取布局矩形
↓
合并同一行相接或重叠的矩形
↓
绘制色带、下划线和波浪线合并矩形时还要比较高度、纵向位置和裁剪区域。不同字号或基线的片段需要保留各自的矩形,避免将两行内容错误合并。
高亮色带使用绝对定位元素绘制。下划线支持直线、虚线和波浪线,也可以与背景高亮组合。
坐标转换与裁剪
Range 测量得到视口坐标,而覆盖层挂载在指定容器中。渲染器需要根据容器的位置、滚动和缩放,将测量结果转换到覆盖层的局部坐标。
在嵌套滚动区域中,还需要计算可见范围。项目将文本根、配置的滚动容器、文本祖先的 overflow 裁剪范围,以及 visual viewport 的可见区域取交集,再裁剪标注。
例如,一段文字滚出阅读区域后,它的高亮也应被该区域裁剪。图形标注使用同样的可见区域计算,并通过 SVG 的 clipPath 限制显示范围。
布局变化后的刷新
滚动通常改变屏幕位置;文本修改可能改变锚点位置。项目为这两种变化提供了不同的处理入口:
refresh():安排下一帧重新测量并绘制,保留可复用的锚点解析缓存。invalidate():清除文本索引和解析缓存,再安排绘制。
滚动、窗口尺寸变化、字体加载和元素尺寸变化会触发刷新;相关 DOM mutation 会使索引失效。多次刷新请求通过 requestAnimationFrame 合并到下一帧,减少同一帧中的重复绘制。
这套机制适用于常规横排富文本。旋转、倾斜变换、竖排文字和非矩形裁剪仍有适配边界。持续 transform 动画也可能需要业务侧主动调用 refresh()。
图形标注如何保存坐标
矩形、椭圆、图钉和自由笔使用 SVG 绘制。持久化时,几何数据记录相对于锚定元素边界框的归一化坐标。
假设锚定元素的位置是 (left, top),尺寸是 (width, height),指针位置是 (clientX, clientY),则:
const x = (clientX - left) / width;
const y = (clientY - top) / height;恢复时,重新测量锚定元素,再反向换算:
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 持续接收指针事件。
图形绘制过程包括:
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 管理当前会话,再在上层安排异步保存。
每条标注独立保存,存储键形如:
annotate:item:<编码后的页面 URL>:<标注 ID>写入按页面会话串行排队,界面显示“正在保存”“已保存”或“部分修改未保存”。保存失败时保留页面中的修改,允许用户重试或导出。
异步恢复期间,用户可能已经新增或修改了标注。扩展会先读取已保存记录,再合并当前会话中尚待协调的变更,避免读取结果覆盖刚刚发生的编辑。
删除使用带 deleted: true 的记录。读取时它会覆盖旧页面桶中的同 ID 标注,兼容旧存储格式,避免已删除数据再次出现。
当前页面身份使用完整的 location.href。因此,查询参数和 hash 的变化会形成不同的页面会话。扩展通过定时检查地址变化切换会话,同时监听存储变更并重新读取对应页面的数据。
串行队列和逐条存储可以降低覆盖风险,但当前实现没有跨标签页的版本冲突合并。多个标签页同时编辑同一条记录时,仍可能出现后续写入覆盖前一次写入的情况。
标注列表的数据量处理
“全部标注”面板需要同时处理可见高度和渲染数量。当前面板最大高度为 calc(100vh - 32px),标题、导出栏、保存状态和分页栏不收缩,列表区域独立滚动。
.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 中的文字,不在默认文本遍历的覆盖范围内。内容被删除或定位存在歧义时,需要保留笔记并向用户展示定位失败。
以上是结合当前源码整理的实现说明。具体设备兼容性和大量数据下的性能,需要在对应页面和设备上测量。
相关链接
- 初始实现来源:reviewjs/annotate。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
