diff --git a/README.md b/README.md index 8f45476..5fc8977 100644 --- a/README.md +++ b/README.md @@ -99,3 +99,5 @@ 详情查看器默认完整留在当前窗口内。全部资源在自动模式中使用预览容器的实际宽高、100% 比例重新排版,组件保持正常字号;手动设备模拟按可用宽度等比适应。桌面关闭“适应画布”可按当前设备的原尺寸滚动查看。长演示内容、源码和说明在各自区域内滚动,外层页面无需下滑。高度不超过 480px 时收起站点导航,可用查看器左上角返回按钮回到资源列表。[适配规则与截图](docs/ui/viewport-fit/README.md)。 组件详情在宽屏合并标题与内容操作行,动效参数位于预览工具栏,说明栏减少重复标签与留白。详情说明覆盖在画布右侧,独立滚动;开关详情不改变演示尺寸或位置。窄屏说明覆盖预览,关闭后恢复操作。查看完整画布时可关闭说明或进入全屏。[稳定性对比与验证](docs/ui/stable-details/README.md)。[空间与清晰度对比](docs/ui/component-clarity/README.md)。 + +原生页面缩放与经典滚动条也纳入验证:自动预览用容器百分比保持小数尺寸,设备模拟按实际宽度适应,避免 110% 等倍率下反复触发滚动条。[缩放稳定性与验证](docs/ui/native-zoom/README.md)。 diff --git a/atlas/viewer.js b/atlas/viewer.js index bd49789..5dfe779 100644 --- a/atlas/viewer.js +++ b/atlas/viewer.js @@ -327,23 +327,29 @@ export function createViewer({ if (layout.style.getPropertyValue("--preview-tools-height") !== toolsHeight) layout.style.setProperty("--preview-tools-height", toolsHeight); const automatic = !appState.width && (innerWidth < 1024 || appState.fit); - const desired = automatic - ? viewport.clientWidth - : appState.width || appState.selected.preview.width; - // Device presets constrain width. Give their document the visible height, - // so short windows scroll inside the demo instead of shrinking all controls. + const desired = appState.width || appState.selected.preview.width; + // Browser zoom produces fractional content boxes. Percentage constraints + // fit those boxes exactly; writing rounded clientWidth/clientHeight back + // can alternately add/remove classic scrollbars on every observer callback. + canvas.style.width = automatic + ? "100%" + : appState.fit + ? `min(100%, ${desired}px)` + : desired + "px"; + canvas.style.height = + automatic || appState.width + ? "100%" + : appState.selected.preview.height + "px"; const scale = automatic || !appState.fit ? 1 - : Math.min(1, viewport.clientWidth / desired); - const h = - automatic || appState.width - ? viewport.clientHeight / scale - : appState.selected.preview.height; - canvas.style.width = desired * scale + "px"; - canvas.style.height = h * scale + "px"; - frame.style.width = desired + "px"; - frame.style.height = h + "px"; + : Math.min(1, parseFloat(getComputedStyle(canvas).width) / desired); + frame.style.width = automatic ? "100%" : desired + "px"; + frame.style.height = automatic + ? "100%" + : appState.width + ? 100 / scale + "%" + : appState.selected.preview.height + "px"; frame.style.transform = `scale(${scale})`; element("#preview-scale").textContent = `${Math.round(scale * 100)}%`; } diff --git a/docs/ui/native-zoom/README.md b/docs/ui/native-zoom/README.md new file mode 100644 index 0000000..3612551 --- /dev/null +++ b/docs/ui/native-zoom/README.md @@ -0,0 +1,38 @@ +# 原生缩放与真实滚动条下的预览稳定性 + +本轮处理的是整幅预览 UI 的尺寸振荡。用户确认 Edge 页面缩放 100% 正常,110% 后静置也抖动;Windows 显示缩放为 100%,鼠标移到页面外仍发生。 + +## 原因与复现 + +原先读取整数 `clientWidth/clientHeight`,再将它们写回画布和 iframe 的像素尺寸。原生缩放下容器实际宽高可能是小数,整数回写产生溢出。经典滚动条出现后可用宽高缩小,ResizeObserver 再缩小画布,滚动条消失后又放大,形成逐帧循环。 + +Playwright 默认在无头模式加入 `--hide-scrollbars`。此前测试没有保留滚动条,遗漏了该循环。本轮测试明确移除这个默认参数,启动完整 Chromium,通过浏览器设置 API 改变原生页面缩放,并验证 DPR,未用 CSS zoom、捏合模拟或单纯改变 viewport 代替。 + +同一旧版本 `66bb953`、1910×873 浏览器窗口、110% 原生缩放下,90 帧触发 90 次 iframe resize,可用宽高在 1727×572 与 1713×558 CSS px 之间反复切换。1440px 窗口的 150%、1273px 窗口的 125% 也复现。 + +## 修复与结果 + +自动画布与 iframe 以百分比匹配真实容器;设备模式用 CSS `min()` 限制宽度,再从实际计算宽度得到缩放比例,高度也由百分比承载。原始尺寸模式继续允许必要滚动,不关闭浏览器滚动条,不停止演示动画。 + +| 条件 | 修复前 | 修复后 | +| ----------------------------- | ------------ | ------------ | +| 110%、90 帧中的 iframe resize | 90 次 | 0 次 | +| 同一静置区间的画布状态 | 两组反复切换 | 一组稳定状态 | +| iframe 内部经典滚动条 | 显示 | 显示 | + +[完整对比及逐倍率数据](measurements.json)、[Linux Edge 110% 截图](edge-110.png)。每个倍率的前后状态比较在各自的静置区间内进行;主动改变缩放、窗口、设备或全屏产生的正常尺寸变化不计为抖动。 + +## 验收 + +- 完整 Chromium 原生设置:100%、110%、125%、150%、175%、200%、返回 100% 再到 110%。同一页面实时切换,也验证在 110% 重新载入。 +- 1910×873、1440×655、900×900 浏览器窗口,保留经典滚动条;自动、375/768px 设备、原始尺寸、详情、全屏及退出。 +- 静置逐帧比较画布、卡片、内部根节点和滚动位置,要求零 iframe resize;缩放和控制操作不增加文档请求,原文档和表单输入保留。 +- 三引擎既有关键流程与完整生产测试继续执行。另在临时解压的 Microsoft Edge 155.0.4283.62(Linux)中实际执行相同原生缩放与 90 帧检查;没有安装到系统或修改依赖版本。尚未在原生 Windows 上人工验证。 + +测试见 `tests/native-zoom.spec.mjs` 与 `tests/native-zoom.mjs`。浏览器窗口尺寸包含浏览器 UI,记录与截图以实际网页尺寸及 DPR 为准。 + +本地最终验证:`npm run check` 通过(严格类型检查与 12 项单元测试);`npm test` 先完成生产构建,再通过全部 156 项浏览器测试(Chromium 140、Firefox 8、WebKit 8),无跳过或 flaky;`npm run test:dev` 的热更新验收通过。原生缩放、跨浏览器与沉浸预览的 31 项定向回归也通过。独立 Linux Edge 与 Chromium 的各 8 轮原生倍率检查,每轮 90 帧均为零 resize、一组稳定状态。 + +## 发布与回退 + +CI 成功后以 Merge 合并,沿用现有 Pages 推送自动流程。以部署运行关联的确切 merge SHA、成功状态、线上新代码及 110% 真实滚动条检查共同确认发布。前一版为 `66bb953`;如需回退,应通过回退 PR、CI 和同一 Pages 流程恢复,保留历史,不强推主分支。本轮不额外创建 Release 或升级版本。 diff --git a/docs/ui/native-zoom/edge-110.png b/docs/ui/native-zoom/edge-110.png new file mode 100644 index 0000000..94aab92 Binary files /dev/null and b/docs/ui/native-zoom/edge-110.png differ diff --git a/docs/ui/native-zoom/measurements.json b/docs/ui/native-zoom/measurements.json new file mode 100644 index 0000000..15c1b7e --- /dev/null +++ b/docs/ui/native-zoom/measurements.json @@ -0,0 +1,1331 @@ +{ + "baseline": { + "source": "66bb953", + "window": [ + 1910, + 873 + ], + "nativeZoom": 1.1, + "dpr": 1.100000023841858, + "frames": 90, + "resizes": 90, + "states": [ + { + "viewport": [ + 1727, + 572 + ], + "frame": { + "x": 11.690340042114258, + "y": 144.7017059326172, + "width": 1712.9970703125, + "height": 557.9971313476562, + "top": 144.7017059326172, + "right": 1724.6874103546143, + "bottom": 702.6988372802734, + "left": 11.690340042114258 + }, + "innerScrollbar": 14 + }, + { + "viewport": [ + 1713, + 558 + ], + "frame": { + "x": 4.900568008422852, + "y": 137.76988220214844, + "width": 1726.988525390625, + "height": 571.9885864257812, + "top": 137.76988220214844, + "right": 1731.8890933990479, + "bottom": 709.7584686279297, + "left": 4.900568008422852 + }, + "innerScrollbar": 13 + } + ], + "first12Frames": [ + { + "viewport": [ + 1727, + 572 + ], + "frame": { + "x": 11.690340042114258, + "y": 144.7017059326172, + "width": 1712.9970703125, + "height": 557.9971313476562, + "top": 144.7017059326172, + "right": 1724.6874103546143, + "bottom": 702.6988372802734, + "left": 11.690340042114258 + }, + "innerScrollbar": 14 + }, + { + "viewport": [ + 1713, + 558 + ], + "frame": { + "x": 4.900568008422852, + "y": 137.76988220214844, + "width": 1726.988525390625, + "height": 571.9885864257812, + "top": 137.76988220214844, + "right": 1731.8890933990479, + "bottom": 709.7584686279297, + "left": 4.900568008422852 + }, + "innerScrollbar": 13 + }, + { + "viewport": [ + 1727, + 572 + ], + "frame": { + "x": 11.690340042114258, + "y": 144.7017059326172, + "width": 1712.9970703125, + "height": 557.9971313476562, + "top": 144.7017059326172, + "right": 1724.6874103546143, + "bottom": 702.6988372802734, + "left": 11.690340042114258 + }, + "innerScrollbar": 14 + }, + { + "viewport": [ + 1713, + 558 + ], + "frame": { + "x": 4.900568008422852, + "y": 137.76988220214844, + "width": 1726.988525390625, + "height": 571.9885864257812, + "top": 137.76988220214844, + "right": 1731.8890933990479, + "bottom": 709.7584686279297, + "left": 4.900568008422852 + }, + "innerScrollbar": 13 + }, + { + "viewport": [ + 1727, + 572 + ], + "frame": { + "x": 11.690340042114258, + "y": 144.7017059326172, + "width": 1712.9970703125, + "height": 557.9971313476562, + "top": 144.7017059326172, + "right": 1724.6874103546143, + "bottom": 702.6988372802734, + "left": 11.690340042114258 + }, + "innerScrollbar": 14 + }, + { + "viewport": [ + 1713, + 558 + ], + "frame": { + "x": 4.900568008422852, + "y": 137.76988220214844, + "width": 1726.988525390625, + "height": 571.9885864257812, + "top": 137.76988220214844, + "right": 1731.8890933990479, + "bottom": 709.7584686279297, + "left": 4.900568008422852 + }, + "innerScrollbar": 13 + }, + { + "viewport": [ + 1727, + 572 + ], + "frame": { + "x": 11.690340042114258, + "y": 144.7017059326172, + "width": 1712.9970703125, + "height": 557.9971313476562, + "top": 144.7017059326172, + "right": 1724.6874103546143, + "bottom": 702.6988372802734, + "left": 11.690340042114258 + }, + "innerScrollbar": 14 + }, + { + "viewport": [ + 1713, + 558 + ], + "frame": { + "x": 4.900568008422852, + "y": 137.76988220214844, + "width": 1726.988525390625, + "height": 571.9885864257812, + "top": 137.76988220214844, + "right": 1731.8890933990479, + "bottom": 709.7584686279297, + "left": 4.900568008422852 + }, + "innerScrollbar": 13 + }, + { + "viewport": [ + 1727, + 572 + ], + "frame": { + "x": 11.690340042114258, + "y": 144.7017059326172, + "width": 1712.9970703125, + "height": 557.9971313476562, + "top": 144.7017059326172, + "right": 1724.6874103546143, + "bottom": 702.6988372802734, + "left": 11.690340042114258 + }, + "innerScrollbar": 14 + }, + { + "viewport": [ + 1713, + 558 + ], + "frame": { + "x": 4.900568008422852, + "y": 137.76988220214844, + "width": 1726.988525390625, + "height": 571.9885864257812, + "top": 137.76988220214844, + "right": 1731.8890933990479, + "bottom": 709.7584686279297, + "left": 4.900568008422852 + }, + "innerScrollbar": 13 + }, + { + "viewport": [ + 1727, + 572 + ], + "frame": { + "x": 11.690340042114258, + "y": 144.7017059326172, + "width": 1712.9970703125, + "height": 557.9971313476562, + "top": 144.7017059326172, + "right": 1724.6874103546143, + "bottom": 702.6988372802734, + "left": 11.690340042114258 + }, + "innerScrollbar": 14 + }, + { + "viewport": [ + 1713, + 558 + ], + "frame": { + "x": 4.900568008422852, + "y": 137.76988220214844, + "width": 1726.988525390625, + "height": 571.9885864257812, + "top": 137.76988220214844, + "right": 1731.8890933990479, + "bottom": 709.7584686279297, + "left": 4.900568008422852 + }, + "innerScrollbar": 13 + } + ] + }, + "chromium": [ + { + "zoom": 1, + "dpr": 1, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 15, + "sameDocument": true, + "state": { + "viewport": [ + 80, + 138, + 1750, + 643 + ], + "frame": [ + 80, + 138, + 1750, + 643 + ], + "root": [ + 0, + 0, + 1735, + 752 + ], + "cards": [ + [ + 16, + 16, + 557, + 352 + ], + [ + 589, + 16, + 1130, + 352 + ], + [ + 16, + 384, + 1130, + 352 + ], + [ + 1162, + 384, + 557, + 352 + ] + ], + "viewportWidth": 1750, + "viewportHeight": 643, + "scrollWidth": 1750, + "scrollHeight": 643, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1750, + "innerHeight": 643 + } + }, + { + "zoom": 1.1, + "dpr": 1.100000023841858, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 13, + "sameDocument": true, + "state": { + "viewport": [ + 4.900568008422852, + 137.76988220214844, + 1726.5623779296875, + 571.8607788085938 + ], + "frame": [ + 4.900568008422852, + 137.76988220214844, + 1726.5623779296875, + 571.8607788085938 + ], + "root": [ + 0, + 0, + 1712.7271728515625, + 751.960205078125 + ], + "cards": [ + [ + 15.994318008422852, + 15.994318008422852, + 549.5738525390625, + 351.9886169433594 + ], + [ + 581.5625, + 15.994318008422852, + 1115.17041015625, + 351.9886169433594 + ], + [ + 15.994318008422852, + 383.9772644042969, + 1115.15625, + 351.9886169433594 + ], + [ + 1147.1448974609375, + 383.9772644042969, + 549.5880737304688, + 351.9886169433594 + ] + ], + "viewportWidth": 1727, + "viewportHeight": 572, + "scrollWidth": 1727, + "scrollHeight": 572, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1726, + "innerHeight": 572 + } + }, + { + "zoom": 1.25, + "dpr": 1.25, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 12, + "sameDocument": true, + "state": { + "viewport": [ + 4.800000190734863, + 137.60000610351562, + 1518.4000244140625, + 486.3999938964844 + ], + "frame": [ + 4.800000190734863, + 137.60000610351562, + 1518.4000244140625, + 486.3999938964844 + ], + "root": [ + 0, + 0, + 1506.4000244140625, + 752 + ], + "cards": [ + [ + 16, + 16, + 480.8000183105469, + 352 + ], + [ + 512.7999877929688, + 16, + 977.6000366210938, + 352 + ], + [ + 16, + 384, + 977.6000366210938, + 352 + ], + [ + 1009.6000366210938, + 384, + 480.8000183105469, + 352 + ] + ], + "viewportWidth": 1518, + "viewportHeight": 486, + "scrollWidth": 1518, + "scrollHeight": 486, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1518, + "innerHeight": 486 + } + }, + { + "zoom": 1.5, + "dpr": 1.5, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 10, + "sameDocument": true, + "state": { + "viewport": [ + 4.6666669845581055, + 137.33334350585938, + 1264, + 382 + ], + "frame": [ + 4.6666669845581055, + 137.33334350585938, + 1264, + 382 + ], + "root": [ + 0, + 0, + 1254, + 752 + ], + "cards": [ + [ + 16, + 16, + 396.66668701171875, + 352 + ], + [ + 428.66668701171875, + 16, + 809.3333740234375, + 352 + ], + [ + 16, + 384, + 809.3333740234375, + 352 + ], + [ + 841.3333740234375, + 384, + 396.66668701171875, + 352 + ] + ], + "viewportWidth": 1264, + "viewportHeight": 382, + "scrollWidth": 1264, + "scrollHeight": 382, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1264, + "innerHeight": 382 + } + }, + { + "zoom": 1.75, + "dpr": 1.75, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 8, + "sameDocument": true, + "state": { + "viewport": [ + 4.5714287757873535, + 93.14286041259766, + 1082.2857666015625, + 351.4285888671875 + ], + "frame": [ + 4.5714287757873535, + 93.14286041259766, + 1082.2857666015625, + 351.4285888671875 + ], + "root": [ + 0, + 0, + 1073.71435546875, + 752.0000610351562 + ], + "cards": [ + [ + 16, + 16, + 336.5714416503906, + 352.0000305175781 + ], + [ + 368.5714416503906, + 16, + 689.1428833007812, + 352.0000305175781 + ], + [ + 16, + 384.0000305175781, + 689.1428833007812, + 352.0000305175781 + ], + [ + 721.1428833007812, + 384.0000305175781, + 336.5714416503906, + 352.0000305175781 + ] + ], + "viewportWidth": 1082, + "viewportHeight": 351, + "scrollWidth": 1082, + "scrollHeight": 351, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1082, + "innerHeight": 351 + } + }, + { + "zoom": 2, + "dpr": 2, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 8, + "sameDocument": true, + "state": { + "viewport": [ + 5, + 135, + 945, + 253 + ], + "frame": [ + 5, + 135, + 945, + 253 + ], + "root": [ + 0, + 0, + 937.5, + 1488 + ], + "cards": [ + [ + 16, + 16, + 905.5, + 352 + ], + [ + 16, + 384, + 905.5, + 352 + ], + [ + 16, + 752, + 905.5, + 352 + ], + [ + 16, + 1120, + 905.5, + 352 + ] + ], + "viewportWidth": 945, + "viewportHeight": 253, + "scrollWidth": 945, + "scrollHeight": 253, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 945, + "innerHeight": 253 + } + }, + { + "zoom": 1, + "dpr": 1, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 15, + "sameDocument": true, + "state": { + "viewport": [ + 80, + 138, + 1750, + 643 + ], + "frame": [ + 80, + 138, + 1750, + 643 + ], + "root": [ + 0, + 0, + 1735, + 752 + ], + "cards": [ + [ + 16, + 16, + 557, + 352 + ], + [ + 589, + 16, + 1130, + 352 + ], + [ + 16, + 384, + 1130, + 352 + ], + [ + 1162, + 384, + 557, + 352 + ] + ], + "viewportWidth": 1750, + "viewportHeight": 643, + "scrollWidth": 1750, + "scrollHeight": 643, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1750, + "innerHeight": 643 + } + }, + { + "zoom": 1.1, + "dpr": 1.100000023841858, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 13, + "sameDocument": true, + "state": { + "viewport": [ + 4.900568008422852, + 137.76988220214844, + 1726.5623779296875, + 571.8607788085938 + ], + "frame": [ + 4.900568008422852, + 137.76988220214844, + 1726.5623779296875, + 571.8607788085938 + ], + "root": [ + 0, + 0, + 1712.7271728515625, + 751.960205078125 + ], + "cards": [ + [ + 15.994318008422852, + 15.994318008422852, + 549.5738525390625, + 351.9886169433594 + ], + [ + 581.5625, + 15.994318008422852, + 1115.17041015625, + 351.9886169433594 + ], + [ + 15.994318008422852, + 383.9772644042969, + 1115.15625, + 351.9886169433594 + ], + [ + 1147.1448974609375, + 383.9772644042969, + 549.5880737304688, + 351.9886169433594 + ] + ], + "viewportWidth": 1727, + "viewportHeight": 572, + "scrollWidth": 1727, + "scrollHeight": 572, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1726, + "innerHeight": 572 + } + } + ], + "edge": [ + { + "zoom": 1, + "dpr": 1, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 15, + "sameDocument": true, + "state": { + "viewport": [ + 76, + 138, + 1750, + 646 + ], + "frame": [ + 76, + 138, + 1750, + 646 + ], + "root": [ + 0, + 0, + 1735, + 752 + ], + "cards": [ + [ + 16, + 16, + 557, + 352 + ], + [ + 589, + 16, + 1130, + 352 + ], + [ + 16, + 384, + 1130, + 352 + ], + [ + 1162, + 384, + 557, + 352 + ] + ], + "viewportWidth": 1750, + "viewportHeight": 646, + "scrollWidth": 1750, + "scrollHeight": 646, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1750, + "innerHeight": 646 + } + }, + { + "zoom": 1.1, + "dpr": 1.100000023841858, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 14, + "sameDocument": true, + "state": { + "viewport": [ + 4.900568008422852, + 137.76988220214844, + 1719.2896728515625, + 574.6022338867188 + ], + "frame": [ + 4.900568008422852, + 137.76988220214844, + 1719.2896728515625, + 574.6022338867188 + ], + "root": [ + 0, + 0, + 1705.4544677734375, + 751.960205078125 + ], + "cards": [ + [ + 15.994318008422852, + 15.994318008422852, + 547.1590576171875, + 351.9886169433594 + ], + [ + 579.147705078125, + 15.994318008422852, + 1110.3125, + 351.9886169433594 + ], + [ + 15.994318008422852, + 383.9772644042969, + 1110.3125, + 351.9886169433594 + ], + [ + 1142.3011474609375, + 383.9772644042969, + 547.1590576171875, + 351.9886169433594 + ] + ], + "viewportWidth": 1719, + "viewportHeight": 575, + "scrollWidth": 1719, + "scrollHeight": 575, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1719, + "innerHeight": 575 + } + }, + { + "zoom": 1.25, + "dpr": 1.25, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 12, + "sameDocument": true, + "state": { + "viewport": [ + 4.800000190734863, + 137.60000610351562, + 1512, + 488.8000183105469 + ], + "frame": [ + 4.800000190734863, + 137.60000610351562, + 1512, + 488.8000183105469 + ], + "root": [ + 0, + 0, + 1500, + 752 + ], + "cards": [ + [ + 16, + 16, + 478.6625061035156, + 352 + ], + [ + 510.6625061035156, + 16, + 973.3250122070312, + 352 + ], + [ + 16, + 384, + 973.3250122070312, + 352 + ], + [ + 1005.3250122070312, + 384, + 478.6625061035156, + 352 + ] + ], + "viewportWidth": 1512, + "viewportHeight": 489, + "scrollWidth": 1512, + "scrollHeight": 489, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1512, + "innerHeight": 489 + } + }, + { + "zoom": 1.5, + "dpr": 1.5, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 10, + "sameDocument": true, + "state": { + "viewport": [ + 4.6666669845581055, + 137.33334350585938, + 1258.666748046875, + 384 + ], + "frame": [ + 4.6666669845581055, + 137.33334350585938, + 1258.666748046875, + 384 + ], + "root": [ + 0, + 0, + 1248.666748046875, + 752 + ], + "cards": [ + [ + 16, + 16, + 394.88543701171875, + 352 + ], + [ + 426.88543701171875, + 16, + 805.7708740234375, + 352 + ], + [ + 16, + 384, + 805.7708740234375, + 352 + ], + [ + 837.7708740234375, + 384, + 394.88543701171875, + 352 + ] + ], + "viewportWidth": 1259, + "viewportHeight": 384, + "scrollWidth": 1259, + "scrollHeight": 384, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1259, + "innerHeight": 384 + } + }, + { + "zoom": 1.75, + "dpr": 1.75, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 9, + "sameDocument": true, + "state": { + "viewport": [ + 4.5714287757873535, + 93.14286041259766, + 1077.71435546875, + 353.14288330078125 + ], + "frame": [ + 4.5714287757873535, + 93.14286041259766, + 1077.71435546875, + 353.14288330078125 + ], + "root": [ + 0, + 0, + 1069.1429443359375, + 752.0000610351562 + ], + "cards": [ + [ + 16, + 16, + 335.0446472167969, + 352.0000305175781 + ], + [ + 367.0446472167969, + 16, + 686.0892944335938, + 352.0000305175781 + ], + [ + 16, + 384.0000305175781, + 686.0892944335938, + 352.0000305175781 + ], + [ + 718.0892944335938, + 384.0000305175781, + 335.0446472167969, + 352.0000305175781 + ] + ], + "viewportWidth": 1078, + "viewportHeight": 353, + "scrollWidth": 1078, + "scrollHeight": 353, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1078, + "innerHeight": 353 + } + }, + { + "zoom": 2, + "dpr": 2, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 8, + "sameDocument": true, + "state": { + "viewport": [ + 5, + 135, + 941, + 254.5 + ], + "frame": [ + 5, + 135, + 941, + 254.5 + ], + "root": [ + 0, + 0, + 933.5, + 1488 + ], + "cards": [ + [ + 16, + 16, + 901.5, + 352 + ], + [ + 16, + 384, + 901.5, + 352 + ], + [ + 16, + 752, + 901.5, + 352 + ], + [ + 16, + 1120, + 901.5, + 352 + ] + ], + "viewportWidth": 941, + "viewportHeight": 255, + "scrollWidth": 941, + "scrollHeight": 255, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 941, + "innerHeight": 254 + } + }, + { + "zoom": 1, + "dpr": 1, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 15, + "sameDocument": true, + "state": { + "viewport": [ + 76, + 138, + 1750, + 646 + ], + "frame": [ + 76, + 138, + 1750, + 646 + ], + "root": [ + 0, + 0, + 1735, + 752 + ], + "cards": [ + [ + 16, + 16, + 557, + 352 + ], + [ + 589, + 16, + 1130, + 352 + ], + [ + 16, + 384, + 1130, + 352 + ], + [ + 1162, + 384, + 557, + 352 + ] + ], + "viewportWidth": 1750, + "viewportHeight": 646, + "scrollWidth": 1750, + "scrollHeight": 646, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1750, + "innerHeight": 646 + } + }, + { + "zoom": 1.1, + "dpr": 1.100000023841858, + "frames": 90, + "resizes": 0, + "distinctStates": 1, + "innerScrollbar": 14, + "sameDocument": true, + "state": { + "viewport": [ + 4.900568008422852, + 137.76988220214844, + 1719.2896728515625, + 574.6022338867188 + ], + "frame": [ + 4.900568008422852, + 137.76988220214844, + 1719.2896728515625, + 574.6022338867188 + ], + "root": [ + 0, + 0, + 1705.4544677734375, + 751.960205078125 + ], + "cards": [ + [ + 15.994318008422852, + 15.994318008422852, + 547.1590576171875, + 351.9886169433594 + ], + [ + 579.147705078125, + 15.994318008422852, + 1110.3125, + 351.9886169433594 + ], + [ + 15.994318008422852, + 383.9772644042969, + 1110.3125, + 351.9886169433594 + ], + [ + 1142.3011474609375, + 383.9772644042969, + 547.1590576171875, + 351.9886169433594 + ] + ], + "viewportWidth": 1719, + "viewportHeight": 575, + "scrollWidth": 1719, + "scrollHeight": 575, + "scroll": [ + 0, + 0, + 0, + 0 + ], + "innerWidth": 1719, + "innerHeight": 575 + } + } + ] +} diff --git a/tests/native-zoom.mjs b/tests/native-zoom.mjs new file mode 100644 index 0000000..08c709c --- /dev/null +++ b/tests/native-zoom.mjs @@ -0,0 +1,140 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +export async function createNativeZoomBrowser({ + baseURL, + width = 1910, + height = 873, + executablePath, +} = {}) { + const profile = await mkdtemp(path.join(tmpdir(), "atlas-native-zoom-test-")); + let context; + try { + context = await chromium.launchPersistentContext(profile, { + ...(executablePath ? { executablePath } : { channel: "chromium" }), + headless: true, + viewport: null, + baseURL, + // Playwright hides scrollbars by default in headless mode. These tests + // must retain real gutters to expose zoom/rounding feedback loops. + ignoreDefaultArgs: ["--hide-scrollbars"], + args: [ + `--window-size=${width},${height}`, + "--disable-features=OverlayScrollbar", + ], + }); + const settings = await context.newPage(); + await settings.goto( + executablePath + ? "edge://settings/appearance" + : "chrome://settings/appearance", + ); + await settings.waitForFunction( + () => typeof chrome.settingsPrivate?.setDefaultZoom === "function", + ); + return { + context, + async setZoom(factor, page) { + // The browser's own settings API changes native page zoom (and DPR), + // rather than pinch emulation, CSS zoom or just viewport dimensions. + await settings.evaluate( + (zoom) => + new Promise((resolve) => + chrome.settingsPrivate.setDefaultZoom(zoom, resolve), + ), + factor, + ); + if (page) { + await page.bringToFront(); + await expect + .poll(() => page.evaluate(() => devicePixelRatio)) + .toBeCloseTo(factor, 4); + } + }, + async close() { + await context.close(); + await rm(profile, { recursive: true, force: true }); + }, + }; + } catch (error) { + await context?.close(); + await rm(profile, { recursive: true, force: true }); + throw error; + } +} + +export async function recordStableZoom(page, frames = 40) { + await page.evaluate(() => document.fonts.ready); + await page + .locator("#case-preview") + .evaluate((f) => f.contentDocument.fonts.ready); + return page.locator("#case-preview").evaluate(async (frame, count) => { + for (let i = 0; i < 4; i++) await new Promise(requestAnimationFrame); + const doc = frame.contentDocument, + viewport = document.querySelector("#preview-viewport"), + root = doc.querySelector("#root, #preview-root") || doc.body; + const grid = doc.querySelector(".demo-surface > div > .grid"); + const rect = (el) => { + const r = el.getBoundingClientRect(); + return [r.x, r.y, r.width, r.height]; + }; + let resizes = 0; + const resized = () => resizes++; + frame.contentWindow.addEventListener("resize", resized); + const samples = []; + try { + for (let i = 0; i < count; i++) { + await new Promise(requestAnimationFrame); + samples.push({ + viewport: rect(viewport), + frame: rect(frame), + root: rect(root), + cards: grid ? [...grid.children].map(rect) : [], + viewportWidth: viewport.clientWidth, + viewportHeight: viewport.clientHeight, + scrollWidth: viewport.scrollWidth, + scrollHeight: viewport.scrollHeight, + scroll: [ + viewport.scrollLeft, + viewport.scrollTop, + frame.contentWindow.scrollX, + frame.contentWindow.scrollY, + ], + innerWidth: frame.contentWindow.innerWidth, + innerHeight: frame.contentWindow.innerHeight, + }); + } + } finally { + frame.contentWindow.removeEventListener("resize", resized); + } + return { + resizes, + samples, + dpr: devicePixelRatio, + innerScrollbar: + frame.contentWindow.innerWidth - doc.documentElement.clientWidth, + sameDocument: frame.contentDocument === doc, + }; + }, frames); +} + +export function expectStableZoom(result, fitted = true) { + expect(result.sameDocument).toBe(true); + expect(result.resizes).toBe(0); + expect(result.samples.length).toBeGreaterThan(20); + for (const sample of result.samples) { + expect(sample).toEqual(result.samples[0]); + if (fitted) { + expect(sample.scrollWidth).toBe(sample.viewportWidth); + expect(sample.scrollHeight).toBe(sample.viewportHeight); + const [x, y, width, height] = sample.frame, + [vx, vy, vw, vh] = sample.viewport; + expect(x).toBeGreaterThanOrEqual(vx - 0.1); + expect(y).toBeGreaterThanOrEqual(vy - 0.1); + expect(x + width).toBeLessThanOrEqual(vx + vw + 0.1); + expect(y + height).toBeLessThanOrEqual(vy + vh + 0.1); + } + } +} diff --git a/tests/native-zoom.spec.mjs b/tests/native-zoom.spec.mjs new file mode 100644 index 0000000..019aa8f --- /dev/null +++ b/tests/native-zoom.spec.mjs @@ -0,0 +1,97 @@ +import { test, expect } from "@playwright/test"; +import { + createNativeZoomBrowser, + recordStableZoom, + expectStableZoom, +} from "./native-zoom.mjs"; + +for (const [width, height, resource] of [ + [1910, 873, "block/magicui-bento-grid"], + [1440, 655, "component/folio-form"], + [900, 900, "block/magicui-bento-grid"], +]) { + test(`Native zoom and real scrollbars keep ${resource} stable at ${width}×${height}`, async ({ + baseURL, + }, info) => { + test.setTimeout(150000); + const native = await createNativeZoomBrowser({ baseURL, width, height }); + try { + const page = await native.context.newPage(); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + let documents = 0; + page.on("request", (r) => { + if (r.isNavigationRequest() && r.frame() !== page.mainFrame()) + documents++; + }); + await page.goto(`#${resource}?lang=zh-CN&theme=dark`); + await expect(page.locator(".viewer-load-state")).toBeHidden(); + if (resource.includes("form")) + await page + .frameLocator("#case-preview") + .locator("input[type=email]") + .fill("zoom@example.com"); + await page.locator("#case-preview").evaluate((f) => { + window.zoomDocument = f.contentDocument; + }); + const initialRequests = documents, + records = []; + // The same live document crosses zoom levels and breakpoint boundaries. + for (const factor of [1, 1.1, 1.25, 1.5, 1.75, 2, 1, 1.1]) { + await native.setZoom(factor, page); + await page.mouse.move(0, 0); + for (const device of ["0", "375", "768"]) { + await page.locator(`[data-device="${device}"]`).click(); + await page.mouse.move(0, 0); + const result = await recordStableZoom(page); + expectStableZoom(result); + if (resource.includes("bento")) + expect(result.innerScrollbar).toBeGreaterThan(0); + records.push({ factor, device, ...result }); + } + await page.locator('[data-device="0"]').click(); + } + await page.locator("[data-fit]").click(); + expectStableZoom(await recordStableZoom(page), false); + await page.locator("[data-fit]").click(); + const before = await recordStableZoom(page); + await page.locator(".experience-actions [data-toggle-details]").click(); + const open = await recordStableZoom(page); + expectStableZoom(open); + expect(open.samples[0].frame).toEqual(before.samples[0].frame); + await page.locator(".inspector-close").click(); + await page.locator("[data-fullscreen]").click(); + await expect(page.locator("#preview-panel:fullscreen")).toBeVisible(); + expectStableZoom(await recordStableZoom(page)); + await page.locator("[data-exit-fullscreen]").click(); + expectStableZoom(await recordStableZoom(page)); + expect( + await page + .locator("#case-preview") + .evaluate((f) => f.contentDocument === window.zoomDocument), + ).toBe(true); + if (resource.includes("form")) + await expect( + page.frameLocator("#case-preview").locator("input[type=email]"), + ).toHaveValue("zoom@example.com"); + expect(documents - initialRequests).toBe(0); + await page.reload(); + await expect(page.locator(".viewer-load-state")).toBeHidden(); + // Also exercise a cold load at 110%, independently of live transitions. + expectStableZoom(await recordStableZoom(page)); + // Reload is expected to add one document request, zoom and controls none. + expect(documents - initialRequests).toBe(1); + expect(errors).toEqual([]); + await info.attach("native-zoom-frames.json", { + body: JSON.stringify(records), + contentType: "application/json", + }); + await info.attach("native-zoom-110.png", { + body: await page.screenshot(), + contentType: "image/png", + }); + } finally { + await native.close(); + } + }); +}