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

资讯详情

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

Readest PDF 跨页文本选择技术解析:复合选区(segments)如何在分页 iframe 上实现跨页选择

Readest PDF 跨页文本选择技术解析:复合选区(segments)如何在分页 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点击查看免费下载导读本篇文章基于 Readest 开源仓库中对 issue [#5809]FR: select text across PDF pages as one whole将跨 PDF 页的文本作为一个整体选择的实现记录与技术记忆文档深入剖析其核心方案在 Fixed-Layout 书籍每页独占一个 iframe 的前提下如何把「每页一段原生 Selection」组合成一个统一的TextSelection.segments复合选区并分别解决桌面鼠标与 Android 触摸两条路径的跨页拖动问题。读完本文你将理解 DOM Selection 无法跨文档的底层约束、Blink 对 out-of-frame 拖拽的怪异解析、以及 Readest 用user-select冻结与自绘手柄两条思路绕开引擎限制的完整工程实践。一、问题背景为什么 PDF 跨页选择是个难题1.1 Fixed-Layout 书籍的 iframe 渲染结构Readest 的固定版式书籍Fixed-Layout典型如 PDF、CBZ使用 foliate 渲染内核每一页都被渲染在独立的 iframe中。这种结构带来了一个浏览器平台的硬性限制一个 DOMSelection无法离开它所在的 document。也就是说无论用户如何拖动鼠标一次原生选区永远只能存在于某一个页面 iframe 内部无法跨越两页之间的 document 边界。这一约束在仓库的记忆文档中写得很清楚Fixed-layout books render every page in its own iframe, so a DOM Selection cant cross pages.在apps/readest-app/src/app/reader/utils/crossDocSelection.ts的头部注释里同样记录了这条约束并把解决方案定义为「每页保留一段原生选区segment再由文本选择器组合成单一 TextSelection」// A selection that runs across section documents (#5809). Fixed-layout books // render every PDF page in its own iframe, and a DOM Selection cannot leave its // document, so a selection that continues onto the next page is kept as one // native selection per page — a segment — and composed into a single // TextSelection by the text selector.1.2 方案的总体思路PR #58312026-08-23 合并的 PR #5831提交4df8b37b7实现了这个「复合选区composite selection」方案核心骨架是分段segments一次跨页选择被拆成 N 段原生选区每段位于一个页面 iframe 内按阅读顺序排列合并发布对外复制、翻译、查词、搜索、TTS、复制链接等消费方仍然只暴露一个TextSelection其中text是所有段文本用空格拼接的结果range/index/cfi锚定在第一段上——因此所有下游功能「零改动」即可继续工作两端分别攻坚桌面鼠标路径用「user-select 冻结」技巧对抗 Blink 的拖拽行为Android 触摸路径则用「应用自绘手柄」接管原生手柄无法跨页的缺陷。二、核心数据模型TextSelection.segments2.1 类型定义复合选区的载体是TextSelection上新增的可选字段segments。其定义位于 apps/readest-app/src/utils/sel.tsexport interface SelectionSegment { range: Range; index: number; text: string; } export interface TextSelection { key: string; text: string; page: number; range: Range; index: number; cfi?: string; ... // Present when the selection spans several section documents, in reading // order. range/index/cfi are the first segments and text is all // segments text joined, so single-range consumers keep working. segments?: SelectionSegment[]; ... }契约要点字段语义segments[].range该页 iframe 内的原生 Rangesegments[].index该页在渲染内容getContents()中的节索引segments[].text该段文本text顶层各段文本以空格拼接parts.map((part) part.text).join( )range/index/cfi顶层第一段的值保证单选区消费者无需感知分段由于cfi、range、index均取自第一段复制/翻译/查词典/站内搜索/TTS/复制链接这些只消费单 Range 的功能都可以原样工作只有需要「全选」语义的功能如跨页高亮才需要遍历segments。2.2 分段助手模块所有分段相关的地层工具集中在 apps/readest-app/src/app/reader/utils/crossDocSelection.ts它们承担了从「窗口坐标点」到「各页 DOM 位置」的全部转换工作函数职责findContentAtPoint(contents, point)根据窗口坐标找到指针所在页面的 iframe跳过无节索引的空占位帧因为getContents()在分页固定版式下会包含index: undefined的空白帧必须过滤index ! nulltoDocPoint(doc, point)把窗口坐标换算成页面文档自身的 CSS 像素坐标撤销 iframe 缩放如捏合缩放预览getCaretPosition(doc, x, y)优先用caretPositionFromPoint回退caretRangeFromPoint得到文档内位置isTextAtPoint(doc, x, y)判断点是否落在真实文本上12px 容差用于区分「选词拖动」与「空白处平移」getDocTextBounds(doc)用 TreeWalker 找出页面首个与末个非空文本节点返回文本范围边界getCaretPositionInText(doc, x, y)把空白区域的点钳制到页面文本的首/末位置pdf.js 绝对定位的文本运行会让浏览器把空白处的 caret 吸附到页面中部的某个文本运行上导致反向闪烁此函数专门修复rangeBetweenPositions/rangeFromPositions构造 Range若折叠则返回 nullbuildCrossDocSegments(anchor, target, contents)组装分段较早页从锚点位置到页末、中间所有渲染页整页、较晚页从页首到目标位置折叠段丢弃按 index 排序applyCrossDocSegments(segments, contents, anchor)把各段写回对应文档的 live Selection并清空其它所有渲染页的选区setNativeDragFrozen(doc, frozen)冻结/解冻页面原生拖拽html {user-select:none}.textLayer {user-select:text}三、桌面鼠标路径user-select 冻结 程序化分段3.1 Blink 的怪异行为out-of-frame 拖拽解析到页面起始桌面端遇到一个非常反直觉的浏览器行为鼠标拖拽一旦开始浏览器就会持续把pointermove/pointerup投递给拖拽起始的那个 iframe即使指针已经移到了另一个页面 iframe 上方——只不过坐标会变成「帧外坐标out-of-frame」。更麻烦的是Blink 会把帧外拖拽点解析到页面起始位置即#canvasdiv 之后的位置于是原生选区会「反选」——从锚点一路反卷回整页开头用户看到的是一次整页级反向选区完全不可用。Blink resolves an out-of-frame drag point to the page START (position after the#canvasdiv), inverting the selection.3.2 冻结技巧让 Chromium 148 别再扩展选区Readest 的解法是一个精巧的 CSS 技巧——在指针悬停于其它页面期间把起始页冻结起来html { user-select: none; } .textLayer { user-select: text; }冻结期间页面根元素不可选中、而文本层仍可选中Chromium 148 对user-select: none目标不再扩展选区因此程序化写入的分段就能「钉住」不被原生拖拽破坏。文档里特意注明这与 pdf.js 因 Chromium 148 而删掉其 endOfContent 移动 hack 是同一个原因——引擎已经学会了尊重user-select: none。实现位于 crossDocSelection.ts 的setNativeDragFrozenexport const setNativeDragFrozen (doc: Document, frozen: boolean) { const root doc.documentElement; const layer doc.querySelectorHTMLElement(.textLayer) ?? doc.body; if (!root || !layer) return; root.style.userSelect frozen ? none : ; root.style.webkitUserSelect frozen ? none : ; layer.style.userSelect frozen ? text : ; layer.style.webkitUserSelect frozen ? text : ; };3.3 完整拖动流程useTextSelector整个鼠标跨页拖动的状态机实现在 apps/readest-app/src/app/reader/hooks/useTextSelector.ts关键阶段如下handlePointerDown布防拖拽锚点只有指针类型为 mouse、左键按下、crossPageEnabled()为真、且isTextAtPoint判定按下点确实落在文本上时才会记录dragAnchorRef { doc, index, pos }。空白边距上的拖动永远不会布防——在固定版式滚动模式下边距拖动是平移操作。handlePointerMove扩展与冻结指针移到其它页面 iframe 上方时此时浏览器仍把事件投递给起始页调用extendCrossDocTo(anchor, pointerPos)findContentAtPoint找到指针所在的目标页若目标页就是锚点页则解除跨页状态原生拖拽接管否则用getCaretPositionInText求 caret、用buildCrossDocSegments组段、applyCrossDocSegments写入各页 DOM首次跨页时对锚点页执行setNativeDragFrozen(doc, true)之后的selectionchange回声由程序化守卫programmaticSelectionRef忽略。handlePointerUp提交释放时先再扩展一次保证终点最新然后重新applyCrossDocSegments防止中途原生拖拽与锚点页分段「打架」留下脏状态最后clearCrossDoc(true)并makeCrossDocSelection(segments)发布复合选区setNativeDragFrozen(doc, false)在clearCrossDoc中恢复 user-select。程序化守卫guardProgrammaticSelection()/releaseProgrammaticSelection()成对出现。所有自己写入的选区变化都会触发selectionchange若不当成程序化回声忽略这些回声会像用户操作一样吞掉后续真实的 selectionchange。releaseProgrammaticSelection在 150ms 定时器后解除守卫。makeCrossDocSelection的发布逻辑useTextSelector.tsconst makeCrossDocSelection async (segments: CrossDocSegment[], handlesSuppressed false) { const parts: { range: Range; index: number; text: string }[] []; for (const segment of segments) { const range rangeFromPositions(segment.doc, segment.start, segment.end); if (!range) continue; const text await getAnnotationText(range); if (!text.trim()) continue; parts.push({ range, index: segment.index, text }); } const first parts[0]; if (!first) return false; ... setSelection({ key: bookKey, text: parts.map((part) part.text).join( ), cfi: view?.getCFI(first.index, first.range), page: bookData?.isFixedLayout ? first.index 1 : progress?.page || 0, range: first.range, index: first.index, segments: parts.length 1 ? parts : undefined, handlesSuppressed, }); return true; };注意segments只有在多于一段时才存在单页选择依然走普通路径行为与旧版完全一致。四、Android 触摸路径应用自绘手柄接管4.1 原生手柄为何无法跨页在 Android 上验证机型小米 WebView Canary 1532026-08-23 实测确认原生选区手柄悬浮在系统PopupWindow中——手柄的触摸事件永远不会到达MainActivity 的原生触摸桥也不会到达页面 DOM。因此拖动手柄越过页底时应用无法跟踪该手势更糟的是Blink 会把选区终点跳到页面起始整页反向选区。4.2 方案丢弃原生手柄换成应用手柄解决思路是「固定版式 滚动Webtoon模式」下手势结束时主动丢弃原生手柄让位给应用自己的 SelectionRangeEditor 手柄suppressNativeHandlesForPagesuseTextSelector.ts找到持有有效选区的渲染页克隆 Range然后removeAllRanges()→等两帧 rAF让引擎把原生手柄画掉→addRange(range)重新加回。引擎只会为「用户主动发起的选区」绘制原生手柄程序化重加不会把它们带回来——这正是 #1553 里既有的技巧重加后发布makeSelection(sel, index, false, true)handlesSuppressed: true使应用自绘手柄SelectionRangeEditor接管显示复合选区提交后仍保留handlesSuppressed手柄持续可见。4.3 dragSelectionTo应用手柄的跨页拖动引擎应用手柄的每一次拖动都会调用dragSelectionTo(anchor, point, commit)useTextSelector.ts它是统一的双路径入口单文档路径非启用态走老的rangeFromAnchorToPoint在锚点文档内求 Range不做 caret 钳制、不写跨文档选区跨文档路径启用态findContentAtPoint找到目标页——目标页是锚点页则重建本页选区是其它页则extendCrossDocTo跨页扩展commit时clearCrossDoc(true)makeCrossDocSelection(segments, true)提交。文档中特别记录了一个 CodeRabbit 复审结论提交89dee49aadragSelectionTo在 commit 时如果解析不出新 Range指针落在任何页之外或锚点页上折叠必须调用releaseProgrammaticSelection()——否则拖拽期间一直保持的程序化守卫会吞掉之后每一次真实的 selectionchange。设备实测adb input swipe将结束手柄拖到下一页59 字符锚点段 目标页段翻译源文本为拼接后的完整文本手柄与工具栏保持完全符合预期。4.4 分页模式与 iOS 的边界分页Paginated模式保留原生手柄不做任何替换crossPageEnabled为假suppressNativeHandlesForPages直接返回iOS未覆盖。WebKit 的原生手柄没有可挂钩的入口iOS: not covered (WebKit native handles; no hook)自动化测试提示CDP 的Input.dispatchTouchEvent无法拖动原生手柄那是浏览器侧行为设备端必须用adb shell input swipe从手柄矩形区域起始模拟拖动。五、功能门控crossPageEnabled跨页选择不是无条件的它被严格门控在「固定版式 滚动模式」这一组合下用户诉求即perfectly gated in PDF scrolled mode提交82faf9570// The cross-page selection (#5809) exists only for fixed-layout pages in // scroll mode, where the next page is on screen to continue onto. Reflowable // books and paginated fixed layout keep the single-document behaviour. const crossPageEnabled () !!bookData?.isFixedLayout !!getViewSettings(bookKey)?.scrolled;门控生效的位置覆盖整条链路位置门控效果handlePointerDown鼠标拖拽锚点的布防非启用态不记录锚点suppressNativeHandlesForPagesAndroid 原生手柄→应用手柄的替换非启用态不替换dragSelectionTo非启用态走老的rangeFromAnchorToPoint单文档路径无 caret 钳制、无跨文档写入makeSelection跨页「陈旧选区」清理只在启用态执行而handleHighlight在 EPUB / 分页 PDF 下走的是单段循环语义相同对应测试在useTextSelector-crossPage.test.ts中同时覆盖了 EPUB 与分页 PDF 场景。另外一个重要边界EPUB 的滚动跨章节选择没有被启用同样门控在isFixedLayout上以及关闭 Webtoon Mode 并不会自动退回滚动模式这是设计使然——需要手动点击缩放模式按钮切到 Single Page单页模式。六、跨页高亮与笔记每页一条 BookNote6.1 handleHighlight 的分段循环跨页选择的高亮不再一次性作用于一个 Range而是在 Annotator.tsx 的handleHighlight中按段循环每一页生成一条独立的 BookNote 记录选区本身仍锚定在第一段每段文本part.text各自存入对应笔记。// A selection across pages (#5809) is highlighted one page at a time: one // record per part, the selection itself staying anchored on the first. const parts selection.segments ?? [selection]; const cfis parts.map((part) selection.popup ? selection.cfi : view?.getCFI(part.index, part.range), );6.2 切换语义补齐缺失段全高亮才整体取消PR 期间发现并修复了一个切换 bug提交412a7103a原本对复合选区执行 Highlight 时每段会被独立切换——已高亮的段反而被取消。修复后的语义是只补齐尚未高亮的段仅当所有段都已高亮时再整体取消。代码实现// Toggling off only once every part is highlighted; otherwise the missing // parts are added and the already highlighted ones left alone. const allExist cfis.every((cfi) cfi findExisting(cfi) ! -1);同时handleHighlight返回BookNote[]新建的高亮记录数组每页一条只有这些「新创建的占位符」才能被笔记取消流程移除对已有记录的重新着色/切换绝不会拆掉用户自己的笔记。6.3 笔记占位符追踪笔记编辑器通过noteEditorTarget.placeholderIds追踪跨页高亮创建的占位符每页一个占位符即created.map((annotation) annotation.id)当编辑器被取消/关闭时useEffect监听noteEditorTarget消失removeNotePlaceholders会逐一撤销这些占位符Annotator.tsx。这正是 CodeRabbit 后续要求把追踪机制改为notebookNewHighlightIds: string[]的落地位置——Annotate 每页创建一个占位符Cancel 则全部拆除相关 store 定义在 apps/readest-app/src/store/notebookStore.ts。七、测试与设备验证7.1 单元测试跨页选择的核心行为由 apps/readest-app/src/tests/app/reader/hooks/useTextSelector-crossPage.test.ts 覆盖测试用两个模拟 PDF 页 iframepageA/pageBjsdom 中以矩形近似 glyph 盒构造场景主要用例包括拖到下一页选中两段并提交一个分段选区断言起始页userSelect冻结为none、目标页选区出现提交后selection.text page text second、segments.map(s s.index) [1, 2]、两页的 DOM 选区都保留拖回起始页交还拖动并清空另一页回到锚点页后冻结解除、目标页选区清空、最终发布的选区不含segments单页内拖动不触碰其它页dragSelectionTo应用手柄路径手柄拖到下一页跨页扩展并提交分段选区。7.2 设备验证要点PR APK 在小米设备上通过了完整回归md5 校验匹配验证矩阵包括正向拖动、拖回起始页、反向拖动起始手柄拖到上一页、复合高亮双页标记 删除双页移除、激活选区时平移不扩展选区、单页View Options Single Page模式保持原生手柄。最终 APK89dee49aa复验复合 → Highlight 双侧 → Delete 双侧PR CI 全绿。7.3 真机验证配方Verify recipe文档记录的可复现验证流程推送一个文本 PDF 到设备通过 MediaStore VIEW intent 打开content://media/external/file/idView Options 切到 Webtoon Mode滚动模式view.goTo(40)跳到目标页第 40 页长按 obtaining 出两枚应用手柄用adb shell input swipe x y x y 900模拟真实长按CDP 的Input.synthesizeTapGesture {duration:800}在本机不生效拖动手柄跨页用adb shell input swipeCDP 驱动脚本位于仓库apps/readest-app/scripts/cdp.mjsnode WebSocket adb forward webview_devtools_remote_pid。八、踩坑记录与已知限制8.1 调试中踩过的坑Gotchaspdf.js 链接注释长按时显示黄色 hover 框容易被误认为即时高亮TOC 行本身就是链接长按得不到选区Instant Highlight 快捷操作设备上开着即时高亮时会影响选择体验可通过 Header 快捷操作下拉点击当前激活项关闭toast 提示 Instant Highlight DisabledChrome-MCP 坐标映射该 Mac 上为命中截图 (sx,sy) 处的点需传 (sx×1.112, sy×1.112)窗口缩放 90% 时的换算系数getContents()空帧分页固定版式下包含index: undefined的空白帧处理时务必过滤index ! null。8.2 已知限制Open弹出定位复合选区的弹出层仍按既有的 start/end 房间规则锚定可能落在第一段上方iOS 不支持WebKit 原生手柄无挂钩入口EPUB 滚动跨章未启用门控在isFixedLayout跨页双击拖动保留的是 caret 锚点而非词首。九、小结Readest 的 PDF 跨页选择是一个典型的「浏览器平台约束 × 产品体验」对抗案例用分段复合模型TextSelection.segments绕开 DOM Selection 的 document 边界用user-select冻结技巧驯服 Chromium 148 对 out-of-frame 拖拽的怪异解析用#1553 手柄替换技巧绕开 Android 原生手柄的 PopupWindow 触摸黑洞并以crossPageEnabled()严格门控在「固定版式 滚动模式」上保证 EPUB、分页 PDF 与 iOS 的既有行为完全不变。对任何需要在 iframe 分页渲染架构下实现跨页选区的 Web 阅读器或文档产品这套「分段 冻结 自绘手柄」的组合都是极具参考价值的工程范式。延伸阅读仓库内资源数据模型apps/readest-app/src/utils/sel.tsTextSelection/SelectionSegment定义分段助手apps/readest-app/src/app/reader/utils/crossDocSelection.ts选择器主逻辑apps/readest-app/src/app/reader/hooks/useTextSelector.ts跨页高亮apps/readest-app/src/app/reader/components/annotator/Annotator.tsx单元测试apps/readest-app/src/tests/app/reader/hooks/useTextSelector-crossPage.test.tsCDP 驱动脚本apps/readest-app/scripts/cdp.mjs赞分享桌面应用跨平台前端【免费下载链接】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 分页模式跨页选择与角驻留自动翻页useAutoPageTurn 抽取与四手势驱动的实现解析Readest 分页模式跨页选择与角驻留自动翻页useAutoPageTurn 抽取与四手势驱动的实现解析 导读 本文基于 Readest 仓库中 cross桌面应用跨平台前端Layuimini单页版vs iframe版如何选择最适合你的项目方案Layuimini单页版vs iframe版如何选择最适合你的项目方案 Layuimini作为一款基于Layui和Vue.js的轻量级前端管理后台框架提供了前端UI组件Puppeteer Frame.select() 方法详解在页面与 iframe 中批量选择下拉框选项Puppeteer Frame.select 方法详解在页面与 iframe 中批量选择下拉框选项 导读 Frame.select 是 Puppeteer 面浏览器控制测试网页爬虫开发工具上一篇Tauri跨平台编译中的常见问题与解决方案下一篇解决ExplorerPatcher导致的Windows资源管理器崩溃问题从根源到修复创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表