08 · 高亮系统与锚点恢复
高亮的难点不是把文字染黄,而是把一次瞬时 DOM Range 变成可持久化、可撤销、可跨页面变化恢复的数据。
一、两种高亮模型
highlighter.ts 将高亮分为:
AnyHighlightData
├─ TextHighlightData
│ ├─ start/end XPath
│ ├─ start/end text offset
│ └─ TextQuoteAnchor { exact, prefix, suffix }
└─ ElementHighlightData
└─ element/media HTML 与定位信息共同字段包括 id、type、content、position、timestamp、notes 等。文本与元素分开,是因为 Range 与媒体元素的恢复算法根本不同。
二、URL 是第一层锚点
存储结构大致是:
HighlightsStorage = Record<normalizedUrl, StoredData>
StoredData:
highlights[]
title
site
favicon
updatedAt / domain metadatanormalizeUrl() 删除一组 ephemeral query 参数与 text fragment,避免同一文章因追踪参数产生多个 key。
reconcileLegacyUrlKey() 处理旧版本未规范化 URL 的数据,把历史 key 迁到新 key。迁移发生在读写边界,而不是要求用户清空数据。
三、从 Selection 到高亮数据
用户选中文字后:
handleTextSelection(selection)
→ getHighlightRanges(range)
→ 按 text block split tags 切分跨块 Range
→ 序列化保留必要祖先格式
→ 计算 XPath + offsets
→ createTextQuoteAnchor()
→ addHighlight()
→ mergeOverlappingHighlights()
→ sortHighlights()
→ saveHighlights()
→ applyHighlights()跨段落 selection 不能简单保存成一条 Range,因为 DOM 结构、Markdown 转换和渲染矩形都更不稳定。源码按 block 语义拆分,再在导出时按相邻/重叠关系分组。
四、双锚点策略
锚点 A:XPath + offset
保存 range 起止容器的 XPath 与字符 offset。恢复时直接定位节点,速度快、精度高。
弱点:页面插入包装元素、广告或 A/B DOM 后,XPath 会漂移。
锚点 B:Text Quote Anchor
保存:
{
exact: "被选中的文本",
prefix: "前方最多 64 字符",
suffix: "后方最多 64 字符"
}恢复时在规范化正文中搜索 exact;若多处匹配,用 prefix/suffix 相似度消歧。
弱点:文章正文被改写或 exact 过短/重复时可能误定位。
两者组合的策略是:优先结构定位,失败后内容定位。
五、文本规范化索引
highlighter-overlays.ts 为 quote fallback 构造 NormalizedTextIndex:
root text nodes
→ collapse whitespace
→ concatenate normalized text
→ segments[] 保存 normalized offset ↔ 原 Text node offset搜索发生在连续规范化字符串上;找到区间后,再用 segments 二分/查找映射回真实 DOM position,构造 Range。
缓存包含:
normalizedTextIndexCache;normalizedTextIndexRoot;- exact text cache。
DOM 变化或重新加载时必须失效,否则 offset 会指向旧节点。
六、渲染为什么使用 CSS Highlights API
文本高亮优先通过 CSS.highlights 与 Highlight 注册:
Range[] → Highlight instance → CSS.highlights.set('obsidian-highlight', ...)优点:
- 不需要用
<mark>拆分文本节点; - 不破坏页面 DOM 与事件监听;
- 多个 Range 可共享一个命名 highlight;
- 页面重新布局时浏览器自动绘制。
元素高亮仍需要 overlay rectangles,因为图片、视频、表格等不是纯文本 Range 语义。
七、元素覆盖层
planHighlightOverlayRects() 从目标元素的 client rect 规划 overlay。系统还要:
- 跳过无效/隐藏 rect;
- 读取有效背景色,保证视觉对比;
- 监听 resize/scroll 重新定位;
- 用 MutationObserver 观察 DOM 变化;
- throttle 批量更新,避免每个 mutation 重排;
- 在 hover/click 时显示删除按钮。
覆盖层是视觉副本,不是持久化真相;真实数据仍是 HighlightData。
八、重叠与相邻合并
连续拖动可能创建交叠片段。源码提供:
doHighlightsOverlap(a, b)
areHighlightsAdjacent(a, b)
mergeOverlappingHighlights(existing, new)
mergeHighlights(h1, h2)合并不仅拼 content,还要更新起止 XPath/offset、quote anchor 与排序位置。错误合并会造成恢复区间扩大,因此 block 边界与 element 类型要参与判断。
九、撤销与重做
高亮维护两个栈:
highlightHistory
redoHistory
MAX_HISTORY_LENGTH = 30每个 HistoryAction 保存变更前后高亮数组。新增/删除时 push history 并清空 redo;undo 恢复 old,redo 恢复 new。
为什么保存快照而不是命令对象?高亮集合规模通常小,快照实现简单,能覆盖复杂 merge。30 条上限控制内存。
十、单一状态实例问题
content.js 与 reader-script.js 是两个 webpack bundle,各 import 一次 highlighter module。若不处理,会出现:
content.highlights[] ≠ reader.highlights[]因此 content 在 window 暴露 __obsidianHighlighter,Reader 的 hl() 委托给它。独立 reader page 没有 content 时才回退本地实例。
这个 bridge 是高亮架构的关键不变量:同一 tab 只有一个可变高亮集合。
十一、保存与加载
保存
saveHighlights():
- 读取当前 normalized URL;
- 合并/更新
storage.local.highlights; - 写入 title/site/favicon;
- 通知 background 当前是否有 highlights;
- 更新菜单状态。
加载
loadHighlights():
- 读取 local storage;
- reconcile legacy URL;
- 执行 stored data migration;
- 更新内存数组;
- 按 settings 决定是否立即 apply;
- 同步 background/tab UI。
高亮用 local 而不是 sync,原因包括体量、网页隐私和 sync quota。
十二、恢复缓存与版本号
高亮模块维护:
highlightsVersion
lastAppliedVersion每次集合变化 bump version。若 apply 时版本未变,可跳过昂贵的 Range 重建。invalidateHighlightCache() 供 Reader 更新 DOM 后显式失效。
这是状态系统常见的 generation counter,比对每个对象深比较更便宜。
十三、导出语义
高亮不仅用于页面染色,还可:
- 作为
{{highlights}}进入剪藏模板; - 在 highlights library 按 domain/page 浏览;
- 导出当前 page/domain/all;
- 通过
buildExportedPage()生成含来源信息的 Markdown/HTML; - 将视觉上连续的片段 collapse 成阅读友好的条目。
collapseGroupsForExport() 与 expandExportedEntries() 说明“存储粒度”和“呈现粒度”不同:存储要精确,导出要易读。
十四、Highlights Library 的增量渲染
core/highlights.ts 把数据分成 DomainGroup → PageGroup → entries。功能包括:
- URL query 驱动的 domain/page 选择;
- 搜索;
- A-Z/Z-A/new/old 排序;
- sidebar 计数与展开;
- 删除当前 page/domain;
- 分批 render next batch;
- 增量更新已有 DOM cache;
- 对远端页面执行 Defuddle fetch 以补充上下文。
大量高亮时,renderNextBatch() 避免一次创建全部节点。CachedDomainNode 避免 sidebar 每次状态变化整棵重画。
十五、安全与交互边界
- 高亮库展示远端 HTML 前用 DOMPurify;
- link click 在 highlighter mode 可暂时禁用,避免选择时导航;
- Alt/hover/touch 的删除交互分别处理;
- touch 用移动距离区分 tap 与 scroll;
- MutationObserver 更新 overlay,但用 throttle 防止布局风暴;
- 页面卸载时通知 background 模式退出。
十六、测试重点
高亮相关测试覆盖:
- overlay rect 规划;
- 文本 quote 恢复;
- overlap/adjacent 行为;
- manager 对存储数据的增删改;
- 缓存与 DOM 边界。
这类测试应优先使用 jsdom 构造真实文本节点和 Range,单纯 mock 几何值无法覆盖 offset 映射错误。
十七、设计精华
- 双锚点:结构定位与内容定位互补。
- 数据与视觉分离:HighlightData 是真相,CSS Highlight/overlay 是投影。
- generation cache:集合版本驱动是否重建。
- 跨 bundle 单实例:显式 window bridge 保证状态一致。
- 精确存储、友好导出:内部片段可以细,呈现时再 collapse。
十八、本章检查点
- 能解释 XPath/offset 与 quote anchor 各自的优缺点;
- 能描述 normalized text index 如何映射回 DOM;
- 能区分文本 CSS Highlight 与元素 overlay;
- 能解释为什么需要 window bridge;
- 能说明 version counter、undo/redo 和 export grouping。