domotion-svg 0.5.0 → 0.6.0
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/README.md +33 -3
- package/dist/animation/animator.d.ts +26 -2
- package/dist/animation/animator.js +125 -3
- package/dist/animation/index.d.ts +1 -0
- package/dist/animation/index.js +3 -2
- package/dist/animation/magic-move.d.ts +61 -0
- package/dist/animation/magic-move.js +243 -0
- package/dist/capture/index.js +117 -3
- package/dist/capture/script/index.js +5 -0
- package/dist/capture/script/pseudo-rules.d.ts +1 -1
- package/dist/capture/script/pseudo-rules.js +10 -3
- package/dist/capture/script/walker/borders-backgrounds.d.ts +61 -1
- package/dist/capture/script/walker/borders-backgrounds.js +22 -9
- package/dist/capture/script/walker/masks-clips.js +35 -12
- package/dist/capture/script/walker/text-segments.d.ts +2 -2
- package/dist/capture/script/walker/text-segments.js +14 -3
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +24 -0
- package/dist/cli/animate.d.ts +3 -2
- package/dist/cli/animate.js +33 -4
- package/dist/cli/capture.js +95 -5
- package/dist/cli/common.d.ts +10 -0
- package/dist/cli/common.js +35 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +27 -2
- package/dist/cli/review.d.ts +18 -0
- package/dist/cli/review.js +213 -0
- package/dist/cli/svg-to-video-core.d.ts +133 -0
- package/dist/cli/svg-to-video-core.js +533 -0
- package/dist/cli/svg-to-video.d.ts +14 -0
- package/dist/cli/svg-to-video.js +161 -0
- package/dist/render/borders.d.ts +50 -0
- package/dist/render/borders.js +55 -0
- package/dist/render/element-tree-to-svg.d.ts +60 -2
- package/dist/render/element-tree-to-svg.js +273 -71
- package/dist/render/embedded-font-builder.js +38 -0
- package/dist/render/glyph-helper.d.ts +73 -0
- package/dist/render/{coretext.js → glyph-helper.js} +68 -21
- package/dist/render/helper-acquire.d.ts +67 -0
- package/dist/render/helper-acquire.js +189 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.js +6 -1
- package/dist/render/text-to-path.d.ts +145 -0
- package/dist/render/text-to-path.js +863 -92
- package/dist/render/text.js +153 -13
- package/dist/review/client.bundle.generated.d.ts +1 -0
- package/dist/review/client.bundle.generated.js +3 -0
- package/dist/review/client.d.ts +10 -0
- package/dist/review/client.js +197 -0
- package/dist/review/compare-pngs.d.ts +151 -0
- package/dist/review/compare-pngs.js +475 -0
- package/dist/review/region-overlay.d.ts +50 -0
- package/dist/review/region-overlay.js +361 -0
- package/dist/review/server.d.ts +45 -0
- package/dist/review/server.js +140 -0
- package/dist/scroll/composer.d.ts +7 -1
- package/dist/scroll/composer.js +14 -10
- package/package.json +10 -8
- package/dist/animation/animator.test.d.ts +0 -5
- package/dist/animation/animator.test.js +0 -418
- package/dist/animation/cursor-overlay.test.d.ts +0 -1
- package/dist/animation/cursor-overlay.test.js +0 -88
- package/dist/cli/animate.test.d.ts +0 -7
- package/dist/cli/animate.test.js +0 -185
- package/dist/cross-origin-font-face.test.d.ts +0 -1
- package/dist/cross-origin-font-face.test.js +0 -107
- package/dist/dark-mode-capture.test.d.ts +0 -1
- package/dist/dark-mode-capture.test.js +0 -158
- package/dist/dark-mode-form-controls.test.d.ts +0 -1
- package/dist/dark-mode-form-controls.test.js +0 -218
- package/dist/embed-remote-images.test.d.ts +0 -1
- package/dist/embed-remote-images.test.js +0 -424
- package/dist/kerfjs-imports.test.d.ts +0 -1
- package/dist/kerfjs-imports.test.js +0 -36
- package/dist/mask.test.d.ts +0 -1
- package/dist/mask.test.js +0 -211
- package/dist/post-processing/optimize.test.d.ts +0 -1
- package/dist/post-processing/optimize.test.js +0 -40
- package/dist/preserve-aspect-ratio.test.d.ts +0 -1
- package/dist/preserve-aspect-ratio.test.js +0 -38
- package/dist/render/borders.test.d.ts +0 -1
- package/dist/render/borders.test.js +0 -148
- package/dist/render/conic-raster.test.d.ts +0 -1
- package/dist/render/conic-raster.test.js +0 -187
- package/dist/render/coretext.d.ts +0 -60
- package/dist/render/coretext.test.d.ts +0 -1
- package/dist/render/coretext.test.js +0 -94
- package/dist/render/form-controls.test.d.ts +0 -7
- package/dist/render/form-controls.test.js +0 -37
- package/dist/render/gradients.test.d.ts +0 -1
- package/dist/render/gradients.test.js +0 -184
- package/dist/render/text-to-path.test.d.ts +0 -1
- package/dist/render/text-to-path.test.js +0 -951
- package/dist/render/text.test.d.ts +0 -1
- package/dist/render/text.test.js +0 -234
- package/dist/scroll/composer.test.d.ts +0 -1
- package/dist/scroll/composer.test.js +0 -452
- package/dist/scroll/executor.test.d.ts +0 -1
- package/dist/scroll/executor.test.js +0 -236
- package/dist/scroll/hoist-fixed.test.d.ts +0 -1
- package/dist/scroll/hoist-fixed.test.js +0 -103
- package/dist/scroll/hoist-sticky.test.d.ts +0 -1
- package/dist/scroll/hoist-sticky.test.js +0 -154
- package/dist/scroll/pattern.test.d.ts +0 -1
- package/dist/scroll/pattern.test.js +0 -438
- package/dist/stacking-context.test.d.ts +0 -1
- package/dist/stacking-context.test.js +0 -927
- package/dist/tree-ops/frame-merge.test.d.ts +0 -6
- package/dist/tree-ops/frame-merge.test.js +0 -189
- package/dist/tree-ops/resize-embedded-images.test.d.ts +0 -9
- package/dist/tree-ops/resize-embedded-images.test.js +0 -255
- package/dist/tree-ops/tree-diff.test.d.ts +0 -1
- package/dist/tree-ops/tree-diff.test.js +0 -267
- package/dist/tree-ops/viewbox-culling.test.d.ts +0 -1
- package/dist/tree-ops/viewbox-culling.test.js +0 -240
- package/dist/utils/region-feedback.test.d.ts +0 -1
- package/dist/utils/region-feedback.test.js +0 -221
- package/dist/webfont-unicode-range.test.d.ts +0 -1
- package/dist/webfont-unicode-range.test.js +0 -174
- package/src/animation/animator.test.ts +0 -445
- package/src/animation/animator.ts +0 -927
- package/src/animation/cursor-overlay.test.ts +0 -95
- package/src/animation/cursor-overlay.ts +0 -295
- package/src/animation/index.ts +0 -29
- package/src/capture/embed.ts +0 -305
- package/src/capture/emoji.ts +0 -226
- package/src/capture/index.ts +0 -1309
- package/src/capture/script/color-norm.ts +0 -80
- package/src/capture/script/emoji-detect.ts +0 -97
- package/src/capture/script/font-metrics.ts +0 -130
- package/src/capture/script/index.ts +0 -1514
- package/src/capture/script/placeholder-shown.ts +0 -54
- package/src/capture/script/pseudo-rules.ts +0 -223
- package/src/capture/script/utils.ts +0 -18
- package/src/capture/script/walker/borders-backgrounds.ts +0 -277
- package/src/capture/script/walker/counter-style-resolver.ts +0 -184
- package/src/capture/script/walker/form-controls.ts +0 -242
- package/src/capture/script/walker/input-value.ts +0 -221
- package/src/capture/script/walker/lists-counters.ts +0 -110
- package/src/capture/script/walker/masks-clips.ts +0 -181
- package/src/capture/script/walker/pseudo-content.ts +0 -784
- package/src/capture/script/walker/pseudo-inject.ts +0 -203
- package/src/capture/script/walker/replaced-elements.ts +0 -102
- package/src/capture/script/walker/text-segments.ts +0 -404
- package/src/capture/script/walker/transforms.ts +0 -160
- package/src/capture/script/warnings.ts +0 -39
- package/src/capture/script.generated.ts +0 -7
- package/src/capture/types.ts +0 -891
- package/src/capture/warnings.ts +0 -39
- package/src/cli/animate.test.ts +0 -225
- package/src/cli/animate.ts +0 -956
- package/src/cli/capture.ts +0 -200
- package/src/cli/common.ts +0 -196
- package/src/cli/index.ts +0 -183
- package/src/cross-origin-font-face.test.ts +0 -119
- package/src/dark-mode-capture.test.ts +0 -178
- package/src/dark-mode-form-controls.test.ts +0 -229
- package/src/embed-remote-images.test.ts +0 -460
- package/src/globals.d.ts +0 -2
- package/src/index.ts +0 -82
- package/src/kerf-jsx-augmentation.d.ts +0 -36
- package/src/kerfjs-imports.test.tsx +0 -45
- package/src/mask.test.ts +0 -279
- package/src/post-processing/gzip.ts +0 -14
- package/src/post-processing/index.ts +0 -5
- package/src/post-processing/optimize.test.ts +0 -48
- package/src/post-processing/optimize.ts +0 -34
- package/src/preserve-aspect-ratio.test.ts +0 -49
- package/src/render/borders.test.ts +0 -160
- package/src/render/borders.ts +0 -783
- package/src/render/box-shadow.ts +0 -65
- package/src/render/colors.ts +0 -163
- package/src/render/conic-raster.test.ts +0 -213
- package/src/render/conic-raster.ts +0 -306
- package/src/render/coretext.test.ts +0 -131
- package/src/render/coretext.ts +0 -256
- package/src/render/css-tokens.ts +0 -44
- package/src/render/element-tree-to-svg.ts +0 -5660
- package/src/render/embedded-font-builder.ts +0 -221
- package/src/render/form-controls.test.ts +0 -42
- package/src/render/form-controls.ts +0 -1195
- package/src/render/format.ts +0 -24
- package/src/render/gradients.test.ts +0 -221
- package/src/render/gradients.ts +0 -1047
- package/src/render/index.ts +0 -22
- package/src/render/opentype.js.d.ts +0 -7
- package/src/render/text-to-path.test.ts +0 -1050
- package/src/render/text-to-path.ts +0 -2902
- package/src/render/text.test.ts +0 -262
- package/src/render/text.ts +0 -1017
- package/src/render/transforms.ts +0 -41
- package/src/scroll/composer.test.ts +0 -505
- package/src/scroll/composer.ts +0 -375
- package/src/scroll/executor.test.ts +0 -270
- package/src/scroll/executor.ts +0 -592
- package/src/scroll/hoist-fixed.test.ts +0 -117
- package/src/scroll/hoist-fixed.ts +0 -95
- package/src/scroll/hoist-sticky.test.ts +0 -173
- package/src/scroll/hoist-sticky.ts +0 -193
- package/src/scroll/index.ts +0 -36
- package/src/scroll/pattern.test.ts +0 -531
- package/src/scroll/pattern.ts +0 -610
- package/src/stacking-context.test.ts +0 -968
- package/src/tree-ops/frame-merge.test.ts +0 -208
- package/src/tree-ops/frame-merge.ts +0 -470
- package/src/tree-ops/index.ts +0 -11
- package/src/tree-ops/resize-embedded-images.test.ts +0 -292
- package/src/tree-ops/resize-embedded-images.ts +0 -177
- package/src/tree-ops/tree-diff.test.ts +0 -295
- package/src/tree-ops/tree-diff.ts +0 -236
- package/src/tree-ops/viewbox-culling.test.ts +0 -292
- package/src/tree-ops/viewbox-culling.ts +0 -327
- package/src/utils/escapeHtml.ts +0 -16
- package/src/utils/region-feedback.test.ts +0 -261
- package/src/utils/region-feedback.ts +0 -216
- package/src/webfont-unicode-range.test.ts +0 -207
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="examples/output/domotion-word-demo.svg" alt="Domotion — an animated wordmark cycling through twenty neon-retro typographic variants of the word domotion" width="600">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
5
|
DOM-to-animated-SVG renderer. Captures HTML/CSS rendered in headless Chromium and converts the captured tree into a self-contained SVG with optional CSS animations — pixel-faithful to what Chromium painted, scales crisply at any size, and embeds without external assets.
|
|
4
6
|
|
|
@@ -39,6 +41,9 @@ yourself to keep the first job's runtime down.
|
|
|
39
41
|
The fastest way in is the `domotion` CLI — no TypeScript, no Playwright bring-up. Point it at a URL or HTML file:
|
|
40
42
|
|
|
41
43
|
```bash
|
|
44
|
+
# Zero-install: run the published CLI straight from npm.
|
|
45
|
+
npx domotion-svg capture https://example.com -o example.svg
|
|
46
|
+
|
|
42
47
|
# Capture a URL as SVG.
|
|
43
48
|
domotion capture https://example.com -o example.svg
|
|
44
49
|
|
|
@@ -61,19 +66,44 @@ domotion animate ./demo.json
|
|
|
61
66
|
|
|
62
67
|
The config describes each frame (input, duration, transition) plus a declarative surface for interaction demos: continuous-session frames that carry client-side state across steps (omit `input` / set `"continue": true`), DOM-mutation and interaction actions, richer readiness waits (`waitForText` / `waitForGone` / `waitForCount`), typing / tap / svg / blink overlays that can anchor to an element's box, an on-screen `cursor` (explicit or `"auto"`), `vars` + `${}` interpolation, and a small `evaluate` escape hatch. See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
|
|
63
68
|
|
|
69
|
+
### Export to video
|
|
70
|
+
|
|
71
|
+
The package also ships a standalone `svg-to-video` CLI that renders an animated SVG (a `domotion animate` output, or any CSS-/SMIL-animated SVG) to a video file. It steps the animation timeline frame by frame in Chromium for frame-accurate timing, then pipes the frames to **ffmpeg** (a required external dependency — install via `brew` / `apt` / `winget`).
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# h264/mp4 at 30fps, contained to 1280px wide.
|
|
75
|
+
svg-to-video demo.svg -o demo.mp4 --width 1280
|
|
76
|
+
|
|
77
|
+
# 60fps VP9/webm with looping background music.
|
|
78
|
+
svg-to-video demo.svg -o demo.webm --format vp9 --fps 60 --music bed.mp3
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Supports target size (`--width`/`--height`, aspect-preserving), `--fps`, `--format` / `--container`, supersampling (`--scale`), background music / foreground audio / captions, and a disk-space pre-flight. See `svg-to-video --help`.
|
|
82
|
+
|
|
83
|
+
### Reviewing a regression
|
|
84
|
+
|
|
85
|
+
If a capture comes out looking different from how Chromium painted the source page, the package ships an `svg-review` CLI to help you file a focused bug report. Capture once with `--debug` to get a reproduction bundle (HAR + the Chromium screenshot of the source + the SVG we produced), then open the bundle in the local review UI:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
domotion capture https://example.com --debug -o example.svg
|
|
89
|
+
svg-review --expected example.debug/expected.png --actual example.debug/actual.svg
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The browser opens a single review card showing the expected / actual / diff PNGs. Arrow keys cycle through the three at full size; drag on any image to mark a problem region and caption it. The side panel builds a GitHub-issue-ready Markdown block as you go — copy it, then file the issue at <https://github.com/brianwestphal/domotion/issues/new> and attach `expected.png` + `actual.svg` so a maintainer can reproduce.
|
|
93
|
+
|
|
64
94
|
### Scripting API
|
|
65
95
|
|
|
66
96
|
When you outgrow the CLI — custom interaction loops, programmatic frame composition, custom overlays — the same primitives are available as a library:
|
|
67
97
|
|
|
68
98
|
```ts
|
|
69
|
-
import { captureElementTree, elementTreeToSvg, launchChromium
|
|
99
|
+
import { captureElementTree, elementTreeToSvg, launchChromium } from "domotion-svg";
|
|
70
100
|
|
|
71
101
|
const browser = await launchChromium();
|
|
72
102
|
const page = await browser.newPage();
|
|
73
103
|
await page.setContent(`<div style="padding:20px;color:white;background:#0d1117">Hello</div>`);
|
|
74
104
|
|
|
75
105
|
const tree = await captureElementTree(page, "body", { x: 0, y: 0, width: 800, height: 200 });
|
|
76
|
-
const svg =
|
|
106
|
+
const svg = elementTreeToSvg(tree, 800, 200);
|
|
77
107
|
|
|
78
108
|
console.log(svg);
|
|
79
109
|
await browser.close();
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* animated SVG with CSS keyframe transitions.
|
|
6
6
|
*/
|
|
7
7
|
import { type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
|
|
8
|
+
import type { MagicMove } from "./magic-move.js";
|
|
8
9
|
export interface AnimationFrame {
|
|
9
10
|
/** SVG content for this frame (from dom-to-svg) */
|
|
10
11
|
svgContent: string;
|
|
@@ -28,11 +29,26 @@ export interface AnimationFrame {
|
|
|
28
29
|
* `crossfade` (default) overlaps fade-out and fade-in. `push-left` slides
|
|
29
30
|
* the outgoing frame off and the incoming frame in from the right.
|
|
30
31
|
* `scroll` keeps both visible during the transition. `cut` is instant —
|
|
31
|
-
* no fade, no slide. For `cut`, `duration` is ignored.
|
|
32
|
+
* no fade, no slide. For `cut`, `duration` is ignored. `magic-move` blends
|
|
33
|
+
* shared elements between the two frames — matched elements slide from
|
|
34
|
+
* their old position to their new one while added/removed elements
|
|
35
|
+
* cross-fade (DM-898; see `docs/53-magic-move-transition.md`). It requires
|
|
36
|
+
* the per-frame `magicMove` bridge layer (built caller-side from the
|
|
37
|
+
* element trees); when that's absent it degrades to `crossfade`.
|
|
32
38
|
*/
|
|
33
|
-
type: "crossfade" | "push-left" | "scroll" | "cut";
|
|
39
|
+
type: "crossfade" | "push-left" | "scroll" | "cut" | "magic-move";
|
|
34
40
|
duration: number;
|
|
35
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Magic-move bridge layer for this frame's transition to the next, built by
|
|
44
|
+
* the caller via `buildMagicMove(prevTree, nextTree, …)`. Present only when
|
|
45
|
+
* `transition.type === "magic-move"` and both frames' element trees were
|
|
46
|
+
* available; the animator shows it during the transition window (moved
|
|
47
|
+
* elements slide, added fade in, removed fade out) between the hard-cut prev
|
|
48
|
+
* and next frame blobs. When `transition.type` is `magic-move` but this is
|
|
49
|
+
* null, the animator falls back to `crossfade`.
|
|
50
|
+
*/
|
|
51
|
+
magicMove?: MagicMove | null;
|
|
36
52
|
/** Overlays: typing, tap ripple */
|
|
37
53
|
overlays?: AnimationOverlay[];
|
|
38
54
|
/**
|
|
@@ -228,5 +244,13 @@ export interface AnimationConfig {
|
|
|
228
244
|
* uses `selector`; otherwise pass undefined / null.
|
|
229
245
|
*/
|
|
230
246
|
resolveSelector?: SelectorResolver;
|
|
247
|
+
/**
|
|
248
|
+
* Canvas background color painted behind every frame (a full-viewport
|
|
249
|
+
* `<rect>`). Mirrors the single-frame path's `transparentRootBgRect`
|
|
250
|
+
* (DM-554): pass the captured page's root background so animated output
|
|
251
|
+
* matches `capture` output. Omitted / `"transparent"` / `"rgba(0, 0, 0, 0)"`
|
|
252
|
+
* → no rect, i.e. a transparent SVG that composites over a host background.
|
|
253
|
+
*/
|
|
254
|
+
background?: string;
|
|
231
255
|
}
|
|
232
256
|
export declare function generateAnimatedSvg(config: AnimationConfig): string;
|
|
@@ -51,6 +51,11 @@ export function generateAnimatedSvg(config) {
|
|
|
51
51
|
const prevFrame = i > 0 ? frames[i - 1] : null;
|
|
52
52
|
const entersViaPush = prevFrame?.transition?.type === "push-left";
|
|
53
53
|
const entersViaScroll = prevFrame?.transition?.type === "scroll";
|
|
54
|
+
// DM-898: a frame entered from a magic-move transition appears at its own
|
|
55
|
+
// start (= the predecessor's transition end), NOT overlap-faded — the
|
|
56
|
+
// magic-move bridge layer already covered the window, so a crossfade
|
|
57
|
+
// overlap here would double-show the next frame on top of the bridge.
|
|
58
|
+
const entersViaMagicMove = prevFrame?.transition?.type === "magic-move" && prevFrame?.magicMove != null;
|
|
54
59
|
// Both push-left and scroll overlap their transition with the next
|
|
55
60
|
// frame's entry — the next frame is already sliding in while the current
|
|
56
61
|
// one slides out, so its show window starts at `timeOffset - prevTransDur`
|
|
@@ -117,6 +122,117 @@ export function generateAnimatedSvg(config) {
|
|
|
117
122
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
|
|
118
123
|
.fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
|
|
119
124
|
}
|
|
125
|
+
else if (transType === "magic-move" && frame.magicMove != null) {
|
|
126
|
+
// DM-898: magic-move. Frame i holds [start..holdEnd] then HARD-CUTS out;
|
|
127
|
+
// a bridge composite covers the transition window [holdEnd..transEnd],
|
|
128
|
+
// inside which matched elements slide prev→next, added elements fade in,
|
|
129
|
+
// and removed elements fade out. The next frame cuts in at transEnd
|
|
130
|
+
// (= its own start). The bridge's start state matches the prev frame's
|
|
131
|
+
// final paint and its end state the next frame's initial paint, so both
|
|
132
|
+
// hard cuts are seamless. (When `frame.magicMove` is null the type falls
|
|
133
|
+
// through to the crossfade branch below — the documented fallback.)
|
|
134
|
+
const mm = frame.magicMove;
|
|
135
|
+
const sNum = parseFloat(startPct);
|
|
136
|
+
const hNum = parseFloat(holdEndPct);
|
|
137
|
+
const tNum = parseFloat(transEndPct);
|
|
138
|
+
const beforeS = Math.max(0, sNum - 0.001).toFixed(3);
|
|
139
|
+
const afterH = Math.min(100, hNum + 0.001).toFixed(3);
|
|
140
|
+
const beforeH = Math.max(0, hNum - 0.001).toFixed(3);
|
|
141
|
+
const afterT = Math.min(100, tNum + 0.001).toFixed(3);
|
|
142
|
+
// Frame i blob: visible only during its hold, hard-cut out at hold end.
|
|
143
|
+
frameGroups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
|
|
144
|
+
keyframes.push(`
|
|
145
|
+
@keyframes fv-${i} {
|
|
146
|
+
0% { opacity: 0; visibility: hidden; }
|
|
147
|
+
${beforeS}% { opacity: 0; visibility: hidden; }
|
|
148
|
+
${sNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
149
|
+
${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
150
|
+
${afterH}% { opacity: 0; visibility: hidden; }
|
|
151
|
+
100% { opacity: 0; visibility: hidden; }
|
|
152
|
+
}
|
|
153
|
+
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
|
|
154
|
+
// Bridge composite: visible during the transition window only.
|
|
155
|
+
frameGroups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
|
|
156
|
+
keyframes.push(`
|
|
157
|
+
@keyframes mmv-${i} {
|
|
158
|
+
0% { opacity: 0; visibility: hidden; }
|
|
159
|
+
${beforeH}% { opacity: 0; visibility: hidden; }
|
|
160
|
+
${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
161
|
+
${tNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
162
|
+
${afterT}% { opacity: 0; visibility: hidden; }
|
|
163
|
+
100% { opacity: 0; visibility: hidden; }
|
|
164
|
+
}
|
|
165
|
+
.mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
|
|
166
|
+
// Per-element slide / fade keyframes within the window (linear interp).
|
|
167
|
+
// The composite is only visible [holdEnd..transEnd], so the held values
|
|
168
|
+
// outside that window are never painted — they just pin the endpoints.
|
|
169
|
+
//
|
|
170
|
+
// A dual-render cross-fade copy (DM-903) is BOTH a slide and a fade, so
|
|
171
|
+
// its element needs two animations. They MUST go in one `animation:`
|
|
172
|
+
// declaration (comma-joined) — two separate `.cls { animation: … }` rules
|
|
173
|
+
// would have the later one silently override the former, dropping the
|
|
174
|
+
// slide. Accumulate per-class animation entries and emit one rule each.
|
|
175
|
+
const animEntries = new Map();
|
|
176
|
+
const addAnim = (cls, name) => {
|
|
177
|
+
const list = animEntries.get(cls) ?? [];
|
|
178
|
+
list.push(`${name} ${totalSec.toFixed(2)}s infinite`);
|
|
179
|
+
animEntries.set(cls, list);
|
|
180
|
+
};
|
|
181
|
+
for (const s of mm.slides) {
|
|
182
|
+
// Interpolate the element's transform `from → to` across the window.
|
|
183
|
+
// The next-appearance copy maps its prev rect → `none` (final next
|
|
184
|
+
// rect); a cross-fade prev copy maps `none` → its next rect, so both
|
|
185
|
+
// copies trace the same path (DM-899 geometry; DM-903 paired copies).
|
|
186
|
+
keyframes.push(`
|
|
187
|
+
@keyframes mms-${s.cls} {
|
|
188
|
+
0%, ${hNum.toFixed(3)}% { transform: ${s.from}; }
|
|
189
|
+
${tNum.toFixed(3)}%, 100% { transform: ${s.to}; }
|
|
190
|
+
}`);
|
|
191
|
+
addAnim(s.cls, `mms-${s.cls}`);
|
|
192
|
+
}
|
|
193
|
+
for (const cls of mm.fadeIn) {
|
|
194
|
+
keyframes.push(`
|
|
195
|
+
@keyframes mmf-${cls} {
|
|
196
|
+
0%, ${hNum.toFixed(3)}% { opacity: 0; }
|
|
197
|
+
${tNum.toFixed(3)}%, 100% { opacity: 1; }
|
|
198
|
+
}`);
|
|
199
|
+
addAnim(cls, `mmf-${cls}`);
|
|
200
|
+
}
|
|
201
|
+
for (const cls of mm.fadeOut) {
|
|
202
|
+
keyframes.push(`
|
|
203
|
+
@keyframes mmf-${cls} {
|
|
204
|
+
0%, ${hNum.toFixed(3)}% { opacity: 1; }
|
|
205
|
+
${tNum.toFixed(3)}%, 100% { opacity: 0; }
|
|
206
|
+
}`);
|
|
207
|
+
addAnim(cls, `mmf-${cls}`);
|
|
208
|
+
}
|
|
209
|
+
for (const [cls, entries] of animEntries) {
|
|
210
|
+
keyframes.push(` .${cls} { animation: ${entries.join(", ")}; }`);
|
|
211
|
+
}
|
|
212
|
+
// DM-901: honor `prefers-reduced-motion: reduce` — pin everything to the
|
|
213
|
+
// NEXT state instead of animating, so the transition degrades to a
|
|
214
|
+
// cut-like reveal for motion-sensitive viewers. Slides drop to their
|
|
215
|
+
// final transform (`none` for the next copy; the prev cross-fade copy is
|
|
216
|
+
// also hidden via its fade-out below). Added / next-appearance fades snap
|
|
217
|
+
// to opacity 1; removed / prev-appearance fades snap to opacity 0. Static
|
|
218
|
+
// CSS, so output stays deterministic; rasterizers default to
|
|
219
|
+
// `no-preference` and play the full move. (DM-903: the fade rules now
|
|
220
|
+
// also matter — without pinning fade-out to 0 the prev-appearance copy
|
|
221
|
+
// would stay visible at full opacity.)
|
|
222
|
+
const reduceRules = [];
|
|
223
|
+
if (mm.slides.length > 0)
|
|
224
|
+
reduceRules.push(`${mm.slides.map((s) => `.${s.cls}`).join(", ")} { animation: none; transform: none; }`);
|
|
225
|
+
if (mm.fadeIn.length > 0)
|
|
226
|
+
reduceRules.push(`${mm.fadeIn.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 1; }`);
|
|
227
|
+
if (mm.fadeOut.length > 0)
|
|
228
|
+
reduceRules.push(`${mm.fadeOut.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 0; }`);
|
|
229
|
+
if (reduceRules.length > 0) {
|
|
230
|
+
keyframes.push(`
|
|
231
|
+
@media (prefers-reduced-motion: reduce) {
|
|
232
|
+
${reduceRules.join("\n ")}
|
|
233
|
+
}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
120
236
|
else {
|
|
121
237
|
// Crossfade or cut: opacity in/out.
|
|
122
238
|
//
|
|
@@ -157,7 +273,7 @@ export function generateAnimatedSvg(config) {
|
|
|
157
273
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
|
|
158
274
|
}
|
|
159
275
|
else {
|
|
160
|
-
const fadeInStartPct = i > 0
|
|
276
|
+
const fadeInStartPct = (i > 0 && !entersViaMagicMove)
|
|
161
277
|
? pct(Math.max(0, timeOffset - prevTransDur), totalDuration)
|
|
162
278
|
: startPct;
|
|
163
279
|
const prevEnd = i > 0
|
|
@@ -221,6 +337,13 @@ export function generateAnimatedSvg(config) {
|
|
|
221
337
|
const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
|
|
222
338
|
overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
|
|
223
339
|
}
|
|
340
|
+
// Canvas background rect — only when a non-transparent background is given.
|
|
341
|
+
// Default (none / transparent) emits nothing so the SVG composites over the
|
|
342
|
+
// host page, matching the single-frame `transparentRootBgRect` path (DM-554).
|
|
343
|
+
const bg = config.background;
|
|
344
|
+
const canvasBgRect = (bg != null && bg !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)")
|
|
345
|
+
? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
|
|
346
|
+
: "";
|
|
224
347
|
const out = `<?xml version="1.0" encoding="UTF-8"?>
|
|
225
348
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
|
226
349
|
<defs>
|
|
@@ -232,8 +355,7 @@ ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss +
|
|
|
232
355
|
${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
|
|
233
356
|
</style>
|
|
234
357
|
<g clip-path="url(#viewport-clip)">
|
|
235
|
-
|
|
236
|
-
${frameGroups.join("\n")}${overlayMarkup}
|
|
358
|
+
${canvasBgRect}${frameGroups.join("\n")}${overlayMarkup}
|
|
237
359
|
</g>
|
|
238
360
|
</svg>`;
|
|
239
361
|
return out;
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
export { buildMagicMove, type MagicMove, type MagicMoveSlide, } from "./magic-move.js";
|
|
1
2
|
export { generateAnimatedSvg, type AnimationConfig, type AnimationFrame, type AnimationOverlay, type TypingOverlay, type TapOverlay, type SvgOverlay, type IntraFrameAnimation, } from "./animator.js";
|
|
2
3
|
export { cursorOverlayMarkup, resolveCursorScript, type CursorOverlay, type CursorEvent, type CursorMoveEvent, type CursorClickEvent, type CursorShowEvent, type CursorHideEvent, type CursorStyle, type SelectorResolver, } from "./cursor-overlay.js";
|
package/dist/animation/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Public surface of the animation pipeline. The composer
|
|
2
2
|
// (`generateAnimatedSvg`) consumes per-frame element trees + transition /
|
|
3
3
|
// overlay config and emits one self-contained SVG with `@keyframes` cross-
|
|
4
|
-
// fade / push-left / scroll / cut transitions and optional
|
|
5
|
-
// SVG / cursor overlays.
|
|
4
|
+
// fade / push-left / scroll / cut / magic-move transitions and optional
|
|
5
|
+
// typing / tap / SVG / cursor overlays.
|
|
6
|
+
export { buildMagicMove, } from "./magic-move.js";
|
|
6
7
|
export { generateAnimatedSvg, } from "./animator.js";
|
|
7
8
|
export { cursorOverlayMarkup, resolveCursorScript, } from "./cursor-overlay.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic-move transition composer — phase 1 of the Keynote-style magic-move
|
|
3
|
+
* transition (DM-898 / DM-112; spec in `docs/53-magic-move-transition.md`).
|
|
4
|
+
*
|
|
5
|
+
* Builds the "bridge" layer shown during a magic-move transition window: a
|
|
6
|
+
* single composite, rendered from the NEXT frame's element tree, in which
|
|
7
|
+
* - elements that MOVED between the two frames (diff `translated`) slide from
|
|
8
|
+
* their previous position to their next one,
|
|
9
|
+
* - elements only in the NEXT frame (`added`) fade in,
|
|
10
|
+
* - elements only in the PREV frame (`removed`) — appended from the prev tree
|
|
11
|
+
* — fade out,
|
|
12
|
+
* - everything else (`static` / `modified`) renders in place.
|
|
13
|
+
* At the window's start the composite matches the PREV frame's final state, and
|
|
14
|
+
* at its end the NEXT frame's initial state, so the animator hard-cuts the prev
|
|
15
|
+
* frame out at hold-end and the next frame in at the window end with no visible
|
|
16
|
+
* jump (see `generateAnimatedSvg`'s magic-move branch).
|
|
17
|
+
*
|
|
18
|
+
* Rendering happens HERE (caller-side) rather than inside the animator because
|
|
19
|
+
* the glyph/font `<defs>` are accumulated globally during rendering and emitted
|
|
20
|
+
* once by the caller BEFORE `generateAnimatedSvg` runs — re-rendering inside the
|
|
21
|
+
* animator would reference glyphs missing from the already-finalized defs. (This
|
|
22
|
+
* refines the original "animator re-renders" sketch in docs/53.)
|
|
23
|
+
*
|
|
24
|
+
* v1 scope (DM-898): translate only. Size/style morph is DM-899; `data-magic-key`
|
|
25
|
+
* author pairing is DM-900; reduced-motion + deeper nesting hardening is DM-901.
|
|
26
|
+
*/
|
|
27
|
+
import type { CapturedElement } from "../capture/types.js";
|
|
28
|
+
/** One element that slides during the transition. The animator interpolates
|
|
29
|
+
* `transform: <from> → <to>` over the window. A `translate(dx,dy)` for a pure
|
|
30
|
+
* move, the full `translate · scale · translate` affine for a size change
|
|
31
|
+
* (DM-899). The next-appearance copy goes `<prev-rect map> → none`; a prev-
|
|
32
|
+
* appearance copy in a cross-fade pair goes `none → <next-rect map>` so both
|
|
33
|
+
* copies trace the same prev→next path while their opacities swap (DM-903). */
|
|
34
|
+
export interface MagicMoveSlide {
|
|
35
|
+
/** CSS class the renderer stamped on the element (`anim-<id>`). */
|
|
36
|
+
cls: string;
|
|
37
|
+
/** CSS transform at the window start. */
|
|
38
|
+
from: string;
|
|
39
|
+
/** CSS transform at the window end (`"none"` for the next-appearance copy). */
|
|
40
|
+
to: string;
|
|
41
|
+
}
|
|
42
|
+
export interface MagicMove {
|
|
43
|
+
/** Composite SVG markup shown for the transition window (no XML preamble). */
|
|
44
|
+
compositeSvg: string;
|
|
45
|
+
/** Elements that translate prev→next. */
|
|
46
|
+
slides: MagicMoveSlide[];
|
|
47
|
+
/** Classes that fade in over the window (`added`). */
|
|
48
|
+
fadeIn: string[];
|
|
49
|
+
/** Classes that fade out over the window (`removed`). */
|
|
50
|
+
fadeOut: string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the magic-move bridge layer between two captured trees. Returns `null`
|
|
54
|
+
* when there is nothing worth animating (no moved / added / removed elements) —
|
|
55
|
+
* the caller then falls back to `crossfade`.
|
|
56
|
+
*
|
|
57
|
+
* `render` turns a list of element roots into SVG markup (the caller passes a
|
|
58
|
+
* thin `elementTreeToSvg(roots, W, H, prefix, …)` wrapper); injecting it keeps
|
|
59
|
+
* this module renderer-agnostic and unit-testable.
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildMagicMove(prevTree: CapturedElement | CapturedElement[], nextTree: CapturedElement | CapturedElement[], render: (roots: CapturedElement[], idPrefix: string) => string, idPrefix: string): MagicMove | null;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic-move transition composer — phase 1 of the Keynote-style magic-move
|
|
3
|
+
* transition (DM-898 / DM-112; spec in `docs/53-magic-move-transition.md`).
|
|
4
|
+
*
|
|
5
|
+
* Builds the "bridge" layer shown during a magic-move transition window: a
|
|
6
|
+
* single composite, rendered from the NEXT frame's element tree, in which
|
|
7
|
+
* - elements that MOVED between the two frames (diff `translated`) slide from
|
|
8
|
+
* their previous position to their next one,
|
|
9
|
+
* - elements only in the NEXT frame (`added`) fade in,
|
|
10
|
+
* - elements only in the PREV frame (`removed`) — appended from the prev tree
|
|
11
|
+
* — fade out,
|
|
12
|
+
* - everything else (`static` / `modified`) renders in place.
|
|
13
|
+
* At the window's start the composite matches the PREV frame's final state, and
|
|
14
|
+
* at its end the NEXT frame's initial state, so the animator hard-cuts the prev
|
|
15
|
+
* frame out at hold-end and the next frame in at the window end with no visible
|
|
16
|
+
* jump (see `generateAnimatedSvg`'s magic-move branch).
|
|
17
|
+
*
|
|
18
|
+
* Rendering happens HERE (caller-side) rather than inside the animator because
|
|
19
|
+
* the glyph/font `<defs>` are accumulated globally during rendering and emitted
|
|
20
|
+
* once by the caller BEFORE `generateAnimatedSvg` runs — re-rendering inside the
|
|
21
|
+
* animator would reference glyphs missing from the already-finalized defs. (This
|
|
22
|
+
* refines the original "animator re-renders" sketch in docs/53.)
|
|
23
|
+
*
|
|
24
|
+
* v1 scope (DM-898): translate only. Size/style morph is DM-899; `data-magic-key`
|
|
25
|
+
* author pairing is DM-900; reduced-motion + deeper nesting hardening is DM-901.
|
|
26
|
+
*/
|
|
27
|
+
import { diffTrees, entriesOfKind } from "../tree-ops/tree-diff.js";
|
|
28
|
+
function asRoots(t) {
|
|
29
|
+
return Array.isArray(t) ? t : [t];
|
|
30
|
+
}
|
|
31
|
+
/** Resolve a tree path (`[rootIdx, childIdx, …]`, per `diffTrees`) to its element. */
|
|
32
|
+
function elementAtPath(roots, path) {
|
|
33
|
+
let el = roots[path[0]];
|
|
34
|
+
for (let i = 1; i < path.length && el != null; i++)
|
|
35
|
+
el = el.children?.[path[i]];
|
|
36
|
+
return el ?? null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* CSS `transform` that maps an element rendered at its NEXT rect back onto its
|
|
40
|
+
* PREV rect — the window-start state the animator interpolates away to `none`.
|
|
41
|
+
* The element's painted geometry sits at next-space coordinates, so the affine
|
|
42
|
+
* is `translate(prevOrigin) · scale(prevSize/nextSize) · translate(-nextOrigin)`
|
|
43
|
+
* (prepend-translate to next origin, scale about it, then place at prev origin).
|
|
44
|
+
* Pure moves (size unchanged) collapse to a single `translate(dx, dy)` for
|
|
45
|
+
* smaller, more legible output; a zero next-dimension can't be scaled, so it
|
|
46
|
+
* also falls back to translate-only.
|
|
47
|
+
*/
|
|
48
|
+
function rectMapTransform(prev, next) {
|
|
49
|
+
const sizeChanged = Math.abs(prev.width - next.width) > 0.5 || Math.abs(prev.height - next.height) > 0.5;
|
|
50
|
+
if (!sizeChanged || next.width <= 0 || next.height <= 0) {
|
|
51
|
+
return `translate(${r(prev.x - next.x)}px, ${r(prev.y - next.y)}px)`;
|
|
52
|
+
}
|
|
53
|
+
const sx = prev.width / next.width;
|
|
54
|
+
const sy = prev.height / next.height;
|
|
55
|
+
return `translate(${r(prev.x)}px, ${r(prev.y)}px) scale(${r5(sx)}, ${r5(sy)}) translate(${r(-next.x)}px, ${r(-next.y)}px)`;
|
|
56
|
+
}
|
|
57
|
+
/** Round to 2dp (px positions) / 5dp (scale factors), trimming trailing zeros. */
|
|
58
|
+
function r(n) { return Number(n.toFixed(2)); }
|
|
59
|
+
function r5(n) { return Number(n.toFixed(5)); }
|
|
60
|
+
/** True iff `a` is a strict prefix of `b` (i.e. `a` is an ancestor path of `b`). */
|
|
61
|
+
function isAncestorPath(a, b) {
|
|
62
|
+
if (a.length >= b.length)
|
|
63
|
+
return false;
|
|
64
|
+
for (let i = 0; i < a.length; i++)
|
|
65
|
+
if (a[i] !== b[i])
|
|
66
|
+
return false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Collect every element carrying a `data-magic-key` (`el.magicKey`), keyed by
|
|
71
|
+
* the attribute value, with its tree path (`[rootIdx, childIdx, …]`, matching
|
|
72
|
+
* `diffTrees`). First occurrence per key wins — a duplicate key within one
|
|
73
|
+
* frame is author error; we pair the first. (DM-900)
|
|
74
|
+
*/
|
|
75
|
+
function collectKeyed(roots) {
|
|
76
|
+
const out = new Map();
|
|
77
|
+
const walk = (el, path) => {
|
|
78
|
+
const k = el.magicKey;
|
|
79
|
+
if (k != null && k !== "" && !out.has(k))
|
|
80
|
+
out.set(k, { el, path });
|
|
81
|
+
const kids = el.children ?? [];
|
|
82
|
+
for (let i = 0; i < kids.length; i++)
|
|
83
|
+
walk(kids[i], [...path, i]);
|
|
84
|
+
};
|
|
85
|
+
for (let i = 0; i < roots.length; i++)
|
|
86
|
+
walk(roots[i], [i]);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* True iff a matched element's PAINT changed between frames — text content or
|
|
91
|
+
* a visible style (`color` / `backgroundColor` / `borderColor` / `borderTopColor`
|
|
92
|
+
* / `opacity`). Gates the DM-903 dual-render cross-fade: geometry-only movers
|
|
93
|
+
* (same paint, just moved/resized) keep a single copy; paint-changed movers
|
|
94
|
+
* render prev + next appearances and cross-fade. The fingerprint matcher keys
|
|
95
|
+
* on (tag, text, children) not style, so a recolored-but-moved element stays
|
|
96
|
+
* `translated` — style equality has to be checked here, not read off the kind.
|
|
97
|
+
*/
|
|
98
|
+
function appearanceChanged(prev, next) {
|
|
99
|
+
if ((prev.text ?? "") !== (next.text ?? ""))
|
|
100
|
+
return true;
|
|
101
|
+
const p = prev.styles;
|
|
102
|
+
const q = next.styles;
|
|
103
|
+
if (p == null || q == null)
|
|
104
|
+
return false;
|
|
105
|
+
return p.color !== q.color
|
|
106
|
+
|| (p.backgroundColor ?? "") !== (q.backgroundColor ?? "")
|
|
107
|
+
|| (p.borderColor ?? "") !== (q.borderColor ?? "")
|
|
108
|
+
|| (p.borderTopColor ?? "") !== (q.borderTopColor ?? "")
|
|
109
|
+
|| p.opacity !== q.opacity;
|
|
110
|
+
}
|
|
111
|
+
/** True iff the two rects differ in origin or size beyond the diff tolerance. */
|
|
112
|
+
function rectChanged(p, q) {
|
|
113
|
+
return Math.abs(q.x - p.x) > 0.5 || Math.abs(q.y - p.y) > 0.5
|
|
114
|
+
|| Math.abs(q.width - p.width) > 0.5 || Math.abs(q.height - p.height) > 0.5;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build the magic-move bridge layer between two captured trees. Returns `null`
|
|
118
|
+
* when there is nothing worth animating (no moved / added / removed elements) —
|
|
119
|
+
* the caller then falls back to `crossfade`.
|
|
120
|
+
*
|
|
121
|
+
* `render` turns a list of element roots into SVG markup (the caller passes a
|
|
122
|
+
* thin `elementTreeToSvg(roots, W, H, prefix, …)` wrapper); injecting it keeps
|
|
123
|
+
* this module renderer-agnostic and unit-testable.
|
|
124
|
+
*/
|
|
125
|
+
export function buildMagicMove(prevTree, nextTree, render, idPrefix) {
|
|
126
|
+
const prevRoots = asRoots(prevTree);
|
|
127
|
+
const nextRoots = asRoots(nextTree);
|
|
128
|
+
const diff = diffTrees(prevRoots, nextRoots);
|
|
129
|
+
// DM-900: author `data-magic-key` force-pairs the same logical element across
|
|
130
|
+
// frames AHEAD of the fingerprint heuristic. A key present in BOTH trees is a
|
|
131
|
+
// forced mover that supersedes whatever diffTrees decided for those elements
|
|
132
|
+
// (the heuristic may have mis-paired them, or — when their content changed —
|
|
133
|
+
// split them into add + remove, which would cross-fade instead of slide).
|
|
134
|
+
const prevKeyed = collectKeyed(prevRoots);
|
|
135
|
+
const nextKeyed = collectKeyed(nextRoots);
|
|
136
|
+
const keyedMovers = [];
|
|
137
|
+
const keyedNextPaths = new Set();
|
|
138
|
+
const keyedPrevPaths = new Set();
|
|
139
|
+
for (const [key, nx] of nextKeyed) {
|
|
140
|
+
const pv = prevKeyed.get(key);
|
|
141
|
+
if (pv == null)
|
|
142
|
+
continue; // key only in next → genuinely added; leave to heuristic
|
|
143
|
+
keyedMovers.push({ nextPath: nx.path, prev: pv.el, next: nx.el });
|
|
144
|
+
keyedNextPaths.add(nx.path.join(","));
|
|
145
|
+
keyedPrevPaths.add(pv.path.join(","));
|
|
146
|
+
}
|
|
147
|
+
// Heuristic movers: any element matched by diffTrees (static / translated /
|
|
148
|
+
// modified all carry prev + next) whose rect changed in ORIGIN or SIZE —
|
|
149
|
+
// re-derived from the rects since diffTrees keys its kind on origin only and
|
|
150
|
+
// size isn't in its fingerprint (a grow-in-place lands as `static`). Skip
|
|
151
|
+
// elements a key already claimed.
|
|
152
|
+
const heuristicMovers = entriesOfKind(diff, "static", "translated", "modified")
|
|
153
|
+
.filter((e) => e.nextPath != null && e.prev != null && e.next != null
|
|
154
|
+
&& !keyedNextPaths.has(e.nextPath.join(","))
|
|
155
|
+
&& rectChanged(e.prev, e.next))
|
|
156
|
+
.map((e) => ({ nextPath: e.nextPath, prev: e.prev, next: e.next }));
|
|
157
|
+
// Keyed pairs animate only when their rect actually changed (a keyed but
|
|
158
|
+
// unmoved element is just static — the key still guaranteed the pairing).
|
|
159
|
+
const allMovers = [...keyedMovers.filter((m) => rectChanged(m.prev, m.next)), ...heuristicMovers];
|
|
160
|
+
// Only animate the HIGHEST moved ancestor of each changed subtree: when a
|
|
161
|
+
// card moves/grows the diff reports every descendant as changed too, but the
|
|
162
|
+
// ancestor's transform already carries them — animating each would
|
|
163
|
+
// double-apply. Keep a mover only when no other mover is its ancestor.
|
|
164
|
+
const allMoverPaths = allMovers.map((m) => m.nextPath);
|
|
165
|
+
const rootMovers = allMovers.filter((m) => !allMoverPaths.some((p) => isAncestorPath(p, m.nextPath)));
|
|
166
|
+
// Added / removed, minus anything a key force-paired (those slide, not fade).
|
|
167
|
+
const added = entriesOfKind(diff, "added")
|
|
168
|
+
.filter((e) => e.nextPath == null || !keyedNextPaths.has(e.nextPath.join(",")));
|
|
169
|
+
const removed = entriesOfKind(diff, "removed")
|
|
170
|
+
.filter((e) => e.prevPath == null || !keyedPrevPaths.has(e.prevPath.join(",")));
|
|
171
|
+
if (rootMovers.length === 0 && added.length === 0 && removed.length === 0) {
|
|
172
|
+
return null; // nothing to magic-move → caller uses crossfade
|
|
173
|
+
}
|
|
174
|
+
// Clone the next tree so the `animId` annotations we add for the composite
|
|
175
|
+
// don't leak into the next frame's own (already-rendered / to-be-rendered)
|
|
176
|
+
// blob. CapturedElement is plain data, so structuredClone is a safe deep copy.
|
|
177
|
+
const compositeNext = nextRoots.map((r) => structuredClone(r));
|
|
178
|
+
const slides = [];
|
|
179
|
+
const fadeIn = [];
|
|
180
|
+
const fadeOut = [];
|
|
181
|
+
// Prev-appearance copies + removed subtrees, appended after the next tree so
|
|
182
|
+
// they render at their prev coordinates.
|
|
183
|
+
const extraRoots = [];
|
|
184
|
+
let n = 0;
|
|
185
|
+
for (const m of rootMovers) {
|
|
186
|
+
const el = elementAtPath(compositeNext, m.nextPath);
|
|
187
|
+
if (el == null)
|
|
188
|
+
continue;
|
|
189
|
+
const nextId = `${idPrefix}mv${n++}`;
|
|
190
|
+
el.animId = nextId;
|
|
191
|
+
// Next-appearance copy: slide from the prev rect to its final next rect.
|
|
192
|
+
slides.push({ cls: `anim-${nextId}`, from: rectMapTransform(m.prev, m.next), to: "none" });
|
|
193
|
+
// DM-903: when the element's PAINT also changed (text / color / background
|
|
194
|
+
// / border / opacity), a single next-appearance copy would snap the new
|
|
195
|
+
// look on at the window start. Render a SECOND copy at the PREV appearance,
|
|
196
|
+
// co-moving along the same prev→next path, and cross-fade — prev fades out,
|
|
197
|
+
// next fades in. Geometry-only movers keep the cheaper single copy (nothing
|
|
198
|
+
// to cross-fade). The SVG children carry baked-in fills a wrapper can't
|
|
199
|
+
// restyle, so dual-render + cross-fade is how the paint morph is expressed.
|
|
200
|
+
if (appearanceChanged(m.prev, m.next)) {
|
|
201
|
+
fadeIn.push(`anim-${nextId}`);
|
|
202
|
+
const prevClone = structuredClone(m.prev);
|
|
203
|
+
const prevId = `${nextId}p`;
|
|
204
|
+
prevClone.animId = prevId;
|
|
205
|
+
extraRoots.push(prevClone);
|
|
206
|
+
// Prev copy renders at its prev rect; map it FORWARD onto the next rect
|
|
207
|
+
// (rectMapTransform with args swapped) so it traces the same path as the
|
|
208
|
+
// next copy while fading out.
|
|
209
|
+
slides.push({ cls: `anim-${prevId}`, from: "none", to: rectMapTransform(m.next, m.prev) });
|
|
210
|
+
fadeOut.push(`anim-${prevId}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let a = 0;
|
|
214
|
+
for (const e of added) {
|
|
215
|
+
if (e.nextPath == null)
|
|
216
|
+
continue;
|
|
217
|
+
const el = elementAtPath(compositeNext, e.nextPath);
|
|
218
|
+
if (el == null)
|
|
219
|
+
continue;
|
|
220
|
+
const id = `${idPrefix}in${a++}`;
|
|
221
|
+
el.animId = id;
|
|
222
|
+
fadeIn.push(`anim-${id}`);
|
|
223
|
+
}
|
|
224
|
+
// Removed elements aren't in the next tree — append their prev subtrees (at
|
|
225
|
+
// their prev coordinates) so they render where the prev frame had them, and
|
|
226
|
+
// fade them out. Skip a removed entry nested inside another removed subtree
|
|
227
|
+
// (its ancestor already carries it).
|
|
228
|
+
const removedPaths = removed.map((e) => e.prevPath).filter((p) => p != null);
|
|
229
|
+
let o = 0;
|
|
230
|
+
for (const e of removed) {
|
|
231
|
+
if (e.prevPath == null || e.prev == null)
|
|
232
|
+
continue;
|
|
233
|
+
if (removedPaths.some((p) => isAncestorPath(p, e.prevPath)))
|
|
234
|
+
continue;
|
|
235
|
+
const clone = structuredClone(e.prev);
|
|
236
|
+
const id = `${idPrefix}out${o++}`;
|
|
237
|
+
clone.animId = id;
|
|
238
|
+
extraRoots.push(clone);
|
|
239
|
+
fadeOut.push(`anim-${id}`);
|
|
240
|
+
}
|
|
241
|
+
const compositeSvg = render([...compositeNext, ...extraRoots], idPrefix);
|
|
242
|
+
return { compositeSvg, slides, fadeIn, fadeOut };
|
|
243
|
+
}
|