划词高亮原理与实现
AI 参与说明(Agent:Devin):本文依据
tcitry/tcitry.github.io的main分支源码(含 #328–#331)整理,描述的是当前 yindongliang.com 读者端划词高亮与写笔记的实现;文中选择器、事件名、字段和上限均从源码读出。整理日期 2026-10-30,执行入口 Linear;模型完整标识与 reasoning effort 未取得任务级运行记录。
在 yindongliang.com 的文章页里选中一段文字,右下角助手头像旁会展开两个图标:高亮和写笔记。点高亮,选区背景变黄,刷新后还在;点写笔记,右侧面板切到「笔记」,引文已经放好,只差写下内容。之后再点这段黄色文字,会出现一个「取消高亮」按钮。
这些交互背后有四件事:从浏览器选区里取出一段稳定的文本,把工具放到合适的位置,把它按账户保存到后端,以及下次打开页面时找回原来那几个字并重新涂上颜色。本文按这个顺序讲。
先认识几个名称
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Selection | 选区 | 浏览器记录的用户当前选中范围,可包含一个或多个 Range |
| Range | 范围 | 由起点和终点两个 DOM 边界点(容器节点 + 偏移)组成的一段文档 |
| Text Quote Selector | 文本引用定位 | 用“原文 + 前文 + 后文”而不是 DOM 路径来记录选中位置的方法 |
| TreeWalker | 节点遍历器 | 按过滤规则依次访问 DOM 节点的标准接口 |
| Repaint | 回绘 | 页面加载后按保存的引文重新包裹 <mark>,让高亮重新出现 |
| Mutation | 变更函数 | Convex 中修改数据库的服务端函数 |
| Query | 查询函数 | Convex 中只读、可订阅实时更新的服务端函数 |
整体结构
划词功能拆在三层。src/scripts/selection-tools.mjs 是不依赖框架的 DOM 脚本,负责监听选区、显示扇形工具,把用户动作作为 CustomEvent 抛出。src/components/reader/ReaderAnnotations.tsx 是挂在助手头像里的 React 组件,处理登录状态、草稿和写入队列。Convex 的 convex/reader.ts 保存 textAnnotations,并只返回当前登录者自己的数据。
flowchart TD
A["article.markdown<br/>正文 DOM"] -->|selectionchange / pointerup| B["selection-tools.mjs<br/>captureSelection"]
B -->|"blog:text-annotation"| C["ReaderAnnotations<br/>AnnotationIntent"]
B -->|"blog:text-annotation-clear"| R["AnnotationRemover"]
C -->|未登录| S["sessionStorage<br/>blog-annotation-draft"]
C -->|高亮| W["AnnotationWriter<br/>createTextAnnotation"]
C -->|写笔记| P["笔记 tab<br/>TextAnnotationsPanel"]
P -->|保存笔记| W
W --> DB[("Convex<br/>textAnnotations")]
R -->|deleteTextAnnotation| DB
DB -->|listPageTextAnnotations| G["AnnotationPainter<br/>paintAnnotations"]
G --> A
DOM 脚本和 React 之间只用三个文档级事件通信:blog:text-annotation(高亮或写笔记)、blog:text-annotation-clear(取消高亮)和 blog:open-assistant(打开面板并切到指定 tab)。这样划词脚本不需要等 React、Clerk 或 Convex 加载完成,也不持有任何凭据。
正文选区捕获
哪些页面、哪段 DOM
只有文章页才开放划词。BookLayout.astro 在 docs、posts、weekly、links 四类且有正文 HTML 的页面上,把 canonical pathname 和标题写进助手容器的 data-reader-pathname、data-reader-title;其他页面这两个属性为空,refresh() 读不到 pathname 就直接收起工具。
正文根节点固定为:
export function articleContentRoot(doc = document) {
return doc.querySelector('main .book-page article.markdown');
}
侧边目录、评论区、助手面板都不在这个节点里,所以在那里选中的文字不会触发工具。
只数“正文文字”
Range.toString() 会把块级元素之间的换行也算进去,而且会把代码块、Mermaid 图里的文字一起带上。实现没有用它,而是用 TreeWalker 只收集正文中的文本节点,拼成一个字符串,并记录每个文本节点在这个字符串里的起止位置(textIndex)。
被跳过的区域由一个选择器决定:
const SKIP = 'script, style, noscript, svg, canvas, nav, pre, [data-book-island], [data-pagefind-ignore], [data-blog-code], [data-demo], .blog-comments, .blog-chat-widget, .mermaid';
pre、站点 CodeBlock([data-blog-code])、Mermaid 和交互演示里的文字都不进入索引。这里只排除块级区域,行内 code 保留:一段正文里经常夹着行内代码,把它排除会让整段选区失效。
从 Range 算出引文
captureRange 把 Range 的两个边界点换算成上面那个字符串里的偏移,取出中间的文字,去掉首尾空白,再各取 48 个字符作为前文和后文:
return {
quote,
prefix: index.text.slice(Math.max(0, start - CONTEXT), start),
suffix: index.text.slice(end, end + CONTEXT),
};
空选区、全是空白或超过 2000 字符的选区返回 null,工具不出现。保存的是 {quote, prefix, suffix} 三个字符串,不保存 DOM 路径或字符偏移。文章以后改了排版、插了段落,只要原句和附近的文字还在,就能重新找到。
三击整段与代码块边界
三击选中一个段落时,浏览器给出的 Range 终点常常落在下一个元素里,比如紧跟着的代码块开头。Range.intersectsNode 会把“刚好碰到边界”也当作相交,早期实现因此拒绝了整段选区。现在的 endpointOffset 在边界点落进跳过区域时,把起点推到该区域之后、把终点拉回该区域之前:
function endpointOffset(index, container, offset, edge) {
if (!skipped(container)) return offsetOf(index, container, offset);
const element = elementOf(container);
const region = element?.closest(SKIP);
if (!(region instanceof Element)) return null;
return edge === 'end' ? positionBefore(index, region) : positionAfter(index, region);
}
选区完全在代码块内部时,推拉之后终点不大于起点,结果仍为空,代码块里的文字不能高亮。
三击还有一个时间问题:按下鼠标的过程中浏览器已经提交了段落选区,但同一次按压里随后可能再来一次折叠的 selectionchange,把选区清掉。selection-tools.mjs 在 pointerdown 到 pointerup 之间不刷新工具,只把最后一个非空选区克隆下来(rememberGesture);pointerup 后等 80 ms 再调用 refresh()。如果那时当前选区已经取不出引文,就退回使用刚才记下的 Range。拖动选择的过程中工具也因此不会闪现。
扇形工具
两个动作
工具的 DOM 写在 BlogChatWidget.astro 里,是一个 role="group"、aria-label="划词工具" 的容器,里面有三个只显示图标的按钮:
data-selection-action | 可见时机 | 作用 |
|---|---|---|
highlight | 有新选区 | 保存为高亮 |
note | 有新选区 | 打开「笔记」tab 写笔记 |
clear-highlight | 选区或光标落在已有高亮上 | 删除这条高亮 |
中文名称放在 aria-label 和悬停提示里。早期版本还有“划线”(underline),#330 已经去掉,后端也拒绝再写入这个类型。
容器本身是 position: fixed、宽高为 0 的锚点,按钮绝对定位在它的原点上。加上 data-open 后,各按钮用 transform 平移到不同方向,并错开 40 ms 的 transition-delay,形成从锚点展开的扇形。prefers-reduced-motion: reduce 时关闭动画和过渡。
工具容器的 pointerdown 调用了 preventDefault(),点按钮时页面焦点和选区不会先被清掉。按钮同时监听 pointerup 和 click;第一次触发后 quote 被置空,第二次不会重复提交。
锚点:头像还是折叠边
show() 每次都要决定工具挂在哪里,结果写进 data-anchor:
flowchart TD
S{"有新选区?"} -->|是| O{"html 有<br/>blog-chat-sidebar-open?"}
O -->|是,且找到可见的折叠边控件| E["data-anchor=edge<br/>锚在侧栏左边缘"]
O -->|否| L["data-anchor=launcher<br/>锚在右下角头像"]
S -->|否| C{"光标落在已有高亮里?"}
C -->|是| M["data-anchor=mark<br/>只显示取消高亮"]
C -->|否| H["收起工具"]
launcher:侧栏关闭时,工具以右下角的助手按钮([data-chat-launcher],登录后显示头像)为原点,在它左上方展开。头像同时加上data-selection-active,播放一次轻微的缩放、旋转动画,提示工具从这里出来。edge:侧栏打开时,头像被面板挡住。edgeAnchor()依次查找.assistant-workspace__close、.blog-chat-widget__close、[data-chat-expand]中第一个可见、尺寸大于 0 的元素,也就是侧栏左边缘的折叠控件。宽度不小于 640 px 时,CSS 把工具定位到right: calc(var(--blog-chat-sidebar-width) + .75rem)、top: 50%,贴着侧栏左缘出现。mark:只是点了一下已有高亮时,用这段<mark>的getBoundingClientRect()计算left/top,把「取消高亮」按钮放在高亮文字上方。
窄屏(小于 640 px)上助手是全屏弹层,弹层打开时 .reader-selection-tools 被设为 visibility: hidden。
收起
工具打开时,以下情况会收起它并清除选区:按 Esc(输入法组合输入中除外);在工具和正文之外的位置按下指针;点击助手按钮。在正文里按下指针不会立即收起,因为用户可能正在开始一次新的选择,交给下一次 refresh() 判断。
写入:登录、草稿与队列
activate() 把动作整理成 {type, pathname, title, quote, prefix, suffix},通过 blog:text-annotation 事件交给 React。ReaderAnnotations 由助手按钮上的 ChatLauncherFace 挂载,处在 BlogClerkProvider 里,能读到 Clerk 的登录状态。它包含四个只有副作用、不渲染界面的组件:
| 组件 | 职责 |
|---|---|
AnnotationIntent | 接收动作;按登录状态和类型分流 |
AnnotationWriter | 依次把排队的高亮写入 Convex |
AnnotationPainter | 订阅本页高亮和笔记,回绘到正文 |
AnnotationRemover | 处理取消高亮 |
后三个组件放在 ConvexProviderWithClerk 里,只在已登录时挂载;Convex 请求携带 Clerk 签发的身份令牌。
sequenceDiagram
participant U as 读者
participant T as selection-tools
participant I as AnnotationIntent
participant K as Clerk
participant Q as 写入队列
participant C as Convex
U->>T: 选中文字,点「高亮」
T->>I: blog:text-annotation
alt 未登录
I->>I: sessionStorage 保存草稿(fresh: true)
I->>K: openClerkSignIn
K-->>I: 登录完成
I->>Q: takeStoredMark → enqueueAnnotationSave
else 已登录
I->>Q: enqueueAnnotationSave
end
Q->>C: createTextAnnotation
C-->>I: 返回 id,订阅推送新列表
未登录
未登录时,动作先写进 sessionStorage 的 blog-annotation-draft,标记 fresh: true,再打开 Clerk 登录弹窗,屏幕阅读器状态区播报「登录后同步」。草稿只保留最近一条,读回时由 parseAnnotationDraft 重新校验:类型只能是 highlight 或 note,引文 1–2000 字符,前后文各不超过 80 字符,pathname 以 / 开头。
登录完成后,AnnotationIntent 的另一个 effect 读取草稿:
- 是新鲜的笔记草稿:改成
fresh: false,打开「笔记」tab,引文已经放进编辑框上方。 - 是高亮:
takeStoredMark在本次页面生命周期内只取一次,放进写入队列;写入成功后才清除草稿。
已登录
高亮直接进入内存队列 enqueueAnnotationSave。AnnotationWriter 订阅队列,一条一条调用 createTextAnnotation,正在写入时到达的新条目会在本轮结束后继续处理;组件卸载时,没写完的条目放回队列。成功播报「已高亮」,失败播报「未能保存,请稍后重试。」。
写笔记不进队列:草稿以 fresh: false 存入 sessionStorage,然后派发 blog:annotation-draft 和 blog:open-assistant(view: 'notes'),面板切到「笔记」tab,由读者写完内容再保存。
后端:textAnnotations
表结构在 convex/schema.ts:
textAnnotations: defineTable({
owner: v.string(),
pathname: v.string(),
title: v.string(),
type: annotationType,
quote: v.string(),
prefix: v.string(),
suffix: v.string(),
note: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_owner_and_updatedAt", ["owner", "updatedAt"])
.index("by_owner_and_pathname_and_updatedAt", ["owner", "pathname", "updatedAt"])
.index("by_owner_and_type_and_updatedAt", ["owner", "type", "updatedAt"]),
owner 不是客户端传来的。每个函数先调用 requireOwner,用 ctx.auth.getUserIdentity() 取得 Clerk 身份,以 tokenIdentifier 作为 owner;没有身份就抛出 UNAUTHENTICATED。三个索引都以 owner 开头,查询天然只落在当前账户的数据上。
annotationType 仍包含 underline,保证旧数据通过 schema 校验;但所有读取函数都过滤掉它,createTextAnnotation 遇到它会报「不再支持划线。」。
| 函数 | 类型 | 行为 |
|---|---|---|
listPageTextAnnotations | Query | 按 pathname 取本人最近 200 条,去掉 underline,按时间正序返回给回绘 |
listTextAnnotations | Query | 分页列出本人全部条目,可按 type 筛选,供「笔记」tab |
createTextAnnotation | Mutation | 规范化 pathname,校验文本,去重后插入 |
deleteTextAnnotation | Mutation | 确认条目属于本人后删除 |
写入前的校验:引文去掉首尾空白后 1–2000 字符;前文、后文各不超过 80 字符;笔记 1–4000 字符;都不允许控制字符。pathname 经过 canonicalPathname 统一百分号编码和末尾斜杠,同一篇文章不会因为 URL 写法不同而分成两组。
去重只看同一页最近 200 条:类型、引文、前后文都相同(笔记还要求笔记内容相同)时,直接返回已有的 id。重复点同一段的「高亮」,或登录前后各提交一次,都只留一条。新增与删除都经过 limitWrite 限流。
这些数据只供本人读取。docs/member-services.md 明确规定划词笔记按账户隔离,不进入公开 AI Search 语料。
回绘与取消高亮
找回原句
AnnotationPainter 用 useQuery 订阅 listPageTextAnnotations。Convex 的查询是实时的,新增或删除一条后,列表推送到页面,effect 重新执行一次完整回绘:先 clearAnnotations 拆掉所有旧 <mark>(把子节点移回原位并 normalize() 合并文本节点),再逐条定位、包裹。
定位由 src/scripts/text-quote.mjs 的 locateQuote 完成,分两步:
- 精确匹配:在正文字符串里查找
quote的每一处出现(最多 50 处),给每处打分:基础 1 分;前文完全一致加 4 分,前文足够长(≥8 字符)且末尾 16 字符一致加 2 分;后文同理。取最高分;前后文都完全一致(9 分)时提前结束。 - 空白归一化:精确匹配找不到时,把连续空白折叠成一个空格,再在归一化后的文本中做同样的打分,最后用位置映射换回原文偏移。
同一句话在文中出现多次时,前后文决定选哪一处;排版调整带来的换行、缩进变化由第二步吸收。两步都失败的条目跳过,不会涂在错误的位置,仍可以在「笔记」tab 里看到和删除。
包裹 <mark>
找到 {start, end} 后,wrapSlice 在首尾文本节点上调用 splitText 切出边界,然后把范围内的每个文本节点(同样跳过 SKIP 区域)各自包进一个 <mark>。一段选区跨越加粗、链接、行内代码时,会生成多个 <mark>,不破坏原有的元素嵌套。
<mark class="reader-annotation reader-annotation--highlight reader-annotation--anchor"
id="annotation-<id>" data-reader-annotation="<id>" data-annotation-type="highlight">…</mark>
同一条记录的第一个 <mark> 带 id="annotation-<id>" 和 --anchor 类,作为跳转目标;笔记类型用浅蓝底色和下边线,并在锚点后加一个小圆点。高亮在深色模式下降低黄色的不透明度。
点一下已有高亮
refresh() 每次都会用 highlightInRange 检查当前 Range 是否落在 mark[data-annotation-type="highlight"] 上:起点在某个高亮里,或非空选区与某个高亮重叠。重叠判断用 compareBoundaryPoints 的严格不等式,选区只是在高亮边界上“碰到”时不算命中:
return range.compareBoundaryPoints(Range.END_TO_START, markRange) < 0
&& range.compareBoundaryPoints(Range.START_TO_END, markRange) > 0;
- 单击高亮文字:选区折叠成一个光标,工具以
mark方式出现在这段文字上方,只显示「取消高亮」。 - 重新选中一段与高亮重叠的文字:仍然展开高亮、写笔记,同时多出「取消高亮」。
点「取消高亮」会派发 blog:text-annotation-clear,AnnotationRemover 调用 deleteTextAnnotation。删除成功后,订阅推送新列表,回绘时这段 <mark> 自然消失;状态区播报「已取消高亮」。读者不需要精确地重新框选整句。
「笔记」tab
助手面板的 tab 里,「笔记」紧跟在「阅读」之后,内容是 TextAnnotationsPanel,首次打开时才懒加载。
- 筛选:顶部
Segment提供「全部 / 高亮 / 笔记」,对应listTextAnnotations的type参数,每次分页 20 条,可「加载更多」。 - 写笔记:
sessionStorage里有笔记草稿时,列表上方出现编辑区:引文用blockquote展示,下面是最多 4000 字符的textarea。保存时调用createTextAnnotation(type: 'note'),成功后清除草稿;「取消」直接丢弃草稿。 - 跳转:每条记录链接到
pathname#annotation-<id>。在本页时,点击调用focusAnnotation平滑滚动到对应<mark>并闪烁一次,不刷新页面;在其他页时正常导航,目标页回绘完成后读取 hash 再定位。 - 删除:每条都有「删除」按钮,高亮和笔记都可以从这里删掉。
边界与取舍
| 场景 | 当前行为 |
|---|---|
| 选区在代码块、Mermaid、演示组件内部 | 不出现工具 |
| 三击段落,终点落进下一个代码块 | 终点拉回代码块之前,整段可高亮 |
选区含行内 code | 正常捕获 |
| 选区超过 2000 字符 | 不出现工具 |
| 拖动选择途中 | 不刷新工具,pointerup 后 80 ms 再判断 |
| 同一句在文中重复出现 | 用前后文打分选位置 |
| 文章修订后原句消失 | 不回绘,「笔记」tab 中保留 |
| 未登录点工具 | 保存草稿并打开 Clerk 登录 |
| 一篇文章条目很多 | 回绘只读最近 200 条,更早的在「笔记」tab 分页查看 |
用文本引用而不是 DOM 路径定位,是为了经得起文章修订。代价是原句改写后无法自动迁移,且每次回绘都要重建文本索引;按每页最多 200 条计算,这个成本可以接受。
划词脚本和 React 之间只通过事件通信,是为了让未登录读者也能看到工具,而不必提前加载 Clerk 与 Convex 客户端。登录前的意图暂存在 sessionStorage,关闭标签页即失效,不会写进持久存储。