domotion-svg 0.1.1
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 +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- package/src/webfont-unicode-range.test.ts +207 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor / click overlay for animated SVGs (DM-277).
|
|
3
|
+
*
|
|
4
|
+
* Paints a macOS-style cursor moving along a user-authored timeline and
|
|
5
|
+
* QuickTime-style click pulses at click events. Single pointer at a time;
|
|
6
|
+
* multi-touch is out of scope for v1.
|
|
7
|
+
*
|
|
8
|
+
* The overlay is opt-in via `AnimationConfig.cursorOverlay`. The emitted
|
|
9
|
+
* markup goes inside the viewport-clipped group, after the frame groups,
|
|
10
|
+
* so it paints above the frame content and is clipped to the viewport.
|
|
11
|
+
*
|
|
12
|
+
* Selector resolution: events that target a captured element via `selector`
|
|
13
|
+
* are resolved by an optional `resolveSelector(sel, frameIndex)` callback
|
|
14
|
+
* the caller supplies. The frame index is computed from the event's `t`
|
|
15
|
+
* (each frame's start/end time is derived from the animation timing).
|
|
16
|
+
*
|
|
17
|
+
* See docs/13-cursor-overlay.md for the design.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_STYLE = {
|
|
20
|
+
pointer: "mouse",
|
|
21
|
+
cursorFill: "rgb(255, 255, 255)",
|
|
22
|
+
cursorStroke: "rgb(0, 0, 0)",
|
|
23
|
+
pulseStroke: "rgba(255, 255, 255, 0.95)",
|
|
24
|
+
pulseStrokeOuter: "rgba(0, 0, 0, 0.4)",
|
|
25
|
+
pulseDurationMs: 500,
|
|
26
|
+
pulseRadius: 32,
|
|
27
|
+
cursorScale: 1,
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a script into absolute-coord position keyframes + click pulses.
|
|
31
|
+
* Caller passes a `resolveSelector(sel, frameIndex)` if the script uses
|
|
32
|
+
* selectors; otherwise pass `null` and selector events become no-ops with
|
|
33
|
+
* a console warning.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveCursorScript(overlay, totalDurationMs, frameStartTimes, resolveSelector) {
|
|
36
|
+
const baseStyle = { ...DEFAULT_STYLE, ...overlay.style };
|
|
37
|
+
const events = [...overlay.events].sort((a, b) => a.t - b.t);
|
|
38
|
+
const positions = [];
|
|
39
|
+
const clicks = [];
|
|
40
|
+
let curX = 0;
|
|
41
|
+
let curY = 0;
|
|
42
|
+
let visible = false;
|
|
43
|
+
const pushKey = (t, x, y, vis) => {
|
|
44
|
+
positions.push({ t, x, y, visible: vis });
|
|
45
|
+
curX = x;
|
|
46
|
+
curY = y;
|
|
47
|
+
visible = vis;
|
|
48
|
+
};
|
|
49
|
+
const frameForT = (t) => {
|
|
50
|
+
let idx = 0;
|
|
51
|
+
for (let i = 0; i < frameStartTimes.length; i++) {
|
|
52
|
+
if (t >= frameStartTimes[i])
|
|
53
|
+
idx = i;
|
|
54
|
+
else
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
return idx;
|
|
58
|
+
};
|
|
59
|
+
for (const ev of events) {
|
|
60
|
+
if (ev.type === "show") {
|
|
61
|
+
pushKey(ev.t, ev.x, ev.y, true);
|
|
62
|
+
}
|
|
63
|
+
else if (ev.type === "hide") {
|
|
64
|
+
pushKey(ev.t, curX, curY, false);
|
|
65
|
+
}
|
|
66
|
+
else if (ev.type === "move") {
|
|
67
|
+
const dur = ev.duration ?? 0;
|
|
68
|
+
const target = resolveMoveTarget(ev, curX, curY, frameForT(ev.t), resolveSelector);
|
|
69
|
+
if (target == null)
|
|
70
|
+
continue;
|
|
71
|
+
// First time we ever position the cursor — also turn it visible.
|
|
72
|
+
const becomeVisible = !visible;
|
|
73
|
+
if (dur > 0) {
|
|
74
|
+
if (becomeVisible) {
|
|
75
|
+
// Hold the cursor at its previous spot until the move begins so we
|
|
76
|
+
// don't see it pop in mid-frame; the start keyframe carries `false`
|
|
77
|
+
// visibility, the end keyframe carries `true` and the cursor fades
|
|
78
|
+
// in at the move's start.
|
|
79
|
+
pushKey(ev.t, curX, curY, true);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
pushKey(ev.t, curX, curY, true);
|
|
83
|
+
}
|
|
84
|
+
pushKey(ev.t + dur, target.x, target.y, true);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
pushKey(ev.t, target.x, target.y, true);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (ev.type === "click") {
|
|
91
|
+
const button = ev.button ?? "primary";
|
|
92
|
+
const style = { ...baseStyle, ...(ev.style ?? {}) };
|
|
93
|
+
clicks.push({ t: ev.t, x: curX, y: curY, button, style });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Always anchor an initial keyframe at t=0 with visibility `false` so the
|
|
97
|
+
// animation doesn't accidentally show the cursor at (0, 0) before the
|
|
98
|
+
// first `show` / `move`.
|
|
99
|
+
if (positions.length === 0 || positions[0].t > 0) {
|
|
100
|
+
positions.unshift({ t: 0, x: positions[0]?.x ?? 0, y: positions[0]?.y ?? 0, visible: false });
|
|
101
|
+
}
|
|
102
|
+
// Anchor a final keyframe at totalDurationMs so animateTransform interpolates
|
|
103
|
+
// through the whole loop.
|
|
104
|
+
if (positions[positions.length - 1].t < totalDurationMs) {
|
|
105
|
+
const last = positions[positions.length - 1];
|
|
106
|
+
positions.push({ t: totalDurationMs, x: last.x, y: last.y, visible: last.visible });
|
|
107
|
+
}
|
|
108
|
+
return { positions, clicks, style: baseStyle };
|
|
109
|
+
}
|
|
110
|
+
function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
|
|
111
|
+
if (ev.to != null)
|
|
112
|
+
return { x: ev.to.x, y: ev.to.y };
|
|
113
|
+
if (ev.by != null)
|
|
114
|
+
return { x: curX + ev.by.dx, y: curY + ev.by.dy };
|
|
115
|
+
if (ev.selector != null) {
|
|
116
|
+
if (resolveSelector == null) {
|
|
117
|
+
// eslint-disable-next-line no-console
|
|
118
|
+
console.warn(`cursor-overlay: selector "${ev.selector}" used but no resolveSelector provided; skipping`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const rect = resolveSelector(ev.selector, frameIndex);
|
|
122
|
+
if (rect == null) {
|
|
123
|
+
// eslint-disable-next-line no-console
|
|
124
|
+
console.warn(`cursor-overlay: selector "${ev.selector}" matched no element in frame ${frameIndex}; skipping`);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const cx = rect.x + rect.w / 2 + (ev.offset?.dx ?? 0);
|
|
128
|
+
const cy = rect.y + rect.h / 2 + (ev.offset?.dy ?? 0);
|
|
129
|
+
return { x: cx, y: cy };
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Emit the `<g class="cursor-overlay">` markup for an already-resolved
|
|
135
|
+
* timeline. Returns "" when the timeline has no positions or every keyframe
|
|
136
|
+
* is invisible.
|
|
137
|
+
*/
|
|
138
|
+
export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs) {
|
|
139
|
+
if (positions.length === 0 || totalDurationMs <= 0)
|
|
140
|
+
return "";
|
|
141
|
+
const totalSec = totalDurationMs / 1000;
|
|
142
|
+
// animateTransform with values + keyTimes drives the cursor's translate.
|
|
143
|
+
const valueStrs = [];
|
|
144
|
+
const keyTimes = [];
|
|
145
|
+
for (const p of positions) {
|
|
146
|
+
valueStrs.push(`${num(p.x)},${num(p.y)}`);
|
|
147
|
+
keyTimes.push((p.t / totalDurationMs).toFixed(4));
|
|
148
|
+
}
|
|
149
|
+
// SMIL animateTransform requires keyTimes to start at 0 and end at 1; the
|
|
150
|
+
// resolveCursorScript anchor at t=0 and t=totalDurationMs guarantees this.
|
|
151
|
+
// Visibility timeline: opacity flips between 0 and 1 at keyframes whose
|
|
152
|
+
// `visible` differs from the previous one. Use `discrete` calc-mode for
|
|
153
|
+
// step-wise transitions (no interpolation between 0 and 1).
|
|
154
|
+
const visValues = [];
|
|
155
|
+
for (const p of positions)
|
|
156
|
+
visValues.push(p.visible ? "1" : "0");
|
|
157
|
+
const cursorPath = macosCursorPath(style.cursorScale);
|
|
158
|
+
// Pulse SVG fragments — one per click, with timing keyed off `t`.
|
|
159
|
+
const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, totalDurationMs)).join("\n");
|
|
160
|
+
return ` <g class="cursor-overlay" pointer-events="none">
|
|
161
|
+
<g class="cursor-arrow" opacity="0">
|
|
162
|
+
<animateTransform attributeName="transform" type="translate" values="${valueStrs.join("; ")}" keyTimes="${keyTimes.join("; ")}" dur="${totalSec}s" repeatCount="indefinite" fill="freeze" />
|
|
163
|
+
<animate attributeName="opacity" values="${visValues.join(";")}" keyTimes="${keyTimes.join(";")}" dur="${totalSec}s" repeatCount="indefinite" calcMode="discrete" fill="freeze" />
|
|
164
|
+
${cursorPath}
|
|
165
|
+
</g>
|
|
166
|
+
${pulseMarkup}
|
|
167
|
+
</g>`;
|
|
168
|
+
}
|
|
169
|
+
/** Build the SVG fragment for a single click pulse. */
|
|
170
|
+
function buildPulseFragment(c, idx, totalDurationMs) {
|
|
171
|
+
const beginSec = (c.t / 1000).toFixed(3);
|
|
172
|
+
const durSec = (c.style.pulseDurationMs / 1000).toFixed(3);
|
|
173
|
+
const r0 = 4;
|
|
174
|
+
const r1 = c.style.pulseRadius;
|
|
175
|
+
const innerR = r1 * 0.55;
|
|
176
|
+
// Right-half-disc fill for secondary clicks.
|
|
177
|
+
let secondaryHalf = "";
|
|
178
|
+
if (c.button === "secondary") {
|
|
179
|
+
const halfPath = `M ${num(c.x)} ${num(c.y - innerR)} A ${num(innerR)} ${num(innerR)} 0 0 1 ${num(c.x)} ${num(c.y + innerR)} Z`;
|
|
180
|
+
secondaryHalf = `
|
|
181
|
+
<path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0">
|
|
182
|
+
<animate attributeName="opacity" values="0; 1; 0" keyTimes="0; 0.2; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
183
|
+
</path>`;
|
|
184
|
+
}
|
|
185
|
+
return ` <g class="cursor-click cursor-click-${idx}">
|
|
186
|
+
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0">
|
|
187
|
+
<animate attributeName="r" values="${r0}; ${r1}" keyTimes="0; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
188
|
+
<animate attributeName="opacity" values="0; 0.9; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
189
|
+
</circle>
|
|
190
|
+
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStroke}" stroke-width="1" opacity="0">
|
|
191
|
+
<animate attributeName="r" values="${r0}; ${r1 - 1}" keyTimes="0; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
192
|
+
<animate attributeName="opacity" values="0; 0.95; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
193
|
+
</circle>${secondaryHalf}
|
|
194
|
+
</g>`;
|
|
195
|
+
}
|
|
196
|
+
/** macOS-style cursor arrow path. The hot point (0, 0) sits at the tip. */
|
|
197
|
+
function macosCursorPath(scale) {
|
|
198
|
+
// Path approximates the macOS pointer: tip at (0, 0), tail extends down and
|
|
199
|
+
// right with a small notch. Stroked white with a thin black outline so it
|
|
200
|
+
// reads on any background.
|
|
201
|
+
const d = "M 0 0 L 0 16 L 4 12 L 7 18 L 9 17 L 6 11 L 12 11 Z";
|
|
202
|
+
const transform = scale !== 1 ? ` transform="scale(${num(scale)})"` : "";
|
|
203
|
+
return `<path d="${d}" fill="rgb(255,255,255)" stroke="rgb(0,0,0)" stroke-width="1" stroke-linejoin="round"${transform} />`;
|
|
204
|
+
}
|
|
205
|
+
function num(n) {
|
|
206
|
+
return Number(n.toFixed(2)).toString();
|
|
207
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
|
|
3
|
+
const TOTAL = 4000;
|
|
4
|
+
const FRAME_STARTS = [0, 1500, 3000];
|
|
5
|
+
describe("resolveCursorScript: position keyframes", () => {
|
|
6
|
+
it("anchors a hidden keyframe at t=0 even when the script starts later", () => {
|
|
7
|
+
const overlay = {
|
|
8
|
+
events: [{ type: "show", t: 500, x: 100, y: 200 }],
|
|
9
|
+
};
|
|
10
|
+
const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
|
|
11
|
+
expect(r.positions[0]).toMatchObject({ t: 0, visible: false });
|
|
12
|
+
expect(r.positions.find((p) => p.t === 500)).toMatchObject({ x: 100, y: 200, visible: true });
|
|
13
|
+
expect(r.positions[r.positions.length - 1].t).toBe(TOTAL);
|
|
14
|
+
});
|
|
15
|
+
it("resolves `to` (absolute), `by` (relative), and `selector` move targets", () => {
|
|
16
|
+
const overlay = {
|
|
17
|
+
events: [
|
|
18
|
+
{ type: "show", t: 0, x: 10, y: 20 },
|
|
19
|
+
{ type: "move", t: 100, to: { x: 50, y: 60 } },
|
|
20
|
+
{ type: "move", t: 200, by: { dx: 10, dy: -5 } },
|
|
21
|
+
{ type: "move", t: 300, selector: ".btn" },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
const resolveSelector = () => ({ x: 200, y: 300, w: 80, h: 24 });
|
|
25
|
+
const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, resolveSelector);
|
|
26
|
+
const at100 = r.positions.find((p) => p.t === 100);
|
|
27
|
+
const at200 = r.positions.find((p) => p.t === 200);
|
|
28
|
+
const at300 = r.positions.find((p) => p.t === 300);
|
|
29
|
+
expect(at100).toMatchObject({ x: 50, y: 60 });
|
|
30
|
+
expect(at200).toMatchObject({ x: 60, y: 55 });
|
|
31
|
+
// Center of (200, 300, 80, 24) is (240, 312).
|
|
32
|
+
expect(at300).toMatchObject({ x: 240, y: 312 });
|
|
33
|
+
});
|
|
34
|
+
it("emits two keyframes for a duration move (start + end) so SMIL interpolates", () => {
|
|
35
|
+
const overlay = {
|
|
36
|
+
events: [
|
|
37
|
+
{ type: "show", t: 0, x: 0, y: 0 },
|
|
38
|
+
{ type: "move", t: 1000, to: { x: 100, y: 100 }, duration: 500 },
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
|
|
42
|
+
const at1000 = r.positions.find((p) => p.t === 1000);
|
|
43
|
+
const at1500 = r.positions.find((p) => p.t === 1500);
|
|
44
|
+
expect(at1000).toMatchObject({ x: 0, y: 0, visible: true });
|
|
45
|
+
expect(at1500).toMatchObject({ x: 100, y: 100, visible: true });
|
|
46
|
+
});
|
|
47
|
+
it("captures clicks with the cursor's current position at the click's t", () => {
|
|
48
|
+
const overlay = {
|
|
49
|
+
events: [
|
|
50
|
+
{ type: "show", t: 0, x: 30, y: 40 },
|
|
51
|
+
{ type: "click", t: 100, button: "primary" },
|
|
52
|
+
{ type: "move", t: 200, to: { x: 80, y: 80 } },
|
|
53
|
+
{ type: "click", t: 300, button: "secondary" },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
|
|
57
|
+
expect(r.clicks).toHaveLength(2);
|
|
58
|
+
expect(r.clicks[0]).toMatchObject({ t: 100, x: 30, y: 40, button: "primary" });
|
|
59
|
+
expect(r.clicks[1]).toMatchObject({ t: 300, x: 80, y: 80, button: "secondary" });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("cursorOverlayMarkup: SVG emission", () => {
|
|
63
|
+
it("includes a cursor-arrow group and a click pulse per click", () => {
|
|
64
|
+
const overlay = {
|
|
65
|
+
events: [
|
|
66
|
+
{ type: "show", t: 0, x: 50, y: 50 },
|
|
67
|
+
{ type: "click", t: 200, button: "primary" },
|
|
68
|
+
{ type: "click", t: 600, button: "secondary" },
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
|
|
72
|
+
const svg = cursorOverlayMarkup(r.positions, r.clicks, r.style, TOTAL);
|
|
73
|
+
expect(svg).toContain('class="cursor-overlay"');
|
|
74
|
+
expect(svg).toContain('class="cursor-arrow"');
|
|
75
|
+
expect(svg).toContain('class="cursor-click cursor-click-0"');
|
|
76
|
+
expect(svg).toContain('class="cursor-click cursor-click-1"');
|
|
77
|
+
// Secondary click adds the right-half-disc fill.
|
|
78
|
+
expect(svg).toContain('rgba(0,0,0,0.2)');
|
|
79
|
+
});
|
|
80
|
+
it("emits empty string when there are no events", () => {
|
|
81
|
+
const r = resolveCursorScript({ events: [] }, TOTAL, FRAME_STARTS, null);
|
|
82
|
+
const svg = cursorOverlayMarkup(r.positions, r.clicks, r.style, TOTAL);
|
|
83
|
+
// Even with no events, resolveCursorScript anchors t=0 and t=total
|
|
84
|
+
// (both invisible). The markup is non-empty but contains no clicks.
|
|
85
|
+
expect(svg).toContain('class="cursor-overlay"');
|
|
86
|
+
expect(svg).not.toContain('class="cursor-click');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { rootSvgColorSchemeAttr, transparentRootBgRect, wrapSvg } from "./dom-to-svg.js";
|
|
3
|
+
// DM-552: capture-side propagation of `rootColorScheme` and `rootBgComputed`
|
|
4
|
+
// stamped on the captured tree's root element by CAPTURE_SCRIPT, plus the
|
|
5
|
+
// renderer-side helper that emits `color-scheme="dark"` on the wrapping
|
|
6
|
+
// `<svg>` element. End-to-end CAPTURE_SCRIPT integration is covered by
|
|
7
|
+
// the real-world suite + the dark-mode form-control fixture (DM-553); this
|
|
8
|
+
// file unit-tests the renderer-side surface in isolation.
|
|
9
|
+
function makeRoot(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
tag: "body",
|
|
12
|
+
text: "",
|
|
13
|
+
x: 0, y: 0, width: 100, height: 100,
|
|
14
|
+
children: [],
|
|
15
|
+
styles: {
|
|
16
|
+
backgroundColor: "rgb(255,255,255)",
|
|
17
|
+
borderColor: "rgb(0,0,0)",
|
|
18
|
+
borderWidth: "0",
|
|
19
|
+
borderRadius: "0",
|
|
20
|
+
borderTopLeftRadius: "0",
|
|
21
|
+
borderTopRightRadius: "0",
|
|
22
|
+
borderBottomRightRadius: "0",
|
|
23
|
+
borderBottomLeftRadius: "0",
|
|
24
|
+
borderTopWidth: "0",
|
|
25
|
+
borderRightWidth: "0",
|
|
26
|
+
borderBottomWidth: "0",
|
|
27
|
+
borderLeftWidth: "0",
|
|
28
|
+
borderTopColor: "rgb(0,0,0)",
|
|
29
|
+
borderRightColor: "rgb(0,0,0)",
|
|
30
|
+
borderBottomColor: "rgb(0,0,0)",
|
|
31
|
+
borderLeftColor: "rgb(0,0,0)",
|
|
32
|
+
borderTopStyle: "none",
|
|
33
|
+
borderRightStyle: "none",
|
|
34
|
+
borderBottomStyle: "none",
|
|
35
|
+
borderLeftStyle: "none",
|
|
36
|
+
color: "rgb(0,0,0)",
|
|
37
|
+
fontSize: "16px",
|
|
38
|
+
fontFamily: "sans-serif",
|
|
39
|
+
fontWeight: "400",
|
|
40
|
+
fontStyle: "normal",
|
|
41
|
+
lineHeight: "20px",
|
|
42
|
+
letterSpacing: "normal",
|
|
43
|
+
textAlign: "left",
|
|
44
|
+
textTransform: "none",
|
|
45
|
+
textDecoration: "none",
|
|
46
|
+
textDecorationLine: "none",
|
|
47
|
+
textDecorationStyle: "solid",
|
|
48
|
+
textDecorationColor: "rgb(0,0,0)",
|
|
49
|
+
textDecorationThickness: "auto",
|
|
50
|
+
textIndent: "0",
|
|
51
|
+
textShadow: "none",
|
|
52
|
+
textWrap: "wrap",
|
|
53
|
+
whiteSpace: "normal",
|
|
54
|
+
wordBreak: "normal",
|
|
55
|
+
overflowWrap: "normal",
|
|
56
|
+
verticalAlign: "baseline",
|
|
57
|
+
backgroundImage: "none",
|
|
58
|
+
backgroundSize: "auto",
|
|
59
|
+
backgroundPosition: "0% 0%",
|
|
60
|
+
backgroundRepeat: "repeat",
|
|
61
|
+
backgroundAttachment: "scroll",
|
|
62
|
+
backgroundClip: "border-box",
|
|
63
|
+
backgroundOrigin: "padding-box",
|
|
64
|
+
opacity: "1",
|
|
65
|
+
position: "static",
|
|
66
|
+
top: "auto",
|
|
67
|
+
left: "auto",
|
|
68
|
+
right: "auto",
|
|
69
|
+
bottom: "auto",
|
|
70
|
+
display: "block",
|
|
71
|
+
flexDirection: "row",
|
|
72
|
+
visibility: "visible",
|
|
73
|
+
zIndex: "auto",
|
|
74
|
+
transform: "none",
|
|
75
|
+
transformOrigin: "50% 50%",
|
|
76
|
+
writingMode: "horizontal-tb",
|
|
77
|
+
textOrientation: "mixed",
|
|
78
|
+
cursor: "auto",
|
|
79
|
+
pointerEvents: "auto",
|
|
80
|
+
...overrides,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
describe("rootSvgColorSchemeAttr (DM-552)", () => {
|
|
85
|
+
it("returns empty string when the captured tree is empty", () => {
|
|
86
|
+
expect(rootSvgColorSchemeAttr([])).toBe("");
|
|
87
|
+
});
|
|
88
|
+
it("returns empty string when rootColorScheme is undefined (default scheme is attribute-free — today's SVG byte-identical)", () => {
|
|
89
|
+
expect(rootSvgColorSchemeAttr([makeRoot()])).toBe("");
|
|
90
|
+
});
|
|
91
|
+
it("returns empty string when rootColorScheme is explicitly 'light'", () => {
|
|
92
|
+
expect(rootSvgColorSchemeAttr([makeRoot({ rootColorScheme: "light" })])).toBe("");
|
|
93
|
+
});
|
|
94
|
+
it("returns ` color-scheme=\"dark\"` (with leading space, ready to concat) when rootColorScheme is 'dark'", () => {
|
|
95
|
+
expect(rootSvgColorSchemeAttr([makeRoot({ rootColorScheme: "dark" })])).toBe(` color-scheme="dark"`);
|
|
96
|
+
});
|
|
97
|
+
it("only inspects elements[0] — siblings with mismatched scheme are ignored (only the root carries the page-level signal)", () => {
|
|
98
|
+
const root = makeRoot({ rootColorScheme: "light" });
|
|
99
|
+
const sibling = makeRoot({ rootColorScheme: "dark" });
|
|
100
|
+
expect(rootSvgColorSchemeAttr([root, sibling])).toBe("");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("wrapSvg with tree option (DM-552)", () => {
|
|
104
|
+
it("emits a vanilla <svg> when no tree is passed (back-compat)", () => {
|
|
105
|
+
const out = wrapSvg("<g/>", 100, 50);
|
|
106
|
+
expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50"><g/></svg>`);
|
|
107
|
+
});
|
|
108
|
+
it("emits a vanilla <svg> when the tree's root has no rootColorScheme", () => {
|
|
109
|
+
const out = wrapSvg("<g/>", 100, 50, { tree: [makeRoot()] });
|
|
110
|
+
expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50"><g/></svg>`);
|
|
111
|
+
});
|
|
112
|
+
it("includes color-scheme=\"dark\" on the root <svg> when tree's root reports dark scheme", () => {
|
|
113
|
+
const out = wrapSvg("<g/>", 100, 50, { tree: [makeRoot({ rootColorScheme: "dark" })] });
|
|
114
|
+
expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50" color-scheme="dark"><g/></svg>`);
|
|
115
|
+
});
|
|
116
|
+
it("does NOT add color-scheme=\"light\" — the absence of the attribute IS the light default (today's output preserved verbatim)", () => {
|
|
117
|
+
const out = wrapSvg("<g/>", 100, 50, { tree: [makeRoot({ rootColorScheme: "light" })] });
|
|
118
|
+
expect(out).not.toContain("color-scheme");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
describe("transparentRootBgRect (DM-554)", () => {
|
|
122
|
+
it("returns empty string for an empty tree", () => {
|
|
123
|
+
expect(transparentRootBgRect([], 100, 50)).toBe("");
|
|
124
|
+
});
|
|
125
|
+
it("returns empty string when rootBgComputed is missing (pre-DM-552 capture)", () => {
|
|
126
|
+
expect(transparentRootBgRect([makeRoot()], 100, 50)).toBe("");
|
|
127
|
+
});
|
|
128
|
+
it("returns empty string when rootBgComputed is explicitly transparent (intentional transparent SVG)", () => {
|
|
129
|
+
expect(transparentRootBgRect([makeRoot({ rootBgComputed: "rgba(0, 0, 0, 0)" })], 100, 50)).toBe("");
|
|
130
|
+
expect(transparentRootBgRect([makeRoot({ rootBgComputed: "transparent" })], 100, 50)).toBe("");
|
|
131
|
+
});
|
|
132
|
+
it("emits a body-bg <rect> with the resolved color when rootBgComputed is set", () => {
|
|
133
|
+
const out = transparentRootBgRect([makeRoot({ rootBgComputed: "rgb(28, 28, 28)" })], 1280, 800);
|
|
134
|
+
expect(out).toBe(`<rect width="1280" height="800" fill="rgb(28, 28, 28)" />`);
|
|
135
|
+
});
|
|
136
|
+
it("uses the same color whether the scheme is light or dark — the rect mirrors Chromium's resolved bg, not a hardcoded scheme palette", () => {
|
|
137
|
+
const lightOut = transparentRootBgRect([makeRoot({ rootBgComputed: "rgb(255, 255, 255)", rootColorScheme: "light" })], 100, 50);
|
|
138
|
+
const darkOut = transparentRootBgRect([makeRoot({ rootBgComputed: "rgb(28, 28, 28)", rootColorScheme: "dark" })], 100, 50);
|
|
139
|
+
expect(lightOut).toBe(`<rect width="100" height="50" fill="rgb(255, 255, 255)" />`);
|
|
140
|
+
expect(darkOut).toBe(`<rect width="100" height="50" fill="rgb(28, 28, 28)" />`);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe("wrapSvg with body-bg rect injection (DM-554)", () => {
|
|
144
|
+
it("emits the body-bg rect BEFORE the inner content so the inner paints on top", () => {
|
|
145
|
+
const out = wrapSvg("<g class='content'/>", 100, 50, { tree: [makeRoot({ rootBgComputed: "rgb(28, 28, 28)" })] });
|
|
146
|
+
expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50"><rect width="100" height="50" fill="rgb(28, 28, 28)" /><g class='content'/></svg>`);
|
|
147
|
+
});
|
|
148
|
+
it("combines color-scheme attr and body-bg rect when the tree has both signals", () => {
|
|
149
|
+
const tree = [makeRoot({ rootColorScheme: "dark", rootBgComputed: "rgb(28, 28, 28)" })];
|
|
150
|
+
const out = wrapSvg("<g/>", 100, 50, { tree });
|
|
151
|
+
expect(out).toContain(`color-scheme="dark"`);
|
|
152
|
+
expect(out).toContain(`<rect width="100" height="50" fill="rgb(28, 28, 28)" />`);
|
|
153
|
+
});
|
|
154
|
+
it("stays byte-identical to today's output when no tree is passed (back-compat)", () => {
|
|
155
|
+
const out = wrapSvg("<g/>", 100, 50);
|
|
156
|
+
expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50"><g/></svg>`);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|