@hyperframes/studio 0.8.125 → 0.8.126

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 (47) hide show
  1. package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-BbbN-wWN.js} +1 -1
  2. package/dist/assets/{index-CrYoEZ5p.js → index-B8kU2xXl.js} +170 -170
  3. package/dist/assets/{index-yCK8osBg.js → index-Cll7uTNy.js} +1 -1
  4. package/dist/assets/{index-4LRR6o2F.js → index-nvLF29ZH.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +4251 -3984
  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 +140 -48
  13. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +30 -61
  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/motionPathGeometry.test.ts +48 -1
  19. package/src/components/editor/motionPathGeometry.ts +46 -8
  20. package/src/components/editor/motionPathHome.ts +75 -0
  21. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  22. package/src/components/editor/motionPathLayerNode.ts +20 -16
  23. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  24. package/src/components/editor/studioMotion.ts +7 -19
  25. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  26. package/src/components/editor/useMotionPathData.ts +23 -61
  27. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  28. package/src/hooks/gsapParsedTween.ts +16 -0
  29. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  30. package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
  31. package/src/hooks/gsapShared.ts +2 -1
  32. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  33. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  34. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  35. package/src/hooks/timelineEditingHelpers.ts +2 -9
  36. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  37. package/src/hooks/useThumbnailStripSize.ts +206 -17
  38. package/src/noWindowBoundDomChecks.test.ts +31 -0
  39. package/src/player/components/CompositionThumbnail.tsx +10 -6
  40. package/src/player/components/ImageThumbnail.tsx +11 -5
  41. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  42. package/src/player/components/ThumbnailTiles.tsx +41 -0
  43. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  44. package/src/player/components/VideoThumbnail.tsx +11 -5
  45. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  46. package/src/player/lib/timelineGroupInfo.ts +2 -1
  47. 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.126",
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/core": "0.8.126",
64
+ "@hyperframes/sdk": "0.8.126",
65
+ "@hyperframes/shader-transitions": "0.8.126",
66
+ "@hyperframes/player": "0.8.126",
67
+ "@hyperframes/parsers": "0.8.126",
68
+ "@hyperframes/studio-server": "0.8.126"
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.126"
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,18 @@
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
5
  import { usePlayerStore } from "../../player/store/playerStore";
6
6
  import { MotionPathOverlay } from "./MotionPathOverlay";
7
7
  import type { DomEditSelection } from "./domEditing";
8
8
 
9
9
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
10
 
11
+ const { commitMutation } = vi.hoisted(() => ({
12
+ commitMutation: vi.fn(async (..._args: unknown[]) => {}),
13
+ }));
11
14
  vi.mock("../../contexts/DomEditContext", () => ({
12
- useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation: vi.fn() }),
15
+ useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation }),
13
16
  }));
14
17
  vi.mock("./motionPathSelection", () => ({
15
18
  selectorFor: () => "#box",
@@ -28,72 +31,161 @@ vi.mock("./useMotionPathData", async (importOriginal) => ({
28
31
  points: "60,30 120,30",
29
32
  nodes: [
30
33
  { x: 60, y: 30, ref: { type: "keyframe", pct: 66.667 } },
31
- { x: 120, y: 30, ref: { type: "keyframe", pct: 100 } },
34
+ // 140 wide there against 100 now: drawn 20 px right, where the layer's centre will be.
35
+ { x: 120, y: 30, w: 140, ref: { type: "keyframe", pct: 100 } },
32
36
  ],
37
+ start: { x: 40, y: 30, w: 120 },
33
38
  },
34
39
  geometryResolved: true,
35
40
  visibleInPreview: true,
36
- home: { x: 0, y: 0 },
41
+ home: { x: 0, y: 0, w: 100, h: 50, ax: 0.5, ay: 0.5 },
37
42
  pScale: 1,
38
43
  }),
39
44
  }));
40
45
 
41
- it("the layer's node and another node's ring inside the layer's box press the layer; a dot keeps its node", () => {
46
+ const cleanups: Array<() => void> = [];
47
+ afterEach(() => {
48
+ while (cleanups.length) cleanups.pop()!();
49
+ commitMutation.mockClear();
50
+ });
51
+
52
+ /** What lies under the pointer below the path: the layer's box, a handle over it, a marker, or nothing. */
53
+ type Under = boolean | "handle" | "marker";
54
+
55
+ /** The overlay over a selected layer's box and a crop handle, scale 1: client px are composition px. */
56
+ function mount() {
57
+ const overlay = document.createElement("div");
58
+ const chrome = overlay.appendChild(document.createElement("div"));
59
+ chrome.setAttribute("data-dom-edit-chrome", "true");
42
60
  const box = document.createElement("div");
43
61
  box.setAttribute("data-dom-edit-selection-box", "true");
62
+ box.style.cursor = "move";
63
+ const handle = document.createElement("button");
64
+ handle.style.cursor = "ew-resize";
65
+ chrome.append(box, handle);
66
+ // Another layer's off-canvas marker: no handler of its own, so a press on it reaches the overlay's marquee.
67
+ const marker = overlay.appendChild(document.createElement("div"));
68
+ overlay.addEventListener("pointerdown", (e) => {
69
+ if (!(e.target as Element).closest("[data-dom-edit-chrome]")) e.preventDefault();
70
+ });
44
71
  const host = document.createElement("div");
45
- document.body.append(box, host);
72
+ document.body.append(overlay, host);
73
+ const handlePresses: number[] = [];
74
+ handle.addEventListener("pointerdown", (e) => {
75
+ handlePresses.push((e as PointerEvent).clientX);
76
+ e.preventDefault();
77
+ });
46
78
  const boxPresses: number[] = [];
47
- let boxStarts = true;
79
+ const state = { boxStarts: true };
48
80
  box.addEventListener("pointerdown", (e) => {
49
81
  boxPresses.push((e as PointerEvent).clientX);
50
- if (boxStarts) e.preventDefault();
82
+ if (state.boxStarts) e.preventDefault();
51
83
  });
52
84
  const captured = vi.spyOn(Element.prototype, "setPointerCapture").mockImplementation(() => {});
53
85
  const root = createRoot(host);
54
86
  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) => {
87
+ act(() =>
88
+ root.render(
89
+ <MotionPathOverlay
90
+ iframeRef={{ current: null }}
91
+ selection={selection}
92
+ compositionSize={{ width: 1920, height: 1080 }}
93
+ isPlaying={false}
94
+ />,
95
+ ),
96
+ );
97
+ cleanups.push(() => {
98
+ captured.mockRestore();
99
+ act(() => root.unmount());
100
+ overlay.remove();
101
+ host.remove();
102
+ });
103
+ /** Fires `type` at `target`; `under`: the layer's box, a handle over the box, or only the canvas. */
104
+ const fire = (target: Element, type: string, x: number, under: Under) => {
105
+ const stack = { handle: [handle, box], marker: [marker], true: [box], false: [] }[
106
+ String(under)
107
+ ]!;
108
+ document.elementsFromPoint = () => [target, ...stack, overlay];
109
+ const init = { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: 30 };
110
+ act(() => void target.dispatchEvent(new PointerEvent(type, init)));
111
+ };
112
+ return { host, boxPresses, handlePresses, state, captured, fire };
113
+ }
114
+
115
+ it("the layer's node, and any node inside the layer's box, press the layer; outside it a node keeps the press", () => {
116
+ const { host, boxPresses, state, captured, fire } = mount();
117
+ const press = (node: number, x: number, inBox: Under) => {
57
118
  const hit = [...host.querySelectorAll("circle.pointer-events-auto")].find(
58
119
  (c) => c.getAttribute("cx") === String(node),
59
120
  )!;
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)));
121
+ fire(hit, "pointerdown", x, inBox);
63
122
  };
64
- try {
65
- act(() =>
66
- root.render(
67
- <MotionPathOverlay
68
- iframeRef={{ current: null }}
69
- selection={selection}
70
- compositionSize={{ width: 1920, height: 1080 }}
71
- isPlaying={false}
72
- />,
73
- ),
74
- );
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);
93
- } finally {
94
- captured.mockRestore();
95
- act(() => root.unmount());
96
- box.remove();
97
- host.remove();
98
- }
123
+ press(60, 63, false);
124
+ press(140, 130, true);
125
+ // On the dot itself: a drag from the layer's middle moves the layer (the edit bench's keys case).
126
+ press(140, 140, true);
127
+ expect(boxPresses).toEqual([63, 130, 140]);
128
+ act(() => usePlayerStore.setState({ activeKeyframePct: 100 }));
129
+ press(140, 140, true);
130
+ act(() => usePlayerStore.setState({ activeKeyframePct: null }));
131
+ expect(boxPresses).toEqual([63, 130, 140, 140]);
132
+ press(140, 138, false);
133
+ expect(boxPresses).toEqual([63, 130, 140, 140]);
134
+ // Over another layer's off-canvas marker the node keeps its press; the canvas starts no marquee.
135
+ captured.mockClear();
136
+ press(140, 138, "marker");
137
+ expect(captured).toHaveBeenCalledTimes(1);
138
+ expect(boxPresses).toEqual([63, 130, 140, 140]);
139
+ // A box that starts no gesture leaves the press to the node.
140
+ state.boxStarts = false;
141
+ captured.mockClear();
142
+ press(60, 63, false);
143
+ expect(captured).toHaveBeenCalledTimes(1);
144
+ });
145
+
146
+ it("the path's line inside the layer's box moves the layer and offers no add; outside it adds a keyframe", async () => {
147
+ const { host, boxPresses, fire } = mount();
148
+ const line = host.querySelector("polyline.pointer-events-auto") as SVGElement;
149
+ const ghost = () => host.querySelector("rect.pointer-events-none");
150
+
151
+ fire(line, "pointermove", 90, true);
152
+ expect(line.style.cursor).toBe("move");
153
+ expect(ghost()).toBeNull();
154
+ fire(line, "pointerdown", 90, true);
155
+ expect(boxPresses).toEqual([90]);
156
+ expect(commitMutation).not.toHaveBeenCalled();
157
+
158
+ fire(line, "pointermove", 90, false);
159
+ expect(line.style.cursor).toBe("copy");
160
+ expect(ghost()).not.toBeNull();
161
+ // Halfway along the drawn segment 60 to 140: halfway between the stored offsets 60 and 120.
162
+ fire(line, "pointerdown", 100, false);
163
+ expect(boxPresses).toEqual([90]);
164
+ await vi.waitFor(() => expect(commitMutation).toHaveBeenCalledTimes(1));
165
+ expect(commitMutation.mock.calls[0]![0]).toMatchObject({ properties: { x: 90, y: 30 } });
166
+ });
167
+
168
+ it("a crop or resize handle over a node or the line takes the press, not the node or the layer", () => {
169
+ const { host, boxPresses, handlePresses, captured, fire } = mount();
170
+ const node = host.querySelector('circle.pointer-events-auto[cx="140"]')!;
171
+ const line = host.querySelector("polyline.pointer-events-auto") as SVGElement;
172
+ captured.mockClear();
173
+ fire(node, "pointerdown", 142, "handle");
174
+ fire(line, "pointermove", 100, "handle");
175
+ expect(line.style.cursor).toBe("ew-resize");
176
+ fire(line, "pointerdown", 100, "handle");
177
+ expect(handlePresses).toEqual([142, 100]);
178
+ expect(boxPresses).toEqual([]);
179
+ expect(captured).not.toHaveBeenCalled();
180
+ expect(commitMutation).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it("draws where GSAP started the tween before the first keyframe, as a mark that takes no press", () => {
184
+ const { host } = mount();
185
+ const start = host.querySelector("circle[data-motion-path-start]")!;
186
+ // 120 wide at the start against 100 now: drawn 10 px right.
187
+ expect([start.getAttribute("cx"), start.getAttribute("cy")]).toEqual(["50", "30"]);
188
+ expect(start.classList.contains("pointer-events-none")).toBe(true);
189
+ const drawn = host.querySelector("polyline:not(.pointer-events-auto)")!;
190
+ expect(drawn.getAttribute("points")).toBe("50,30 60,30 140,30");
99
191
  });
@@ -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
+ });
@@ -8,11 +8,12 @@ import { usePlayerStore } from "../../player/store/playerStore";
8
8
  import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
9
9
  import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
10
10
  import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
11
- import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
11
+ import { nearestPointOnPath, nodeCentre, type MotionNodeRef } from "./motionPathGeometry";
12
12
  import { editableAnimationId, selectorFor } from "./motionPathSelection";
13
- import { dotRadius, pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
13
+ import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
14
14
  import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
15
- import { ACCENT, MotionPathNode } from "./MotionPathNode";
15
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
16
+ import { ACCENT, MotionPathLine, MotionPathNode } from "./MotionPathNode";
16
17
  import {
17
18
  KeyframeDiamondContextMenu,
18
19
  type KeyframeDiamondContextMenuState,
@@ -25,13 +26,8 @@ import {
25
26
  commitNode,
26
27
  commitRemoveWaypoint,
27
28
  } from "./motionPathCommit";
28
- import {
29
- elementHome,
30
- hasMotionPathPlugin,
31
- isPreviewHtmlElement,
32
- transformWDivisor,
33
- useMotionPathData,
34
- } from "./useMotionPathData";
29
+ import { elementHome } from "./motionPathHome";
30
+ import { hasMotionPathPlugin, transformWDivisor, useMotionPathData } from "./useMotionPathData";
35
31
 
36
32
  interface MotionPathOverlayProps {
37
33
  iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -101,6 +97,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
101
97
  );
102
98
  const [draft, setDraft] = useState<Draft | null>(null);
103
99
  const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null);
100
+ const [lineCursor, setLineCursor] = useState("copy");
104
101
  const [hoverNode, setHoverNode] = useState<number | null>(null);
105
102
  // Right-click context menu on a path node — same delete actions as the
106
103
  // timeline keyframe diamond. The node it was opened on rides along, because
@@ -194,7 +191,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
194
191
  // Resolve the element LIVE from the current iframe document — the selected
195
192
  // node may be detached after a soft-reload, which would skew home.
196
193
  const live = frame.contentDocument?.querySelector(createSelector);
197
- if (!isPreviewHtmlElement(live, frame)) return;
194
+ if (!isHtmlElement(live)) return;
198
195
  e.stopPropagation();
199
196
  e.preventDefault();
200
197
  const sc = r.width / compW;
@@ -284,12 +281,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
284
281
  // pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D
285
282
  // elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a
286
283
  // pointer position is mapped back to a stored offset (create + node drag).
287
- const abs = nodes.map((n) => ({
288
- ...n,
289
- ax: home.x + n.x * pScale,
290
- ay: home.y + n.y * pScale,
291
- }));
284
+ const abs = nodes.map((n) => {
285
+ const c = nodeCentre(n, home, pScale);
286
+ return { ...n, ax: c.x, ay: c.y };
287
+ });
292
288
  const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
289
+ const startAt = geometry.start && nodeCentre(geometry.start, home, pScale);
293
290
  // Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
294
291
  // rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
295
292
  // absolute-positioned SVG), so subtracting them from a viewport clientX/Y would
@@ -313,9 +310,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
313
310
  if (e.button !== 0) return;
314
311
  e.stopPropagation();
315
312
  const live = readGsapPositionFromIframe(iframeRef.current, selectorFor(selection) ?? "");
316
- const dotR = dotRadius(nodeR, ref.type === "keyframe" && ref.pct === activeKeyframePct);
317
- if (pressBelongsToLayer(e, clientToComp(e), abs[index]!, dotR, live) && pressSelectedLayer(e))
318
- return;
313
+ if (pressControl(e, controlForNode(e, abs[index]!, live))) return;
319
314
  (e.target as Element).setPointerCapture(e.pointerId);
320
315
  dragRef.current = {
321
316
  index,
@@ -378,10 +373,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
378
373
  // high zoom) would commit an identical value — a no-op undo entry. Skip the
379
374
  // commit, but don't treat it as a click either (the user did drag).
380
375
  if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
381
- // With auto-keyframe off (#1808), dragging a keyframe's node on the motion
382
- // path (the common way to nudge a KEYFRAMED element's position on canvas,
383
- // since the element renders exactly at its current keyframe) shifts the
384
- // whole path instead of moving just that one keyframe.
376
+ // Auto-keyframe off (#1808): dragging a keyframe's node shifts the whole path.
385
377
  const anim =
386
378
  d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
387
379
  if (
@@ -405,9 +397,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
405
397
  } else {
406
398
  void commitNode(d.ref, x, y, animId, commitMutation);
407
399
  }
408
- // Park the playhead on the edited keyframe's time so the element previews AT
409
- // that keyframe. Without it, a playhead sitting before the tween renders the
410
- // element's base pose — the edit (correct on the path) looks like it vanished.
400
+ // Park on the edited keyframe, or a playhead before the tween hides the edit.
411
401
  if (d.ref.type === "keyframe" && anim) {
412
402
  parkPlayheadOnKeyframe(anim, d.ref.pct);
413
403
  }
@@ -415,15 +405,21 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
415
405
 
416
406
  // Ghost "add" affordance: project the cursor onto the path; click inserts.
417
407
  const onPathHover = (e: React.PointerEvent) => {
408
+ const control = controlUnder(e);
409
+ setLineCursor(control ? getComputedStyle(control).cursor : "copy");
418
410
  const c = clientToComp(e);
419
411
  const np = nearestPointOnPath(
420
412
  c.x,
421
413
  c.y,
422
414
  abs.map((p) => ({ x: p.ax, y: p.ay })),
423
415
  );
424
- setGhost(np ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
416
+ setGhost(np && !control ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
425
417
  };
426
418
  const onPathDown = (e: React.PointerEvent) => {
419
+ if (pressControl(e, controlUnder(e))) {
420
+ e.stopPropagation();
421
+ return;
422
+ }
427
423
  if (!animId) return;
428
424
  // Compute the insertion point from the event directly so a click works
429
425
  // without (or faster than) a preceding hover.
@@ -434,8 +430,9 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
434
430
  abs.map((p) => ({ x: p.ax, y: p.ay })),
435
431
  );
436
432
  if (!np) return;
437
- const x = Math.round(np.x - home.x);
438
- const y = Math.round(np.y - home.y);
433
+ const [a, b] = [abs[np.segIndex]!, abs[np.segIndex + 1]!];
434
+ const x = Math.round(a.x + np.t * (b.x - a.x));
435
+ const y = Math.round(a.y + np.t * (b.y - a.y));
439
436
  if (geometry.kind === "arc") {
440
437
  e.stopPropagation();
441
438
  void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
@@ -458,10 +455,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
458
455
  };
459
456
 
460
457
  const elementId = selection?.id ?? null;
461
- // Right-click any path node → the timeline's keyframe context menu (delete this
462
- // one / delete all), so path nodes are removable in place. Waypoints open it
463
- // too: returning early for them let the browser's own context menu open over
464
- // the editor overlay, which is never what a right-click on a node should do.
458
+ // Right-click any path node → the timeline's keyframe menu. Waypoints too, or the
459
+ // browser's own context menu opens over the editor.
465
460
  const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
466
461
  if (!animId || !elementId || !timelineElement) return;
467
462
  e.preventDefault();
@@ -525,39 +520,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
525
520
  stroke="transparent"
526
521
  strokeWidth={14 / scale}
527
522
  className="pointer-events-auto"
528
- style={{ cursor: "copy" }}
523
+ style={{ cursor: lineCursor }}
529
524
  onPointerMove={onPathHover}
530
525
  onPointerLeave={() => setGhost(null)}
531
526
  onPointerDown={onPathDown}
532
527
  />
533
528
  )}
534
- <polyline
535
- points={points}
536
- fill="none"
537
- style={{ stroke: ACCENT }}
538
- strokeWidth={1.5}
539
- strokeDasharray="5 5"
540
- strokeLinejoin="round"
541
- strokeLinecap="round"
542
- vectorEffect="non-scaling-stroke"
543
- opacity={0.85}
544
- />
545
- {ghost && (
546
- <rect
547
- x={ghost.x - nodeR * 0.707}
548
- y={ghost.y - nodeR * 0.707}
549
- width={nodeR * 1.414}
550
- height={nodeR * 1.414}
551
- rx={nodeR * 0.24}
552
- transform={`rotate(45 ${ghost.x} ${ghost.y})`}
553
- fill="none"
554
- strokeWidth={1.5}
555
- strokeDasharray="2 2"
556
- vectorEffect="non-scaling-stroke"
557
- className="pointer-events-none"
558
- style={{ stroke: ACCENT }}
559
- />
560
- )}
529
+ <MotionPathLine points={points} start={startAt} ghost={ghost} r={nodeR} />
561
530
  {abs.map((p, i) => (
562
531
  <MotionPathNode
563
532
  key={i}
@@ -124,17 +124,8 @@ export function readEffectiveZIndex(el: HTMLElement): number {
124
124
  return 0;
125
125
  }
126
126
 
127
- /**
128
- * Realm-safe HTMLElement check. The target lives in the preview IFRAME's
129
- * document, but this module runs in the top window, so `child instanceof
130
- * HTMLElement` (top-window constructor) is ALWAYS false for iframe elements —
131
- * which silently emptied the sibling list and left every z-order action
132
- * permanently disabled. Compare against the element's own realm instead, with
133
- * a nodeType fallback for detached / cross-realm edge cases.
134
- */
127
+ /** Any element, asked structurally: the preview's nodes are not this window's. */
135
128
  function isElementNode(node: Node): node is HTMLElement {
136
- const view = node.ownerDocument?.defaultView;
137
- if (view && node instanceof view.HTMLElement) return true;
138
129
  return node.nodeType === 1;
139
130
  }
140
131
 
@@ -1,4 +1,5 @@
1
1
  // Puts Studio's committed edits back after a timeline seek has rendered over them.
2
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
2
3
  import {
3
4
  STUDIO_BOX_SIZE_ATTR,
4
5
  STUDIO_HEIGHT_PROP,
@@ -15,15 +16,11 @@ import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTyp
15
16
  import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
16
17
 
17
18
  function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
18
- const ctor = doc.defaultView?.HTMLElement;
19
- if (!ctor) return [];
20
- const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
21
- (el): el is HTMLElement => el instanceof ctor,
22
- );
19
+ const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(isHtmlElement);
23
20
  // Handle legacy HTML files where attributes were persisted with a double data- prefix
24
21
  const legacyAttr = `data-${attr}`;
25
22
  for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
26
- if (el instanceof ctor && !el.hasAttribute(attr)) {
23
+ if (isHtmlElement(el) && !el.hasAttribute(attr)) {
27
24
  el.setAttribute(attr, "true");
28
25
  el.removeAttribute(legacyAttr);
29
26
  elements.push(el);