ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Readest 即时词典选中状态修复剖析:从 5585 看 selectionchange 竞态与跨平台选择 UI 处理

2026/9/20 16:21:39 拓冰建站 浏览量
Readest 即时词典选中状态修复剖析:从 5585 看 selectionchange 竞态与跨平台选择 UI 处理 Readest 即时词典选中状态修复剖析从 #5585 看 selectionchange 竞态与跨平台选择 UI 处理【免费下载链接】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 阅读器内核中一个真实且典型的交互缺陷为主线深入剖析即时词典Instant Dictionary打开时取消文本选中这一修复的前因后果。文中将完整还原 Issue #5585 的现象与根因定位、修复方案中isTextSelected.current false与view.deselect()的负载顺序设计、#5213 与 #6213 两个关联 Issue 之间的行为边界并结合 Annotator.tsx 源码、useTextSelector.ts 钩子与 annotation.spec.ts 端到端测试给出可验证的实现证据。读完本文你将理解移动端阅读器中选区selection—弹窗popup—工具栏toolbar三者之间的状态流转协议以及如何避免selectionchange事件竞态导致的弹窗自毁问题。一、问题背景#5585 的点击任意位置弹出工具栏1.1 现象描述2026-08-16 报告的 Issue #5585 描述了一个在移动端非常恼人的交互问题在启用Instant Dictionary即时词典的情况下用户长按查词、词典弹窗打开之后关闭查询弹窗时会再次弹出选择工具栏。在 Android 手机上报告者的描述是tap anywhere点击任意位置都会触发——这实际上就是点击弹窗以外的遮罩区域backdrop关闭弹窗时的表现。1.2 根因定位handleDismissPopupShowToolbar的返回逻辑根因并不在词典弹窗本身而在更早的 #5213 引入的一个设计Annotator.tsx中的handleDismissPopupShowToolbar。该函数在查询类弹窗关闭时执行以下决策若isTextSelected.current仍为 true则返回选择工具栏handleShowAnnotPopup否则执行完整关闭并清除选区。const handleDismissPopupShowToolbar () { // ...#6213 的归还逻辑见后文 if (isTextSelected.current toolButtons.length 0) { handleShowAnnotPopup(); } else { handleDismissPopupAndSelection(); } };关键在于handleDictionary、handleTranslation、handleProofread这些查询类动作只翻转弹窗标志位从不取消选中参见 Annotator.tsx 的注释。因此只要isTextSelected.current仍然为 true任何查询弹窗关闭都会走返回工具栏分支——这是 #5213 有意为之的设计详见下文第五节但对于即时词典路径却成为 bug 的根源。1.3 为什么 Android 上表现为点哪都弹从源码结构看弹窗的形态由宽度启发式决定Annotator.tsxconst useSheet window.innerWidth 640 || window.innerHeight 640;在 Android 手机上innerWidth 640词典查询渲染为底部DictionarySheet而非锚定在选区旁的弹窗。底部弹窗天然带一个全屏遮罩backdrop用户关闭弹窗最常见的方式就是点击遮罩而DictionarySheet的onDismiss正好接的就是handleDismissPopupShowToolbarAnnotator.tsxDictionarySheet word{selection?.text as string} lang{bookData.bookDoc?.metadata.language as string} onDismiss{handleDismissPopupShowToolbar} onManage{onManage} /于是点击任意位置→ 触发onDismiss→handleDismissPopupShowToolbar发现选区仍活着 → 弹起选择工具栏。这一连串逻辑链条完整解释了报告的现场。二、修复方案先清标志再取消选中2.1 修复落点handleQuickAction的case dictionary修复合并 PR #5730文档记录时 iOS/Android 真机验证待完成落在handleQuickAction的case dictionary分支。先看完整实现Annotator.tsxcase dictionary: // A dictionary lookup only makes sense for a single word (or a short // CJK term); on a longer selection fall back to the annotation // toolbar so highlighting and copying stay reachable (#5213). if (selection isSingleLookupTerm(selection.text)) { handleDictionary(); // Drop the selection for as long as the lookup is up, so iOSs // native handles and blue highlight — painted above web content — // dont sit on top of the popup (#5585). It is handed back on // dismiss (#6213): keeping it dropped for good left no way to // highlight or copy the word, because re-selecting it with a quick // action armed only opens the dictionary again. // Clear the flag before deselecting: the selectionchange this fires // would otherwise dismiss the popup we just opened. isTextSelected.current false; instantLookupDeselectedRef.current true; view?.deselect(); } else { handleShowAnnotPopup(); } break;核心两行就是isTextSelected.current false; // 1. 先清标志 view?.deselect(); // 2. 再执行取消选中2.2 顺序为什么load-bearing承载正确性文档与源码注释都反复强调顺序是决定性的order is load-bearing。原因在于view.deselect()并非静默操作它会触发一次selectionchange事件。在useTextSelector钩子中空选区empty-selection分支的典型处理如下useTextSelector.tsif (isTextSelected.current) { handleDismissPopup(); isTextSelected.current false; }也就是说如果先调用view.deselect()、后清isTextSelected.current那么deselect()同步派发的selectionchange到达时标志仍为 trueuseTextSelector的空选区分支会调用handleDismissPopup()把我们刚刚打开的词典弹窗立刻关掉——修复变成秒关弹窗的另一个 bug。反过来先置isTextSelected.current false再view.deselect()则selectionchange到达时条件不成立弹窗得以保留。这正是文档中Clear the flag before deselecting: the selectionchange this fires would otherwise dismiss the popup we just opened注释的完整含义。这一先清标志、后触发事件的模式并非孤例——Annotator.tsx 中给标注编辑器用的dropSelectionForOverlay遵循同样的约定const dropSelectionForOverlay () { isTextSelected.current false; view?.deselect(); // A popup-window selection lives in its own document ... if (selection?.popup) { selection.range.startContainer.ownerDocument?.getSelection()?.removeAllRanges(); } };可以推断这是整个 Annotator 模块对打开覆盖层前必须释放选区的统一协议——先翻转isTextSelected引用标志再调用会派发selectionchange的deselect()避免事件回调把正要展示的表面关掉。三、iOS 侧的同源修复原生选择 UI 不再覆盖词典窗口3.1 WKWebView 的原生选择覆盖层#5585 在 iOS 上还有一个独立表现选择抓取器selection grabbers与蓝色高亮会被绘制在词典窗口之上。这与平台机制直接相关——WKWebView 将选择 UI 绘制在原生覆盖层native overlay中其层级高于 web 内容。因此只要选区在词典弹窗打开期间仍然存活iOS 就会在弹窗顶部画出一套原生手柄和高亮视觉上骑在弹窗脸上。3.2 同一处修复同时解决两端即时词典路径中view.deselect()之后选区在弹窗存活期间被真正移除iOS 的原生选择 UI 自然随之消失词典窗口不再被遮挡。这是同一修复在 Android工具栏误弹与 iOS原生覆盖层置顶两个平台上的双重收益也是为什么 #5585 的修复同时覆盖两个平台的设备验证。四、作用域边界为什么只有即时词典这样改4.1 工具栏 → 词典路径必须保留选区#5213修复刻意限制在即时快捷操作instant quick action路径内。工具栏selection toolbar上的查词按钮走的是另一条路点击按钮 →handleDictionary()→ 弹窗打开但选区全程保留。这是 #5213 的原始设计意图查一个词 → 关掉弹窗 → 立刻高亮或复制这个词。若在工具栏路径也执行取消选中用户将失去这个能力等于回退 #5213 的功能。4.2 Translate / Proofread 保持原状Translate 与 Proofread 快捷操作case translate、case proofread在本次修复中未做改动——文档明确记录它们未被报告为问题。这也说明修复遵循最小变更、按报告驱动的原则没有把未被证实的模式强行推广到其他路径。4.3 选区长度的门控isSingleLookupTerm即时词典分支还有一个前置门控只有当选区文本是单一查询词时才走词典并取消选中否则回退到handleShowAnnotPopup()显示标注工具栏高亮、复制仍可达。门控实现位于 word.tsconst MAX_CJK_LOOKUP_CHARS 8; export const isSingleLookupTerm (text: string): boolean { const trimmed text.trim(); if (!trimmed) return false; if (/\s/u.test(trimmed)) return false; const cjkPattern /[\p{ScriptHan}\p{ScriptHiragana}\p{ScriptKatakana}\p{ScriptHangul}]/u; if (!cjkPattern.test(trimmed)) return true; if (/[\p{P}\p{S}]/u.test(trimmed)) return false; return [...trimmed].length MAX_CJK_LOOKUP_CHARS; };规则细节空格分隔的文本只要不含空白即为单个 tokenCJK 文本没有词间空格以 8 字符长度上限近似词边界含标点/符号则判定为短语。由此可以推断长句选区在即时词典场景下永远不会触发取消选中高亮与复制操作始终保持可达。五、归还选区#6213 对修复的补全5.1 一个新问题查完词就再也无法高亮或复制如果只是弹窗打开时取消选中会立刻产生新的缺陷#6213词典关闭后选区被永久丢弃而快捷操作仍处于武装状态——用户重新选中同一个词时handleQuickAction会再次打开词典形成一个死循环查词 → 取消选中 → 重新选中 → 又查词永远到不了高亮/复制这一步。5.2instantLookupDeselectedRef关闭时把选区交还修复在handleDismissPopupShowToolbar的入口增加了归还逻辑Annotator.tsxconst handleDismissPopupShowToolbar () { // The instant dictionary is the one lookup that deselects as it opens, so // its dismiss has to put the range back before the check below — otherwise // the word it just defined can never be highlighted or copied (#6213). if (instantLookupDeselectedRef.current) { instantLookupDeselectedRef.current false; if (selection restoreSelectionRange(selection.range)) { isTextSelected.current true; // quickActionHandled rides along with the selection from here on, so a // later republish of it (handleHighlight stamps annotated) cant be // read as a fresh selection and re-open the lookup we just closed. setSelection({ ...selection, quickActionHandled: true }); } } if (isTextSelected.current toolButtons.length 0) { handleShowAnnotPopup(); } else { handleDismissPopupAndSelection(); } };整个闭环可以概括为三段状态机阶段状态目的即时词典打开isTextSelectedfalseinstantLookupDeselectedReftrueview.deselect()释放选区避免 iOS 原生覆盖层遮挡弹窗词典存活期间选区为空点击遮罩关闭时不会误弹工具栏词典关闭时restoreSelectionRange还原选区 quickActionHandledtrue交还选中的词工具栏正常出现且不再重新触发快捷操作quickActionHandled标志是防止归还选区被误认为新选区的关键归还后的选区在useTextSelector的入口判断enableAnnotationQuickActions annotationQuickAction isTextSelected.current !selection.quickActionHandled中会被排除避免查词弹窗被第二次打开。六、测试验证e2e 回归用例与桌面驱动方式6.1 覆盖策略e2e 而非单元测试文档明确说明本修复的覆盖是e2e端到端而非单元测试理由可以从前文推断selectionchange的派发时序、WKWebView 原生覆盖层行为、deselect()与事件回调的同步性都依赖真实浏览器环境难以在纯单元测试中稳定复现。当前仓库中的回归用例位于 annotation.spec.ts测试名称为 the instant dictionary hands the selection back when it closes (#6213)——它同时覆盖了 #5585弹窗期间选区被丢弃与 #6213关闭后选区归还两个阶段test(the instant dictionary hands the selection back when it closes (#6213), async ({ openBook, }) { const reader await openBook(); await reader.setQuickAction(Dictionary); const word await reader.selectWord(); await expect(reader.dictionaryPopup).toBeVisible(); await expect(reader.annotationPopup).toBeHidden(); expect(await reader.selectedSectionText()).toBe(); await reader.page.keyboard.press(Escape); await expect(reader.dictionaryPopup).toBeHidden(); await expect(reader.annotationPopup).toBeVisible(); expect(await reader.selectedSectionText()).toBe(word); });断言链清晰地验证了完整闭环选中单词后词典弹窗可见、标注弹窗隐藏弹窗存活期间selectedSectionText()为空选区已被丢弃#5585 要求关闭弹窗后词典隐藏、标注工具栏重新可见selectedSectionText()恢复为原单词选区已归还#6213 要求。作为对照同文件中的 closing the proofread popup returns to the selection toolbar (#5213) 用例annotation.spec.ts验证了非即时路径的行为Proofread 弹窗关闭后返回选择工具栏——这证明 #5213 的设计在普通查询路径上仍然生效与即时词典路径刻意区分。6.2 新增测试辅助ReaderPage 的selectWord/selectedSectionText/setQuickAction为了支撑上述用例ReaderPage增加了三个辅助方法文档记录于本修复配套实现中setQuickAction(action)设置快捷操作类型如DictionaryselectWord()选中一个单词并返回其文本selectedSectionText()读取当前选区文本断言选区是否为空/已归还。这些辅助方法让用例可以直接断言选区生命周期而不必依赖脆弱的 DOM 细节。6.3 桌面 Chromium 如何驱动即时路径一个容易忽略的细节是即时词典路径原本依赖**长按long-press**手势而 Playwright 驱动的桌面 Chromium 并没有真实触屏长按。文档给出的解释是鼠标选择会跳过isLongPressHold门控——isLongPressHold(0, ...)对鼠标按下即为 trueAnnotator.tsx 中该门控只对isAndroidApp之外的平台生效且quickActionMinHoldMs 300因此桌面浏览器可以直接驱动即时路径完成 e2e 回归。此外真实触摸场景下 Android 还会通过runOrDeferAction将快捷操作延迟到touchend避免弹窗被进行中的触摸关掉关联 #3935。七、功能入口与配置项7.1 头部下拉菜单启用即时词典即时词典通过阅读器头部栏的下拉菜单启用HeaderBar.tsx当enableAnnotationQuickActions开启时头部出现一个按钮aria-label在未启用时为 Enable Quick Action on Selection启用后变为 Disable Quick Action点击展开QuickActionMenu选择Instant Dictionary菜单项。未选择任何快捷操作时按钮呈灰色高亮图标选中highlight时显示对应颜色其他动作显示通用快捷操作图标。7.2 默认值与类型定义配置项定义于 types/book.tsenableAnnotationQuickActions: boolean; annotationQuickAction: AnnotationToolType | null;按文档记录与 ControlPanel.tsx 的设置界面enableAnnotationQuickActions默认为trueannotationQuickAction默认为null即不启用任何快捷操作。有趣的是 settingsService.ts 中还存在一条历史兼容迁移将旧的wikipedia快捷操作值重写为dictionary说明词典曾经经历过来源提供方的替换读者在分析旧版本配置时应注意这一重映射。八、经验总结移动端阅读器选区状态协议从 #5585 的完整修复链可以提炼出三条可复用的工程经验先改状态再派发事件。任何会触发selectionchange的deselect()调用之前必须先翻转isTextSelected这类引用标志否则事件回调会在错误的时机关闭正在打开的表面。这是 Annotator.tsx 与dropSelectionForOverlay共同遵循的协议。平台差异必须显式建模。iOS 的 WKWebView 将选择 UI 绘制在原生覆盖层层级高于 web 内容Android 的小屏则把弹窗渲染为带遮罩的底部 Sheet——同一个选区存活状态在两端的视觉后果完全不同只有按平台分别验证才能发现全部问题。状态归还要闭环。打开覆盖层时释放选区就必须在关闭时显式归还并且用quickActionHandled之类的标志防止归还动作被识别为新选区而重新触发同一动作。Readest 的即时词典在这条链路上经历了 #5585丢弃选区→ #6213归还选区两次迭代最终形成打开即释放、关闭即归还、归还即标记的完整状态机由 e2e 用例一次性锁定全部行为。如需继续深入建议阅读 Annotator.tsx 中handleQuickAction与handleDismissPopupShowToolbar的完整实现、useTextSelector.ts 的selectionchange处理分支以及 annotation.spec.ts 中 #5213 / #6213 两个对照用例。【免费下载链接】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),仅供参考