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.ts→transientHighlight.tsshowTransientSearchHighlight→showTransientHighlight- 覆盖层 key
library-search-highlight→transient-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)走的是handleGoToSource:view?.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 会解析为0,showTransientHighlight对数字返回值直接放弃(见下文),不会画出任何高亮。
核心陷阱: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.Segmenter(granularity: 'sentence',locale 取自doc.documentElement.lang)把高亮收缩到覆盖该 range 的首尾两个句子,再用TreeWalker按文本偏移定位精确节点(transientHighlight.ts#L72-L111)。这一段对脚注场景通常是旁路(Element 分支先返回),但对图书馆搜索入口是主路径。
3. 数字 0 → 直接放弃。章节级 href 在此被自然跳过,与上文"不对脚注做门禁"的策略闭环。
另一个工程细节是getRenderedContent:resolveNavigation给出的index对应的章节可能还没渲染完成,函数以requestAnimationFrame为节拍轮询view.renderer.getContents(),直到取到doc与overlayer,上限 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()构造了五个场景,正好覆盖上述分支:
- Range 命中句子精化:
<p>Before. Professor\nQuirrell! After.</p>中 offset 18–26 的 range,断言画出的高亮文本恰为Professor\nQuirrell!,且推进 4000ms 后overlayer.remove('transient-highlight')被调用; - href 锚点解析为 Element:
<p id="fn1">1. The footnote text.</p>,断言整块文本被高亮; - 行内空标记:
<p><a id="fn2"></a>Second footnote.</p>,回退到最近p容器,高亮Second footnote.; - 无文本锚点的父级回退:
<table><td><a id="fn3"></a>Note in a cell.</td></table>,<td>不在SENTENCE_CONTAINER中且closest无命中,最终回退到高亮单元格文本; - 章节级 href(anchor 返回 0):断言
overlayer.add从未被调用。
测试中resolveNavigation与renderer.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),仅供参考