@hyperframes/studio 0.8.126 → 0.8.128
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-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
- package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +2184 -1745
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
- package/src/components/editor/MotionPathOverlay.tsx +20 -26
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- 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/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +41 -6
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapEditOutcome.ts +27 -2
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +27 -7
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +49 -35
- package/src/hooks/gsapShared.ts +35 -33
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
- package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
- package/src/hooks/gsapValueAtPlayhead.ts +49 -10
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +62 -42
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
|
@@ -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
|
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
import { studioApiFetch } from "../../utils/studioApiFetch";
|
|
3
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
3
4
|
|
|
4
|
-
type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
|
|
5
|
+
export type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
|
|
6
|
+
|
|
7
|
+
export function knownSourceAnswer(
|
|
8
|
+
previous: DomEditSelection | null | undefined,
|
|
9
|
+
element: HTMLElement,
|
|
10
|
+
sourceFile: string,
|
|
11
|
+
target: ProbeTarget,
|
|
12
|
+
): boolean | undefined {
|
|
13
|
+
if (!previous || previous.element !== element || previous.sourceFile !== sourceFile) return;
|
|
14
|
+
const same =
|
|
15
|
+
previous.id === target.id &&
|
|
16
|
+
previous.hfId === target.hfId &&
|
|
17
|
+
previous.selector === target.selector &&
|
|
18
|
+
previous.selectorIndex === target.selectorIndex;
|
|
19
|
+
return same ? previous.existsInSource : undefined;
|
|
20
|
+
}
|
|
5
21
|
|
|
6
22
|
interface PendingProbe {
|
|
7
23
|
target: ProbeTarget;
|
|
@@ -79,24 +79,14 @@ function mountMirrorHarness(history: {
|
|
|
79
79
|
moveCoalesceKeys: string[];
|
|
80
80
|
records: RecordedWrite[];
|
|
81
81
|
}) {
|
|
82
|
-
const record = (
|
|
83
|
-
label: string,
|
|
84
|
-
coalesceKey: string,
|
|
85
|
-
_coalesceMs: number | undefined,
|
|
86
|
-
after: string,
|
|
87
|
-
) => {
|
|
82
|
+
const record = (label: string, coalesceKey: string, after: string) => {
|
|
88
83
|
history.records.push({ label, coalesceKey, after });
|
|
89
84
|
history.fileContent.current = after;
|
|
90
85
|
};
|
|
91
86
|
|
|
92
|
-
const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (
|
|
93
|
-
_edits,
|
|
94
|
-
coalesceKey,
|
|
95
|
-
_operation,
|
|
96
|
-
coalesceMs,
|
|
97
|
-
) => {
|
|
87
|
+
const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (_edits, coalesceKey) => {
|
|
98
88
|
history.moveCoalesceKeys.push(coalesceKey ?? "<none>");
|
|
99
|
-
record("Move timeline clips", coalesceKey ?? "<none>",
|
|
89
|
+
record("Move timeline clips", coalesceKey ?? "<none>", "C-move");
|
|
100
90
|
};
|
|
101
91
|
|
|
102
92
|
const api: Partial<HarnessApi> = {};
|
|
@@ -104,7 +94,7 @@ function mountMirrorHarness(history: {
|
|
|
104
94
|
const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
|
|
105
95
|
makeLifecycleOpsParams({
|
|
106
96
|
commitDomEditPatchBatches: async (_batches, options) => {
|
|
107
|
-
record(options.label, options.coalesceKey,
|
|
97
|
+
record(options.label, options.coalesceKey, "B-z");
|
|
108
98
|
return { durable: true, allMatched: true, changed: true };
|
|
109
99
|
},
|
|
110
100
|
}),
|
|
@@ -163,13 +163,6 @@ function useMirrorLaneMoveCommit(): (
|
|
|
163
163
|
// stacking sync must not re-trigger and fight the just-set z values.
|
|
164
164
|
},
|
|
165
165
|
coalesceKey,
|
|
166
|
-
// Unbounded fold window: this record lands only AFTER the z persist's
|
|
167
|
-
// server round-trip resolved, so the gap between the gesture's two
|
|
168
|
-
// records exceeds editHistory's 300ms default under real latency and
|
|
169
|
-
// the fold would silently split into two undo entries. The shared key
|
|
170
|
-
// is unique per gesture (zReorderCoalesceKey's gesture seq), so the
|
|
171
|
-
// unbounded window can never merge two distinct user actions.
|
|
172
|
-
Number.POSITIVE_INFINITY,
|
|
173
166
|
);
|
|
174
167
|
},
|
|
175
168
|
[onMoveElements],
|
|
@@ -679,6 +679,33 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
679
679
|
view.unmount();
|
|
680
680
|
});
|
|
681
681
|
|
|
682
|
+
it("retimes a duration-less tween's keyframe in the window GSAP plays it, not the clip", async () => {
|
|
683
|
+
const durationless = { ...authoredInteriorAnimation(), duration: undefined };
|
|
684
|
+
mocks.animations = [durationless];
|
|
685
|
+
usePlayerStore.setState({ gsapAnimations: new Map([["box", [durationless]]]) });
|
|
686
|
+
const view = renderCallbacks();
|
|
687
|
+
|
|
688
|
+
// GSAP plays it 0-0.5 s of the 1 s clip, so a drop at 0.4 s is 80% of the tween.
|
|
689
|
+
await view.callbacks.onMoveKeyframe?.(
|
|
690
|
+
"box",
|
|
691
|
+
{
|
|
692
|
+
percentage: 25,
|
|
693
|
+
propertyGroup: "position",
|
|
694
|
+
tweenPercentage: 50,
|
|
695
|
+
animationId: durationless.id,
|
|
696
|
+
},
|
|
697
|
+
40,
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
|
701
|
+
durationless.id,
|
|
702
|
+
50,
|
|
703
|
+
80,
|
|
704
|
+
mocks.selection,
|
|
705
|
+
);
|
|
706
|
+
view.unmount();
|
|
707
|
+
});
|
|
708
|
+
|
|
682
709
|
// A drag starts on whatever diamond the pointer is over, which need not be the
|
|
683
710
|
// selected element. Resolving against the selection would retime the selected
|
|
684
711
|
// element's tween and commit it through the selected element's file.
|
|
@@ -777,46 +804,4 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
777
804
|
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
|
|
778
805
|
view.unmount();
|
|
779
806
|
});
|
|
780
|
-
|
|
781
|
-
it("uses the clip timing basis when retiming a duration-less tween", async () => {
|
|
782
|
-
const durationless = {
|
|
783
|
-
...authoredInteriorAnimation(),
|
|
784
|
-
position: 3.2,
|
|
785
|
-
resolvedStart: 3.2,
|
|
786
|
-
duration: undefined,
|
|
787
|
-
};
|
|
788
|
-
const wideElement = { ...element, start: 10.94, duration: 16.26 };
|
|
789
|
-
mocks.animations = [durationless];
|
|
790
|
-
usePlayerStore.setState({
|
|
791
|
-
elements: [wideElement],
|
|
792
|
-
gsapAnimations: new Map([["box", [durationless]]]),
|
|
793
|
-
});
|
|
794
|
-
const view = renderCallbacks();
|
|
795
|
-
|
|
796
|
-
await expect(
|
|
797
|
-
view.callbacks.onMoveKeyframe?.(
|
|
798
|
-
"box",
|
|
799
|
-
{
|
|
800
|
-
percentage: 19.1,
|
|
801
|
-
propertyGroup: "position",
|
|
802
|
-
tweenPercentage: 50,
|
|
803
|
-
animationId: durationless.id,
|
|
804
|
-
},
|
|
805
|
-
40,
|
|
806
|
-
),
|
|
807
|
-
).resolves.toBe(true);
|
|
808
|
-
|
|
809
|
-
// The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
|
|
810
|
-
// 17.444s, and the duration-less tween borrows the clip's 16.26s window from
|
|
811
|
-
// its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
|
|
812
|
-
// zero-length tween, or the clip's own 0-100 %) produces a different number.
|
|
813
|
-
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
|
814
|
-
durationless.id,
|
|
815
|
-
50,
|
|
816
|
-
expect.closeTo(87.601, 3),
|
|
817
|
-
mocks.selection,
|
|
818
|
-
);
|
|
819
|
-
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
|
820
|
-
view.unmount();
|
|
821
|
-
});
|
|
822
807
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
useDomEditSelectionContext,
|
|
13
13
|
} from "../../contexts/DomEditContext";
|
|
14
14
|
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
|
|
15
|
+
import { runEaseOf } from "../../utils/gsapKeyframeEases";
|
|
15
16
|
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
|
|
16
17
|
import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
17
18
|
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
|
|
@@ -32,7 +33,6 @@ export interface TimelineEditCallbackDeps {
|
|
|
32
33
|
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
|
|
33
34
|
coalesceKey?: string,
|
|
34
35
|
operation?: TimelineMoveOperation,
|
|
35
|
-
coalesceMs?: number,
|
|
36
36
|
) => Promise<void> | void;
|
|
37
37
|
handleTimelineElementResize: (
|
|
38
38
|
element: TimelineElement,
|
|
@@ -332,7 +332,7 @@ export function useTimelineEditCallbacks({
|
|
|
332
332
|
elements,
|
|
333
333
|
domClipChildren,
|
|
334
334
|
);
|
|
335
|
-
const tweenDuration = resolveTweenDuration(anim
|
|
335
|
+
const tweenDuration = resolveTweenDuration(anim);
|
|
336
336
|
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
|
|
337
337
|
const decision = resolveKeyframeRetime({
|
|
338
338
|
keyframes: anim.keyframes?.keyframes ?? [],
|
|
@@ -340,6 +340,7 @@ export function useTimelineEditCallbacks({
|
|
|
340
340
|
tweenStart,
|
|
341
341
|
tweenDuration,
|
|
342
342
|
dropAbsTime,
|
|
343
|
+
runEase: runEaseOf(anim),
|
|
343
344
|
});
|
|
344
345
|
if (decision.kind === "move" && decision.toTweenPct != null) {
|
|
345
346
|
return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
|
|
@@ -5,14 +5,19 @@
|
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
percentageToAbsoluteForAnimation,
|
|
10
|
+
resolveTweenStart,
|
|
11
|
+
resolveTweenDuration,
|
|
12
|
+
} from "../utils/globalTimeCompiler";
|
|
9
13
|
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
|
10
14
|
import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
|
|
11
15
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
12
16
|
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
13
17
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
14
18
|
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
|
|
15
|
-
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
19
|
+
import { GsapEditBlockedError, type PlayheadEditRefusal } from "./gsapEditOutcome";
|
|
20
|
+
import { isTweenConfigKey } from "@hyperframes/parsers/gsap-constants";
|
|
16
21
|
export {
|
|
17
22
|
findExistingPositionWrite,
|
|
18
23
|
findRotationSetAnimation,
|
|
@@ -53,10 +58,9 @@ export function computeCurrentPercentage(
|
|
|
53
58
|
// 1.2 start), so the post-commit reseek renders the element's base pose and the edit
|
|
54
59
|
// looks like it snapped away. Keeping the playhead on the edited keyframe avoids that.
|
|
55
60
|
export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td));
|
|
61
|
+
const time = percentageToAbsoluteForAnimation(pct, anim);
|
|
62
|
+
if (time === null || resolveTweenDuration(anim) <= 0) return;
|
|
63
|
+
usePlayerStore.getState().requestSeek(roundTo3(time));
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
async function replaceKeyframedPositionHold(
|
|
@@ -117,6 +121,36 @@ export async function materializeIfDynamic(
|
|
|
117
121
|
throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/** Why a percentage keyframe can't stand in for this array step entry, or null when it can. */
|
|
125
|
+
function stepBlock([key, value]: [string, number | string]): PlayheadEditRefusal | null {
|
|
126
|
+
if (key === "delay") return "array-step-delay";
|
|
127
|
+
if (/^on[A-Z]/.test(key)) return "array-step-callback";
|
|
128
|
+
if (isTweenConfigKey(key)) return "array-step-config";
|
|
129
|
+
if (typeof value === "number") return null;
|
|
130
|
+
return STEP_VALUE_BLOCKS.find(([pattern]) => pattern.test(value))?.[1] ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** First match wins: code is computed, even when it calls random(). */
|
|
134
|
+
const STEP_VALUE_BLOCKS: Array<[RegExp, PlayheadEditRefusal]> = [
|
|
135
|
+
[/^__raw:/, "array-step-computed"],
|
|
136
|
+
[/random\(/, "array-step-random"],
|
|
137
|
+
[/[-+*/]=/, "array-step-relative"],
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
/** Why a step list can't be rewritten as percentage keyframes, or null. */
|
|
141
|
+
export function stepListBlock(anim: GsapAnimation): PlayheadEditRefusal | null {
|
|
142
|
+
const data = anim.keyframes;
|
|
143
|
+
if (data?.format !== "object-array") return null;
|
|
144
|
+
const entries = data.keyframes.flatMap((kf) => Object.entries(kf.properties));
|
|
145
|
+
return entries.map(stepBlock).find(Boolean) ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Whole-offset writers rewrite every step as a keyframe; refuse a list that holds more than values. */
|
|
149
|
+
export function refuseStepListRewrite(anim: GsapAnimation): void {
|
|
150
|
+
const step = stepListBlock(anim);
|
|
151
|
+
if (step) throw new GsapEditBlockedError("keyframes-uneditable", step);
|
|
152
|
+
}
|
|
153
|
+
|
|
120
154
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
121
155
|
|
|
122
156
|
/**
|
|
@@ -330,6 +364,7 @@ export async function commitWholePathOffset(
|
|
|
330
364
|
// fallow-ignore-next-line code-duplication
|
|
331
365
|
let effectiveAnim = anim;
|
|
332
366
|
if (anim.keyframes) {
|
|
367
|
+
refuseStepListRewrite(anim);
|
|
333
368
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
334
369
|
if (newId) effectiveAnim = { ...anim, id: newId };
|
|
335
370
|
}
|