@hyperframes/studio 0.8.125 → 0.8.127

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.
Files changed (61) hide show
  1. package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-uqzDaxVL.js} +1 -1
  2. package/dist/assets/{index-4LRR6o2F.js → index-CLBNYlDY.js} +1 -1
  3. package/dist/assets/{index-CrYoEZ5p.js → index-COhbL1xq.js} +177 -177
  4. package/dist/assets/{index-yCK8osBg.js → index-DXSYlqmV.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +5368 -4986
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -8
  9. package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
  10. package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
  11. package/src/components/editor/MotionPathNode.tsx +56 -0
  12. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +167 -33
  13. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +49 -86
  15. package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
  16. package/src/components/editor/manualEditsSeekReapply.ts +3 -6
  17. package/src/components/editor/manualOffsetDrag.ts +2 -3
  18. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  19. package/src/components/editor/motionPathCommit.ts +58 -0
  20. package/src/components/editor/motionPathGeometry.test.ts +61 -1
  21. package/src/components/editor/motionPathGeometry.ts +50 -10
  22. package/src/components/editor/motionPathHome.ts +75 -0
  23. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  24. package/src/components/editor/motionPathLayerNode.ts +20 -16
  25. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  26. package/src/components/editor/studioMotion.ts +7 -19
  27. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  28. package/src/components/editor/useMotionPathData.ts +23 -61
  29. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  30. package/src/hooks/gsapDragCommit.ts +33 -1
  31. package/src/hooks/gsapEditOutcome.ts +27 -2
  32. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  33. package/src/hooks/gsapParsedTween.ts +39 -2
  34. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  35. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  36. package/src/hooks/gsapRuntimeKeyframes.ts +11 -4
  37. package/src/hooks/gsapShared.test.ts +1 -0
  38. package/src/hooks/gsapShared.ts +8 -3
  39. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  40. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
  41. package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
  42. package/src/hooks/gsapValueAtPlayhead.ts +46 -6
  43. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  44. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  45. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  46. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  47. package/src/hooks/timelineEditingHelpers.ts +2 -9
  48. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  49. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  50. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  51. package/src/hooks/useThumbnailStripSize.ts +206 -17
  52. package/src/noWindowBoundDomChecks.test.ts +31 -0
  53. package/src/player/components/CompositionThumbnail.tsx +10 -6
  54. package/src/player/components/ImageThumbnail.tsx +11 -5
  55. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  56. package/src/player/components/ThumbnailTiles.tsx +41 -0
  57. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  58. package/src/player/components/VideoThumbnail.tsx +11 -5
  59. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  60. package/src/player/lib/timelineGroupInfo.ts +2 -1
  61. package/src/webmcp/webmcpTestUtils.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.125",
3
+ "version": "0.8.127",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.125",
64
- "@hyperframes/player": "0.8.125",
65
- "@hyperframes/core": "0.8.125",
66
- "@hyperframes/sdk": "0.8.125",
67
- "@hyperframes/shader-transitions": "0.8.125",
68
- "@hyperframes/studio-server": "0.8.125"
63
+ "@hyperframes/parsers": "0.8.127",
64
+ "@hyperframes/sdk": "0.8.127",
65
+ "@hyperframes/player": "0.8.127",
66
+ "@hyperframes/core": "0.8.127",
67
+ "@hyperframes/shader-transitions": "0.8.127",
68
+ "@hyperframes/studio-server": "0.8.127"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.125"
84
+ "@hyperframes/producer": "0.8.127"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -101,6 +101,7 @@
101
101
  "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
102
102
  "test:watch": "vitest",
103
103
  "test:colors:browser": "node tests/e2e/native-css-colors.mjs",
104
+ "test:motion-path-home:browser": "node tests/e2e/motion-path-home.mjs",
104
105
  "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
105
106
  "test:timeline-default": "bun run test:timeline-virtualization"
106
107
  }
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
  import type { DomEditSelection } from "./domEditing";
7
7
  import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
8
+ import { SELECTION_CHROME } from "./motionPathLayerNode";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -127,6 +128,10 @@ describe("DomEditSelectionChrome crop composition", () => {
127
128
  }
128
129
  expect(rotations).toHaveLength(1);
129
130
  expect(Number.parseFloat(rotations[0]!.slice("rotate(".length))).toBeCloseTo(30, 5);
131
+ // A motion-path node over any of these hands its press to it.
132
+ const controls = [...host.querySelectorAll(".pointer-events-auto, button")];
133
+ expect(controls.length).toBeGreaterThan(8);
134
+ for (const control of controls) expect(control.closest(SELECTION_CHROME)).not.toBeNull();
130
135
  act(() => root.unmount());
131
136
  });
132
137
  });
@@ -169,6 +169,7 @@ export function DomEditSelectionChrome({
169
169
  return (
170
170
  <>
171
171
  <div
172
+ data-dom-edit-chrome="true"
172
173
  className="pointer-events-none absolute inset-0"
173
174
  style={{
174
175
  transformOrigin: `${overlayRect.left + overlayRect.width / 2}px ${overlayRect.top + overlayRect.height / 2}px`,
@@ -98,3 +98,59 @@ export function MotionPathNode(props: {
98
98
  </g>
99
99
  );
100
100
  }
101
+
102
+ type Point = { x: number; y: number };
103
+
104
+ /** The drawn path from GSAP's start (a passive mark) through the nodes, and the add-on-line ghost. */
105
+ export function MotionPathLine(props: {
106
+ points: string;
107
+ start?: Point;
108
+ ghost: Point | null;
109
+ r: number;
110
+ }) {
111
+ const { points, start, ghost, r: nodeR } = props;
112
+ return (
113
+ <>
114
+ <polyline
115
+ points={start ? `${start.x},${start.y} ${points}` : points}
116
+ fill="none"
117
+ style={{ stroke: ACCENT }}
118
+ strokeWidth={1.5}
119
+ strokeDasharray="5 5"
120
+ strokeLinejoin="round"
121
+ strokeLinecap="round"
122
+ vectorEffect="non-scaling-stroke"
123
+ opacity={0.85}
124
+ />
125
+ {start && (
126
+ <circle
127
+ data-motion-path-start
128
+ cx={start.x}
129
+ cy={start.y}
130
+ r={nodeR * 0.6}
131
+ fill="none"
132
+ strokeWidth={1.5}
133
+ vectorEffect="non-scaling-stroke"
134
+ className="pointer-events-none"
135
+ style={{ stroke: ACCENT }}
136
+ />
137
+ )}
138
+ {ghost && (
139
+ <rect
140
+ x={ghost.x - nodeR * 0.707}
141
+ y={ghost.y - nodeR * 0.707}
142
+ width={nodeR * 1.414}
143
+ height={nodeR * 1.414}
144
+ rx={nodeR * 0.24}
145
+ transform={`rotate(45 ${ghost.x} ${ghost.y})`}
146
+ fill="none"
147
+ strokeWidth={1.5}
148
+ strokeDasharray="2 2"
149
+ vectorEffect="non-scaling-stroke"
150
+ className="pointer-events-none"
151
+ style={{ stroke: ACCENT }}
152
+ />
153
+ )}
154
+ </>
155
+ );
156
+ }
@@ -1,15 +1,28 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
- import { expect, it, vi } from "vitest";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
5
6
  import { usePlayerStore } from "../../player/store/playerStore";
6
7
  import { MotionPathOverlay } from "./MotionPathOverlay";
8
+ import { commitNodeDrop } from "./motionPathCommit";
7
9
  import type { DomEditSelection } from "./domEditing";
8
10
 
9
11
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
12
 
13
+ const { commitMutation } = vi.hoisted(() => ({
14
+ commitMutation: vi.fn(async (..._args: unknown[]) => {}),
15
+ }));
11
16
  vi.mock("../../contexts/DomEditContext", () => ({
12
- useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation: vi.fn() }),
17
+ useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation }),
18
+ }));
19
+ const showToast = vi.hoisted(() => vi.fn());
20
+ vi.mock("../../contexts/StudioContext", () => ({
21
+ useStudioShellContextOptional: () => ({ showToast }),
22
+ }));
23
+ vi.mock("./motionPathCommit", async (importOriginal) => ({
24
+ ...(await importOriginal<typeof import("./motionPathCommit")>()),
25
+ commitNodeDrop: vi.fn(),
13
26
  }));
14
27
  vi.mock("./motionPathSelection", () => ({
15
28
  selectorFor: () => "#box",
@@ -28,39 +41,172 @@ vi.mock("./useMotionPathData", async (importOriginal) => ({
28
41
  points: "60,30 120,30",
29
42
  nodes: [
30
43
  { x: 60, y: 30, ref: { type: "keyframe", pct: 66.667 } },
31
- { x: 120, y: 30, ref: { type: "keyframe", pct: 100 } },
44
+ // 140 wide there against 100 now: drawn 20 px right, where the layer's centre will be.
45
+ { x: 120, y: 30, w: 140, ref: { type: "keyframe", pct: 100 } },
32
46
  ],
47
+ start: { x: 40, y: 30, w: 120 },
33
48
  },
34
49
  geometryResolved: true,
35
50
  visibleInPreview: true,
36
- home: { x: 0, y: 0 },
51
+ home: { x: 0, y: 0, w: 100, h: 50, ax: 0.5, ay: 0.5 },
37
52
  pScale: 1,
38
53
  }),
39
54
  }));
40
55
 
41
- it("the layer's node and another node's ring inside the layer's box press the layer; a dot keeps its node", () => {
56
+ const cleanups: Array<() => void> = [];
57
+ afterEach(() => {
58
+ while (cleanups.length) cleanups.pop()!();
59
+ commitMutation.mockClear();
60
+ });
61
+
62
+ /** What lies under the pointer below the path: the layer's box, a handle over it, a marker, or nothing. */
63
+ type Under = boolean | "handle" | "marker";
64
+
65
+ /** The overlay over a selected layer's box and a crop handle, scale 1: client px are composition px. */
66
+ function mount() {
67
+ const overlay = document.createElement("div");
68
+ const chrome = overlay.appendChild(document.createElement("div"));
69
+ chrome.setAttribute("data-dom-edit-chrome", "true");
42
70
  const box = document.createElement("div");
43
71
  box.setAttribute("data-dom-edit-selection-box", "true");
72
+ box.style.cursor = "move";
73
+ const handle = document.createElement("button");
74
+ handle.style.cursor = "ew-resize";
75
+ chrome.append(box, handle);
76
+ // Another layer's off-canvas marker: no handler of its own, so a press on it reaches the overlay's marquee.
77
+ const marker = overlay.appendChild(document.createElement("div"));
78
+ overlay.addEventListener("pointerdown", (e) => {
79
+ if (!(e.target as Element).closest("[data-dom-edit-chrome]")) e.preventDefault();
80
+ });
44
81
  const host = document.createElement("div");
45
- document.body.append(box, host);
82
+ document.body.append(overlay, host);
83
+ const handlePresses: number[] = [];
84
+ handle.addEventListener("pointerdown", (e) => {
85
+ handlePresses.push((e as PointerEvent).clientX);
86
+ e.preventDefault();
87
+ });
46
88
  const boxPresses: number[] = [];
47
- let boxStarts = true;
89
+ const state = { boxStarts: true };
48
90
  box.addEventListener("pointerdown", (e) => {
49
91
  boxPresses.push((e as PointerEvent).clientX);
50
- if (boxStarts) e.preventDefault();
92
+ if (state.boxStarts) e.preventDefault();
51
93
  });
52
94
  const captured = vi.spyOn(Element.prototype, "setPointerCapture").mockImplementation(() => {});
53
95
  const root = createRoot(host);
54
96
  const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
55
- // Scale 1: client px are composition px. `inBox`: the layer's box is under the press.
56
- const press = (node: number, x: number, inBox: boolean) => {
97
+ act(() =>
98
+ root.render(
99
+ <MotionPathOverlay
100
+ iframeRef={{ current: null }}
101
+ selection={selection}
102
+ compositionSize={{ width: 1920, height: 1080 }}
103
+ isPlaying={false}
104
+ />,
105
+ ),
106
+ );
107
+ cleanups.push(() => {
108
+ captured.mockRestore();
109
+ act(() => root.unmount());
110
+ overlay.remove();
111
+ host.remove();
112
+ });
113
+ /** Fires `type` at `target`; `under`: the layer's box, a handle over the box, or only the canvas. */
114
+ const fire = (target: Element, type: string, x: number, under: Under) => {
115
+ const stack = { handle: [handle, box], marker: [marker], true: [box], false: [] }[
116
+ String(under)
117
+ ]!;
118
+ document.elementsFromPoint = () => [target, ...stack, overlay];
119
+ const init = { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: 30 };
120
+ act(() => void target.dispatchEvent(new PointerEvent(type, init)));
121
+ };
122
+ return { host, boxPresses, handlePresses, state, captured, fire };
123
+ }
124
+
125
+ it("the layer's node, and any node inside the layer's box, press the layer; outside it a node keeps the press", () => {
126
+ const { host, boxPresses, state, captured, fire } = mount();
127
+ const press = (node: number, x: number, inBox: Under) => {
57
128
  const hit = [...host.querySelectorAll("circle.pointer-events-auto")].find(
58
129
  (c) => c.getAttribute("cx") === String(node),
59
130
  )!;
60
- document.elementsFromPoint = () => (inBox ? [hit, box] : [hit]);
61
- const down = { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: 30 };
62
- act(() => void hit.dispatchEvent(new PointerEvent("pointerdown", down)));
131
+ fire(hit, "pointerdown", x, inBox);
63
132
  };
133
+ press(60, 63, false);
134
+ press(140, 130, true);
135
+ // On the dot itself: a drag from the layer's middle moves the layer (the edit bench's keys case).
136
+ press(140, 140, true);
137
+ expect(boxPresses).toEqual([63, 130, 140]);
138
+ act(() => usePlayerStore.setState({ activeKeyframePct: 100 }));
139
+ press(140, 140, true);
140
+ act(() => usePlayerStore.setState({ activeKeyframePct: null }));
141
+ expect(boxPresses).toEqual([63, 130, 140, 140]);
142
+ press(140, 138, false);
143
+ expect(boxPresses).toEqual([63, 130, 140, 140]);
144
+ // Over another layer's off-canvas marker the node keeps its press; the canvas starts no marquee.
145
+ captured.mockClear();
146
+ press(140, 138, "marker");
147
+ expect(captured).toHaveBeenCalledTimes(1);
148
+ expect(boxPresses).toEqual([63, 130, 140, 140]);
149
+ // A box that starts no gesture leaves the press to the node.
150
+ state.boxStarts = false;
151
+ captured.mockClear();
152
+ press(60, 63, false);
153
+ expect(captured).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ it("the path's line inside the layer's box moves the layer and offers no add; outside it adds a keyframe", async () => {
157
+ const { host, boxPresses, fire } = mount();
158
+ const line = host.querySelector("polyline.pointer-events-auto") as SVGElement;
159
+ const ghost = () => host.querySelector("rect.pointer-events-none");
160
+
161
+ fire(line, "pointermove", 90, true);
162
+ expect(line.style.cursor).toBe("move");
163
+ expect(ghost()).toBeNull();
164
+ fire(line, "pointerdown", 90, true);
165
+ expect(boxPresses).toEqual([90]);
166
+ expect(commitMutation).not.toHaveBeenCalled();
167
+
168
+ fire(line, "pointermove", 90, false);
169
+ expect(line.style.cursor).toBe("copy");
170
+ expect(ghost()).not.toBeNull();
171
+ // Halfway along the drawn segment 60 to 140: halfway between the stored offsets 60 and 120.
172
+ fire(line, "pointerdown", 100, false);
173
+ expect(boxPresses).toEqual([90]);
174
+ await vi.waitFor(() => expect(commitMutation).toHaveBeenCalledTimes(1));
175
+ expect(commitMutation.mock.calls[0]![0]).toMatchObject({ properties: { x: 90, y: 30 } });
176
+ });
177
+
178
+ it("a crop or resize handle over a node or the line takes the press, not the node or the layer", () => {
179
+ const { host, boxPresses, handlePresses, captured, fire } = mount();
180
+ const node = host.querySelector('circle.pointer-events-auto[cx="140"]')!;
181
+ const line = host.querySelector("polyline.pointer-events-auto") as SVGElement;
182
+ captured.mockClear();
183
+ fire(node, "pointerdown", 142, "handle");
184
+ fire(line, "pointermove", 100, "handle");
185
+ expect(line.style.cursor).toBe("ew-resize");
186
+ fire(line, "pointerdown", 100, "handle");
187
+ expect(handlePresses).toEqual([142, 100]);
188
+ expect(boxPresses).toEqual([]);
189
+ expect(captured).not.toHaveBeenCalled();
190
+ expect(commitMutation).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("draws where GSAP started the tween before the first keyframe, as a mark that takes no press", () => {
194
+ const { host } = mount();
195
+ const start = host.querySelector("circle[data-motion-path-start]")!;
196
+ // 120 wide at the start against 100 now: drawn 10 px right.
197
+ expect([start.getAttribute("cx"), start.getAttribute("cy")]).toEqual(["50", "30"]);
198
+ expect(start.classList.contains("pointer-events-none")).toBe(true);
199
+ const drawn = host.querySelector("polyline:not(.pointer-events-auto)")!;
200
+ expect(drawn.getAttribute("points")).toBe("50,30 60,30 140,30");
201
+ });
202
+
203
+ it("a node drop the writer refuses says why", async () => {
204
+ const refusal = new GsapEditBlockedError("keyframes-uneditable");
205
+ vi.mocked(commitNodeDrop).mockRejectedValue(refusal);
206
+ vi.spyOn(Element.prototype, "setPointerCapture").mockImplementation(() => {});
207
+ const host = document.body.appendChild(document.createElement("div"));
208
+ const root = createRoot(host);
209
+ const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
64
210
  try {
65
211
  act(() =>
66
212
  root.render(
@@ -72,28 +218,16 @@ it("the layer's node and another node's ring inside the layer's box press the la
72
218
  />,
73
219
  ),
74
220
  );
75
- press(60, 63, false);
76
- press(120, 110, true);
77
- expect(boxPresses).toEqual([63, 110]);
78
- press(120, 118, true);
79
- press(120, 110, false);
80
- expect(boxPresses).toEqual([63, 110]);
81
- // A selected node draws its dot 1.5x as large, and the larger dot is the node's.
82
- act(() => usePlayerStore.setState({ activeKeyframePct: 100 }));
83
- press(120, 112, true);
84
- expect(boxPresses).toEqual([63, 110]);
85
- act(() => usePlayerStore.setState({ activeKeyframePct: null }));
86
- press(120, 112, true);
87
- expect(boxPresses).toEqual([63, 110, 112]);
88
- // A box that starts no gesture leaves the press to the node.
89
- boxStarts = false;
90
- captured.mockClear();
91
- press(60, 63, false);
92
- expect(captured).toHaveBeenCalledTimes(1);
221
+ const node = host.querySelector('circle.pointer-events-auto[cx="140"]')!;
222
+ document.elementsFromPoint = () => [node];
223
+ const at = (clientX: number) => ({ bubbles: true, button: 0, clientX, clientY: 30 });
224
+ act(() => void node.dispatchEvent(new PointerEvent("pointerdown", at(140))));
225
+ act(() => void node.dispatchEvent(new PointerEvent("pointermove", at(180))));
226
+ await act(async () => void node.dispatchEvent(new PointerEvent("pointerup", at(180))));
227
+ expect(commitNodeDrop).toHaveBeenCalledTimes(1);
228
+ expect(showToast).toHaveBeenCalledWith(refusal.message, "error");
93
229
  } finally {
94
- captured.mockRestore();
95
230
  act(() => root.unmount());
96
- box.remove();
97
231
  host.remove();
98
232
  }
99
233
  });
@@ -6,12 +6,17 @@ import { MotionPathOverlay } from "./MotionPathOverlay";
6
6
  import { usePlayerStore } from "../../player/store/playerStore";
7
7
  import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
8
8
  import type { DomEditSelection } from "./domEditing";
9
+ import { commitCreatePath } from "./motionPathCommit";
9
10
 
10
11
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
11
12
 
12
13
  vi.mock("../../contexts/DomEditContext", () => ({
13
14
  useDomEditContext: () => ({ selectedGsapAnimations: [] }),
14
15
  }));
16
+ vi.mock("./motionPathCommit", async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import("./motionPathCommit")>()),
18
+ commitCreatePath: vi.fn(),
19
+ }));
15
20
  vi.mock("./motionPathSelection", () => ({
16
21
  selectorFor: () => "#box",
17
22
  editableAnimationId: () => null,
@@ -63,3 +68,43 @@ it("re-evaluates set-destination availability when a reload swaps the live ifram
63
68
  after.remove();
64
69
  }
65
70
  });
71
+
72
+ it("sets a destination on a layer whichever window built its node", () => {
73
+ const host = document.createElement("div");
74
+ const surface = document.createElement("div");
75
+ surface.setAttribute("data-preview-pan-surface", "");
76
+ const frame = document.createElement("iframe");
77
+ document.body.append(host, surface, frame);
78
+ Reflect.set(frame.contentWindow as object, "MotionPathPlugin", {});
79
+ frame.getBoundingClientRect = () => new DOMRect(0, 0, 1920, 1080);
80
+ // As on the loads that hid the path, the layer's node carries the editor window's prototypes.
81
+ const box = document.createElement("div");
82
+ box.id = "box";
83
+ frame.contentDocument!.body.append(box);
84
+ const root = createRoot(host);
85
+ const selection = { element: box } as unknown as DomEditSelection;
86
+ try {
87
+ act(() => usePreviewIframeStore.getState().setIframe(frame));
88
+ act(() =>
89
+ root.render(
90
+ <MotionPathOverlay
91
+ iframeRef={{ current: frame }}
92
+ selection={selection}
93
+ compositionSize={{ width: 1920, height: 1080 }}
94
+ isPlaying={false}
95
+ />,
96
+ ),
97
+ );
98
+ act(() => usePlayerStore.getState().setMotionPathArmed(true));
99
+ const press = { bubbles: true, button: 0, clientX: 100, clientY: 100 };
100
+ act(() => void surface.dispatchEvent(new PointerEvent("pointerdown", press)));
101
+ expect(commitCreatePath).toHaveBeenCalledTimes(1);
102
+ } finally {
103
+ act(() => usePlayerStore.getState().setMotionPathArmed(false));
104
+ act(() => root.unmount());
105
+ usePreviewIframeStore.setState({ iframe: null });
106
+ host.remove();
107
+ surface.remove();
108
+ frame.remove();
109
+ }
110
+ });