Touch 监听器实战解析)
Readest 中如何拦截 foliate iframe 的手势事件捕获阶段capture phaseTouch 监听器实战解析【免费下载链接】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 阅读器中的一个关键工程问题展开如何在与 foliate-js 渲染器共存的 iframe 文档上可靠地拦截/抑制来自应用的触摸手势如左缘滑动调节亮度、右缘滑动调节自动滚动速度而不被 foliate-js 自身的翻页器paginator抢先处理。读完本文你将掌握 DOM 事件捕获阶段与冒泡阶段的监听器注册顺序规则、stopImmediatePropagation与preventDefault的正确使用时机以及 Readest 源码中 capture-phase 监听器的完整实现模式与测试验证方法。背景foliate iframedoc上的三类 Touch 监听器Readest 的阅读器把图书内容渲染在 foliate-js 的 iframe 中应用侧的手势逻辑点击工具栏、翻页、选择文本、滑动调节亮度/速度等与 foliate-js 自身的分页逻辑全部挂载在同一个 iframe 文档对象doc上。据项目记忆文档apps/readest-app/.claude/memory/foliate-touch-listener-capture-phase.md的梳理doc上存在三个相互独立的 touch 监听器注册方FoliateViewer.tsx约 326 行附近——passive 转发器只负责把 iframe 内的触摸事件通过postMessage转发到应用侧自身不拦截、不调用preventDefault。当前实现位于 FoliateViewer.tsx其中touchstart无选项注册touchmove/touchend以{ passive: false }注册非 passive允许后续逻辑调用preventDefaulttouchcancel无选项注册。Annotator.tsx约 332 行附近——non-passive驱动文本选择负责长按选词、拖拽选择等批注交互同样注册在doc上见 Annotator.tsx。它以opts包含passive: false注册touchstart/touchmove并处理touchend/touchcancel。foliate-js 自身的 paginatorpackages/foliate-js/paginator.js:1034——non-passive、bubble 阶段这是最关键的一方。它在view.open()期间注册因此在任何应用层的load事件处理函数之前就已经挂在doc上。它可以调用preventDefault、设置#touchScrolled标记、执行scrollBy来实现翻页与滚动是整个翻页交互的核心驱动。注当前仓库检出中packages/foliate-js/目录为 workspace 占位依赖以foliate-js: workspace:*形式声明见 apps/readest-app/package.jsonpaginator 的具体实现行号以项目记忆文档中记录的packages/foliate-js/paginator.js:1034为准。核心问题为什么更早注册的冒泡阶段监听器拦不住 paginator直觉上应用如果想抢先处理触摸手势只要在doc上先于现有监听器注册一个监听器并调用stopImmediatePropagation即可。但这条路是走不通的原因如下DOM 事件监听器按阶段phase分桶事件传播分为捕获阶段capture与冒泡阶段bubble。同一阶段内部按注册先后顺序执行不同阶段之间则固定按捕获优先于冒泡执行与注册时间无关。paginator 注册在 bubble 阶段而应用若也注册在 bubble 阶段无论注册时间早晚paginator 总是更早被调用它注册于view.open()早于应用侧的load处理器。当事件冒泡到doc时paginator 的监听器已经执行完毕可能已经preventDefault、设置#touchScrolled或调用了scrollBy应用监听器此时调用stopImmediatePropagation只能阻止同阶段内排在后面的监听器对已经跑完的 paginator 毫无影响。简言之注册顺序只控制同一阶段内的相对顺序跨阶段则捕获永远先于冒泡。只要 paginator 在 bubble 阶段且注册更早任何 bubble 阶段的应用监听器都无法通过stopImmediatePropagation将其压制。修复模式{ capture: true, passive: false }正确的修复模式是改用捕获阶段注册const opts { capture: true, passive: false } as const; doc.addEventListener(touchstart, onTouchStart, opts); doc.addEventListener(touchmove, onTouchMove, opts); doc.addEventListener(touchend, onTouchEnd, opts); doc.addEventListener(touchcancel, onTouchEnd, opts);原理当事件目标target是doc的后代元素时触摸必然落在正文内容上捕获阶段的事件会从window一路向下经过doc先于所有 bubble 阶段监听器触发。因此捕获阶段监听器中的stopImmediatePropagation()可以一视同仁地抑制 paginator、Annotator、FoliateViewer 三方的处理函数——它们全部在 bubble 阶段尚未执行。两个选项缺一不可capture: true把监听器放入捕获阶段确保在 bubble 阶段之前执行这是能压制 paginator 的前提。passive: false允许调用preventDefault()。若缺省为 passive现代浏览器默认将touchstart/touchmove视为 passivepreventDefault会被静默忽略无法阻止翻页器或原生滚动。Scrolled滚动模式下的额外要求文档特别强调滚动模式下还需要从第一次 armed 的 move 就开始调用preventDefault。原因在于 paginator 在scrolled模式下会对触摸事件 early-return提前返回不接管手势此时真正移动内容的是原生容器滚动。如果捕获阶段监听器不主动preventDefault原生滚动会先发生随后亮度/速度调节激活时内容就会出现先滚动后冻结的跳动scroll-then-freeze jump。这一要求在 Readest 的源码注释中有明确记载见 useBrightnessGesture.ts 与 useAutoScrollSpeedGesture.ts。实战落地亮度滑动手势useBrightnessGesture这一模式在 Readest 中首次完整落地于左缘滑动调节亮度功能brightness-swipe-gesture实现位于 useBrightnessGesture.ts。监听器注册与运行时状态关键设计是监听器对每个文档只注册一次因此所有运行时可变的状态是否启用、是否滚动模式、当前渲染器、自动亮度开关等都通过latestRef读取每次渲染更新从而避免重复注册/解绑。这与useTouchInterceptor的handler ref 每次渲染更新思路一致见 useTouchInterceptor.ts。const opts { capture: true, passive: false } as const; const onTouchStart (e: TouchEvent) { abortGesture(); if (!latestRef.current.enabled) return; // 第二根手指加入时永久让出本序列给双指缩放/捏合 if (e.touches.length ! 1) return; const selection doc.getSelection?.(); if (selection !selection.isCollapsed) return; // 不劫持进行中的选择 const t e.touches[0]; if (!t) return; const viewWidth window.innerWidth; startXRef.current t.screenX; startYRef.current t.screenY; armedRef.current isInLeftEdge(t.screenX, viewWidth); // 是否落在左侧 10% 条带 };这里有一个容易踩坑的细节使用screenX/screenY而非clientX/clientY。在分页paginated模式下foliate-js 会把内容排版成并排的多栏iframe 文档宽度远超屏幕宽度clientX是文档坐标系而捕获阶段监听器运行在父 realm 中window即应用视口screenX对应真实屏幕位置见 useBrightnessGesture.ts。move 阶段先 preventDefault再 stopImmediatePropagationtouchmove是压制翻页器的核心战场// 滚动模式下从第一个 move 就阻止原生滚动避免先滚动后冻结 if (latestRef.current.scrolled) e.preventDefault(); if (!activeRef.current shouldActivate(dx, dy)) { activeRef.current true; } if (!activeRef.current) return; e.preventDefault(); e.stopImmediatePropagation(); // 捕获阶段调用paginator/Annotator/FoliateViewer 全部被抑制 const value computeBrightness(startValueRef.current, dy, viewHeightRef.current); scheduleBrightness(value); setOverlayVisible(true);touchend同样需要preventDefault()stopImmediatePropagation()并在捕获阶段持有者执行完毕后清理翻页手势的 per-touch 状态setLayeredTurnTouchClaimed(bookKey, false)见 useBrightnessGesture.ts。手势所有权armed / active 与单向让出捕获阶段给了应用抢先的权利但也要防止误伤正常交互。实现用armedRef手指起点是否在左缘条带与activeRef是否已经激活为亮度手势两个状态机配合几个关键的单向让出yield规则左缘条带判定BRIGHTNESS_GESTURE_EDGE_RATIO 0.1即屏幕左侧 10% 宽度的竖条内才可能 armed条带外的触摸不参与见 brightnessGesture.ts。水平方向优先一旦位移明显是水平方向|dx| BRIGHTNESS_GESTURE_ACTIVATION_PX(18px)且|dx| |dy|说明用户要做的是横向翻页Slide/Curl 翻页亮度手势永久放弃本序列的所有权不再在轨迹弯曲回竖直方向后反悔单向、一次性见 useBrightnessGesture.ts。第二根手指加入双指场景让给捏合缩放/原生缩放第一根手指不得继续持有亮度手势偷走后续 move。文本选择优先起点已存在非折叠选区、或触摸开始后 OS 长按选中了单词对应 issue #5939、或渲染器被快捷高亮锁定scrollLocked时一律让出。shouldActivate激活阈值位移超过BRIGHTNESS_GESTURE_ACTIVATION_PX18px且竖直分量占优才激活位移换算为亮度值映射到整个视口高度computeBrightness(start, dy, viewHeight)。这些规则保证了捕获阶段拦截只服务于亮度手势本身翻页、选择、缩放等原生交互在判定为不属于亮度手势时毫发无损。同款模式复刻自动滚动速度手势useAutoScrollSpeedGesture右缘滑动调节自动滚动速度的 useAutoScrollSpeedGesture.ts 复用了完全相同的 capture-phase 模式const opts { capture: true, passive: false } as const;第 48 行四个事件均以此注册第 101-104 行。差异仅在于条带为右侧isInRightEdge第 64 行起点记录同样使用screenX/screenY理由是在滚动模式下 iframe 文档高度远超视口屏幕坐标才与应用视口对齐第 57-59 行注释touchmove中从第一个 move 起无条件e.preventDefault()滚动模式下先冻结原生滚动第 76 行激活后e.stopImmediatePropagation()第 81 行位移换算为滚动速度computeSpeedtouchend提交速度并显示/隐藏速度浮层。两个 hook 均由 FoliateViewer.tsx 在docload 时统一挂载registerBrightnessListeners(detail.doc); registerSpeedListeners(detail.doc);测试验证如何证明paginator 被压制Readest 为 capture-phase 拦截模式编写了专门的监听器级测试见 useBrightnessGesture.test.tsx。测试的搭建思路本身就是对该模式的最好解释const doc makeDoc(); act(() api.registerBrightnessListeners(doc as unknown as Document)); // 用后代元素作为事件目标只有 target 是 doc 的后代捕获阶段才会先于冒泡阶段 const target doc.createElement(div); doc.body.appendChild(target); // 冒泡阶段的 paginator 替身stand-in const paginator vi.fn(); doc.addEventListener(touchmove, paginator);然后断言核心断言左缘上滑touchstart在 x10touchmove在 x10、dy-30后preventDefault与stopImmediatePropagation均被调用且paginator替身从未被调用——证明捕获阶段的stopImmediatePropagation让事件根本没进入冒泡阶段测试第 149-156 行。水平主导滑动dx50, dy10stopImmediatePropagation未被调用paginator照常执行——翻页不被误伤第 158-164 行。水平翻页轨迹先行后亮度手势不能再接管第 166-175 行。条带外x500、已存在文本选择、第二根手指加入、长按后出现选区#5939四种场景均验证了让出逻辑第 177-218 行。用测试中注释的话说a descendant target so capture-phase doc listeners fire before bubble ones——这正是指文档中描述的当事件目标是后代元素时doc上的捕获阶段监听器先于所有冒泡阶段监听器触发这一规则的可执行验证。经验总结捕获阶段拦截的适用前提从 Readest 的实践可以提炼出在 foliate-js 这类内部自带事件处理的阅读器引擎上做手势拦截的完整清单确认事件目标捕获阶段压制只对事件目标是监听器所在节点doc的后代成立。触摸正文内容恰好满足这一前提如果目标就是doc本身捕获与冒泡的先后关系不再适用。capture: true是压制 bubble 阶段引擎逻辑的唯一手段因为引擎paginator在view.open()阶段就已完成注册任何应用侧 bubble 监听器在时间上都不可能早于它。passive: false必须同时开启否则preventDefault被浏览器忽略scrolled 模式下无法阻止原生滚动。Scrolled 模式从第一次 armed 的 move 就preventDefault避免原生滚动先行导致内容跳动。捕获阶段权限很大必须配合手势所有权状态机armed/active、条带判定、激活阈值、单向让出规则把抢占限定在目标手势内不误伤翻页、选择、缩放等原生交互。坐标系统要分清在分页模式的 iframe 中优先使用screenX/screenY父 realm 即应用视口避免clientX的文档坐标系偏移。这套模式在 Readest 中已被亮度滑动与自动滚动速度两个功能端到端验证文档记载 Codex 与 Claude 子代理均在 /autoplan 评审期间对照paginator.js独立确认过结论可以作为在任何基于 foliate-js或其他自带事件处理的 iframe 渲染引擎的阅读器应用中实现自定义手势拦截的通用参考。【免费下载链接】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),仅供参考