资讯详情

资讯详情

Readest 双击选词与快捷操作联动机制深度解析:iframe 事件桥、Intl.Segmenter 词边界与触摸双击合成

桌面应用跨平台前端【免费下载链接】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 阅读器在移动端与桌面端保持选词体验一致的关键手势。本文以项目内部记忆文档apps/readest-app/.claude/memory/iframe-double-click-word-select.md为骨架结合 sel.ts、useTextSelector.ts、Annotator.tsx 等源码与测试用例完整讲解该手势从 iframe 事件发起到词范围解析、选择发布、快捷操作分流再到端到端验证的全链路实现。读完本文你将掌握Readest 如何在无原生双击选词的 Android WebView 中通过postMessage事件桥合成词选择Intl.Segmenter如何同时切分 CJK 与拉丁语词边界桌面端如何避免程序化合成与浏览器原生选择发生冲突以及双击手势与长按快速操作quickActionMinHoldMs300ms 门限之间如何协同。一、背景一个被“发出却无人消费”的 iframe 事件Readest 的图书内容渲染在 foliate 提供的 iframe 内iframe 与宿主页面之间通过window.postMessage通信。在本次改动之前双击事件事实上已经存在iframeEventHandlers.ts 中的handleClick会在两次点击间隔小于DOUBLE_CLICK_INTERVAL_THRESHOLD_MS定义于 constants.ts值为 250ms时向宿主页面发布iframe-double-click消息但该消息没有任何消费者Android 没有原生的触摸双击选词能力双击被当作两次单击而桌面端的双击选词早已由浏览器原生完成走的是handlePointerUp路径。结果是在 Android 上双击一个单词什么都不会发生选词体验与桌面端存在明显落差。本次实现的目标就是为这条既有的iframe-double-click事件补上完整的消费链路让触摸双击在 Android 上也能“像长按一样”选中单词并按配置触发即时快捷操作或弹出注解工具栏。值得注意的细节是handleClick发布该事件时受doubleClickDisabledref 门控对应阅读视图设置中的disableDoubleClick。桌面端默认关闭该选项DEFAULT_VIEW_SETTINGS.disableDoubleClick: false见 constants.ts而移动端默认开启DEFAULT_MOBILE_VIEW_SETTINGS.disableDoubleClick: true见 constants.ts——因为双击检测会把单击翻页延迟约 250ms 的消歧窗口移动端默认不启用该手势需要用户在设置中主动打开。二、词边界解析Intl.Segmenter与 Caret 定位sel.ts整个功能的地基是 sel.ts 中新增的两个函数getWordRangeAt与getWordRangeFromPoint。2.1getWordRangeAt(node, offset)从 caret 扩展到单词该函数接收一个文本节点及其中的偏移量即 caret 位置返回覆盖整个“词形片段”的Range。其核心逻辑sel.ts如下export const getWordRangeAt (node: Node, offset: number): Range | null { if (node.nodeType ! Node.TEXT_NODE) return null; if (typeof Intl undefined || !Intl.Segmenter) return null; const text node.textContent ?? ; if (!text) return null; const doc node.ownerDocument; if (!doc) return null; const segmenter new Intl.Segmenter(undefined, { granularity: word }); for (const seg of segmenter.segment(text)) { if (!seg.isWordLike) continue; const start seg.index; const end seg.index seg.segment.length; // The caret falls inside this word, or sits exactly on either edge (a // caret-from-point at a word boundary should still select the adjacent word). if (offset start offset end) { const range doc.createRange(); try { range.setStart(node, start); range.setEnd(node, end); } catch { return null; } return range.collapsed ? null : range; } } return null; };关键设计点依赖Intl.Segmenter以granularity: word分词isWordLike区分真正的词与空白/标点。CJK中日韩文字同样能被切分为词形片段因此该实现天然同时支持拉丁语与 CJK 文本与同文件snapRangeToWords的切词策略保持一致sel.ts边界 caret 也能选中相邻词判定条件是offset start offset end注意是闭区间。当 caret 恰好落在词边界上时仍会选中与该边界相邻的词——这正是“caret-from-point 落在词边界时仍应选中相邻词”的意图非词形位置返回 null如果 caret 位于空白、标点或非文本节点上函数返回null上层据此放弃本次合成环境中没有Intl.Segmenter如旧引擎时同样返回null保证降级安全。2.2getCaretPointFromPoint与getWordRangeFromPoint坐标 → 词getWordRangeFromPoint(doc, x, y)sel.ts把视口坐标解析为词范围分两步export const getCaretPointFromPoint ( doc: Document, x: number, y: number, ): { node: Node; offset: number } | null { let node: Node | null null; let offset 0; if (doc.caretPositionFromPoint) { const pos doc.caretPositionFromPoint(x, y); if (pos) { node pos.offsetNode; offset pos.offset; } } else if (doc.caretRangeFromPoint) { const range doc.caretRangeFromPoint(x, y); if (range) { node range.startContainer; offset range.startOffset; } } if (!node) return null; return { node, offset }; }; export const getWordRangeFromPoint (doc: Document, x: number, y: number): Range | null { const point getCaretPointFromPoint(doc, x, y); return point ? getWordRangeAt(point.node, point.offset) : null; };优先使用doc.caretPositionFromPoint不存在时回退到caretRangeFromPoint兼容 Safari/旧 WebKit返回的{ node, offset }就是 caret 位置再委托getWordRangeAt完成词扩展这套“坐标 → caret → 词”的管线与rangeFromAnchorToPoint共用同一套点解析逻辑sel.ts但后者的输入是 iframe 局部坐标而双击路径直接使用clientX/clientY无需窗口↔框架坐标映射详见第三节。对应的单元测试位于 sel.test.ts覆盖了caret 落在词中间/词首/词尾getWordRangeAt(node, 8)、getWordRangeAt(node, 6)、getWordRangeAt(node, 11)均能选中整个词位于空白处getWordRangeAt(node, 1)返回null非文本节点getWordRangeAt(container, 0)返回null以及getWordRangeFromPoint对有效坐标解析成功、对空白坐标返回null。三、选择合成与路由useTextSelector.handleDoubleClick词范围解析好后下一步是把它变成应用统一的选择状态。这部分实现在 useTextSelector.ts 的handleDoubleClickconst handleDoubleClick async (doc: Document, index: number, x: number, y: number) { if (isInstantAnnotating.current) return; const sel doc.getSelection(); if (!sel || isValidSelection(sel)) return; const range getWordRangeFromPoint(doc, x, y); if (!range) return; guardProgrammaticSelection(); sel.removeAllRanges(); sel.addRange(range); releaseProgrammaticSelection(); // With the instant-highlight stylesheet suppression active, WebKit may // refuse the programmatic selection on non-selectable content. if (sel.rangeCount 0) return; // No isUpToPopup latch here: a double-tap is two taps both consumed by the // double-click detection, so no trailing single-click follows that would // dismiss the popup — the next deliberate tap should dismiss it normally. await makeSelection(sel, index, false); };3.1 关键守卫if (isValidSelection(sel)) returnisValidSelectionuseTextSelector.ts要求sel.toString().trim().length 0 sel.rangeCount 0。这条守卫是桌面端正确性的核心桌面端浏览器原生双击已经选中了该词经由handlePointerUp路径在 pointerup 时发布选择此时sel非空handleDoubleClick直接返回只在没有任何选择时才合成触摸端Android双击没有产生原生选择sel为空或折叠于是进入合成分支。也就是说合成的词选择只服务触摸双击桌面端不会出现“浏览器选一次、程序又选一次”的双重选择问题。3.2 程序化选择的回声抑制guardProgrammaticSelectionsel.removeAllRanges()/sel.addRange(range)会触发selectionchange事件。如果不加抑制handleSelectionchange会把这次程序化写入当作“新的用户选择”处理导致弹层重复发布甚至闪烁。guardProgrammaticSelection()设置programmaticSelectionRef.current truehandleSelectionchange开头检测到该标志即返回useTextSelector.tsreleaseProgrammaticSelection()则延迟 150ms 清除标志useTextSelector.ts因为selectionchange在 DOM 变更之后才会派发任务。这套“写前加锁、写后延时解锁”的模式与applyProgrammaticSelection、restoreSelectionRange等程序化选择路径完全一致。3.3 没有isUpToPopup闩锁双击不会误关弹层handleSingleClickuseTextSelector.ts会在收到iframe-single-click时根据isUpToPopup与isTextSelected决定是否关闭弹层。handleDoubleClick刻意不设置isUpToPopup闩锁双击 两次单击这两次单击都被双击检测消费掉了handleClick中命中双击分支后立即return不再发布iframe-single-click因此双击之后不会有“尾随单击”来关闭刚弹出的工具栏下一次用户有意的单击仍能正常关闭弹层。若设置了闩锁反而会把下一次单击误判为“尾随单击”而吞掉。这是触摸手势与桌面单击语义在时序上的重要差异。3.4makeSelection复用与长按选词完全同构合成完成后调用makeSelection(sel, index, false)useTextSelector.ts它与长按选词、桌面选词共用同一条发布管线写入isTextSelected、通过view.getCFI(index, range)计算 CFI、组装TextSelection含key/text/cfi/page/range/index。TextSelection结构定义在 sel.ts其中还包含跨章节选区segments、quickActionHandled、popup等进阶字段说明这条管线同时支撑跨页选择、字典查词回还等更复杂场景。四、事件消费端Annotator的 message 监听与门限绕过现在回到事件链的起点——谁在消费iframe-double-click答案是 Annotator.tsx 中的window级message监听器useEffect(() { const handleDoubleClickMessage (msg: MessageEvent) { const data msg.data; if (!data || data.bookKey ! bookKey || data.type ! iframe-double-click) return; const renderer view?.renderer; const contents renderer?.getContents?.() ?? []; const content contents.find((c) c.index renderer?.primaryIndex) ?? contents[0]; const doc content?.doc; const index content?.index; if (!doc || index undefined) return; // A double-click is a deliberate act-on-word gesture, so let the quick // action fire without the touch long-press hold gate (matching a mouse // selection, which sets this to 0 on pointerdown). pointerDownTimeRef.current 0; void handleDoubleClick(doc, index, data.clientX, data.clientY); }; window.addEventListener(message, handleDoubleClickMessage); return () window.removeEventListener(message, handleDoubleClickMessage); }, [bookKey, view]);这里有几个值得展开的实现细节4.1 解析可见章节与原生触摸桥同构getContents()返回当前所有渲染的章节文档foliate 渲染器会预加载邻近章节。由于load事件也会为预加载的邻接章节触发监听器不能捕获加载时的 doc/index而必须在消息到达的当下解析contents.find((c) c.index renderer?.primaryIndex) ?? contents[0]——优先取primaryIndex指向的当前主章节找不到再退回第一个章节这与handleNativeTouchAnnotator.tsx解析 Android/iOS 原生触摸事件的策略完全一致保证消息的坐标系落在正确的章节文档内。4.2 坐标系iframe 局部坐标即所需坐标iframe-double-click消息携带的clientX/clientYiframeEventHandlers.ts是触发 iframe 视口内的坐标而getWordRangeFromPoint的caretPositionFromPoint(x, y)期望的正是文档视口坐标——两者天然一致无需任何窗口↔框架映射。这与同文件中的rangeFromAnchorToPoint形成鲜明对比后者接收的是窗口坐标调用方必须先用frameElement.getBoundingClientRect()减去 iframe 左上角偏移才能使用参见 useTextSelector.ts 的point.x - (feRect?.left ?? 0)换算。记忆文档特别强调了这个区别值得在后续维护中留意。4.3pointerDownTimeRef.current 0绕过 300ms 长按门限handleQuickActionAnnotator.tsx在 iOS/桌面端执行快捷操作前会校验按压时长const quickActionMinHoldMs 300; if ( !appService?.isAndroidApp !isLongPressHold(pointerDownTimeRef.current, Date.now(), quickActionMinHoldMs) ) { return; }真实触摸选择系统长按约 500ms与“快速点按后残留的旧选择”之间需要 300ms 门限来区分只有按住足够久才视为一次有意的快捷操作避免 iOS/桌面端误触但双击是一次明确“作用于单词”的手势不应被长按门限拦截。因此监听器在调用handleDoubleClick前把pointerDownTimeRef.current置为 0使isLongPressHold判定为真快捷操作得以直接触发——这与鼠标选择在 pointerdown 时同样置 0 的行为保持一致记忆文档注释mouse already uses 0。4.4 分支决策复用 Annotator 的 selection effect选中单词后走“快捷操作”还是“注解工具栏”完全复用既有选择状态的 effect 分支Annotator.tsxconst { enableAnnotationQuickActions, annotationQuickAction } viewSettings; // ... enableAnnotationQuickActions annotationQuickAction isTextSelected.current ? handleQuickAction() : handleShowAnnotPopup()默认配置annotationQuickAction: null→ 弹注解工具栏.popup-container.selection-popup用户在设置中开启“即时快捷操作”并选定动作后可选项包括 copy、highlight、search、dictionary、translate、tts、share见 Annotator.tsx 的 switch 分发→ 直接执行对应动作快捷操作的选择界面位于 HeaderBar.tsxhandleAnnotationQuickActionSelect设置面板在 ControlPanel.tsx一个细节dictionary快捷操作仅对单个词或短 CJK 短语isSingleLookupTerm判定生效长选择会回退到注解工具栏Annotator.tsx。五、配置开关与使用方式该手势的启用与配置都围绕ViewSettings展开配置项默认值桌面默认值移动作用disableDoubleClickfalsetruetrue时handleClick不发布iframe-double-click双击退化为两次单击enableAnnotationQuickActions随默认配置随默认配置是否启用“选中即执行快捷操作”annotationQuickActionnullnull快捷操作类型null时双击选词后弹注解工具栏默认值定义见 constants.ts、constants.ts移动端默认disableDoubleClick: true的原因双击检测需要 250ms 消歧窗口DOUBLE_CLICK_INTERVAL_THRESHOLD_MS会拖慢单击翻页响应因此该手势属于移动端的主动开启项从源码结构看annotationQuickAction的候选动作与注解工具栏AnnotationTools共享同一套动作类型枚举具体可见 AnnotationTools.tsx 的quickAction标记。典型使用流程桌面端默认即可用——双击任意单词若未配置快捷操作则弹出注解工具栏移动端先在设置中关闭disableDoubleClick之后触摸双击单词即可获得与桌面一致的行为若在阅读页头部下拉中把快捷操作设为 Dictionary双击单词将直接打开词典查询而不再弹工具栏。六、测试体系单元、Hook 与 Android 端到端三层验证该功能的可靠性由三层测试共同保障6.1 纯函数单元测试sel.test.tssel.test.ts 直接验证getWordRangeAt/getWordRangeFromPointcaret 在词中间offset 8Hello world test中的world、词首6、词尾11都能返回完整词范围caret 落在空白1返回null非文本节点入参返回nullgetWordRangeFromPoint配合 mock 的caretRangeFromPoint验证“坐标→词”的整条解析链路。6.2 Hook 级测试useTextSelector-doubleClick.test.tsuseTextSelector-doubleClick.test.ts 用renderHook驱动真实的useTextSelector重点覆盖触摸双击合成无原生选择时调用handleDoubleClick单词被正确选中并发布选择桌面守卫已存在原生选择时isValidSelection为真handleDoubleClick直接跳过不产生第二次发布原生双击归一化对鼠标双击产生的选择在发布前剔除浏览器附加的分隔符trimRangeWhitespaceAroundPoint路径且随后的双击消息不会二次发布边界情况双击后故意拖入空白的选择会被保留DOUBLE_CLICK_DRAG_MOVE_PX 3的容差而目标之外的选择不会被误归一化。6.3 Android 端到端double-click.android.test.tsdouble-click.android.test.ts 是最高层级的验证运行在任意已安装 debug Readest 的 adb 设备/模拟器上无设备时软跳过beforeAll通过patchGlobalViewSettings({ disableDoubleClick: false })打开移动端默认关闭的手势locateAnyWord在渲染文档中寻找合适的单词至少 4 个拉丁字母、无撇号干扰、渲染为单行框、远离页面边缘确保双击命中稳定的文本目标page.doubleTap(hit.cssX, hit.cssY)执行真实触摸双击随后断言选区存在且非折叠且sel.text hit.word整词被选中与长按选词一致默认无快捷操作时注解工具栏出现.selection-popup该文件同时印证了移动端手势的 opt-in 属性DEFAULT_MOBILE_VIEW_SETTINGS默认disableDoubleClick: true。记忆文档还记录了基于 CDP 的实机验证方式工具栏分支断言.popup-container.selection-popup存在快捷操作分支则通过头部下拉把快捷操作设为 Dictionary断言.popup-container.select-text出现且工具栏不出现。七、与其他手势功能的关联双击选词并非孤立功能它与阅读器的手势体系深度交织记忆文档末尾的链接指向instant-highlight-tap-paginate快捷高亮在触摸端是“按住静止 300ms”才生效INSTANT_HOLD_MS见 useTextSelector.ts以免吞掉翻页的 tap/swipe双击选词是独立于该路径的“快动作”入口tap-to-open-image-table-4600在漫画等固定版式中双击不会触发选词而是打开图片查看器——handleClick对漫画页图片目标优先发布iframe-open-mediaiframeEventHandlers.tsdblclick-drag-pageturn-4524双击后拖动mouseDoubleClickRefDOUBLE_CLICK_DRAG_MOVE_PX会延展选择并支持拖动翻页这条路径在handlePointerUp中通过trimPoint归一化处理与长按选词、跨页选择segments、Android 连字符选择边界 bug#1553修复等共用 sel.ts 这一基础设施。八、小结双击选词功能完整回答了三个工程问题谁来发事件iframe 的handleClick双击检测、谁来解析词Intl.Segmenter caret 定位、谁来消费并分流Annotator 的 message 监听 既有 selection effect。其核心经验可以提炼为补全而非新建事件链iframe-double-click早已发布缺口在消费端——任何“事件已发出却无消费者”的现象都是潜在功能欠账平台差异用守卫而非分支if (isValidSelection(sel)) return让桌面原生选择与触摸合成选择各走各路互不干扰手势时序决定闩锁取舍双击的两击都被检测消费无需isUpToPopup闩锁而长按后的尾随单击则需要闩锁保护坐标系就近使用iframe 局部坐标直接喂给caretPositionFromPoint省去窗口↔框架映射但同样坐标在不同 APIrangeFromAnchorToPoint下含义不同需要严格区分。三层测试纯函数、Hook、Android e2e从词边界解析到真实设备手势的完整覆盖加上记忆文档中记录的 CDP 实机验证方法为后续在更多平台复现或调整该手势提供了可复用的验证模板。赞分享桌面应用跨平台前端【免费下载链接】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 Web 双击拖拽选词误触发翻页4524的根因分析与修复实录Readest Web 双击拖拽选词误触发翻页 4524的根因分析与修复实录 本文以 Readest 仓库中 apps/readest app/.claud桌面应用跨平台前端Zen Browser触控手势支持滑动、捏合与双击操作详解Zen Browser触控手势支持滑动、捏合与双击操作详解 你是否曾在浏览网页时因频繁切换标签而感到手指疲劳是否希望用更自然的手势代替繁琐的鼠标操作Zen桌面应用Textual Click 事件完全指南单击、双击与连击链chain机制详解Textual Click 事件完全指南单击、双击与连击链chain机制详解 点击事件是终端图形界面交互的基础。在 TextualPython 终端应用前端UI组件异步编程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →