domotion-svg 0.2.2 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
package/src/scroll/composer.ts
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
|
|
24
24
|
import type { ScrollSegmentCapture } from "./executor.js";
|
|
25
25
|
import { elementTreeToSvg } from "../render/element-tree-to-svg.js";
|
|
26
|
+
import {
|
|
27
|
+
clearEmbeddedFonts,
|
|
28
|
+
getEmbeddedFontFaceCss,
|
|
29
|
+
setRenderTextMode,
|
|
30
|
+
type RenderTextMode,
|
|
31
|
+
} from "../render/text-to-path.js";
|
|
32
|
+
import { extractFixedSubtrees, dedupeFixedAcrossSegments } from "./hoist-fixed.js";
|
|
33
|
+
import { extractStickyWindows, type StickyOverlay } from "./hoist-sticky.js";
|
|
26
34
|
|
|
27
35
|
export interface ScrollComposerOptions {
|
|
28
36
|
/** Visible viewport width (output SVG width). */
|
|
@@ -40,6 +48,31 @@ export interface ScrollComposerOptions {
|
|
|
40
48
|
* to source-resolution data URIs. Default 2 (matches `elementTreeToSvg`).
|
|
41
49
|
*/
|
|
42
50
|
hiDPIFactor?: number;
|
|
51
|
+
/**
|
|
52
|
+
* DM-648: number of consecutive segments to group inside one
|
|
53
|
+
* `<g style="will-change: transform">` chunk wrapper. Each chunk gets its
|
|
54
|
+
* own GPU backing store so tall composites don't blow Chromium's
|
|
55
|
+
* per-layer raster budget (the 1280 × 6015 px apple-desktop-scroll capture
|
|
56
|
+
* at hi-DPI 2 was ~30 MB of raster on a single layer). Default `2` —
|
|
57
|
+
* meaning every two segments share one layer; a 16-segment scroll yields
|
|
58
|
+
* 8 layers. `1` puts each segment on its own layer (more layers, smaller
|
|
59
|
+
* each); higher values trade layer count for layer size. Must be ≥ 1.
|
|
60
|
+
* See `docs/36-scroll-composite-layer-chunking.md`.
|
|
61
|
+
*/
|
|
62
|
+
chunkSize?: number;
|
|
63
|
+
/**
|
|
64
|
+
* DM-652: text rendering mode. Default `"embedded-font"` emits `<text>`
|
|
65
|
+
* elements with a single `@font-face` per used webfont — much faster
|
|
66
|
+
* in WebKit (≈1.84× perf gain + 4.5× smaller file on text-heavy
|
|
67
|
+
* fixtures per DM-651) and visually equivalent in Chromium. `"paths"`
|
|
68
|
+
* is the legacy Chromium-faithful mode that emits `<use href="#gN">`
|
|
69
|
+
* references to glyph path defs, useful when the consumer needs
|
|
70
|
+
* per-pixel parity with the Chromium capture (e.g. visual-regression
|
|
71
|
+
* diffing against the live page). System-font runs and bidi /
|
|
72
|
+
* fallback-chain runs always stay in `"paths"` mode regardless of
|
|
73
|
+
* this flag (MVP scope).
|
|
74
|
+
*/
|
|
75
|
+
renderText?: RenderTextMode;
|
|
43
76
|
}
|
|
44
77
|
|
|
45
78
|
const DEFAULT_BG = "#0d1117";
|
|
@@ -62,6 +95,23 @@ export function composeScrollSvg(
|
|
|
62
95
|
const VH = opts.viewportH;
|
|
63
96
|
const bg = opts.bgColor ?? DEFAULT_BG;
|
|
64
97
|
const hiDPIFactor = opts.hiDPIFactor ?? 2;
|
|
98
|
+
const chunkSize = opts.chunkSize ?? 2;
|
|
99
|
+
if (chunkSize < 1 || !Number.isInteger(chunkSize)) {
|
|
100
|
+
throw new Error(`composeScrollSvg: chunkSize must be a positive integer, got ${chunkSize}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// DM-652: arm the text-render lifecycle. Default is "embedded-font" —
|
|
104
|
+
// the per-segment text renderer emits `<text>` runs against a single
|
|
105
|
+
// @font-face per used webfont, ~2× faster in WebKit and ~5× smaller
|
|
106
|
+
// SVG payload than the legacy glyph-path output, visually equivalent
|
|
107
|
+
// in Chromium. Callers that need per-pixel Chromium parity (e.g.
|
|
108
|
+
// visual-regression diffing) opt back into "paths" explicitly. State
|
|
109
|
+
// is module-global so every segment's `elementTreeToSvg` call shares
|
|
110
|
+
// the same `@font-face` registry; we collect everything into one
|
|
111
|
+
// top-level <style> block at the bottom of this function.
|
|
112
|
+
const renderTextMode: RenderTextMode = opts.renderText ?? "embedded-font";
|
|
113
|
+
clearEmbeddedFonts();
|
|
114
|
+
setRenderTextMode(renderTextMode);
|
|
65
115
|
|
|
66
116
|
// ── Total scene duration ──
|
|
67
117
|
// The last segment's endMs is the cycle length. For a single-segment input,
|
|
@@ -80,22 +130,175 @@ export function composeScrollSvg(
|
|
|
80
130
|
const compositeH = axis === "y" ? (maxPos - minPos) + VH : VH;
|
|
81
131
|
const compositeW = axis === "x" ? (maxPos - minPos) + W : W;
|
|
82
132
|
|
|
133
|
+
const animClass = `scrl-${Math.random().toString(36).slice(2, 8)}`;
|
|
134
|
+
|
|
135
|
+
// ── Split each capture into "scrolling" + "fixed" subtrees ──
|
|
136
|
+
// DM-643: `position: fixed` elements (site headers, cookie banners, etc.)
|
|
137
|
+
// appear at the same viewport coordinates in every capture. If we leave
|
|
138
|
+
// them inside the per-segment subtrees, the composer offsets each copy by
|
|
139
|
+
// that segment's scrollY and the scrolling composite carries them past the
|
|
140
|
+
// viewport — so the consumer sees the header "scroll" once per segment.
|
|
141
|
+
// Strip the fixed subtrees from every segment and hoist them onto a single
|
|
142
|
+
// viewport-level overlay below.
|
|
143
|
+
const fixedStripped: typeof segments[number]["tree"][] = [];
|
|
144
|
+
const perSegFixed: typeof segments[number]["tree"][] = [];
|
|
145
|
+
for (const seg of segments) {
|
|
146
|
+
const { stripped, fixed } = extractFixedSubtrees(seg.tree);
|
|
147
|
+
fixedStripped.push(stripped);
|
|
148
|
+
perSegFixed.push(fixed);
|
|
149
|
+
}
|
|
150
|
+
const fixedOverlay = dedupeFixedAcrossSegments(perSegFixed);
|
|
151
|
+
|
|
152
|
+
// ── Sticky overlays (DM-647) ──
|
|
153
|
+
// For each `position: sticky` element, find the runs of consecutive
|
|
154
|
+
// segments where it stays at the same viewport-y (stuck windows) and
|
|
155
|
+
// hoist each run onto the same overlay layer as the fixed overlay. The
|
|
156
|
+
// element stays inline in segments where it's still scrolling.
|
|
157
|
+
const { stripped: strippedTrees, overlays: stickyOverlays } = extractStickyWindows(fixedStripped);
|
|
158
|
+
|
|
159
|
+
// ── Per-segment visibility windows (DM-642) ──
|
|
160
|
+
// Each segment K is anchored at scroll-y = offsetK. Reading the keyframe
|
|
161
|
+
// schedule, find the % of cycle at which the composite's scroll-y reaches
|
|
162
|
+
// (offsetK - VH) [segment K enters viewport from below] and (offsetK + VH)
|
|
163
|
+
// [segment K leaves viewport off the top]. Outside that window the segment
|
|
164
|
+
// is `visibility: hidden` so the browser skips painting it. With ~8 segments
|
|
165
|
+
// for an apple.com-style page only ~2 segments are visible at any moment;
|
|
166
|
+
// hiding the other ~6 cuts per-frame paint cost dramatically.
|
|
167
|
+
//
|
|
168
|
+
// DM-641: we use `visibility` (not `display`) for the same reason the
|
|
169
|
+
// animator does — an element whose 0% keyframe is `display: none`
|
|
170
|
+
// never enters Chromium's render tree, so the animation engine never
|
|
171
|
+
// ticks the keyframe that would bring it back in.
|
|
172
|
+
// The full-cycle anchors come from `dedupedStops` below; build a temporary
|
|
173
|
+
// helper here that mirrors the same data shape.
|
|
174
|
+
const segOffsets: number[] = segments.map((s) =>
|
|
175
|
+
(axis === "y" ? s.scrollY : s.scrollX) - minPos,
|
|
176
|
+
);
|
|
177
|
+
const cycleStops: Array<{ pct: number; offset: number }> = [];
|
|
178
|
+
cycleStops.push({ pct: 0, offset: segOffsets[0] });
|
|
179
|
+
for (let i = 0; i < segments.length; i++) {
|
|
180
|
+
const endPct = (segments[i].segmentEndMs / totalMs) * 100;
|
|
181
|
+
cycleStops.push({ pct: endPct, offset: segOffsets[i] });
|
|
182
|
+
}
|
|
183
|
+
function pctAtScrollY(targetY: number, mode: "first" | "last"): number {
|
|
184
|
+
// Linear interpolation across cycleStops to find when scroll-y crosses
|
|
185
|
+
// targetY. With monotonic ascending scroll-y, "first" returns the first
|
|
186
|
+
// crossing (entering); "last" returns the last crossing (leaving).
|
|
187
|
+
// Out-of-range clamps to 0 / 100.
|
|
188
|
+
let hit = mode === "first" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
|
|
189
|
+
for (let s = 0; s + 1 < cycleStops.length; s++) {
|
|
190
|
+
const a = cycleStops[s], b = cycleStops[s + 1];
|
|
191
|
+
const lo = Math.min(a.offset, b.offset);
|
|
192
|
+
const hi = Math.max(a.offset, b.offset);
|
|
193
|
+
if (targetY < lo - 0.001 || targetY > hi + 0.001) continue;
|
|
194
|
+
const span = b.offset - a.offset;
|
|
195
|
+
const t = span === 0 ? 0 : (targetY - a.offset) / span;
|
|
196
|
+
const pct = a.pct + (b.pct - a.pct) * t;
|
|
197
|
+
if (mode === "first" && pct < hit) hit = pct;
|
|
198
|
+
if (mode === "last" && pct > hit) hit = pct;
|
|
199
|
+
}
|
|
200
|
+
if (!isFinite(hit)) return mode === "first" ? 0 : 100;
|
|
201
|
+
return Math.max(0, Math.min(100, hit));
|
|
202
|
+
}
|
|
203
|
+
|
|
83
204
|
// ── Render each capture's content at its position offset ──
|
|
84
205
|
const captureGroups: string[] = [];
|
|
206
|
+
const segmentCullCss: string[] = [];
|
|
85
207
|
for (let i = 0; i < segments.length; i++) {
|
|
86
208
|
const seg = segments[i];
|
|
87
|
-
const offset =
|
|
88
|
-
const inner = elementTreeToSvg(
|
|
209
|
+
const offset = segOffsets[i];
|
|
210
|
+
const inner = elementTreeToSvg(strippedTrees[i], W, VH, `seg${i}-`, true, hiDPIFactor);
|
|
89
211
|
const tx = axis === "x" ? offset : 0;
|
|
90
212
|
const ty = axis === "y" ? offset : 0;
|
|
91
|
-
|
|
92
|
-
|
|
213
|
+
// Visibility window: visible while scroll-y is in the rasterisation
|
|
214
|
+
// window for this segment. The strict mathematical bound is
|
|
215
|
+
// `offset ± VH` (segment content can intersect viewport), but using
|
|
216
|
+
// exactly that triggers a Chromium compositor pop: the segment is
|
|
217
|
+
// `visibility: hidden` until the moment its content first enters the
|
|
218
|
+
// viewport, then `visible` snaps it on — but the GPU has zero time to
|
|
219
|
+
// rasterise the segment before its first frame of "should be visible"
|
|
220
|
+
// content is displayed, so for one or two frames the segment paints
|
|
221
|
+
// empty while raster catches up. Users see "content pops in at the
|
|
222
|
+
// viewport bottom" (DM-668 — reproducible on the NYT-desktop-scroll
|
|
223
|
+
// fixture at the s5→s6 boundary, ~26.3% of cycle).
|
|
224
|
+
//
|
|
225
|
+
// Fix: make each segment visible one extra `dim` (viewport-height /
|
|
226
|
+
// -width) earlier on enter and later on leave. Browsers get a full
|
|
227
|
+
// viewport-height of scroll time to rasterise before the segment's
|
|
228
|
+
// content actually needs to paint, and the segment stays rasterised
|
|
229
|
+
// a viewport longer after it leaves so a rapid scroll reversal also
|
|
230
|
+
// hits a warm GPU layer. Peak segments-visible-at-once roughly
|
|
231
|
+
// doubles (2 → ~4 worst case), which doesn't materially affect file
|
|
232
|
+
// size (the wrappers are hidden, not omitted) but does increase
|
|
233
|
+
// resident raster memory by ~one extra viewport per layer chunk —
|
|
234
|
+
// acceptable on every device we've profiled.
|
|
235
|
+
const dim = axis === "y" ? VH : W;
|
|
236
|
+
const rasterBuffer = dim;
|
|
237
|
+
const enterPct = pctAtScrollY(offset - dim - rasterBuffer, "first");
|
|
238
|
+
const leavePct = pctAtScrollY(offset + dim + rasterBuffer, "last");
|
|
239
|
+
const fullyVisible = enterPct <= 0 && leavePct >= 100;
|
|
240
|
+
if (fullyVisible) {
|
|
241
|
+
captureGroups.push(
|
|
242
|
+
` <g transform="translate(${tx} ${ty})">` +
|
|
243
|
+
`<svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
|
|
244
|
+
inner +
|
|
245
|
+
`</svg>` +
|
|
246
|
+
`</g>`,
|
|
247
|
+
);
|
|
248
|
+
} else {
|
|
249
|
+
const cls = `${animClass}-s${i}`;
|
|
250
|
+
// step-end so the segment snaps in/out at the boundary, no fractional
|
|
251
|
+
// opacity that would force the browser to keep compositing it.
|
|
252
|
+
segmentCullCss.push(
|
|
253
|
+
` @keyframes ${cls} { 0% { visibility: hidden } ${Math.max(0, enterPct - 0.001).toFixed(3)}% { visibility: hidden } ${enterPct.toFixed(3)}% { visibility: visible } ${leavePct.toFixed(3)}% { visibility: visible } ${Math.min(100, leavePct + 0.001).toFixed(3)}% { visibility: hidden } 100% { visibility: hidden } }
|
|
254
|
+
.${cls} { animation: ${cls} ${totalSec.toFixed(3)}s infinite; animation-timing-function: step-end; }`,
|
|
255
|
+
);
|
|
256
|
+
captureGroups.push(
|
|
257
|
+
` <g class="${cls}" transform="translate(${tx} ${ty})">` +
|
|
258
|
+
`<svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
|
|
259
|
+
inner +
|
|
260
|
+
`</svg>` +
|
|
261
|
+
`</g>`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// ── Sticky overlay markup + visibility keyframes ──
|
|
266
|
+
// Each overlay is one stuck window for one sticky element. Wrap the
|
|
267
|
+
// rendered subtree in a <g class="…"> whose visibility keyframe shows it
|
|
268
|
+
// only during [firstSegmentStart, lastSegmentEnd] of the cycle and hides
|
|
269
|
+
// it otherwise. DM-641 convention: visibility, never display.
|
|
270
|
+
const stickyMarkup: string[] = [];
|
|
271
|
+
const stickyCullCss: string[] = [];
|
|
272
|
+
for (let i = 0; i < stickyOverlays.length; i++) {
|
|
273
|
+
const o: StickyOverlay = stickyOverlays[i];
|
|
274
|
+
const visStartPct = (segments[o.firstSegmentIdx].segmentStartMs / totalMs) * 100;
|
|
275
|
+
const visEndPct = (segments[o.lastSegmentIdx].segmentEndMs / totalMs) * 100;
|
|
276
|
+
const alwaysVisible = visStartPct <= 0 && visEndPct >= 100;
|
|
277
|
+
const inner = elementTreeToSvg([o.subtree], W, VH, `stk${i}-`, true, hiDPIFactor);
|
|
278
|
+
if (alwaysVisible) {
|
|
279
|
+
stickyMarkup.push(
|
|
280
|
+
`\n <g><svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">${inner}</svg></g>`,
|
|
281
|
+
);
|
|
282
|
+
} else {
|
|
283
|
+
const cls = `${animClass}-k${i}`;
|
|
284
|
+
stickyCullCss.push(
|
|
285
|
+
` @keyframes ${cls} { 0% { visibility: hidden } ${Math.max(0, visStartPct - 0.001).toFixed(3)}% { visibility: hidden } ${visStartPct.toFixed(3)}% { visibility: visible } ${visEndPct.toFixed(3)}% { visibility: visible } ${Math.min(100, visEndPct + 0.001).toFixed(3)}% { visibility: hidden } 100% { visibility: hidden } }
|
|
286
|
+
.${cls} { animation: ${cls} ${totalSec.toFixed(3)}s infinite; animation-timing-function: step-end; }`,
|
|
287
|
+
);
|
|
288
|
+
stickyMarkup.push(
|
|
289
|
+
`\n <g class="${cls}"><svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">${inner}</svg></g>`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const fixedMarkup = fixedOverlay.length === 0
|
|
295
|
+
? ""
|
|
296
|
+
: `\n <g>` +
|
|
93
297
|
`<svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
|
|
94
|
-
|
|
298
|
+
elementTreeToSvg(fixedOverlay, W, VH, "fix-", true, hiDPIFactor) +
|
|
95
299
|
`</svg>` +
|
|
96
|
-
`</g
|
|
97
|
-
|
|
98
|
-
}
|
|
300
|
+
`</g>`;
|
|
301
|
+
const overlayMarkup = fixedMarkup + stickyMarkup.join("");
|
|
99
302
|
|
|
100
303
|
// ── Keyframes — one stop per segment ──
|
|
101
304
|
// Each segment anchors a (time-percent, position-offset) pair. Linear
|
|
@@ -110,21 +313,51 @@ export function composeScrollSvg(
|
|
|
110
313
|
}
|
|
111
314
|
// Dedupe trivially equal stops to keep CSS small.
|
|
112
315
|
const dedupedStops = stops.filter((s, i, arr) => i === 0 || !(s.pct === arr[i - 1].pct && s.offset === arr[i - 1].offset));
|
|
113
|
-
const
|
|
316
|
+
const translateFn = axis === "x" ? "X" : "Y";
|
|
114
317
|
const keyframes = dedupedStops
|
|
115
|
-
.map((s) =>
|
|
318
|
+
.map((s) =>
|
|
319
|
+
// DM-642: use translate3d to coax the browser into promoting the
|
|
320
|
+
// animated <g> onto its own compositing layer so per-frame motion is
|
|
321
|
+
// a GPU paint rather than a CPU re-rasterisation of every embedded
|
|
322
|
+
// image/path under the composite.
|
|
323
|
+
` ${s.pct.toFixed(3)}% { transform: translate3d(${translateFn === "X" ? `-${s.offset.toFixed(3)}px, 0` : `0, -${s.offset.toFixed(3)}px`}, 0); }`,
|
|
324
|
+
)
|
|
116
325
|
.join("\n");
|
|
117
326
|
|
|
327
|
+
// ── DM-648: chunked compositing layers ──
|
|
328
|
+
// Group consecutive segment wrappers into chunks of `chunkSize`. Each
|
|
329
|
+
// chunk wrapper carries `style="will-change: transform"` so Chromium gives
|
|
330
|
+
// it its own GPU backing store, letting tall composites stay under the
|
|
331
|
+
// per-layer raster cap. The chunk wrapper itself has NO `transform` (would
|
|
332
|
+
// double-translate); the inner per-segment `translate(0 Y)` still places
|
|
333
|
+
// content. The DM-642 cull classes on each inner segment are untouched.
|
|
334
|
+
const chunks: string[] = [];
|
|
335
|
+
for (let i = 0; i < captureGroups.length; i += chunkSize) {
|
|
336
|
+
const slice = captureGroups.slice(i, i + chunkSize);
|
|
337
|
+
chunks.push(`<g style="will-change: transform">\n${slice.join("\n")}\n </g>`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// DM-652: collect every `@font-face` rule the embedded-font path
|
|
341
|
+
// registered during segment + overlay rendering above, into a single
|
|
342
|
+
// top-level <style> block. Each font appears once (registry is keyed
|
|
343
|
+
// per (family, weight, italic)) — segments referencing the same font
|
|
344
|
+
// collapse onto one rule. Restore the default render mode now that
|
|
345
|
+
// all per-segment rendering has finished.
|
|
346
|
+
const fontFaceCss = getEmbeddedFontFaceCss();
|
|
347
|
+
setRenderTextMode("paths");
|
|
348
|
+
|
|
118
349
|
// ── Compose final SVG ──
|
|
119
350
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
120
351
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${VH}" width="${W}" height="${VH}">
|
|
121
352
|
<defs>
|
|
122
353
|
<clipPath id="${animClass}-clip"><rect width="${W}" height="${VH}"/></clipPath>
|
|
123
354
|
<style>
|
|
124
|
-
.${animClass} { animation: ${animClass} ${totalSec.toFixed(3)}s linear infinite; }
|
|
355
|
+
${fontFaceCss !== "" ? fontFaceCss + "\n" : ""} .${animClass} { animation: ${animClass} ${totalSec.toFixed(3)}s linear infinite; will-change: transform; }
|
|
125
356
|
@keyframes ${animClass} {
|
|
126
357
|
${keyframes}
|
|
127
358
|
}
|
|
359
|
+
${segmentCullCss.join("\n")}
|
|
360
|
+
${stickyCullCss.join("\n")}
|
|
128
361
|
</style>
|
|
129
362
|
</defs>
|
|
130
363
|
<rect width="${W}" height="${VH}" fill="${bg}"/>
|
|
@@ -132,9 +365,9 @@ ${keyframes}
|
|
|
132
365
|
<g class="${animClass}">
|
|
133
366
|
<svg x="0" y="0" width="${compositeW}" height="${compositeH}" viewBox="0 0 ${compositeW} ${compositeH}">
|
|
134
367
|
<rect width="${compositeW}" height="${compositeH}" fill="${bg}"/>
|
|
135
|
-
${
|
|
368
|
+
${chunks.join("\n ")}
|
|
136
369
|
</svg>
|
|
137
370
|
</g>
|
|
138
|
-
</g
|
|
371
|
+
</g>${overlayMarkup}
|
|
139
372
|
</svg>`;
|
|
140
373
|
}
|
|
@@ -174,6 +174,33 @@ describe("resolveScrollAction", () => {
|
|
|
174
174
|
expect(r.scrollDurationMs).toBe(3000);
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
it("explicit @<speed> overrides the executor's default speed", async () => {
|
|
178
|
+
const { query, snap } = fakePageQuery({ maxScrollY: 4000 });
|
|
179
|
+
const action: ScrollAction = {
|
|
180
|
+
kind: "scroll",
|
|
181
|
+
target: { kind: "delta", signedLength: { sign: 1, value: 720, unit: "px" } },
|
|
182
|
+
speedPxPerSec: 600,
|
|
183
|
+
};
|
|
184
|
+
// 720 / 600 * 1000 = 1200. Default speed of 1500 is ignored.
|
|
185
|
+
const r = await resolveScrollAction(action, query, snap, 1500);
|
|
186
|
+
expect(r.scrollDurationMs).toBe(1200);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("@<speed> applies to the actual scroll magnitude, not the requested delta", async () => {
|
|
190
|
+
// Scroll request of 5000 px but page maxScrollY=4000 — actual magnitude
|
|
191
|
+
// is 4000 px after clamping. Speed math uses the clamped magnitude so
|
|
192
|
+
// the action lands at the boundary in the right amount of time.
|
|
193
|
+
const { query, snap } = fakePageQuery({ maxScrollY: 4000, scrollY: 0 });
|
|
194
|
+
const action: ScrollAction = {
|
|
195
|
+
kind: "scroll",
|
|
196
|
+
target: { kind: "delta", signedLength: { sign: 1, value: 5000, unit: "px" } },
|
|
197
|
+
speedPxPerSec: 1000,
|
|
198
|
+
};
|
|
199
|
+
const r = await resolveScrollAction(action, query, snap, 1500);
|
|
200
|
+
expect(r.destY).toBe(4000);
|
|
201
|
+
expect(r.scrollDurationMs).toBe(4000);
|
|
202
|
+
});
|
|
203
|
+
|
|
177
204
|
it("`down:-100px` reverses direction (same as up:100px)", async () => {
|
|
178
205
|
const { query, snap } = fakePageQuery({ maxScrollY: 4000, scrollY: 500 });
|
|
179
206
|
const action: ScrollAction = {
|
package/src/scroll/executor.ts
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* a single `PageQuery` interface lets a test inject a fake page-state
|
|
15
15
|
* provider in lieu of the real `Page`.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* Canonical grammar reference: `docs/37-scroll-pattern-grammar.md`. Keep
|
|
18
|
+
* this executor's axis/direction resolution, speed-derived duration math,
|
|
19
|
+
* and `until`-clause semantics in sync with what doc 37 describes.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import type { Page } from "@playwright/test";
|
|
@@ -215,7 +217,14 @@ export async function resolveScrollAction(
|
|
|
215
217
|
const destX = axis === "x" ? clamp(snapshot.scrollX + delta, 0, snapshot.maxScrollX) : snapshot.scrollX;
|
|
216
218
|
const destY = axis === "y" ? clamp(snapshot.scrollY + delta, 0, snapshot.maxScrollY) : snapshot.scrollY;
|
|
217
219
|
const magnitude = Math.abs(axis === "x" ? destX - snapshot.scrollX : destY - snapshot.scrollY);
|
|
218
|
-
|
|
220
|
+
// Duration resolution priority:
|
|
221
|
+
// 1. Explicit `/<duration>` on the action wins (the user pinned the time).
|
|
222
|
+
// 2. Else, action's `@<speed>` suffix (the user pinned constant speed for
|
|
223
|
+
// this action only — magnitude / actionSpeed yields the duration).
|
|
224
|
+
// 3. Else, executor's inherited `defaultSpeed` (whole-pattern fallback).
|
|
225
|
+
// Cases 2 and 3 are mutually exclusive at parse time; safe to read both.
|
|
226
|
+
const effectiveSpeed = action.speedPxPerSec ?? defaultSpeed;
|
|
227
|
+
const scrollDurationMs = action.durationMs ?? Math.round((magnitude / effectiveSpeed) * 1000);
|
|
219
228
|
return { axis, destX, destY, scrollDurationMs };
|
|
220
229
|
}
|
|
221
230
|
|
|
@@ -527,8 +536,8 @@ function realPageQuery(page: Page, selector: string | null): PageQuery {
|
|
|
527
536
|
}));
|
|
528
537
|
}
|
|
529
538
|
return page.evaluate((sel) => {
|
|
530
|
-
const el = document.querySelector(sel)
|
|
531
|
-
if (el
|
|
539
|
+
const el = document.querySelector(sel);
|
|
540
|
+
if (!(el instanceof HTMLElement)) return { maxScrollX: 0, maxScrollY: 0, scrollX: 0, scrollY: 0 };
|
|
532
541
|
return {
|
|
533
542
|
maxScrollX: Math.max(0, el.scrollWidth - el.clientWidth),
|
|
534
543
|
maxScrollY: Math.max(0, el.scrollHeight - el.clientHeight),
|
|
@@ -553,8 +562,8 @@ async function scrollTo(page: Page, selector: string | null, destX: number, dest
|
|
|
553
562
|
await page.evaluate(({ x, y }) => window.scrollTo(x, y), { x: destX, y: destY });
|
|
554
563
|
} else {
|
|
555
564
|
await page.evaluate(({ sel, x, y }) => {
|
|
556
|
-
const el = document.querySelector(sel)
|
|
557
|
-
if (el
|
|
565
|
+
const el = document.querySelector(sel);
|
|
566
|
+
if (el instanceof HTMLElement) { el.scrollLeft = x; el.scrollTop = y; }
|
|
558
567
|
}, { sel: selector, x: destX, y: destY });
|
|
559
568
|
}
|
|
560
569
|
// Wait the action's nominal duration plus a small settle for layout.
|
|
@@ -570,13 +579,13 @@ async function runPrescroll(page: Page, selector: string | null): Promise<void>
|
|
|
570
579
|
await page.waitForTimeout(PRESCROLL_TOP_WAIT_MS);
|
|
571
580
|
} else {
|
|
572
581
|
await page.evaluate((sel) => {
|
|
573
|
-
const el = document.querySelector(sel)
|
|
574
|
-
if (el
|
|
582
|
+
const el = document.querySelector(sel);
|
|
583
|
+
if (el instanceof HTMLElement) el.scrollTop = el.scrollHeight;
|
|
575
584
|
}, selector);
|
|
576
585
|
await page.waitForTimeout(PRESCROLL_BOTTOM_WAIT_MS);
|
|
577
586
|
await page.evaluate((sel) => {
|
|
578
|
-
const el = document.querySelector(sel)
|
|
579
|
-
if (el
|
|
587
|
+
const el = document.querySelector(sel);
|
|
588
|
+
if (el instanceof HTMLElement) el.scrollTop = 0;
|
|
580
589
|
}, selector);
|
|
581
590
|
await page.waitForTimeout(PRESCROLL_TOP_WAIT_MS);
|
|
582
591
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { CapturedElement } from "../capture/types.js";
|
|
4
|
+
import { extractFixedSubtrees, dedupeFixedAcrossSegments } from "./hoist-fixed.js";
|
|
5
|
+
|
|
6
|
+
function el(
|
|
7
|
+
partial: Partial<CapturedElement> & { tag: string; x: number; y: number },
|
|
8
|
+
position: string = "static",
|
|
9
|
+
): CapturedElement {
|
|
10
|
+
return {
|
|
11
|
+
text: "",
|
|
12
|
+
width: partial.width ?? 100,
|
|
13
|
+
height: partial.height ?? 20,
|
|
14
|
+
children: partial.children ?? [],
|
|
15
|
+
...partial,
|
|
16
|
+
styles: {
|
|
17
|
+
position,
|
|
18
|
+
...(partial.styles ?? {}),
|
|
19
|
+
} as CapturedElement["styles"],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("extractFixedSubtrees", () => {
|
|
24
|
+
it("returns input unchanged when no fixed elements exist", () => {
|
|
25
|
+
const tree: CapturedElement[] = [
|
|
26
|
+
el({ tag: "div", x: 0, y: 0 }, "static"),
|
|
27
|
+
el({ tag: "p", x: 0, y: 30 }, "relative"),
|
|
28
|
+
];
|
|
29
|
+
const r = extractFixedSubtrees(tree);
|
|
30
|
+
expect(r.fixed).toEqual([]);
|
|
31
|
+
expect(r.stripped).toBe(tree);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("hoists a top-level position:fixed subtree", () => {
|
|
35
|
+
const header = el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "fixed");
|
|
36
|
+
const main = el({ tag: "main", x: 0, y: 60 }, "static");
|
|
37
|
+
const r = extractFixedSubtrees([header, main]);
|
|
38
|
+
expect(r.fixed).toEqual([header]);
|
|
39
|
+
expect(r.stripped).toHaveLength(1);
|
|
40
|
+
expect(r.stripped[0].tag).toBe("main");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("hoists a fixed subtree nested inside a normal-positioned ancestor", () => {
|
|
44
|
+
const fixedNav = el({ tag: "nav", x: 0, y: 0, width: 800, height: 50 }, "fixed");
|
|
45
|
+
const root = el({
|
|
46
|
+
tag: "body",
|
|
47
|
+
x: 0, y: 0, width: 800, height: 600,
|
|
48
|
+
children: [fixedNav, el({ tag: "section", x: 0, y: 100 }, "static")],
|
|
49
|
+
}, "static");
|
|
50
|
+
const r = extractFixedSubtrees([root]);
|
|
51
|
+
expect(r.fixed).toEqual([fixedNav]);
|
|
52
|
+
// body shallow-copied with the nav stripped out
|
|
53
|
+
expect(r.stripped).toHaveLength(1);
|
|
54
|
+
expect(r.stripped[0]).not.toBe(root);
|
|
55
|
+
expect(r.stripped[0].children).toHaveLength(1);
|
|
56
|
+
expect(r.stripped[0].children![0].tag).toBe("section");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("treats a fixed subtree atomically — inner fixed descendants ride along", () => {
|
|
60
|
+
// Real-world: a fixed header with a fixed dropdown inside it. The
|
|
61
|
+
// dropdown is part of the header's stacking context; we shouldn't try
|
|
62
|
+
// to extract it separately.
|
|
63
|
+
const innerFixed = el({ tag: "div", x: 10, y: 10 }, "fixed");
|
|
64
|
+
const outerFixed = el({
|
|
65
|
+
tag: "header",
|
|
66
|
+
x: 0, y: 0, width: 800, height: 60,
|
|
67
|
+
children: [innerFixed],
|
|
68
|
+
}, "fixed");
|
|
69
|
+
const r = extractFixedSubtrees([outerFixed]);
|
|
70
|
+
expect(r.fixed).toEqual([outerFixed]);
|
|
71
|
+
expect(r.fixed[0].children).toContain(innerFixed);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not mutate the input tree when stripping", () => {
|
|
75
|
+
const fixedChild = el({ tag: "div", x: 0, y: 0 }, "fixed");
|
|
76
|
+
const original = el({
|
|
77
|
+
tag: "body",
|
|
78
|
+
x: 0, y: 0,
|
|
79
|
+
children: [fixedChild, el({ tag: "p", x: 0, y: 20 }, "static")],
|
|
80
|
+
}, "static");
|
|
81
|
+
const originalChildren = original.children;
|
|
82
|
+
extractFixedSubtrees([original]);
|
|
83
|
+
// The original wasn't mutated — its children array reference and contents are intact.
|
|
84
|
+
expect(original.children).toBe(originalChildren);
|
|
85
|
+
expect(original.children).toHaveLength(2);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("dedupeFixedAcrossSegments", () => {
|
|
90
|
+
it("returns first occurrence per (tag, position, size) key", () => {
|
|
91
|
+
const segA: CapturedElement[] = [
|
|
92
|
+
el({ tag: "header", x: 0, y: 0, width: 800, height: 60, text: "v1" }, "fixed"),
|
|
93
|
+
];
|
|
94
|
+
const segB: CapturedElement[] = [
|
|
95
|
+
el({ tag: "header", x: 0, y: 0, width: 800, height: 60, text: "v2" }, "fixed"),
|
|
96
|
+
];
|
|
97
|
+
const r = dedupeFixedAcrossSegments([segA, segB]);
|
|
98
|
+
expect(r).toHaveLength(1);
|
|
99
|
+
expect(r[0].text).toBe("v1");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps distinct fixed elements that differ in size or position", () => {
|
|
103
|
+
const segA: CapturedElement[] = [
|
|
104
|
+
el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "fixed"),
|
|
105
|
+
];
|
|
106
|
+
const segB: CapturedElement[] = [
|
|
107
|
+
el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "fixed"),
|
|
108
|
+
el({ tag: "div", x: 0, y: 540, width: 800, height: 60 }, "fixed"),
|
|
109
|
+
];
|
|
110
|
+
const r = dedupeFixedAcrossSegments([segA, segB]);
|
|
111
|
+
expect(r).toHaveLength(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns empty when no segment contributed fixed elements", () => {
|
|
115
|
+
expect(dedupeFixedAcrossSegments([[], [], []])).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hoist `position: fixed` elements out of scroll-segment trees so the scroll
|
|
3
|
+
* composer doesn't render them once per segment.
|
|
4
|
+
*
|
|
5
|
+
* DM-643: each segment's capture sees the live page at a different scrollY but
|
|
6
|
+
* a `position: fixed` element (e.g. a sticky-feeling site header) always
|
|
7
|
+
* occupies the same viewport coordinates. The scroll composer stacks segments
|
|
8
|
+
* vertically inside a translating composite, so the header from segment 0
|
|
9
|
+
* ends up at composite-y = 0+headerY, the header from segment 1 at composite-y
|
|
10
|
+
* = segment1.scrollY + headerY, and so on — producing the "fixed header
|
|
11
|
+
* repeats every viewport-height" artifact described in the ticket.
|
|
12
|
+
*
|
|
13
|
+
* The fix is to recognise `position: fixed` during composition: strip those
|
|
14
|
+
* subtrees from every per-segment capture and emit them once as a static
|
|
15
|
+
* overlay above the scrolling composite (where they belong visually). They
|
|
16
|
+
* stay in their captured viewport coordinates and never animate.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { CapturedElement } from "../capture/types.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Walk the tree, remove every subtree rooted at a `position: fixed` element
|
|
23
|
+
* (matching what Chromium reports in `getComputedStyle`), and return the list
|
|
24
|
+
* of removed subtrees. The input tree is shallow-copied at every level
|
|
25
|
+
* touched by the strip so the caller's references aren't mutated.
|
|
26
|
+
*
|
|
27
|
+
* A subtree rooted at a fixed element is hoisted whole: descendants follow
|
|
28
|
+
* their fixed ancestor. Inner fixed descendants of an already-hoisted subtree
|
|
29
|
+
* are NOT separately extracted — they live inside their ancestor's hoisted
|
|
30
|
+
* copy, which is the correct paint result (Chromium would paint them in the
|
|
31
|
+
* same stacking context anyway).
|
|
32
|
+
*/
|
|
33
|
+
export function extractFixedSubtrees(tree: CapturedElement[]): {
|
|
34
|
+
stripped: CapturedElement[];
|
|
35
|
+
fixed: CapturedElement[];
|
|
36
|
+
} {
|
|
37
|
+
const fixed: CapturedElement[] = [];
|
|
38
|
+
const stripped = stripFromList(tree, fixed);
|
|
39
|
+
return { stripped, fixed };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stripFromList(
|
|
43
|
+
list: CapturedElement[],
|
|
44
|
+
fixedOut: CapturedElement[],
|
|
45
|
+
): CapturedElement[] {
|
|
46
|
+
const kept: CapturedElement[] = [];
|
|
47
|
+
for (const node of list) {
|
|
48
|
+
if (node.styles?.position === "fixed") {
|
|
49
|
+
fixedOut.push(node);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (node.children != null && node.children.length > 0) {
|
|
53
|
+
const newChildren = stripFromList(node.children, fixedOut);
|
|
54
|
+
if (newChildren !== node.children) {
|
|
55
|
+
// At least one descendant was stripped — emit a shallow copy with the
|
|
56
|
+
// pruned child list so the caller's tree isn't mutated.
|
|
57
|
+
kept.push({ ...node, children: newChildren });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
kept.push(node);
|
|
62
|
+
}
|
|
63
|
+
// If nothing was removed at this level, return the original list (lets
|
|
64
|
+
// ancestor levels avoid copying too).
|
|
65
|
+
if (kept.length === list.length && fixedOut.length === 0) return list;
|
|
66
|
+
return kept;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Cross-segment dedupe: capture 0's fixed elements are typically present in
|
|
71
|
+
* every segment too (the live page kept them fixed throughout the scroll).
|
|
72
|
+
* Returns the union of fixed elements seen across all segments, keyed by a
|
|
73
|
+
* coarse identity (tag + viewport position + size) so the same logical
|
|
74
|
+
* element captured at slightly different times (e.g. with a small text-style
|
|
75
|
+
* tweak) doesn't get emitted twice.
|
|
76
|
+
*
|
|
77
|
+
* v1 keeps the FIRST occurrence's subtree. Future work could pick the
|
|
78
|
+
* occurrence whose neighbours match the largest fraction of segments, or
|
|
79
|
+
* crossfade subtle variants — but for fixed site chrome the first occurrence
|
|
80
|
+
* is already what consumers see at scrollY=0.
|
|
81
|
+
*/
|
|
82
|
+
export function dedupeFixedAcrossSegments(perSegment: CapturedElement[][]): CapturedElement[] {
|
|
83
|
+
const seen = new Map<string, CapturedElement>();
|
|
84
|
+
for (const seg of perSegment) {
|
|
85
|
+
for (const el of seg) {
|
|
86
|
+
const key = fixedKey(el);
|
|
87
|
+
if (!seen.has(key)) seen.set(key, el);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...seen.values()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fixedKey(el: CapturedElement): string {
|
|
94
|
+
return `${el.tag}|${Math.round(el.x)},${Math.round(el.y)}|${Math.round(el.width)}x${Math.round(el.height)}`;
|
|
95
|
+
}
|