domotion-svg 0.28.0 → 0.28.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/dist/scroll/composer.d.ts +16 -0
- package/dist/scroll/composer.js +54 -4
- package/dist/scroll/executor.d.ts +13 -0
- package/dist/scroll/executor.js +40 -19
- package/package.json +1 -1
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* shrinking output size on mostly-static-content pages.
|
|
22
22
|
*/
|
|
23
23
|
import type { ScrollSegmentCapture } from "./executor.js";
|
|
24
|
+
import type { CapturedElement } from "../capture/types.js";
|
|
25
|
+
import { type ChildOverflowClipGeometry } from "../render/element-tree-to-svg.js";
|
|
24
26
|
import { type RenderTextMode } from "../render/text-to-path.js";
|
|
25
27
|
export interface ScrollComposerOptions {
|
|
26
28
|
/** Visible viewport width (output SVG width). */
|
|
@@ -75,6 +77,20 @@ export interface ScrollComposerOptions {
|
|
|
75
77
|
*/
|
|
76
78
|
renderText?: RenderTextMode;
|
|
77
79
|
}
|
|
80
|
+
export interface ElementScrollStaticLayers {
|
|
81
|
+
underlay: CapturedElement[];
|
|
82
|
+
foregroundLayers: Array<{
|
|
83
|
+
elements: CapturedElement[];
|
|
84
|
+
clips: ChildOverflowClipGeometry[];
|
|
85
|
+
}>;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Split the static context around an element scroll owner's paint slot at each
|
|
89
|
+
* ancestor level. Siblings which paint after the owner branch must remain
|
|
90
|
+
* stationary but render above its moving captures; leaving them in one
|
|
91
|
+
* wholesale underlay reverses CSS z-order wherever the two overlap.
|
|
92
|
+
*/
|
|
93
|
+
export declare function splitElementScrollStaticLayers(tree: CapturedElement[], ownerId: string): ElementScrollStaticLayers | null;
|
|
78
94
|
/**
|
|
79
95
|
* Fail closed before composition when frame/allowlist/offset authority is
|
|
80
96
|
* omitted, stale, or assigned to a sibling Chromium frame.
|
package/dist/scroll/composer.js
CHANGED
|
@@ -31,6 +31,7 @@ import { hoistDuplicateImagePayloads } from "../post-processing/hoist-image-payl
|
|
|
31
31
|
import { extractFixedSubtrees, dedupeFixedAcrossSegments } from "./hoist-fixed.js";
|
|
32
32
|
import { extractStickyWindows } from "./hoist-sticky.js";
|
|
33
33
|
import { mapTreePruning } from "../tree-ops/prune-tree.js";
|
|
34
|
+
import { sortChildrenByPaintOrder } from "../render/stacking.js";
|
|
34
35
|
/**
|
|
35
36
|
* Map a parsed scroll-action easing (DM-1076) to a CSS `<timing-function>`, or
|
|
36
37
|
* `null` when it's absent or `linear` — the composite's animation-level default
|
|
@@ -94,6 +95,41 @@ function findCapturedScrollOwnerPath(tree, ownerId, ancestors = []) {
|
|
|
94
95
|
}
|
|
95
96
|
return undefined;
|
|
96
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Split the static context around an element scroll owner's paint slot at each
|
|
100
|
+
* ancestor level. Siblings which paint after the owner branch must remain
|
|
101
|
+
* stationary but render above its moving captures; leaving them in one
|
|
102
|
+
* wholesale underlay reverses CSS z-order wherever the two overlap.
|
|
103
|
+
*/
|
|
104
|
+
export function splitElementScrollStaticLayers(tree, ownerId) {
|
|
105
|
+
const ownerPath = findCapturedScrollOwnerPath(tree, ownerId);
|
|
106
|
+
const owner = ownerPath?.at(-1);
|
|
107
|
+
if (ownerPath == null || owner == null || ownerPath.length < 2)
|
|
108
|
+
return null;
|
|
109
|
+
const foregroundLayers = [];
|
|
110
|
+
const foregroundSet = new Set();
|
|
111
|
+
for (let branchIndex = ownerPath.length - 1; branchIndex >= 1; branchIndex--) {
|
|
112
|
+
const parent = ownerPath[branchIndex - 1];
|
|
113
|
+
const branch = ownerPath[branchIndex];
|
|
114
|
+
const paintOrderedSiblings = sortChildrenByPaintOrder(parent.children, parent.styles.display, parent.styles.flexDirection);
|
|
115
|
+
const branchPaintIndex = paintOrderedSiblings.indexOf(branch);
|
|
116
|
+
if (branchPaintIndex < 0)
|
|
117
|
+
return null;
|
|
118
|
+
const elements = paintOrderedSiblings.slice(branchPaintIndex + 1);
|
|
119
|
+
if (elements.length === 0)
|
|
120
|
+
continue;
|
|
121
|
+
for (const element of elements)
|
|
122
|
+
foregroundSet.add(element);
|
|
123
|
+
foregroundLayers.push({
|
|
124
|
+
elements,
|
|
125
|
+
// A sibling shares the branch's clip ancestors but is not a descendant
|
|
126
|
+
// of the branch's own overflow clip.
|
|
127
|
+
clips: ownerPath.slice(0, branchIndex).map((element) => childOverflowClipGeometry(element)).filter((clip) => clip != null),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const underlay = mapTreePruning(tree, (element) => element === owner || foregroundSet.has(element));
|
|
131
|
+
return { underlay, foregroundLayers };
|
|
132
|
+
}
|
|
97
133
|
/**
|
|
98
134
|
* An element scroll owner moves its own clipped contents, not the page around
|
|
99
135
|
* it. Keep the first capture's surrounding page as a static underlay and feed
|
|
@@ -114,7 +150,9 @@ function isolateElementScrollOwner(segments) {
|
|
|
114
150
|
// the established list-strip contract; there is no surrounding page to pin.
|
|
115
151
|
if (firstOwnerPath == null || firstOwner == null || first.tree.includes(firstOwner))
|
|
116
152
|
return null;
|
|
117
|
-
const
|
|
153
|
+
const staticLayers = splitElementScrollStaticLayers(first.tree, first.scrollOwnerId);
|
|
154
|
+
if (staticLayers == null)
|
|
155
|
+
return null;
|
|
118
156
|
const isolated = segments.map((segment, index) => {
|
|
119
157
|
if (segment.frameScrollState == null || segment.scrollOwnerId == null) {
|
|
120
158
|
throw new Error(`composeScrollSvg: inner-scroll segment ${index} omitted its owner authority`);
|
|
@@ -136,7 +174,8 @@ function isolateElementScrollOwner(segments) {
|
|
|
136
174
|
});
|
|
137
175
|
return {
|
|
138
176
|
segments: isolated,
|
|
139
|
-
staticUnderlay,
|
|
177
|
+
staticUnderlay: staticLayers.underlay,
|
|
178
|
+
staticForegroundLayers: staticLayers.foregroundLayers,
|
|
140
179
|
ownerClips: firstOwnerPath.map((element) => childOverflowClipGeometry(element)).filter((clip) => clip != null),
|
|
141
180
|
};
|
|
142
181
|
}
|
|
@@ -329,6 +368,7 @@ export function composeScrollSvg(segments, opts) {
|
|
|
329
368
|
return withRenderTextMode(renderTextMode, () => composeScrollSvgBody(composedSegments, opts, {
|
|
330
369
|
axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize,
|
|
331
370
|
staticUnderlay: elementScroll?.staticUnderlay,
|
|
371
|
+
staticForegroundLayers: elementScroll?.staticForegroundLayers,
|
|
332
372
|
elementOwnerClips: elementScroll?.ownerClips,
|
|
333
373
|
}));
|
|
334
374
|
}
|
|
@@ -385,7 +425,7 @@ function buildScrollVisibility(segments, segOffsets, totalMs) {
|
|
|
385
425
|
* reads (axis / viewport / background / hiDPI / chunk size).
|
|
386
426
|
*/
|
|
387
427
|
function composeScrollSvgBody(segments, opts, ctx) {
|
|
388
|
-
const { axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize, staticUnderlay, elementOwnerClips, } = ctx;
|
|
428
|
+
const { axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize, staticUnderlay, staticForegroundLayers, elementOwnerClips, } = ctx;
|
|
389
429
|
// ── Total scene duration ──
|
|
390
430
|
// The last segment's endMs is the cycle length. For a single-segment input,
|
|
391
431
|
// the scene is effectively static — emit a 1 s loop so the SVG renders
|
|
@@ -568,6 +608,14 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
568
608
|
: `\n <g data-scroll-static-context="true"><svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
|
|
569
609
|
elementTreeToSvgInner(staticUnderlay, W, VH, "static-", false, hiDPIFactor, false) +
|
|
570
610
|
`</svg></g>`;
|
|
611
|
+
const staticForegroundMarkup = (staticForegroundLayers ?? []).map((layer, layerIndex) => {
|
|
612
|
+
const clipOpen = layer.clips.map((_clip, clipIndex) => ` <g clip-path="url(#${animClass}-foreground-${layerIndex}-clip-${clipIndex})">`).join("\n");
|
|
613
|
+
const clipClose = layer.clips.map(() => " </g>").join("\n");
|
|
614
|
+
const markup = `<g data-scroll-static-foreground="true"><svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
|
|
615
|
+
elementTreeToSvgInner(layer.elements, W, VH, `static-foreground-${layerIndex}-`, false, hiDPIFactor, false) +
|
|
616
|
+
`</svg></g>`;
|
|
617
|
+
return `${clipOpen === "" ? "" : clipOpen + "\n"} ${markup}\n${clipClose}`;
|
|
618
|
+
}).join("\n");
|
|
571
619
|
// DM-652: collect every `@font-face` rule the embedded-font path
|
|
572
620
|
// registered during segment + overlay rendering above, into a single
|
|
573
621
|
// top-level <style> block. Each font appears once (registry is keyed
|
|
@@ -585,6 +633,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
585
633
|
const ownerClipDefs = (elementOwnerClips ?? []).map((clip, index) => ` <clipPath id="${animClass}-owner-clip-${index}">${roundedRectSvg(clip.x, clip.y, clip.width, clip.height, clip.corners, "")}</clipPath>`).join("\n");
|
|
586
634
|
const ownerClipOpen = (elementOwnerClips ?? []).map((_clip, index) => ` <g clip-path="url(#${animClass}-owner-clip-${index})">`).join("\n");
|
|
587
635
|
const ownerClipClose = (elementOwnerClips ?? []).map(() => " </g>").join("\n");
|
|
636
|
+
const foregroundClipDefs = (staticForegroundLayers ?? []).flatMap((layer, layerIndex) => layer.clips.map((clip, clipIndex) => ` <clipPath id="${animClass}-foreground-${layerIndex}-clip-${clipIndex}">${roundedRectSvg(clip.x, clip.y, clip.width, clip.height, clip.corners, "")}</clipPath>`)).join("\n");
|
|
588
637
|
// ── Compose final SVG ──
|
|
589
638
|
// Share each raster payload across the segments that show it (the `<image>`
|
|
590
639
|
// emit is per-element, and a scroll composite repeats a sticky header /
|
|
@@ -594,7 +643,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
594
643
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${VH}" width="${W}" height="${VH}"${a11y.roleAttr}>${a11y.markup}
|
|
595
644
|
<defs>
|
|
596
645
|
<clipPath id="${animClass}-clip"><rect width="${W}" height="${VH}"/></clipPath>
|
|
597
|
-
${ownerClipDefs === "" ? "" : ownerClipDefs + "\n"}${glyphDefs !== "" ? ` ${glyphDefs}\n` : ""} <style>
|
|
646
|
+
${ownerClipDefs === "" ? "" : ownerClipDefs + "\n"}${foregroundClipDefs === "" ? "" : foregroundClipDefs + "\n"}${glyphDefs !== "" ? ` ${glyphDefs}\n` : ""} <style>
|
|
598
647
|
${fontFaceCss !== "" ? fontFaceCss + "\n" : ""} .${animClass} { animation: ${animClass} ${totalSec.toFixed(3)}s linear infinite; will-change: transform; }
|
|
599
648
|
@keyframes ${animClass} {
|
|
600
649
|
${keyframes}
|
|
@@ -615,6 +664,7 @@ ${paintBg ? ` <rect width="${compositeW}" height="${compositeH}" fill="${
|
|
|
615
664
|
</svg>
|
|
616
665
|
</g>
|
|
617
666
|
${ownerClipClose === "" ? "" : ownerClipClose + "\n"}
|
|
667
|
+
${staticForegroundMarkup === "" ? "" : staticForegroundMarkup + "\n"}
|
|
618
668
|
</g>${overlayMarkup}
|
|
619
669
|
</svg>`);
|
|
620
670
|
}
|
|
@@ -117,6 +117,10 @@ export interface PageStateSnapshot {
|
|
|
117
117
|
maxScrollY: number;
|
|
118
118
|
/** Maximum scrollable x. */
|
|
119
119
|
maxScrollX: number;
|
|
120
|
+
/** Width of the live scroll owner's content viewport, when available. */
|
|
121
|
+
clientWidth?: number;
|
|
122
|
+
/** Height of the live scroll owner's content viewport, when available. */
|
|
123
|
+
clientHeight?: number;
|
|
120
124
|
/** Current scroll position. */
|
|
121
125
|
scrollX: number;
|
|
122
126
|
scrollY: number;
|
|
@@ -133,6 +137,15 @@ export interface PageQuery {
|
|
|
133
137
|
/** Bbox of an element matching the CSS selector, in document coordinates. */
|
|
134
138
|
selectorBbox(css: string): Promise<SelectorBbox | null>;
|
|
135
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Size of one contiguous captured slice along the active scroll axis.
|
|
142
|
+
*
|
|
143
|
+
* Window-owned captures fill the configured output viewport. Element-owned
|
|
144
|
+
* captures only fill the element's client box, even when a larger ancestor or
|
|
145
|
+
* the whole body is captured around them. Stepping an element by the outer
|
|
146
|
+
* frame size would therefore leave uncaptured bands in the moving stack.
|
|
147
|
+
*/
|
|
148
|
+
export declare function scrollCaptureChunkSize(axis: ScrollAxis, viewport: Pick<ScrollExecutorOptions, "viewportW" | "viewportH">, snapshot: PageStateSnapshot, elementOwned: boolean): number;
|
|
136
149
|
/**
|
|
137
150
|
* Resolve an `AbsoluteTarget` AST node to an absolute scroll position along
|
|
138
151
|
* the given axis. Returns the position in scroll-coordinate space (i.e. the
|
package/dist/scroll/executor.js
CHANGED
|
@@ -76,6 +76,23 @@ function directionSign(action) {
|
|
|
76
76
|
return 1;
|
|
77
77
|
return -1;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Size of one contiguous captured slice along the active scroll axis.
|
|
81
|
+
*
|
|
82
|
+
* Window-owned captures fill the configured output viewport. Element-owned
|
|
83
|
+
* captures only fill the element's client box, even when a larger ancestor or
|
|
84
|
+
* the whole body is captured around them. Stepping an element by the outer
|
|
85
|
+
* frame size would therefore leave uncaptured bands in the moving stack.
|
|
86
|
+
*/
|
|
87
|
+
export function scrollCaptureChunkSize(axis, viewport, snapshot, elementOwned) {
|
|
88
|
+
const outputSize = axis === "x" ? viewport.viewportW : viewport.viewportH;
|
|
89
|
+
if (!elementOwned)
|
|
90
|
+
return outputSize;
|
|
91
|
+
const ownerSize = axis === "x" ? snapshot.clientWidth : snapshot.clientHeight;
|
|
92
|
+
return typeof ownerSize === "number" && Number.isFinite(ownerSize) && ownerSize > 0
|
|
93
|
+
? ownerSize
|
|
94
|
+
: outputSize;
|
|
95
|
+
}
|
|
79
96
|
/**
|
|
80
97
|
* Resolve an `AbsoluteTarget` AST node to an absolute scroll position along
|
|
81
98
|
* the given axis. Returns the position in scroll-coordinate space (i.e. the
|
|
@@ -286,27 +303,26 @@ export async function executeScrollPattern(page, pattern, opts) {
|
|
|
286
303
|
}
|
|
287
304
|
// op.kind === "scroll"
|
|
288
305
|
// DM-604 §4(a): for smooth-mode scrolls (single long action covering
|
|
289
|
-
// multiple
|
|
290
|
-
// the composer has enough anchor points to stack
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
306
|
+
// multiple visible slices), subdivide by the selected owner's captured
|
|
307
|
+
// scrollport so the composer has enough anchor points to stack
|
|
308
|
+
// contiguously. For the page owner that slice is the configured output
|
|
309
|
+
// viewport; for an element owner it is the element's client box, which can
|
|
310
|
+
// be much smaller than a surrounding body capture. Without this, a long
|
|
311
|
+
// action produces only its endpoints and the composite has an uncaptured
|
|
312
|
+
// band between them. ScrollPattern-mode scrolls with explicit per-token
|
|
313
|
+
// magnitudes ≤ one slice naturally produce one chunk per token.
|
|
296
314
|
//
|
|
297
|
-
// DM-633: chunks MUST land at exact
|
|
298
|
-
// evenly-distributed fractions of totalDelta). Each segment
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// header bleeds into the lower segment's tail — visible as a duplicate
|
|
304
|
-
// nav bar at the bottom of the viewport at t=0.
|
|
315
|
+
// DM-633: chunks MUST land at exact captured-slice multiples (not
|
|
316
|
+
// evenly-distributed fractions of totalDelta). Each segment contributes
|
|
317
|
+
// one visible owner slice at its scroll offset, so the composer stacks it
|
|
318
|
+
// at that coordinate. For window owners, closer-than-viewport anchors
|
|
319
|
+
// overlap and can duplicate fixed paint. For element owners, farther-than-
|
|
320
|
+
// client-box anchors leave a blank band between captured slices.
|
|
305
321
|
const snap0 = await pageQuery.snapshot();
|
|
306
322
|
const dx = op.destX - snap0.scrollX;
|
|
307
323
|
const dy = op.destY - snap0.scrollY;
|
|
308
324
|
const totalDelta = op.axis === "x" ? dx : dy;
|
|
309
|
-
const viewportSize = op.axis
|
|
325
|
+
const viewportSize = scrollCaptureChunkSize(op.axis, opts, snap0, selector != null);
|
|
310
326
|
const numChunks = Math.max(1, Math.ceil(Math.abs(totalDelta) / viewportSize));
|
|
311
327
|
const dir = totalDelta >= 0 ? 1 : -1;
|
|
312
328
|
for (let ci = 1; ci <= numChunks; ci++) {
|
|
@@ -314,7 +330,7 @@ export async function executeScrollPattern(page, pattern, opts) {
|
|
|
314
330
|
// tile contiguously (no overlap, no gap) in the composer. The final
|
|
315
331
|
// chunk clamps to op.destX/op.destY so the scroll completes at the
|
|
316
332
|
// intended target — that single clamped step may overlap the prior
|
|
317
|
-
// chunk by
|
|
333
|
+
// chunk by less than one slice, but it only affects the last frame and
|
|
318
334
|
// never the much-more-common mid-scroll frames.
|
|
319
335
|
const isLast = ci === numChunks;
|
|
320
336
|
const chunkDestX = op.axis === "x"
|
|
@@ -517,17 +533,22 @@ function realPageQuery(page, selector) {
|
|
|
517
533
|
return page.evaluate(() => ({
|
|
518
534
|
maxScrollX: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
|
519
535
|
maxScrollY: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
|
|
536
|
+
clientWidth: document.documentElement.clientWidth,
|
|
537
|
+
clientHeight: document.documentElement.clientHeight,
|
|
520
538
|
scrollX: window.scrollX,
|
|
521
539
|
scrollY: window.scrollY,
|
|
522
540
|
}));
|
|
523
541
|
}
|
|
524
542
|
return page.evaluate((sel) => {
|
|
525
543
|
const el = document.querySelector(sel);
|
|
526
|
-
if (!(el instanceof HTMLElement))
|
|
527
|
-
return { maxScrollX: 0, maxScrollY: 0, scrollX: 0, scrollY: 0 };
|
|
544
|
+
if (!(el instanceof HTMLElement)) {
|
|
545
|
+
return { maxScrollX: 0, maxScrollY: 0, clientWidth: 0, clientHeight: 0, scrollX: 0, scrollY: 0 };
|
|
546
|
+
}
|
|
528
547
|
return {
|
|
529
548
|
maxScrollX: Math.max(0, el.scrollWidth - el.clientWidth),
|
|
530
549
|
maxScrollY: Math.max(0, el.scrollHeight - el.clientHeight),
|
|
550
|
+
clientWidth: el.clientWidth,
|
|
551
|
+
clientHeight: el.clientHeight,
|
|
531
552
|
scrollX: el.scrollLeft,
|
|
532
553
|
scrollY: el.scrollTop,
|
|
533
554
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domotion-svg",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.2",
|
|
4
4
|
"description": "DOM-to-animated-SVG renderer. Captures HTML/CSS via Playwright Chromium and converts it to self-contained SVG with CSS animations — pixel-faithful demos that scale crisply and load lazily.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Brian Westphal",
|