Readest 脚注页内跳转高亮实现:从图书馆搜索到临时高亮的机制复用
2026/9/20 20:45:29 网站建设 项目流程

Readest 脚注页内跳转高亮实现:从图书馆搜索到临时高亮的机制复用

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

本篇基于 Readest 中记录 Issue #5647 修复过程的项目记忆文档展开。核心问题:当 EPUB 中的脚注链接没有打开脚注弹窗、而是在页面内直接跳转时(正向跳到注释块、或从注释回链跳回原文段落),跳转落点没有任何视觉反馈,读者根本不知道跳到了哪里。修复方案是把图书馆搜索(library search)的临时高亮机制泛化并重命名为transientHighlight,在FootnotePopup的三条非弹窗跳转路径上统一接入,目标区块闪烁 4 秒后自动消失。读完本文,你将理解 foliate 链接处理的三条分流路径、resolveNavigation返回类型的实际陷阱(ElementvsRangevs0)、跨 realm 鸭子类型判断,以及空锚点(<a id="fn1"/>)的回退高亮策略,这些细节对任何基于 foliate 的阅读器二次开发都有直接参考价值。

问题定义与方案取舍

Issue #5647 的要求是:当脚注链接执行的是页内跳转而不是打开弹窗时,对跳转目标做一次短暂闪烁(flash),让读者感知到落点。用户指令明确要求复用图书馆搜索已有的临时高亮,而不是新写一套覆盖层逻辑,于是发生了这次重命名:

  • searchHighlight.tstransientHighlight.ts
  • showTransientSearchHighlightshowTransientHighlight
  • 覆盖层 keylibrary-search-highlighttransient-highlight

重命名后,该高亮工具同时服务两类入口:图书馆搜索的深链定位(?highlight=search深链,见 FoliateViewer.tsx 中overrideLocation命中时调用showTransientHighlight)和脚注页内跳转。实现落在 transientHighlight.ts,调用方全部收敛在 FootnotePopup.tsx 内。

非弹窗跳转恰好只有三条路径

记忆文档的核心结论是:不走弹窗的脚注导航恰好有三条路径,且全部位于FootnotePopup.tsx。逐条对照源码可以验证:

路径一:handle()返回 undefined,foliate 自己执行 goTo

主视图的链接点击由docLinkHandler接管(通过 useFoliateEvents 的onLinkClick注册):

const popupPromise = footnoteHandler.handle(bookDoc, event); if (popupPromise) { popupPromise.catch((err: unknown) => { console.warn(err); const detail = (event as CustomEvent).detail; view?.goTo(detail.href); flashLinkTarget(detail.href); }); } else if (!event.defaultPrevented) { // Not handled as a footnote: foliate's default link handling will // navigate in-page. flashLinkTarget(detail.href); }

见 FootnotePopup.tsx 的 docLinkHandler。这里的关键陷阱是钩子时机:当footnoteHandler.handle()判定链接不是脚注形态时返回undefined,随后执行goTo的是 foliate 自己的#handleLinks,而不是Readest 的代码。因此闪烁不能挂在"我调用了 goTo"的地方,而必须挂在"检查完handle()返回值加上event.defaultPrevented之后"——这正是else if (!event.defaultPrevented)分支的作用:既覆盖 handle 未认领、也排除掉其他监听器已经拦截掉的事件。

路径二:handle()返回的 Promise 拒绝(主视图侧)

链接被判定为脚注候选(detail['check'] = true,由 shouldCheckAsFootnote 启发式决定),但后续抽取失败(比如check结构过于复杂导致解析不出注释文本)时,handle()返回的 Promise 走catch,此时 Readest 自己执行view?.goTo(detail.href)并紧随flashLinkTarget(detail.href)(FootnotePopup.tsx#L485-L490)。

路径三:弹窗内部链接监听器中的同样拒绝

弹窗文档里的链接由handleBeforeRender注册的popupView.addEventListener('link', ...)处理,footnoteHandler.handle()的 Promise 在这里同样可能拒绝,catch 分支中执行getView(bookKey)?.goTo(popupLinkDetail.href)flashLinkTarget(popupLinkDetail.href),然后setShowPopup(false)关闭弹窗(FootnotePopup.tsx#L244-L262)。

此外,"跳转到来源"按钮(弹窗右下角的MdOutlineArrowOutward)走的是handleGoToSourceview?.goTo(href)后同样flashLinkTarget(href)(FootnotePopup.tsx#L515-L521)。四条调用点共享同一个防抖入口:

// A link that jumps in-page instead of opening a popup (undetected or // unextractable footnotes, note backlinks) lands without any visual cue; // briefly highlight the target like a library search hit does (#5647). const flashLinkTarget = async (href: string) => { const view = getView(bookKey); if (!view) return; if (flashTimerRef.current) clearTimeout(flashTimerRef.current); flashTimerRef.current = await showTransientHighlight(view, href); };

flashTimerRef存的是showTransientHighlight返回的 4 秒清除定时器句柄;新的跳转先到先清,避免上一次闪烁的remove误删新一次的覆盖层。

为什么不对"是否脚注"做门禁

一个看似自然的实现是"只有跳转目标是脚注才闪烁"。但记忆文档指出了一个循环论证:真实世界中出现"跳过去的脚注"的案例,恰恰就是脚注检测启发式失败的那些——链接形态不像脚注(路径一),或启发式判定为候选但抽取失败(路径二、三)。用同一个启发式去门禁闪烁,等于把闪烁恰好从最需要它的场景里排除掉了。因此最终策略是:任何页内链接跳转都闪烁。而误伤被天然过滤:只有章节、没有#hash的 href,其 anchor 会解析为0showTransientHighlight对数字返回值直接放弃(见下文),不会画出任何高亮。

核心陷阱:resolveNavigation的 anchor 返回类型名不副实

这是本实现中最有价值的底层细节。foliate 的FoliateView.resolveNavigation类型声明中,anchor回调的返回值标注为Range。但从源码结构看,实际行为因 href 形态而异:

  • #hash的 href:epub.js 的getHTMLFragment返回的是目标Element,不是Range
  • 只有章节、没有#hash的 href:anchor 是() => 0,返回数字0
  • 其他情况才真正返回Range

transientHighlight.ts因此显式放宽了类型(transientHighlight.ts#L4-L12):

// resolveNavigation's anchor is typed as returning a Range, but for hash // hrefs foliate resolves to the target Element (and 0 for section-only // hrefs) — widen it so href targets typecheck. type TransientHighlightView = Pick<FoliateView, 'renderer'> & { resolveNavigation: (target: string | number) => { index: number; anchor?: (doc: Document) => Range | Element | number | null; }; };

而对三种返回值分支的判定,采用的是鸭子类型而非instanceof(transientHighlight.ts#L67-L71):

const resolved = anchor(doc); if (!resolved || typeof resolved === 'number') return null; // 数字 0:章节级 href,跳过 if (!('startContainer' in resolved)) { return { overlayer, range: getBlockRange(doc, resolved) }; // Element:按块高亮 } const range = resolved; // Range:按文本精化

'startContainer' in resolved判断是否为 Range,是跨 realm 安全的:阅读器的书籍文档运行在独立 realm(iframe)中,跨 realm 的instanceof Range会因全局对象不同而误判为false,而属性存在性判断不受 realm 边界影响。同文件中isLinkTargetVisible(footnoteHeuristics.ts#L57-L62)也用了同样的'startContainer' in resolved写法,可以印证这是该仓库处理 foliate anchor 的统一惯例。

高亮范围计算:从 Element 到"读者需要看到的范围"

拿到目标后,getTargetHighlight按三种形态分别构造覆盖层Range

1. Element 锚点 → 块级容器回退。脚注 id 经常挂在空的行内标记上(<a id="fn1"/>),高亮一个空节点毫无意义。getBlockRange先向上找最近的句子级容器p, li, blockquote, dd, dt, h1~h6;若该容器textContent为空且存在父元素,再上升一层,然后对容器执行selectNodeContents(transientHighlight.ts#L23-L25, L50-L58):

const SENTENCE_CONTAINER = 'p, li, blockquote, dd, dt, h1, h2, h3, h4, h5, h6'; const HIGHLIGHT_KEY = 'transient-highlight'; const HIGHLIGHT_COLOR = '#808080'; // Footnote ids often sit on an empty inline marker (<a id="fn1"/>); the // enclosing block is what the reader needs to see highlighted. const getBlockRange = (doc: Document, el: Element) => { let root = el.closest(SENTENCE_CONTAINER) ?? el; if (!root.textContent?.trim() && root.parentElement) root = root.parentElement; const range = doc.createRange(); range.selectNodeContents(root); return range; };

2. Range 锚点 → 句子级精化。若解析出的是Range(典型于图书馆搜索命中),且其起点元素所在的句子级容器能容纳整个 range,则进一步用Intl.Segmentergranularity: 'sentence',locale 取自doc.documentElement.lang)把高亮收缩到覆盖该 range 的首尾两个句子,再用TreeWalker按文本偏移定位精确节点(transientHighlight.ts#L72-L111)。这一段对脚注场景通常是旁路(Element 分支先返回),但对图书馆搜索入口是主路径。

3. 数字 0 → 直接放弃。章节级 href 在此被自然跳过,与上文"不对脚注做门禁"的策略闭环。

另一个工程细节是getRenderedContentresolveNavigation给出的index对应的章节可能还没渲染完成,函数以requestAnimationFrame为节拍轮询view.renderer.getContents(),直到取到docoverlayer,上限 30 帧(transientHighlight.ts#L27-L34)。

4 秒生命周期与覆盖层语义

showTransientHighlight的完整契约(transientHighlight.ts#L117-L124):

export const showTransientHighlight = async (view: TransientHighlightView, target: string) => { const highlight = await getTargetHighlight(view, target); if (!highlight) return null; const { overlayer, range } = highlight; overlayer.remove(HIGHLIGHT_KEY); overlayer.add(HIGHLIGHT_KEY, range, Overlayer.highlight, { color: HIGHLIGHT_COLOR }); return setTimeout(() => overlayer.remove(HIGHLIGHT_KEY), 4000); };
  • remove同 key 再add,保证快速连续跳转时旧高亮不会残留;
  • 颜色固定#808080,画在 foliate 的Overlayer.highlight通道上;
  • 返回 4 秒清除的setTimeout句柄,由调用方(flashTimerRef/librarySearchHighlightTimerRef)持有并在新请求到来时先行清除;
  • 组件卸载时FootnotePopup的 cleanup 会clearTimeout(flashTimerRef.current)(FootnotePopup.tsx#L605),防止视图销毁后操作悬挂的 overlayer。

测试与验证方式

单元测试 transient-highlight.test.ts 用vi.useFakeTimers()构造了五个场景,正好覆盖上述分支:

  1. Range 命中句子精化<p>Before. Professor\nQuirrell! After.</p>中 offset 18–26 的 range,断言画出的高亮文本恰为Professor\nQuirrell!,且推进 4000ms 后overlayer.remove('transient-highlight')被调用;
  2. href 锚点解析为 Element<p id="fn1">1. The footnote text.</p>,断言整块文本被高亮;
  3. 行内空标记<p><a id="fn2"></a>Second footnote.</p>,回退到最近p容器,高亮Second footnote.
  4. 无文本锚点的父级回退<table><td><a id="fn3"></a>Note in a cell.</td></table><td>不在SENTENCE_CONTAINER中且closest无命中,最终回退到高亮单元格文本;
  5. 章节级 href(anchor 返回 0):断言overlayer.add从未被调用。

测试中resolveNavigationrenderer.getContents()均以最小 stub 注入(index: 0, doc, overlayer),验证的是纯函数化的范围计算逻辑,与 foliate 实例解耦。

此外记忆文档记录了人工验证结果(web 端 Chrome,端口 3001,通过合成导入的测试书):正向跳转闪烁注释块、回链跳转闪烁源段落、4 秒后清除,且带epub:type="noteref"的链接依然走弹窗路径、不产生闪烁——即弹窗路径完全未被本次改动触碰。该记录还附了一条调试教训:把 epub 以 base64 字符串内联进页面脚本会损坏 zip(zip.js 报process error:-3),正确做法是把文件放到本地带 CORS 的 http 服务器上再fetch(),供复现验证时参考。

涉及文件索引

文件角色
transientHighlight.ts临时高亮核心:范围计算、跨 realm 鸭子类型、4 秒生命周期
FootnotePopup.tsx三条非弹窗跳转路径 +flashLinkTarget防抖入口
footnoteHeuristics.ts脚注候选启发式(shouldCheckAsFootnote)与目标可见性(isLinkTargetVisible
FoliateViewer.tsx另一调用方:图书馆搜索深链的?highlight=search临时高亮
transient-highlight.test.ts五种锚点形态的分支覆盖测试

需要说明的适用前提:以上路径与行号基于当前仓库快照;resolveNavigation返回 Element/0的行为来自 foliate 上游(packages/foliate-js为工作区声明的子包,当前检出为空目录,运行时依赖以 lockfile 锁定版本为准),升级 foliate 后建议先跑transient-highlight.test.ts回归确认 anchor 契约未变。

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询