十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Readest 段落模式修复实录:从 Shift+P 双重触发到章节回退的调试经验(4717 / PR 4725)

Readest 段落模式修复实录:从 Shift+P 双重触发到章节回退的调试经验(4717 / PR 4725) Readest 段落模式修复实录从 ShiftP 双重触发到章节回退的调试经验#4717 / PR #4725【免费下载链接】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开源多端电子书阅读器段落模式Paragraph Mode的一轮真实 bug 修复为线索完整复盘 PR #4725 如何一次性解决 #4717 上报的三个问题ShiftP一次按键触发两次切换、overlay 键盘事件被全局监听器吞掉、进入/退出段落模式后阅读位置回退到章节开头。文中不仅给出可复用的修复模式事件分发器快照迭代、dialog 焦点模式、view.lastLocation.cfi恢复位置还结合仓库源码展开底层原理读者读完可直接将这些经验迁移到自己的阅读器或 iframe 嵌套渲染类项目中。背景一次按键引发的三个连锁问题段落模式是 Readest 阅读器的一种沉浸式阅读形态它通过 ParagraphOverlay 在书页之上渲染一个roledialog的浮层将当前段落以更大的字号、更舒适的排版单独呈现同时支持键盘/触摸/滚轮逐段导航并能与 TTS 朗读联动高亮。它由 useParagraphMode 这个 Hook 驱动用户可用ShiftP快捷键或「视图菜单 → 段落模式」随时进出。#4717 上报的三个症状看似独立实则同源按一次ShiftP段落模式被切换两次——表现为进入后立刻退出、或退出后立刻重新进入的「闪一下」进入段落模式后Escape /ShiftP等按键不响应只有把焦点先点到父文档上才偶尔生效退出并重新进入段落模式后阅读位置回退到了章节开头反复进出还会进一步倒退。PR #4725 是 #4723AltP校对模式与最初的 overlay 尝试的后续修复最终定位到三个彼此独立、但都极具复用价值的根因。下面逐一展开。发现一eventDispatcher.dispatch的 live-Set 重入现象第一次按ShiftP时段落模式被切换两次退出事件派发中途进入逻辑又被触发了一遍形成「退出 → 重入」的闪烁。根因Readest 内部的事件总线 utils/event.ts 用asyncListeners: Mapstring, Setlistener保存异步监听器。修复前的dispatch()直接迭代活着的 Set并且在await每个 listener 期间Set 可能已经发生了变化// 修复前示意for...of 直接迭代 live Set async dispatch(event: string, detail?: unknown): Promisevoid { const listeners this.asyncListeners.get(event); if (listeners) { const customEvent new CustomEvent(event, { detail }); for (const listener of listeners) { // ← live Set await listener(customEvent); } } }问题链路是useParagraphMode订阅了toggle-paragraph-mode事件其订阅 effect 的依赖数组包含paragraphConfig.enabled见 useParagraphMode.ts。当某次派发把enabled从true翻成false时React 状态变化导致 effect 重跑、重新订阅了同一个toggle-paragraph-mode的新 handler。而此刻dispatch()还停留在对同一个 Set的迭代循环中新加入的 handler 被同一轮循环继续调用事件因此双触发。修复在迭代前对监听器做快照。当前 utils/event.ts 的实现async dispatch(event: string, detail?: unknown): Promisevoid { const listeners this.asyncListeners.get(event); if (listeners) { const customEvent new CustomEvent(event, { detail }); // 迭代前快照await 期间监听器可能因 React effect 重跑而重新订阅 // 同一个事件迭代 live Set 会让新 handler 在同一轮派发中再次触发 // 导致事件双触发#4717。dispatchSync 此前已为同样原因做了快照。 for (const listener of [...listeners]) { await listener(customEvent); } } }同步版的dispatchSync早在实现时就采用了同样的[...listeners]快照见同文件 dispatchSync。可复用结论这条坑适用于任何「handler 会触发状态更新、而状态更新会重新订阅该事件」的场景不限于段落模式。只要dispatch是异步的、且监听器存在副作用式重新订阅就必须快照后再迭代快照顺序还保证了本轮派发不受后续订阅影响。仓库中的 paragraph-mode.test.tsx 与 event.test.ts 覆盖了相关行为可作为回归防线。发现二Overlay 键盘处理必须走 dialog/alert 模式而不是全局监听器现象进入段落模式后Escape 关不掉、ShiftP也切不回去。维护者的明确决策维护者 chrox 在评审中明确拒绝了把onEscape直接接进useShortcuts全局快捷键体系的方案。原因是全局监听器与 overlay 的焦点模型天然冲突。正确模式把 overlay 当对话框对待段落模式浮层在 ParagraphOverlay.tsx 中被实现为标准的 dialog 语义容器容器声明roledialog、aria-modaltrue、tabIndex{-1}打开时主动把焦点移入容器containerRef.current?.focus({ preventScroll: true })见 ParagraphOverlay.tsx——这样无论此前焦点在书页 iframe 内还是别处键盘事件都能稳定到达容器Escape、ShiftP切换、段落导航全部在容器自己的onKeyDown中处理并先e.stopPropagation()确保全局快捷键处理器永远看不到这些按键从而避免双触发见 handleKeyDownconst handleKeyDown useCallback((e: React.KeyboardEvent) { e.stopPropagation(); if (e.key Escape || e.key Backspace) { e.preventDefault(); // Escape 优先收起已有选区及其工具栏下一次 Escape 才退出 if (e.key Escape getCloneSelection()) { document.getSelection()?.removeAllRanges(); clearReportedSelection(); return; } onCloseRef.current?.(); return; } if (matchesShortcut(e, loadShortcuts().onToggleParagraphMode.keys)) { e.preventDefault(); onCloseRef.current?.(); return; } // … 段落导航Shift方向键、区域键等 }, [/* … */]);注意这里还用matchesShortcut(e, loadShortcuts().onToggleParagraphMode.keys)在容器内识别ShiftP与全局useShortcuts形成「就近处理」overlay 打开时按键归容器关闭后归全局。两个容易漏掉的细节程序性聚焦的非 tab-stop 容器要加outline-hidden容器是tabIndex{-1}且通过focus()聚焦的若不抑制焦点环浏览器会在整个视口外围画一圈 focus ring类名见 ParagraphOverlay.tsx。原文档记录的是 Tailwind 的outline-none仓库当前实现演进为outline-hidden效果一致旧实现的教训此前的 capture 阶段window.addEventListener(keydown, ..., true)stopImmediatePropagation()会吞掉全局快捷键而且只有当焦点落在父文档时才触发——焦点在 foliate 内容 iframe 内时事件根本不经过 window导致「时灵时不灵」。dialog 焦点模型从根上绕开了这两个问题。仓库测试 paragraph-mode.test.tsx 直接渲染ParagraphOverlay与useParagraphMode用 Testing Library 的fireEvent验证按键行为是理解该模式的权威参考。发现三段落模式恢复位置回退到章节开头现象退出段落模式再进入阅读位置回退到章节开头反复进出位置越退越远。原因 (a)进入/退出时的滚动锚定renderer.goTo/scrollToAnchor会把底层书页滚动到焦点段落开头。当页首段落起始于上一页时这种锚定会让视图回退一页每次进出都会累积一次回退最终一路退回章节开头。修复方式见 useParagraphMode.ts 的注释与实现恢复/首次挂载时调用focusCurrentParagraph(align false)只聚焦不滚动——焦点段落本来就在屏上无需滚动退出时不再执行scrollToAnchor退出分支只记录lastParagraphRef见 toggleParagraphMode只有主动导航上/下段才传align true让视图跟随到屏幕外的段落。原因 (b)恢复位置的数据源选错了原实现恢复时优先使用两样东西rAF 防抖后的readerStore进度FoliateViewer.commitRelocate提交天然滞后、可能不同步退出时用view.getCFI(docIndex, paragraphBlockRange)存储的「最后段落 CFI」。问题出在第 2 项getCFI对块级 range 生成的 CFI 可能畸形例如epubcfi(/6/18!,/4/110,/4)而resolveCFI对它的解析结果是非 null 的空 Range。在??候选链里这个「非空但实际为空」的候选会遮蔽后面更正确的候选最终把findByRangeAsync送进first()——也就是定位到了章节标题。修复后的候选链见 initIteratorconst targetRange resolveRangeFromCfi(view.lastLocation?.cfi) ?? // ① 优先foliate 实时位置 (isSameDoc ? progressRange : null) ?? // ② 次选同文档的 store 进度 resolveRangeFromCfi(progressLocation); // ③ 兜底store 的 location CFI而lastParagraphRef的存储与校验也改成了「location CFI 迭代器索引 段落文本」三元组见 useParagraphMode.ts 与resumeLastParagraph用稳定的文本做最终校验规避畸形 CFI 解析成空 range 的问题const resumeLastParagraph (): boolean { const last lastParagraphRef.current; const iterator iteratorRef.current; const locationCfi view.lastLocation?.cfi; if (!last || !iterator || !locationCfi) return false; if (last.locationCfi ! locationCfi || last.docIndex ! docIndex) return false; const range iterator.goTo(last.index); return !!range range.toString() last.text; // 文本兜底校验 };深入foliateview.lastLocation与 readerStore 进度的取舍这次修复带出一条通用的位置恢复经验任何「恢复/回到当前位置」的逻辑都应优先读view.lastLocation而不是 store 里的进度。两者的关键差异维度view.lastLocationreaderStore progress更新时机foliate 每次 relocate同步写入rAF 防抖后提交commitRelocate会滞后/不同步内容{ cfi, range, ... }经过防抖聚合的进度对象与文档实例的关系CFI 与文档实例无关可跨 iframe 重建存活Range绑定创建它的文档实例段落模式每次切换都会导致章节 iframe 被重建relocate触发重挂载存储的Range一旦文档实例被换掉就失效了而 CFI 是纯文本定位不依赖具体 DOM 实例因此在 iframe 重建后依然可解析。不过view.lastLocation默认并不在 FoliateView 的 TS 类型里仓库因此在 types/view.ts 中补充了可选声明lastLocation?: { cfi?: string; range?: Range | null };验证与测试脚手架的经验浏览器自动化最后一类坑来自「在真实浏览器里验证这段逻辑」的过程原文档记录了几条对同类 e2e/自动化任务非常有用的经验合成按键会缓冲/丢失computer key shiftp这类合成按键输入常常先消失、再延迟成批爆发导致进入不一致的多重切换状态——单次ShiftP经常不触发而ArrowRight重复按键反而稳定。菜单点击视图菜单 → 段落模式是可靠的切换手段验证时优先用它内容 iframe 藏在 foliate-view 的 shadow DOM 里顶层iframeCount是 0。焦点在FOLIATE-VIEW上时keydown 以iframe-keydownpostMessage 到达焦点在父文档上时才是真正的 window keydown探针运行时可用document.querySelector(foliate-view).lastLocation直接读取实时位置工具返回值注意包含overlay字符串的结果可能被[BLOCKED: Cookie/query string data]过滤改为返回 JSON 对象即可绕开。总结三条可移植到任何阅读器项目的经验事件分发器要对监听器集合做快照只要存在「监听器触发状态更新、状态更新重新订阅监听器」的可能异步dispatch就必须for (const l of [...listeners])迭代否则同一事件会在同轮派发中双触发。修复见 utils/event.ts。模态浮层用 dialog 焦点模型别用全局键盘监听roledialog tabIndex{-1} focus({preventScroll}) 容器内 onKeyDown stopPropagation并对程序性聚焦的容器抑制焦点环这比 capture 阶段全局监听稳定得多也天然避免与全局快捷键冲突。实现见 ParagraphOverlay.tsx。位置恢复优先读view.lastLocation.cfistore 的进度是防抖的、可能滞后而 foliate 同步维护的lastLocation.cfi是文档实例无关的纯文本定位能在 iframe 重建后存活。同时警惕getCFI对块级 range 可能生成畸形 CFI——它解析成非 null 空 range 后会在??链中遮蔽正确候选务必用文本等稳定信息兜底校验。核心逻辑见 useParagraphMode.ts。【免费下载链接】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),仅供参考
返回列表