Readest 标注弹出层定位偏移修复:fixed 包含块陷阱与书籍单元格坐标空间剖析
2026/9/20 18:58:12 网站建设 项目流程
  • 桌面应用
  • 跨平台
  • 前端

【免费下载链接】readest

Readest 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 阅读器中一次真实的回归与修复过程展开:PR #6036 为了让标注工具栏(AnnotationPopup)被压在范围编辑手柄(range handles)之下,给弹出层包装了一层pointer-events-none fixed inset-0 z-[43],却因为fixed把弹出层的包含块从"书籍单元格"重新锚定到了视口,导致工具栏在侧边栏打开或分屏阅读时整体偏移gridcell.left像素。文章从根因(两套不共享的坐标空间)、修复方案(fixedabsolute)、回归测试、E2E 测试连带修复到可复现的验证配方,完整还原这一典型的前端定位陷阱,并附上当前仓库的源码级证据,帮助读者理解并规避同类问题。


一、问题现象与引入背景

1.1 回归从何而来

该问题由 commit88ea2de55(PR #6036,"keep the annotation toolbar below the range handles",2026-09-03 引入)造成,截至 2026-09-05 尚未随任何版本发布(不在 v0.12.6 中,因此 web.readest.com 线上不受影响)。

PR #6036 的动机本身是合理的:标注工具栏打开时正对着选区,与选区上悬挂的范围编辑手柄天然重叠,两个图层重叠的像素归谁所有需要被明确裁定。由于手柄是拖拽目标,重叠区域应归手柄层所有——否则被工具栏遮住的那部分手柄无法拖拽,点击还会误触工具栏上的工具按钮。为此,PR 在AnnotationPopup外加了一层pointer-events-none fixed inset-0 z-[43],仅用于在 z 轴堆叠序上制造一条"位于手柄层之下"的堆叠带。

1.2 现象:工具栏整体偏移

包装层引入后,标注工具栏在两种常见场景下会精确偏移gridcell.left像素:

  • 侧边栏打开时:书籍单元格被侧边栏(默认 240px)从视口原点推开;
  • 分屏视图中第一本之后的任何书:非首本书的单元格天然不在视口原点。

此时工具栏渲染在选区左侧gridcell.leftpx 处,与选区完全错位。而字典、翻译、校对等其余弹出层外观一切正常——这个"只有工具栏错位"的特征,正是定位根因的关键线索。


二、根因剖析:两套不共享的坐标空间

2.1 弹出层坐标是"书籍单元格相对"的

在 Annotator.tsx 的repositionPopups中可以看到,所有弹出层(标注工具栏、字典、翻译、校对)的位置计算方式完全一致:

const gridFrame = document.querySelector(`#gridcell-${bookKey}`); if (!gridFrame) return; const rect = gridFrame.getBoundingClientRect(); const triangPos = getPosition(selection, rect, trianglePadding, viewSettings.vertical); const annotPopupPos = getPopupPosition( triangPos, rect, viewSettings.vertical ? annotPopupHeight : annotPopupWidth, viewSettings.vertical ? annotPopupWidth : annotPopupHeight, popupPadding, );

关键在 sel.ts 的getPosition与 getPopupPosition:

  • getPosition以选区各段的矩形(rects)为基准,减去rect(即#gridcell-<bookKey>getBoundingClientRect())的left/top,得到相对书籍单元格左上角的坐标
  • getPopupPosition再基于该相对坐标推导弹出层左上角,并用单元格的宽高做边界钳制(例如popupPoint.x > boundingReact.right - boundingReact.left - popupPaddingPx时回推)。

也就是说,AnnotationPopup收到的position单元格局部坐标系中的值。

2.2 单元格曾经就是弹出层的包含块

在 BooksGrid.tsx 中,每个书籍单元格被显式设置为定位上下文:

<div id={`gridcell-${bookKey}`} >// `absolute`, never `fixed`: `position` is in the book cell's coordinate // space (Annotator subtracts `#gridcell-<bookKey>`'s rect), and the cell // is the popup's `relative` ancestor. A fixed wrapper re-anchors the popup // to the viewport, which drops it `cell.left` px to the left of the // selection the moment the cell leaves the viewport origin — sidebar open, // or any book past the first in a split view. Inset to the cell, this // still makes the stacking context without moving anything. // `pointer-events-none` keeps the cell-covering wrapper from swallowing // the taps outside the popup that dismiss it. <div dir={dir} className='pointer-events-none absolute inset-0 z-[43]'>

3.2 为什么这样改仍然成立

absolutefixed在这里的差异正是修复的全部意义:

  • 包含块回到单元格absolute+inset-0让包装层以最近的relative祖先(即#gridcell-<bookKey>)为包含块,与position坐标空间一致,工具栏重新与选区对齐;
  • 堆叠上下文保留absolute+z-[43]依然会创建独立的堆叠上下文,PR #6036 想要的"压在手柄层之下"的层级关系不因定位方式改变而丢失;
  • inset-0 铺满单元格:包装层覆盖整个单元格(而非视口),配合pointer-events-none不拦截单元格内点击弹出层之外的区域(这些点击用于关闭弹出层)。

也就是说,z-[43]的堆叠意图由z-index承担,fixed/absolute只负责包含块语义——两者解耦后,修复干净利落。

3.3 z 轴层级全景

结合 AnnotationPopup.tsx 与 SelectionRangeEditor.tsx 的注释,阅读器弹出层体系的 z 序如下:

层级定位承载内容
z-40fixed段落模式 / TTS 工具条(paragraph/TTS chrome)
z-[42]脚注弹出层(footnote popup,工具栏也会对着它的文本打开,见 #6145)
z-[43]absolute标注工具栏包装层(本主题修复对象)
z-[44]fixed范围编辑手柄(SelectionRangeEditor/AnnotationRangeEditor
z-[45]侧边面板遮罩
z-50fixed从工具栏打开的各类弹出层、对话框、手柄元素自身

工具栏(43)低于手柄层(44)但高于段落/TTS 层(40)与脚注层(42);从工具栏打开的弹出层保持在 50 及以上、位于手柄之上。


四、坐标空间铁律:哪些层该 absolute,哪些该 fixed

本次回归沉淀出一条可复用的工程规则,记录在文档中并得到当前源码的印证:

标注器的两层渲染物不共享坐标空间。

4.1 弹出层表面 → 单元格相对 → absolute

标注工具栏、字典、翻译、校对这些"弹出层表面"的坐标全部来自Annotator.repositionPopups中的getPosition/getPopupPosition(以#gridcell-<bookKey>的 rect 为基准),因此必须在 gridcell 内部以absolute定位。当前仓库中它们都是单元格内的组件,只有AnnotationPopup拥有包装层,且该包装层已被修复为absolute inset-0 z-[43]

4.2 范围编辑手柄 → 窗口坐标 → fixed

范围编辑手柄(SelectionRangeEditor.tsx、AnnotationRangeEditor.tsx)则相反,它们的位置来自getHandlePositionsFromRange,以frameRect.left + ...计算,得到的是窗口(视口)坐标

<div className='pointer-events-none fixed inset-0 z-[44]'>

因此它们的fixed inset-0 z-[44]是完全正确的——不要"顺手"把它们也改成 absolute。这个"绝对不要动手柄层"的告诫正是为了避免把这次的教训反向套用。

4.3 判断方法

写一个新的覆盖层时,先回答一个问题:坐标是谁算的?

  • 坐标来自单元格 rect(gridFrame.getBoundingClientRect()减去后的结果)→ 用absolute,并把relative定位祖设置对;
  • 坐标来自窗口/视口几何(getBoundingClientRect()原始值、frameRect.left + …)→ 用fixed

一旦坐标空间与定位方式不匹配,bug 会以"只有特定布局状态下才出现"的隐蔽形态存在——这正是本次回归的教训。


五、回归测试:为什么必须是浏览器模式

5.1 测试位置与断言

回归测试位于 annotation-popup-layout.browser.test.tsx,其 "AnnotationPopup anchoring" 用例直接复刻了问题场景:

const CELL_LEFT = 240; // 模拟侧边栏宽度 const CELL_TOP = 32; const ANCHOR = { x: 120, y: 90 }; const renderInCell = (extra?: React.ReactNode) => render( <div id='gridcell-test' style={{ position: 'relative', marginLeft: CELL_LEFT, marginTop: CELL_TOP, width: 500, height: 400, }} > {/* 真实的 AnnotationPopup + HighlightOptions */} </div>, ); it('anchors to the book cell it is positioned against, not the viewport', () => { const cell = container.querySelector('#gridcell-test') as HTMLElement; const popup = container.querySelector('#popup-container') as HTMLElement; const cellRect = cell.getBoundingClientRect(); const popupRect = popup.getBoundingClientRect(); expect({ x: Math.round(popupRect.left - cellRect.left), y: Math.round(popupRect.top - cellRect.top), }).toEqual(ANCHOR); });

测试用一个带marginLeft: 240relative容器模拟被侧边栏推离视口原点的单元格,然后断言弹出层相对单元格左上角的实际渲染偏移等于传入的定位值{x: 120, y: 90}。若包装层仍是fixedpopupRect.left - cellRect.left会多出 240px 而失败。

5.2 为什么 jsdom 捕获不到

文档明确指出:jsdom 无法捕获该回归(没有真实布局引擎),因此必须是.browser.test.tsx。这类问题依赖getBoundingClientRect()getComputedStyle的真实几何计算,单元测试环境里这两者要么返回全零、要么是伪造值,fixedabsolute的包含块差异根本无从体现。凡是涉及"定位/布局"的回归,都应遵循同样的取舍:布局问题测试必须跑在真实浏览器里


六、E2E 测试的连带修复与运行注意事项

6.1 div.fixed 类名探针失效

absolute替换还波及了 PR #6036 同期新增的 E2E 测试:e2e/tests/annotation.spec.ts中的 "draws the range-edit handles above the selection toolbar" 用例。该测试原本用popup.closest('div.fixed')来定位各堆叠带——用类名当定位语义的替身。包装层从fixed改为absolute后,layers.toolbar直接拿到null,测试失效。

6.2 按真实语义修复:向上找 z-index 带

修复提交b804081a6改为按"什么真正使它成为一条堆叠带"来定位——向上遍历到最近的z-indexauto的祖先

const layerOf = (el: Element | null | undefined) => { for (let node = el?.parentElement; node; node = node.parentElement) { const z = getComputedStyle(node).zIndex; // ... 收集到带为止 } };

这里有一个易踩的细节(当前 annotation.spec.ts 的注释与文档均强调):手柄元素自身携带z-50,所以遍历必须从parentElement开始——若从元素自身开始,拿到的会是 50 而不是它所在堆叠带应有的 44。

这个修复思路本身值得借鉴:测试不应依赖实现细节的类名,而应读取真正承载语义的 computed style。这样一来,无论定位方式如何在fixed/absolute之间调整,只要 z 序语义不变,测试就能继续有效。

6.3 E2E 运行注意事项

文档对运行方式给出了明确的经验性结论,避免开发者误判失败:

  • 本机运行使用pnpm test:e2e:web,Playwright 会复用已在:3000端口的 dev server;
  • 本地用 4 个 worker 跑next dev会严重抖动——"adds a note"、"copies a link"、"leaves the first line hittable"、"deletes an annotation"、"opens an EPUB and turns pages" 等用例会在加载阶段失败,而--workers=1时全部通过。不要把这些当作真实失败去排查
  • CI 稳定是因为它运行pnpm start-web(生产构建)并配置了retries: 2
  • e2e/**位于 tsconfig 的include之外,因此编辑器 LSP 报告的reader.pageprotected-property 错误只是噪音,pnpm lint永远不会看到它们。

七、可复现的验证配方

对于此类"只在特定交互路径下出现"的 UI 定位问题,文档还沉淀了一份可操作的验证配方,值得完整保留:

7.1 CDP 无法自行打开工具栏

浏览器自动化(CDP)直接模拟拖拽选择文本时,扩展的left_click_drag可以选中文本,但iframe 内部永远不会收到pointerup,因此工具栏不会打开——自动化无法天然触达这条路径。

7.2 获取真实 iframe:必须穿越 shadow DOM

foliate-view 把书籍渲染 iframe 放在shadow DOM中,document.querySelectorAll('iframe')返回空数组[]。要拿到 iframe,必须手工遍历 shadow roots

// 沿 shadow root 向下钻取,直到找到书籍 iframe function findBookIframe(root) { for (const el of root.querySelectorAll('*')) { if (el.tagName === 'IFRAME') return el; if (el.shadowRoot) { const found = findBookIframe(el.shadowRoot); if (found) return found; } } return null; }

7.3 合成 pointerup 事件

在 iframe 的 document 上派发合成的PointerEvent('pointerup', …),坐标取选区矩形(selection rect)处的clientX/Y

iframeDoc.dispatchEvent( new PointerEvent('pointerup', { bubbles: true, composed: true, pointerType: 'mouse', clientX: selectionRect.left + selectionRect.width / 2, clientY: selectionRect.top + selectionRect.height / 2, }), );

7.4 断言数学关系

修复后的正确性断言是坐标空间的等式

gridcell.left + parseFloat(popup.style.left) - popup.getBoundingClientRect().left === 0;

即"单元格视口偏移 + 弹出层相对样式偏移"应恰好等于"弹出层真实视口偏移"。若差值非零,说明包装层的定位方式与坐标空间不匹配——正是本次回归的检测指纹。相关设备通道的验证经验可参考 feedback-always-verify-on-xiaomi.md。


八、总结:一次定位回归沉淀的三条经验

  1. 坐标空间必须与定位方式配对fixed会更换包含块,任何"先算好坐标再包一层 fixed"的改动都可能悄悄改变坐标解释。改动弹出层时,先确认坐标来自单元格还是视口(源码依据见 sel.ts 与 Annotator.tsx);
  2. z 轴与定位解耦:堆叠上下文由z-index与定位方式共同决定,但"需要堆叠带"绝不等于"必须 fixed"——absolute+z-[43]同样成立(见 AnnotationPopup.tsx);
  3. 测试要锚定语义而非类名:E2E 用div.fixed类名当定位探针,结果在合法重构后静默失效;改为读取 computedz-index后(annotation.spec.ts),测试对定位方式的变化不再敏感。同时,涉及真实布局的回归必须使用浏览器模式测试(annotation-popup-layout.browser.test.tsx),jsdom 无法给出几何答案。

这条修复脉络同时被完整记录在项目记忆中:annotation-popup-fixed-wrapper-offset-6036.md,并与相邻的 annotator-overlay-z-layers 文档互为补充,共同构成阅读器标注覆盖层体系的可检索技术档案。

  • 桌面应用
  • 跨平台
  • 前端

【免费下载链接】readest

Readest 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
点击查看免费下载
上一篇:LevelDB故障恢复机制:数据损坏与修复方案详解
下一篇:免费开源跨平台音乐播放器终极解决方案:any-listen带你重获音乐自由

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询