Skip to content

08 · 高亮系统与锚点恢复

高亮的难点不是把文字染黄,而是把一次瞬时 DOM Range 变成可持久化、可撤销、可跨页面变化恢复的数据。

一、两种高亮模型

highlighter.ts 将高亮分为:

text
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 是第一层锚点

存储结构大致是:

text
HighlightsStorage = Record<normalizedUrl, StoredData>

StoredData:
  highlights[]
  title
  site
  favicon
  updatedAt / domain metadata

normalizeUrl() 删除一组 ephemeral query 参数与 text fragment,避免同一文章因追踪参数产生多个 key。

reconcileLegacyUrlKey() 处理旧版本未规范化 URL 的数据,把历史 key 迁到新 key。迁移发生在读写边界,而不是要求用户清空数据。

三、从 Selection 到高亮数据

用户选中文字后:

text
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

保存:

ts
{
  exact: "被选中的文本",
  prefix: "前方最多 64 字符",
  suffix: "后方最多 64 字符"
}

恢复时在规范化正文中搜索 exact;若多处匹配,用 prefix/suffix 相似度消歧。

弱点:文章正文被改写或 exact 过短/重复时可能误定位。

两者组合的策略是:优先结构定位,失败后内容定位。

五、文本规范化索引

highlighter-overlays.ts 为 quote fallback 构造 NormalizedTextIndex

text
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.highlightsHighlight 注册:

text
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。

八、重叠与相邻合并

连续拖动可能创建交叠片段。源码提供:

text
doHighlightsOverlap(a, b)
areHighlightsAdjacent(a, b)
mergeOverlappingHighlights(existing, new)
mergeHighlights(h1, h2)

合并不仅拼 content,还要更新起止 XPath/offset、quote anchor 与排序位置。错误合并会造成恢复区间扩大,因此 block 边界与 element 类型要参与判断。

九、撤销与重做

高亮维护两个栈:

text
highlightHistory
redoHistory
MAX_HISTORY_LENGTH = 30

每个 HistoryAction 保存变更前后高亮数组。新增/删除时 push history 并清空 redo;undo 恢复 old,redo 恢复 new。

为什么保存快照而不是命令对象?高亮集合规模通常小,快照实现简单,能覆盖复杂 merge。30 条上限控制内存。

十、单一状态实例问题

content.jsreader-script.js 是两个 webpack bundle,各 import 一次 highlighter module。若不处理,会出现:

text
content.highlights[] ≠ reader.highlights[]

因此 content 在 window 暴露 __obsidianHighlighter,Reader 的 hl() 委托给它。独立 reader page 没有 content 时才回退本地实例。

这个 bridge 是高亮架构的关键不变量:同一 tab 只有一个可变高亮集合

十一、保存与加载

保存

saveHighlights()

  1. 读取当前 normalized URL;
  2. 合并/更新 storage.local.highlights
  3. 写入 title/site/favicon;
  4. 通知 background 当前是否有 highlights;
  5. 更新菜单状态。

加载

loadHighlights()

  1. 读取 local storage;
  2. reconcile legacy URL;
  3. 执行 stored data migration;
  4. 更新内存数组;
  5. 按 settings 决定是否立即 apply;
  6. 同步 background/tab UI。

高亮用 local 而不是 sync,原因包括体量、网页隐私和 sync quota。

十二、恢复缓存与版本号

高亮模块维护:

text
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 映射错误。

十七、设计精华

  1. 双锚点:结构定位与内容定位互补。
  2. 数据与视觉分离:HighlightData 是真相,CSS Highlight/overlay 是投影。
  3. generation cache:集合版本驱动是否重建。
  4. 跨 bundle 单实例:显式 window bridge 保证状态一致。
  5. 精确存储、友好导出:内部片段可以细,呈现时再 collapse。

十八、本章检查点

  • 能解释 XPath/offset 与 quote anchor 各自的优缺点;
  • 能描述 normalized text index 如何映射回 DOM;
  • 能区分文本 CSS Highlight 与元素 overlay;
  • 能解释为什么需要 window bridge;
  • 能说明 version counter、undo/redo 和 export grouping。

基于 Obsidian Web Clipper 1.7.1 源码快照的独立学习笔记