@hyperframes/studio 0.8.126 → 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.
- package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-uqzDaxVL.js} +1 -1
- package/dist/assets/{index-nvLF29ZH.js → index-CLBNYlDY.js} +1 -1
- package/dist/assets/{index-B8kU2xXl.js → index-COhbL1xq.js} +171 -171
- package/dist/assets/{index-Cll7uTNy.js → index-DXSYlqmV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1008 -893
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
- package/src/components/editor/MotionPathOverlay.tsx +20 -26
- package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
- package/src/components/editor/motionPathCommit.ts +58 -0
- package/src/components/editor/motionPathGeometry.test.ts +13 -0
- package/src/components/editor/motionPathGeometry.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +33 -1
- package/src/hooks/gsapEditOutcome.ts +27 -2
- package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +23 -2
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
- package/src/hooks/gsapShared.test.ts +1 -0
- package/src/hooks/gsapShared.ts +6 -2
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
- package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
- package/src/hooks/gsapValueAtPlayhead.ts +46 -6
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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/
|
|
64
|
-
"@hyperframes/sdk": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/studio-server": "0.8.
|
|
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.
|
|
84
|
+
"@hyperframes/producer": "0.8.127"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
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);
|
|
@@ -14,6 +16,14 @@ const { commitMutation } = vi.hoisted(() => ({
|
|
|
14
16
|
vi.mock("../../contexts/DomEditContext", () => ({
|
|
15
17
|
useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation }),
|
|
16
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(),
|
|
26
|
+
}));
|
|
17
27
|
vi.mock("./motionPathSelection", () => ({
|
|
18
28
|
selectorFor: () => "#box",
|
|
19
29
|
editableAnimationId: () => "a1",
|
|
@@ -189,3 +199,35 @@ it("draws where GSAP started the tween before the first keyframe, as a mark that
|
|
|
189
199
|
const drawn = host.querySelector("polyline:not(.pointer-events-auto)")!;
|
|
190
200
|
expect(drawn.getAttribute("points")).toBe("50,30 60,30 140,30");
|
|
191
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;
|
|
210
|
+
try {
|
|
211
|
+
act(() =>
|
|
212
|
+
root.render(
|
|
213
|
+
<MotionPathOverlay
|
|
214
|
+
iframeRef={{ current: null }}
|
|
215
|
+
selection={selection}
|
|
216
|
+
compositionSize={{ width: 1920, height: 1080 }}
|
|
217
|
+
isPlaying={false}
|
|
218
|
+
/>,
|
|
219
|
+
),
|
|
220
|
+
);
|
|
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");
|
|
229
|
+
} finally {
|
|
230
|
+
act(() => root.unmount());
|
|
231
|
+
host.remove();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
|
|
2
|
-
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
|
|
3
1
|
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
4
2
|
import { memo, useEffect, useRef, useState, type RefObject } from "react";
|
|
5
3
|
import type { DomEditSelection } from "./domEditing";
|
|
6
4
|
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
5
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
6
|
+
import { useGsapInteractionFailureTelemetry } from "../../hooks/useGsapInteractionFailureTelemetry";
|
|
7
7
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
8
|
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
9
9
|
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
|
|
10
|
-
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
|
|
11
10
|
import { nearestPointOnPath, nodeCentre, type MotionNodeRef } from "./motionPathGeometry";
|
|
12
11
|
import { editableAnimationId, selectorFor } from "./motionPathSelection";
|
|
13
12
|
import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
|
|
@@ -23,8 +22,9 @@ import {
|
|
|
23
22
|
commitAddKeyframe,
|
|
24
23
|
commitAddWaypoint,
|
|
25
24
|
commitCreatePath,
|
|
26
|
-
|
|
25
|
+
commitNodeDrop,
|
|
27
26
|
commitRemoveWaypoint,
|
|
27
|
+
nodeDropLabel,
|
|
28
28
|
} from "./motionPathCommit";
|
|
29
29
|
import { elementHome } from "./motionPathHome";
|
|
30
30
|
import { hasMotionPathPlugin, transformWDivisor, useMotionPathData } from "./useMotionPathData";
|
|
@@ -66,6 +66,7 @@ const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
|
|
|
66
66
|
// (select the keyframe); at or above it the gesture commits a move. Screen-space
|
|
67
67
|
// (not composition px) so it behaves identically at any zoom.
|
|
68
68
|
const DRAG_THRESHOLD_PX = 3;
|
|
69
|
+
const noToast = () => {};
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Draws the selected element's GSAP motion path over the canvas — a dashed
|
|
@@ -91,6 +92,11 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
91
92
|
handleGsapRemoveAllKeyframes,
|
|
92
93
|
handleGsapMoveKeyframeToPlayhead,
|
|
93
94
|
} = useDomEditContext();
|
|
95
|
+
const shell = useStudioShellContextOptional();
|
|
96
|
+
const reportFailure = useGsapInteractionFailureTelemetry(
|
|
97
|
+
shell?.activeCompPath ?? null,
|
|
98
|
+
shell?.showToast ?? noToast,
|
|
99
|
+
);
|
|
94
100
|
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
|
|
95
101
|
iframeRef,
|
|
96
102
|
selectorFor(selection),
|
|
@@ -373,30 +379,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
373
379
|
// high zoom) would commit an identical value — a no-op undo entry. Skip the
|
|
374
380
|
// commit, but don't treat it as a click either (the user did drag).
|
|
375
381
|
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
|
|
376
|
-
// Auto-keyframe off (#1808): dragging a keyframe's node shifts the whole path.
|
|
377
382
|
const anim =
|
|
378
383
|
d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
selection,
|
|
390
|
-
anim,
|
|
391
|
-
{ x, y },
|
|
392
|
-
d.ref.pct,
|
|
393
|
-
iframeRef.current,
|
|
394
|
-
{ commitMutation: writes.commit! },
|
|
395
|
-
"Move animation path",
|
|
396
|
-
).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
|
|
397
|
-
} else {
|
|
398
|
-
void commitNode(d.ref, x, y, animId, commitMutation);
|
|
399
|
-
}
|
|
384
|
+
const label = nodeDropLabel(d.ref);
|
|
385
|
+
commitNodeDrop({
|
|
386
|
+
ref: d.ref,
|
|
387
|
+
at: { x, y },
|
|
388
|
+
animId,
|
|
389
|
+
anim,
|
|
390
|
+
selection,
|
|
391
|
+
iframe: iframeRef.current,
|
|
392
|
+
commitMutation,
|
|
393
|
+
}).catch((error: unknown) => reportFailure(error, selection, "drag", label));
|
|
400
394
|
// Park on the edited keyframe, or a playhead before the tween hides the edit.
|
|
401
395
|
if (d.ref.type === "keyframe" && anim) {
|
|
402
396
|
parkPlayheadOnKeyframe(anim, d.ref.pct);
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, expect, it, onTestFinished, vi } from "vitest";
|
|
3
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
|
+
import type { DomEditSelection } from "./domEditing";
|
|
5
|
+
import { boxSteps, previewWith, tween } from "../../hooks/gsapParsedTween.test-helpers";
|
|
6
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
7
|
+
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
|
|
8
|
+
import { commitNodeDrop, nodeDropLabel, type CommitFn } from "./motionPathCommit";
|
|
9
|
+
|
|
10
|
+
it("names a node drop for its undo entry by what it moved", () => {
|
|
11
|
+
const { autoKeyframeEnabled } = usePlayerStore.getState();
|
|
12
|
+
onTestFinished(() => usePlayerStore.setState({ autoKeyframeEnabled }));
|
|
13
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
|
14
|
+
expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move keyframe");
|
|
15
|
+
usePlayerStore.setState({ autoKeyframeEnabled: false });
|
|
16
|
+
expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move animation path");
|
|
17
|
+
expect(nodeDropLabel({ type: "waypoint", index: 0 })).toBe("Move waypoint");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null, requestedSeekTime: null });
|
|
22
|
+
document.body.innerHTML = "";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function box(id = "box") {
|
|
26
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
27
|
+
element.id = id;
|
|
28
|
+
return { element, selection: { id, selector: `#${id}`, element } as DomEditSelection };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Drop = Parameters<typeof commitNodeDrop>[0];
|
|
32
|
+
|
|
33
|
+
function drop(fields: Omit<Drop, "animId" | "commitMutation"> & { anim: GsapAnimation }) {
|
|
34
|
+
const commitMutation = vi.fn<CommitFn>(async () => {});
|
|
35
|
+
const done = commitNodeDrop({ ...fields, animId: fields.anim.id, commitMutation });
|
|
36
|
+
const written = () =>
|
|
37
|
+
(commitMutation.mock.calls[0]![0] as { keyframes: { properties: object }[] }).keyframes.map(
|
|
38
|
+
(kf) => kf.properties,
|
|
39
|
+
);
|
|
40
|
+
return { done, commitMutation, written };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it("a keyframe node's drop changes that keyframe and keeps a newly animated channel on the others", async () => {
|
|
44
|
+
const { element, selection } = box();
|
|
45
|
+
// Over a CSS translate GSAP reads as y 30.
|
|
46
|
+
const { keys, live } = boxSteps([
|
|
47
|
+
[2, { x: 60 }],
|
|
48
|
+
[1, { x: 120 }],
|
|
49
|
+
]);
|
|
50
|
+
usePlayerStore.setState({ currentTime: 2, autoKeyframeEnabled: true });
|
|
51
|
+
|
|
52
|
+
const { done, commitMutation, written } = drop({
|
|
53
|
+
ref: { type: "keyframe", pct: 100 },
|
|
54
|
+
at: { x: 150, y: 90 },
|
|
55
|
+
anim: keys,
|
|
56
|
+
selection,
|
|
57
|
+
iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
|
|
58
|
+
});
|
|
59
|
+
await done;
|
|
60
|
+
|
|
61
|
+
expect(commitMutation.mock.calls[0]![0].type).toBe("replace-with-keyframes");
|
|
62
|
+
expect(written()).toEqual([
|
|
63
|
+
{ x: 60, y: 30 },
|
|
64
|
+
{ x: 150, y: 90 },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a drop on an array step's node edits that step, though the path spaces nodes evenly", async () => {
|
|
69
|
+
const { element, selection } = box();
|
|
70
|
+
// Step ends at 2 s and 3 s; the path draws the first node at 0%, the parser and lane at 66.7%.
|
|
71
|
+
const { keys, live } = boxSteps([
|
|
72
|
+
[2, { x: 60, y: 30 }],
|
|
73
|
+
[1, { x: 120, y: 30 }],
|
|
74
|
+
]);
|
|
75
|
+
usePlayerStore.setState({ currentTime: 1, autoKeyframeEnabled: true, activeKeyframePct: null });
|
|
76
|
+
|
|
77
|
+
const { done, written } = drop({
|
|
78
|
+
ref: { type: "keyframe", pct: 0, step: 0 },
|
|
79
|
+
at: { x: 40, y: 90 },
|
|
80
|
+
anim: keys,
|
|
81
|
+
selection,
|
|
82
|
+
iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
|
|
83
|
+
});
|
|
84
|
+
await done;
|
|
85
|
+
|
|
86
|
+
expect(written()).toEqual([
|
|
87
|
+
{ x: 40, y: 90 },
|
|
88
|
+
{ x: 120, y: 30 },
|
|
89
|
+
]);
|
|
90
|
+
expect(usePlayerStore.getState().requestedSeekTime).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("a drop the writer refuses rejects with the reason, writes nothing and selects nothing", async () => {
|
|
94
|
+
const [a, b] = [box("a"), box("b")];
|
|
95
|
+
for (const { element } of [a, b]) element.className = "card";
|
|
96
|
+
const shared = tween({
|
|
97
|
+
targetSelector: ".card",
|
|
98
|
+
method: "to",
|
|
99
|
+
properties: {},
|
|
100
|
+
resolvedStart: 0,
|
|
101
|
+
duration: 1,
|
|
102
|
+
keyframes: { format: "percentage", keyframes: [{ percentage: 100, properties: { x: 20 } }] },
|
|
103
|
+
});
|
|
104
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
|
|
105
|
+
|
|
106
|
+
const { done, commitMutation } = drop({
|
|
107
|
+
ref: { type: "keyframe", pct: 100 },
|
|
108
|
+
at: { x: 5, y: 0 },
|
|
109
|
+
anim: shared,
|
|
110
|
+
selection: a.selection,
|
|
111
|
+
iframe: previewWith(b.element, [], { x: 0, y: 0 }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await expect(done).rejects.toBeInstanceOf(GsapEditBlockedError);
|
|
115
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
116
|
+
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each([true, false])(
|
|
120
|
+
"a drop on a step list with a step delay says the delay blocks it and writes nothing (auto-keyframe %s)",
|
|
121
|
+
async (autoKeyframeEnabled) => {
|
|
122
|
+
const { element, selection } = box();
|
|
123
|
+
const { keys } = boxSteps([
|
|
124
|
+
[1.5, { x: 100, delay: 0.5 }],
|
|
125
|
+
[1.5, { x: 200, delay: 0.5 }],
|
|
126
|
+
]);
|
|
127
|
+
usePlayerStore.setState({ autoKeyframeEnabled, activeKeyframePct: null });
|
|
128
|
+
|
|
129
|
+
const { done, commitMutation } = drop({
|
|
130
|
+
ref: { type: "keyframe", pct: 100 },
|
|
131
|
+
at: { x: 150, y: 0 },
|
|
132
|
+
anim: keys,
|
|
133
|
+
selection,
|
|
134
|
+
iframe: previewWith(element, [], { x: 0, y: 0 }),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await expect(done).rejects.toThrow(/its own delay.*Code tab/);
|
|
138
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("with auto-keyframe off, a step's node shifts the path by its own keyframe's move", async () => {
|
|
143
|
+
const { selection } = box();
|
|
144
|
+
// Steps of 4 s, 1 s and 1 s: drawn at 0/50/100%, parsed at their step ends.
|
|
145
|
+
const { keys } = boxSteps([
|
|
146
|
+
[4, { x: 60, y: 0 }],
|
|
147
|
+
[1, { x: 120, y: 0 }],
|
|
148
|
+
[1, { x: 180, y: 0 }],
|
|
149
|
+
]);
|
|
150
|
+
usePlayerStore.setState({ autoKeyframeEnabled: false, activeKeyframePct: null });
|
|
151
|
+
|
|
152
|
+
const { done, written } = drop({
|
|
153
|
+
ref: { type: "keyframe", pct: 50, step: 1 },
|
|
154
|
+
at: { x: 157, y: 0 },
|
|
155
|
+
anim: keys,
|
|
156
|
+
selection,
|
|
157
|
+
iframe: null,
|
|
158
|
+
});
|
|
159
|
+
await done;
|
|
160
|
+
|
|
161
|
+
expect(written().map((props) => (props as { x: number }).x)).toEqual([97, 157, 217]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("a drop on an inner keyframe of a tween eased as a whole changes that keyframe", async () => {
|
|
165
|
+
const { element, selection } = box();
|
|
166
|
+
const keys = tween({
|
|
167
|
+
id: "#box-to-0-position",
|
|
168
|
+
method: "to",
|
|
169
|
+
properties: {},
|
|
170
|
+
resolvedStart: 0,
|
|
171
|
+
duration: 2,
|
|
172
|
+
ease: "power1.inOut",
|
|
173
|
+
keyframes: {
|
|
174
|
+
format: "percentage",
|
|
175
|
+
keyframes: [0, 100, 200].map((x, i) => ({ percentage: i * 50, properties: { x, y: 0 } })),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
|
|
179
|
+
|
|
180
|
+
const { done, written } = drop({
|
|
181
|
+
ref: { type: "keyframe", pct: 50 },
|
|
182
|
+
at: { x: 150, y: 0 },
|
|
183
|
+
anim: keys,
|
|
184
|
+
selection,
|
|
185
|
+
iframe: previewWith(element, [], { x: 0, y: 0 }),
|
|
186
|
+
});
|
|
187
|
+
await done;
|
|
188
|
+
|
|
189
|
+
expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 200]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("a drop on a short step under 1% from the one before edits the dragged step", async () => {
|
|
193
|
+
const { element, selection } = box();
|
|
194
|
+
// Parsed at 50, 50.5 and 100%: a 0.05 s pop step in a 10 s list.
|
|
195
|
+
const { keys, live } = boxSteps([
|
|
196
|
+
[5, { x: 0, y: 0 }],
|
|
197
|
+
[0.05, { x: 100, y: 0 }],
|
|
198
|
+
[4.95, { x: 100, y: 0 }],
|
|
199
|
+
]);
|
|
200
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
|
|
201
|
+
|
|
202
|
+
const { done, written } = drop({
|
|
203
|
+
ref: { type: "keyframe", pct: 50, step: 1 },
|
|
204
|
+
at: { x: 150, y: 0 },
|
|
205
|
+
anim: keys,
|
|
206
|
+
selection,
|
|
207
|
+
iframe: previewWith(element, [live(element)], { x: 0, y: 0 }),
|
|
208
|
+
});
|
|
209
|
+
await done;
|
|
210
|
+
|
|
211
|
+
expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 100]);
|
|
212
|
+
});
|
|
@@ -5,7 +5,17 @@ import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/preview
|
|
|
5
5
|
* GSAP source mutation routed through the (selection-bound) commit facade, which
|
|
6
6
|
* handles the soft reload, undo snapshot, and save-failure feedback.
|
|
7
7
|
*/
|
|
8
|
+
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
9
|
+
import { assertGsapEditPersisted } from "../../hooks/gsapEditOutcome";
|
|
10
|
+
import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
|
|
11
|
+
import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
|
|
12
|
+
import { commitValueAtPlayhead } from "../../hooks/gsapValueAtPlayhead";
|
|
13
|
+
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
|
|
14
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
15
|
+
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
|
|
16
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
17
|
import type { MotionNodeRef } from "./motionPathGeometry";
|
|
18
|
+
import { selectorFor } from "./motionPathSelection";
|
|
9
19
|
|
|
10
20
|
export type CommitFn = (
|
|
11
21
|
mutation: Record<string, unknown>,
|
|
@@ -41,6 +51,54 @@ export function commitNode(
|
|
|
41
51
|
);
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
type NodeDrop = {
|
|
55
|
+
ref: MotionNodeRef;
|
|
56
|
+
at: { x: number; y: number };
|
|
57
|
+
animId: string;
|
|
58
|
+
anim: GsapAnimation | undefined;
|
|
59
|
+
selection: DomEditSelection | null;
|
|
60
|
+
iframe: HTMLIFrameElement | null;
|
|
61
|
+
commitMutation: CommitFn;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** What a node drop does, for its undo entry and its failure report. */
|
|
65
|
+
export function nodeDropLabel(ref: MotionNodeRef): string {
|
|
66
|
+
if (ref.type !== "keyframe") return "Move waypoint";
|
|
67
|
+
return usePlayerStore.getState().autoKeyframeEnabled ? "Move keyframe" : "Move animation path";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A dropped keyframe goes through the layer drag's writer, GSAP's live values backfilling others
|
|
71
|
+
* (auto-keyframe off, #1808: the whole path shifts); a waypoint moves in place. */
|
|
72
|
+
export function commitNodeDrop(drop: NodeDrop): Promise<void> {
|
|
73
|
+
const { ref, at, anim, selection, iframe, commitMutation } = drop;
|
|
74
|
+
if (ref.type !== "keyframe" || !anim || !selection)
|
|
75
|
+
return commitNode(ref, at.x, at.y, drop.animId, commitMutation);
|
|
76
|
+
const writes = observeGsapGesture((_sel, mutation, options) => commitMutation(mutation, options));
|
|
77
|
+
const callbacks = { commitMutation: writes.commit! };
|
|
78
|
+
let done: Promise<unknown>;
|
|
79
|
+
const store = usePlayerStore.getState();
|
|
80
|
+
const step = ref.step == null ? undefined : anim.keyframes?.keyframes[ref.step];
|
|
81
|
+
const pct = step?.percentage ?? ref.pct;
|
|
82
|
+
if (store.autoKeyframeEnabled) {
|
|
83
|
+
const selected = store.activeKeyframePct;
|
|
84
|
+
store.setActiveKeyframePct(pct);
|
|
85
|
+
const live = readGsapPositionFromIframe(iframe, selectorFor(selection) ?? "");
|
|
86
|
+
done = commitValueAtPlayhead(selection, anim, at, iframe, callbacks, {
|
|
87
|
+
label: nodeDropLabel(ref),
|
|
88
|
+
backfill: live ?? undefined,
|
|
89
|
+
})
|
|
90
|
+
.then(assertGsapEditPersisted)
|
|
91
|
+
.catch((error: unknown) => {
|
|
92
|
+
usePlayerStore.getState().setActiveKeyframePct(selected);
|
|
93
|
+
throw error;
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
const label = nodeDropLabel(ref);
|
|
97
|
+
done = commitWholePropertyOffset(selection, anim, at, pct, iframe, callbacks, label);
|
|
98
|
+
}
|
|
99
|
+
return done.then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
|
|
100
|
+
}
|
|
101
|
+
|
|
44
102
|
export function commitAddWaypoint(
|
|
45
103
|
animationId: string,
|
|
46
104
|
index: number,
|
|
@@ -182,3 +182,16 @@ describe("start", () => {
|
|
|
182
182
|
);
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
it("an array step's node carries its step, so a drop finds that keyframe whatever its time", () => {
|
|
187
|
+
const read = {
|
|
188
|
+
keyframes: [
|
|
189
|
+
{ percentage: 0, properties: { x: 60 }, step: 0 },
|
|
190
|
+
{ percentage: 100, properties: { x: 120 }, step: 1 },
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
expect(buildMotionPathGeometry(read)!.nodes.map((n) => n.ref)).toEqual([
|
|
194
|
+
{ type: "keyframe", pct: 0, step: 0 },
|
|
195
|
+
{ type: "keyframe", pct: 100, step: 1 },
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
@@ -8,7 +8,7 @@ import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
|
|
|
8
8
|
|
|
9
9
|
/** Which source edit a dragged node maps to. */
|
|
10
10
|
export type MotionNodeRef =
|
|
11
|
-
| { type: "keyframe"; pct: number } // x/y
|
|
11
|
+
| { type: "keyframe"; pct: number; step?: number } // x/y keyframe at this tween-relative %, array slot
|
|
12
12
|
| { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
|
|
13
13
|
|
|
14
14
|
/** An offset on the path and the layout width/height set there, when one is. */
|
|
@@ -141,7 +141,9 @@ export function buildMotionPathGeometry(
|
|
|
141
141
|
if (!at) return;
|
|
142
142
|
nodes.push({
|
|
143
143
|
...at,
|
|
144
|
-
ref: isArc
|
|
144
|
+
ref: isArc
|
|
145
|
+
? { type: "waypoint", index: i }
|
|
146
|
+
: { type: "keyframe", pct: kf.percentage, ...(kf.step == null ? {} : { step: kf.step }) },
|
|
145
147
|
});
|
|
146
148
|
});
|
|
147
149
|
|
|
@@ -12,7 +12,8 @@ import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
|
12
12
|
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
13
13
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
14
14
|
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
|
|
15
|
-
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
15
|
+
import { GsapEditBlockedError, type PlayheadEditRefusal } from "./gsapEditOutcome";
|
|
16
|
+
import { isTweenConfigKey } from "@hyperframes/parsers/gsap-constants";
|
|
16
17
|
export {
|
|
17
18
|
findExistingPositionWrite,
|
|
18
19
|
findRotationSetAnimation,
|
|
@@ -117,6 +118,36 @@ export async function materializeIfDynamic(
|
|
|
117
118
|
throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
/** Why a percentage keyframe can't stand in for this array step entry, or null when it can. */
|
|
122
|
+
function stepBlock([key, value]: [string, number | string]): PlayheadEditRefusal | null {
|
|
123
|
+
if (key === "delay") return "array-step-delay";
|
|
124
|
+
if (/^on[A-Z]/.test(key)) return "array-step-callback";
|
|
125
|
+
if (isTweenConfigKey(key)) return "array-step-config";
|
|
126
|
+
if (typeof value === "number") return null;
|
|
127
|
+
return STEP_VALUE_BLOCKS.find(([pattern]) => pattern.test(value))?.[1] ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** First match wins: code is computed, even when it calls random(). */
|
|
131
|
+
const STEP_VALUE_BLOCKS: Array<[RegExp, PlayheadEditRefusal]> = [
|
|
132
|
+
[/^__raw:/, "array-step-computed"],
|
|
133
|
+
[/random\(/, "array-step-random"],
|
|
134
|
+
[/[-+*/]=/, "array-step-relative"],
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
/** Why a step list can't be rewritten as percentage keyframes, or null. */
|
|
138
|
+
export function stepListBlock(anim: GsapAnimation): PlayheadEditRefusal | null {
|
|
139
|
+
const data = anim.keyframes;
|
|
140
|
+
if (data?.format !== "object-array") return null;
|
|
141
|
+
const entries = data.keyframes.flatMap((kf) => Object.entries(kf.properties));
|
|
142
|
+
return entries.map(stepBlock).find(Boolean) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Whole-offset writers rewrite every step as a keyframe; refuse a list that holds more than values. */
|
|
146
|
+
export function refuseStepListRewrite(anim: GsapAnimation): void {
|
|
147
|
+
const step = stepListBlock(anim);
|
|
148
|
+
if (step) throw new GsapEditBlockedError("keyframes-uneditable", step);
|
|
149
|
+
}
|
|
150
|
+
|
|
120
151
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
121
152
|
|
|
122
153
|
/**
|
|
@@ -330,6 +361,7 @@ export async function commitWholePathOffset(
|
|
|
330
361
|
// fallow-ignore-next-line code-duplication
|
|
331
362
|
let effectiveAnim = anim;
|
|
332
363
|
if (anim.keyframes) {
|
|
364
|
+
refuseStepListRewrite(anim);
|
|
333
365
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
334
366
|
if (newId) effectiveAnim = { ...anim, id: newId };
|
|
335
367
|
}
|
|
@@ -15,7 +15,7 @@ export type GsapEditBlockReason =
|
|
|
15
15
|
* true for `provenance-runtime-dynamic`; the others are parser or source-match
|
|
16
16
|
* limits, where the animation may well be plain authored source.
|
|
17
17
|
*
|
|
18
|
-
* Telemetry
|
|
18
|
+
* Telemetry, plus the sharper message a few details get (GSAP_EDIT_DETAIL_COPY).
|
|
19
19
|
*/
|
|
20
20
|
export type GsapEditBlockDetail =
|
|
21
21
|
| "provenance-runtime-dynamic"
|
|
@@ -33,6 +33,12 @@ export type GsapEditBlockDetail =
|
|
|
33
33
|
export type PlayheadEditRefusal =
|
|
34
34
|
| "eased-keyframes"
|
|
35
35
|
| "simple-array-keyframes"
|
|
36
|
+
| "array-step-delay"
|
|
37
|
+
| "array-step-callback"
|
|
38
|
+
| "array-step-config"
|
|
39
|
+
| "array-step-computed"
|
|
40
|
+
| "array-step-relative"
|
|
41
|
+
| "array-step-random"
|
|
36
42
|
| "unknown-ease"
|
|
37
43
|
| "implicit-end-unknown"
|
|
38
44
|
| "not-a-tween"
|
|
@@ -74,12 +80,31 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
|
|
|
74
80
|
"These layers are animated in different files. Move each file's layers separately.",
|
|
75
81
|
};
|
|
76
82
|
|
|
83
|
+
const STEP_LIST = "This animation's keyframes have a step";
|
|
84
|
+
const IN_CODE = "Edit them in the Code tab.";
|
|
85
|
+
/** A refusal a person can act on more precisely than its reason's message: what blocks it, then what to do. */
|
|
86
|
+
const GSAP_EDIT_DETAIL_COPY: Partial<Record<GsapEditBlockDetail, string>> = {
|
|
87
|
+
"array-step-delay": `${STEP_LIST} with its own delay, which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
88
|
+
"array-step-callback": `${STEP_LIST} that runs code as it plays (like onComplete), which an edit would run again. ${IN_CODE}`,
|
|
89
|
+
"array-step-config": `${STEP_LIST} with its own tween setting (like repeat or stagger), which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
90
|
+
"array-step-computed": `${STEP_LIST} whose value comes from code, which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
91
|
+
"array-step-relative": `${STEP_LIST} with a relative value (like "+=40"), which an edit would apply again. ${IN_CODE}`,
|
|
92
|
+
"array-step-random": `${STEP_LIST} with a random() value, which an edit would roll again. ${IN_CODE}`,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function gsapEditBlockMessage(
|
|
96
|
+
reason: GsapEditBlockReason,
|
|
97
|
+
detail?: GsapEditBlockDetail,
|
|
98
|
+
): string {
|
|
99
|
+
return (detail && GSAP_EDIT_DETAIL_COPY[detail]) ?? GSAP_EDIT_BLOCK_COPY[reason];
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
export class GsapEditBlockedError extends Error {
|
|
78
103
|
constructor(
|
|
79
104
|
readonly reason: GsapEditBlockReason,
|
|
80
105
|
readonly detail?: GsapEditBlockDetail,
|
|
81
106
|
) {
|
|
82
|
-
super(
|
|
107
|
+
super(gsapEditBlockMessage(reason, detail));
|
|
83
108
|
this.name = "GsapEditBlockedError";
|
|
84
109
|
}
|
|
85
110
|
}
|
|
@@ -22,7 +22,7 @@ export function liveTween(
|
|
|
22
22
|
vars: tween.vars,
|
|
23
23
|
_from: from,
|
|
24
24
|
...(head && { _pt: { d: { _pt: head } } }),
|
|
25
|
-
...(parts && { timeline: { getChildren: () => parts } }),
|
|
25
|
+
...(parts && { timeline: { getChildren: () => parts, duration: () => tween.duration } }),
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -47,6 +47,33 @@ export function previewWith(
|
|
|
47
47
|
export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
|
|
48
48
|
({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
|
|
49
49
|
|
|
50
|
+
/** `#box`'s position as a keyframe step list, parsed and run by GSAP (step `[duration, props]`). */
|
|
51
|
+
export function boxSteps(steps: Array<[number, Record<string, number | string>]>) {
|
|
52
|
+
const duration = steps.reduce((sum, [d]) => sum + d, 0);
|
|
53
|
+
let end = 0;
|
|
54
|
+
const keyframes = steps.map(([d, properties]) => {
|
|
55
|
+
end += d;
|
|
56
|
+
return { percentage: Math.round((end / duration) * 1000) / 10, properties };
|
|
57
|
+
});
|
|
58
|
+
const keys = tween({
|
|
59
|
+
id: "#box-to-0-position",
|
|
60
|
+
method: "to",
|
|
61
|
+
properties: {},
|
|
62
|
+
resolvedStart: 0,
|
|
63
|
+
duration,
|
|
64
|
+
keyframes: { format: "object-array", keyframes },
|
|
65
|
+
});
|
|
66
|
+
let start = 0;
|
|
67
|
+
const parts = steps.map(([d]) => {
|
|
68
|
+
const at = start;
|
|
69
|
+
start += d;
|
|
70
|
+
return { startTime: () => at, duration: () => d };
|
|
71
|
+
});
|
|
72
|
+
const live = (el: Element) =>
|
|
73
|
+
liveTween(el, { start: 0, duration, vars: { keyframes: [] } }, { parts });
|
|
74
|
+
return { keys, live };
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
/** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */
|
|
51
78
|
export function elTween(
|
|
52
79
|
id: string,
|