拓冰建站拓冰建站
首页 / 资讯中心 / 正文

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

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 idfn1/的回退高亮策略这些细节对任何基于 foliate 的阅读器二次开发都有直接参考价值。问题定义与方案取舍Issue #5647 的要求是当脚注链接执行的是页内跳转而不是打开弹窗时对跳转目标做一次短暂闪烁flash让读者感知到落点。用户指令明确要求复用图书馆搜索已有的临时高亮而不是新写一套覆盖层逻辑于是发生了这次重命名searchHighlight.ts→transientHighlight.tsshowTransientSearchHighlight→showTransientHighlight覆盖层 keylibrary-search-highlight→transient-highlight重命名后该高亮工具同时服务两类入口图书馆搜索的深链定位?highlightsearch深链见 FoliateViewer.tsx 中overrideLocation命中时调用showTransientHighlight和脚注页内跳转。实现落在 transientHighlight.ts调用方全部收敛在 FootnotePopup.tsx 内。非弹窗跳转恰好只有三条路径记忆文档的核心结论是不走弹窗的脚注导航恰好有三条路径且全部位于FootnotePopup.tsx。逐条对照源码可以验证路径一handle()返回 undefinedfoliate 自己执行 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: foliates 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的 hrefepub.js 的getHTMLFragment返回的是目标Element不是Range只有章节、没有#hash的 hrefanchor 是() 0返回数字0其他情况才真正返回Range。transientHighlight.ts因此显式放宽了类型transientHighlight.ts#L4-L12// resolveNavigations 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 PickFoliateView, renderer { resolveNavigation: (target: string | number) { index: number; anchor?: (doc: Document) Range | Element | number | null; }; };而对三种返回值分支的判定采用的是鸭子类型而非instanceoftransientHighlight.ts#L67-L71const 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 安全的阅读器的书籍文档运行在独立 realmiframe中跨 realm 的instanceof Range会因全局对象不同而误判为false而属性存在性判断不受 realm 边界影响。同文件中isLinkTargetVisiblefootnoteHeuristics.ts#L57-L62也用了同样的startContainer in resolved写法可以印证这是该仓库处理 foliate anchor 的统一惯例。高亮范围计算从 Element 到读者需要看到的范围拿到目标后getTargetHighlight按三种形态分别构造覆盖层Range1. Element 锚点 → 块级容器回退。脚注 id 经常挂在空的行内标记上a idfn1/高亮一个空节点毫无意义。getBlockRange先向上找最近的句子级容器p, li, blockquote, dd, dt, h1~h6若该容器textContent为空且存在父元素再上升一层然后对容器执行selectNodeContentstransientHighlight.ts#L23-L25, L50-L58const 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 idfn1/); 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: sentencelocale 取自doc.documentElement.lang把高亮收缩到覆盖该 range 的首尾两个句子再用TreeWalker按文本偏移定位精确节点transientHighlight.ts#L72-L111。这一段对脚注场景通常是旁路Element 分支先返回但对图书馆搜索入口是主路径。3. 数字 0 → 直接放弃。章节级 href 在此被自然跳过与上文不对脚注做门禁的策略闭环。另一个工程细节是getRenderedContentresolveNavigation给出的index对应的章节可能还没渲染完成函数以requestAnimationFrame为节拍轮询view.renderer.getContents()直到取到doc与overlayer上限 30 帧transientHighlight.ts#L27-L34。4 秒生命周期与覆盖层语义showTransientHighlight的完整契约transientHighlight.ts#L117-L124export 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 命中句子精化pBefore. Professor\nQuirrell! After./p中 offset 18–26 的 range断言画出的高亮文本恰为Professor\nQuirrell!且推进 4000ms 后overlayer.remove(transient-highlight)被调用href 锚点解析为 Elementp idfn11. The footnote text./p断言整块文本被高亮行内空标记pa idfn2/aSecond footnote./p回退到最近p容器高亮Second footnote.无文本锚点的父级回退tabletda idfn3/aNote in a cell./td/tabletd不在SENTENCE_CONTAINER中且closest无命中最终回退到高亮单元格文本章节级 hrefanchor 返回 0断言overlayer.add从未被调用。测试中resolveNavigation与renderer.getContents()均以最小 stub 注入index: 0, doc, overlayer验证的是纯函数化的范围计算逻辑与 foliate 实例解耦。此外记忆文档记录了人工验证结果web 端 Chrome端口 3001通过合成导入的测试书正向跳转闪烁注释块、回链跳转闪烁源段落、4 秒后清除且带epub:typenoteref的链接依然走弹窗路径、不产生闪烁——即弹窗路径完全未被本次改动触碰。该记录还附了一条调试教训把 epub 以 base64 字符串内联进页面脚本会损坏 zipzip.js 报process error:-3正确做法是把文件放到本地带 CORS 的 http 服务器上再fetch()供复现验证时参考。涉及文件索引文件角色transientHighlight.ts临时高亮核心范围计算、跨 realm 鸭子类型、4 秒生命周期FootnotePopup.tsx三条非弹窗跳转路径 flashLinkTarget防抖入口footnoteHeuristics.ts脚注候选启发式shouldCheckAsFootnote与目标可见性isLinkTargetVisibleFoliateViewer.tsx另一调用方图书馆搜索深链的?highlightsearch临时高亮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),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门