@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.
- package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-BbbN-wWN.js} +1 -1
- package/dist/assets/{index-CrYoEZ5p.js → index-B8kU2xXl.js} +170 -170
- package/dist/assets/{index-yCK8osBg.js → index-Cll7uTNy.js} +1 -1
- package/dist/assets/{index-4LRR6o2F.js → index-nvLF29ZH.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +4251 -3984
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
- package/src/components/editor/MotionPathNode.tsx +56 -0
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +140 -48
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +30 -61
- package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
- package/src/components/editor/manualEditsSeekReapply.ts +3 -6
- package/src/components/editor/manualOffsetDrag.ts +2 -3
- package/src/components/editor/motionPathGeometry.test.ts +48 -1
- package/src/components/editor/motionPathGeometry.ts +46 -8
- package/src/components/editor/motionPathHome.ts +75 -0
- package/src/components/editor/motionPathLayerNode.test.ts +43 -35
- package/src/components/editor/motionPathLayerNode.ts +20 -16
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
- package/src/components/editor/studioMotion.ts +7 -19
- package/src/components/editor/useMotionPathData.test.tsx +236 -0
- package/src/components/editor/useMotionPathData.ts +23 -61
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
- package/src/hooks/gsapParsedTween.ts +16 -0
- package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
- package/src/hooks/gsapShared.ts +2 -1
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
- package/src/hooks/useThumbnailStripSize.ts +206 -17
- package/src/noWindowBoundDomChecks.test.ts +31 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -6
- package/src/player/components/ImageThumbnail.tsx +11 -5
- package/src/player/components/ThumbnailTiles.test.tsx +30 -0
- package/src/player/components/ThumbnailTiles.tsx +41 -0
- package/src/player/components/VideoThumbnail.test.tsx +116 -9
- package/src/player/components/VideoThumbnail.tsx +11 -5
- package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
- package/src/player/lib/timelineGroupInfo.ts +2 -1
- 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.
|
|
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/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/studio-server": "0.8.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 {
|
|
13
|
+
import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
|
|
14
14
|
import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
|
|
15
|
-
import {
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
289
|
-
ax:
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
438
|
-
const
|
|
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
|
|
462
|
-
//
|
|
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:
|
|
523
|
+
style={{ cursor: lineCursor }}
|
|
529
524
|
onPointerMove={onPathHover}
|
|
530
525
|
onPointerLeave={() => setGhost(null)}
|
|
531
526
|
onPointerDown={onPathDown}
|
|
532
527
|
/>
|
|
533
528
|
)}
|
|
534
|
-
<
|
|
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
|
|
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
|
|
23
|
+
if (isHtmlElement(el) && !el.hasAttribute(attr)) {
|
|
27
24
|
el.setAttribute(attr, "true");
|
|
28
25
|
el.removeAttribute(legacyAttr);
|
|
29
26
|
elements.push(el);
|