news 2026/9/20 21:34:51

Readest 中如何拦截 foliate iframe 的手势事件:捕获阶段(capture phase)Touch 监听器实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Readest 中如何拦截 foliate iframe 的手势事件:捕获阶段(capture phase)Touch 监听器实战解析

Readest 中如何拦截 foliate iframe 的手势事件:捕获阶段(capture phase)Touch 监听器实战解析

【免费下载链接】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 事件捕获阶段与冒泡阶段的监听器注册顺序规则、stopImmediatePropagationpreventDefault的正确使用时机,以及 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 监听器注册方

  1. FoliateViewer.tsx(约 326 行附近)——passive 转发器:只负责把 iframe 内的触摸事件通过postMessage转发到应用侧,自身不拦截、不调用preventDefault。当前实现位于 FoliateViewer.tsx,其中touchstart无选项注册,touchmove/touchend{ passive: false }注册(非 passive,允许后续逻辑调用preventDefault),touchcancel无选项注册。

  2. Annotator.tsx(约 332 行附近)——non-passive,驱动文本选择:负责长按选词、拖拽选择等批注交互,同样注册在doc上,见 Annotator.tsx。它以opts(包含passive: false)注册touchstart/touchmove,并处理touchend/touchcancel

  3. foliate-js 自身的 paginator(packages/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.json),paginator 的具体实现行号以项目记忆文档中记录的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视为 passive),preventDefault会被静默忽略,无法阻止翻页器或原生滚动。

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,再 stopImmediatePropagation

touchmove是压制翻页器的核心战场:

// 滚动模式下从第一个 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_PX(18px)且竖直分量占优才激活,位移换算为亮度值映射到整个视口高度(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 行);
  • 位移换算为滚动速度(computeSpeed),touchend提交速度并显示/隐藏速度浮层。

两个 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在 x=10,touchmove在 x=10、dy=-30)后,preventDefaultstopImmediatePropagation均被调用,且paginator替身从未被调用——证明捕获阶段的stopImmediatePropagation让事件根本没进入冒泡阶段(测试第 149-156 行)。
  • 水平主导滑动(dx=50, dy=10):stopImmediatePropagation未被调用,paginator照常执行——翻页不被误伤(第 158-164 行)。
  • 水平翻页轨迹先行后,亮度手势不能再接管(第 166-175 行)。
  • 条带外(x=500)、已存在文本选择第二根手指加入长按后出现选区(#5939)四种场景均验证了让出逻辑(第 177-218 行)。

用测试中注释的话说:"a descendant target so capture-phase doc listeners fire before bubble ones"——这正是指文档中描述的"当事件目标是后代元素时,doc上的捕获阶段监听器先于所有冒泡阶段监听器触发"这一规则的可执行验证。

经验总结:捕获阶段拦截的适用前提

从 Readest 的实践可以提炼出在 foliate-js 这类"内部自带事件处理"的阅读器引擎上做手势拦截的完整清单:

  1. 确认事件目标:捕获阶段压制只对"事件目标是监听器所在节点(doc)的后代"成立。触摸正文内容恰好满足这一前提;如果目标就是doc本身,捕获与冒泡的先后关系不再适用。
  2. capture: true是压制 bubble 阶段引擎逻辑的唯一手段,因为引擎(paginator)在view.open()阶段就已完成注册,任何应用侧 bubble 监听器在时间上都不可能早于它。
  3. passive: false必须同时开启,否则preventDefault被浏览器忽略,scrolled 模式下无法阻止原生滚动。
  4. Scrolled 模式从第一次 armed 的 move 就preventDefault,避免原生滚动先行导致内容跳动。
  5. 捕获阶段权限很大,必须配合手势所有权状态机(armed/active、条带判定、激活阈值、单向让出规则),把"抢占"限定在目标手势内,不误伤翻页、选择、缩放等原生交互。
  6. 坐标系统要分清:在分页模式的 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 21:33:56

Cookiecutter Hooks 完全指南:在项目生成前后执行自动化任务

开发工具CLI代码生成 【免费下载链接】cookiecutter A cross-platform command-line utility that creates projects from cookiecutters (project templates), e.g. Python package projects, C projects. 项目地址: https://gitcode.com/gh_mirrors/co/cookiecutt…

作者头像 李华
网站建设 2026/9/20 21:32:08

QuickRecorder:一个不到 10MB 的免费 macOS 录屏工具

QuickRecorder:一个不到 10MB 的免费 macOS 录屏工具 【免费下载链接】QuickRecorder A lightweight screen recorder based on ScreenCapture Kit for macOS / 基于 ScreenCapture Kit 的轻量化多功能 macOS 录屏工具 项目地址: https://gitcode.com/GitHub_Tren…

作者头像 李华
网站建设 2026/9/20 21:28:37

Atlas 300V 24G加速卡解读:昇腾NPU上部署YOLO全流程实战

上周同事在项目群里甩过来一张截图,问题写得很直接:Atlas 300V 24G 是运算加速卡吗?看到这个问题我一下就笑了,因为一个月前我刚拿到这张卡时的反应一模一样——把它插进服务器PCIe槽,开机,习惯性敲nvidia-…

作者头像 李华
网站建设 2026/9/20 21:28:17

PolarDB Agent Express:企业级AI Agent PaaS的架构拆解与落地指南

最近在社区里看到不少人在讨论一个叫PolarDB Agent Express的产品名。奇怪的是,问法高度一致:"它到底是个独立产品,还是一堆东西拼起来的组合?"、"它和数据库是什么关系?"、"这名字看起来像个…

作者头像 李华