@hyperframes/studio 0.7.76 → 0.7.78
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-DsRdxz7A.js → hyperframes-player-uiAAPKvw.js} +1 -1
- package/dist/assets/index-BSZrK0bx.js +428 -0
- package/dist/assets/{index-Du1RoLiB.js → index-CDSTMtUs.js} +1 -1
- package/dist/assets/index-DpkO2Hvw.css +1 -0
- package/dist/assets/{index-BLICKger.js → index-r1tKKlU7.js} +1 -1
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +25030 -23214
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +66 -32
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-BdOfFsR8.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -19,11 +19,21 @@ afterEach(() => {
|
|
|
19
19
|
|
|
20
20
|
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
|
|
21
21
|
|
|
22
|
+
function successfulCommitMutation() {
|
|
23
|
+
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
|
|
24
|
+
const options = args[2] as {
|
|
25
|
+
onResult?: (result: { ok: boolean; changed: boolean }) => void;
|
|
26
|
+
};
|
|
27
|
+
options.onResult?.({ ok: true, changed: true });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
function renderKeyframeOps(over: {
|
|
23
32
|
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
|
24
33
|
trackGsapSaveFailure: (...args: unknown[]) => void;
|
|
25
34
|
}) {
|
|
26
35
|
const captured: { api: HookApi | null } = { api: null };
|
|
36
|
+
// This hook harness intentionally mirrors the separate script-commit harness.
|
|
27
37
|
function Probe() {
|
|
28
38
|
// fallow-ignore-next-line code-duplication
|
|
29
39
|
captured.api = useGsapKeyframeOps({
|
|
@@ -49,11 +59,21 @@ function renderKeyframeOps(over: {
|
|
|
49
59
|
return captured.api;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
async function moveKeyframeWith(
|
|
63
|
+
commitMutation: (...args: unknown[]) => Promise<unknown>,
|
|
64
|
+
): Promise<{ committed: boolean; trackGsapSaveFailure: ReturnType<typeof vi.fn> }> {
|
|
65
|
+
const trackGsapSaveFailure = vi.fn();
|
|
66
|
+
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
|
67
|
+
let committed = true;
|
|
68
|
+
await act(async () => {
|
|
69
|
+
committed = await api.moveKeyframe(selection, "box-to-0-position", 50, 75);
|
|
70
|
+
});
|
|
71
|
+
return { committed, trackGsapSaveFailure };
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
53
75
|
it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
|
|
54
|
-
const commitMutation =
|
|
55
|
-
ok: true,
|
|
56
|
-
}));
|
|
76
|
+
const commitMutation = successfulCommitMutation();
|
|
57
77
|
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
|
58
78
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
|
59
79
|
|
|
@@ -61,8 +81,9 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
|
61
81
|
{ from: 0, to: 0 },
|
|
62
82
|
{ from: 100, to: 100 },
|
|
63
83
|
];
|
|
84
|
+
let committed = false;
|
|
64
85
|
await act(async () => {
|
|
65
|
-
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
|
86
|
+
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
|
66
87
|
});
|
|
67
88
|
|
|
68
89
|
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
@@ -76,6 +97,7 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
|
76
97
|
pctRemap,
|
|
77
98
|
});
|
|
78
99
|
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
|
100
|
+
expect(committed).toBe(true);
|
|
79
101
|
});
|
|
80
102
|
|
|
81
103
|
it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => {
|
|
@@ -86,10 +108,11 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
|
86
108
|
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
|
87
109
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
|
88
110
|
|
|
111
|
+
let committed = true;
|
|
89
112
|
await act(async () => {
|
|
90
|
-
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [
|
|
114
|
+
{ from: 100, to: 100 },
|
|
115
|
+
]);
|
|
93
116
|
});
|
|
94
117
|
|
|
95
118
|
expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1);
|
|
@@ -98,14 +121,72 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
|
98
121
|
expect(selArg).toBe(selection);
|
|
99
122
|
expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween");
|
|
100
123
|
expect(labelArg).toBe("Retime keyframe (resize tween)");
|
|
124
|
+
expect(committed).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
|
|
129
|
+
it("returns false when the commit settles without a durable writer result", async () => {
|
|
130
|
+
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(vi.fn(async () => {}));
|
|
131
|
+
|
|
132
|
+
expect(committed).toBe(false);
|
|
133
|
+
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns false when the writer accepts but does not change the keyframe", async () => {
|
|
137
|
+
const commitMutation = vi.fn(async (...args: unknown[]) => {
|
|
138
|
+
const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
|
|
139
|
+
options.onResult?.({ ok: true, changed: false });
|
|
140
|
+
});
|
|
141
|
+
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
|
142
|
+
|
|
143
|
+
expect(committed).toBe(false);
|
|
144
|
+
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns false and tracks a rejected move", async () => {
|
|
148
|
+
const error = new Error("write failed");
|
|
149
|
+
const commitMutation = vi.fn().mockRejectedValue(error);
|
|
150
|
+
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
|
151
|
+
|
|
152
|
+
expect(committed).toBe(false);
|
|
153
|
+
expect(trackGsapSaveFailure).toHaveBeenCalledExactlyOnceWith(
|
|
154
|
+
error,
|
|
155
|
+
selection,
|
|
156
|
+
{
|
|
157
|
+
type: "move-keyframe",
|
|
158
|
+
animationId: "box-to-0-position",
|
|
159
|
+
fromPercentage: 50,
|
|
160
|
+
toPercentage: 75,
|
|
161
|
+
},
|
|
162
|
+
"Move keyframe to 75%",
|
|
163
|
+
);
|
|
101
164
|
});
|
|
102
165
|
});
|
|
103
166
|
|
|
104
167
|
describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
|
168
|
+
it("routes a flat-lane add through the add-keyframe writer mutation", async () => {
|
|
169
|
+
const commitMutation = successfulCommitMutation();
|
|
170
|
+
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
|
171
|
+
|
|
172
|
+
await act(async () => {
|
|
173
|
+
await api.addKeyframeBatch(selection, "box-to-0-position", 50, { x: 210 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
177
|
+
selection,
|
|
178
|
+
{
|
|
179
|
+
type: "add-keyframe",
|
|
180
|
+
animationId: "box-to-0-position",
|
|
181
|
+
percentage: 50,
|
|
182
|
+
properties: { x: 210 },
|
|
183
|
+
},
|
|
184
|
+
{ label: "Add keyframe at 50%", softReload: true },
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
105
188
|
it("soft-reloads a standalone convert when the SDK path is unavailable", async () => {
|
|
106
|
-
const commitMutation =
|
|
107
|
-
ok: true,
|
|
108
|
-
}));
|
|
189
|
+
const commitMutation = successfulCommitMutation();
|
|
109
190
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
|
110
191
|
|
|
111
192
|
await act(async () => {
|
|
@@ -123,9 +204,7 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
|
|
123
204
|
});
|
|
124
205
|
|
|
125
206
|
it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
|
|
126
|
-
const commitMutation =
|
|
127
|
-
ok: true,
|
|
128
|
-
}));
|
|
207
|
+
const commitMutation = successfulCommitMutation();
|
|
129
208
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
|
130
209
|
const coalesceKey = "enable-keyframes:box-to-0-opacity:1";
|
|
131
210
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../utils/sdkCutover";
|
|
16
16
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
17
17
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
18
|
+
import { idFromSelector } from "./gsapShared";
|
|
18
19
|
import {
|
|
19
20
|
clearKeyframeCacheForElement,
|
|
20
21
|
readKeyframeSnapshot,
|
|
@@ -236,7 +237,7 @@ export function useGsapKeyframeOps({
|
|
|
236
237
|
);
|
|
237
238
|
|
|
238
239
|
const moveKeyframe = useCallback(
|
|
239
|
-
(
|
|
240
|
+
async (
|
|
240
241
|
selection: DomEditSelection,
|
|
241
242
|
animationId: string,
|
|
242
243
|
fromPercentage: number,
|
|
@@ -247,18 +248,26 @@ export function useGsapKeyframeOps({
|
|
|
247
248
|
// updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no
|
|
248
249
|
// optimistic cache write is needed (mapping the tween-% to clip-% here would
|
|
249
250
|
// duplicate that math). softReload mirrors remove-keyframe.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
try {
|
|
252
|
+
let changed = false;
|
|
253
|
+
await commitMutation(selection, mutation, {
|
|
254
|
+
label: `Move keyframe to ${toPercentage}%`,
|
|
255
|
+
softReload: true,
|
|
256
|
+
onResult: (result) => {
|
|
257
|
+
changed = result.changed !== false;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return changed;
|
|
261
|
+
} catch (error) {
|
|
254
262
|
trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`);
|
|
255
|
-
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
256
265
|
},
|
|
257
266
|
[commitMutation, trackGsapSaveFailure],
|
|
258
267
|
);
|
|
259
268
|
|
|
260
269
|
const resizeKeyframedTween = useCallback(
|
|
261
|
-
(
|
|
270
|
+
async (
|
|
262
271
|
selection: DomEditSelection,
|
|
263
272
|
animationId: string,
|
|
264
273
|
position: number,
|
|
@@ -275,12 +284,20 @@ export function useGsapKeyframeOps({
|
|
|
275
284
|
// Boundary drag-to-retime: the server re-keys keyframes in place + grows the
|
|
276
285
|
// tween window, preserving _auto / per-keyframe ease / easeEach / outer ease.
|
|
277
286
|
// softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe).
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
287
|
+
try {
|
|
288
|
+
let changed = false;
|
|
289
|
+
await commitMutation(selection, mutation, {
|
|
290
|
+
label: "Retime keyframe (resize tween)",
|
|
291
|
+
softReload: true,
|
|
292
|
+
onResult: (result) => {
|
|
293
|
+
changed = result.changed !== false;
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
return changed;
|
|
297
|
+
} catch (error) {
|
|
282
298
|
trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)");
|
|
283
|
-
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
284
301
|
},
|
|
285
302
|
[commitMutation, trackGsapSaveFailure],
|
|
286
303
|
);
|
|
@@ -322,7 +339,7 @@ export function useGsapKeyframeOps({
|
|
|
322
339
|
// remove-all-keyframes collapses the tween to a static hold and the commit
|
|
323
340
|
// path doesn't return parsed animations, so the keyframe cache is never
|
|
324
341
|
// refreshed — clear it here so the timeline diamonds disappear immediately.
|
|
325
|
-
const elementId = selection.id ?? selection.selector
|
|
342
|
+
const elementId = selection.id ?? idFromSelector(selection.selector);
|
|
326
343
|
if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
|
|
327
344
|
if (sdkSession && sdkDeps) {
|
|
328
345
|
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
|
@@ -298,6 +298,27 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
298
298
|
expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
+
it("publishes the server mutation outcome to callers", async () => {
|
|
302
|
+
mockFetchResult({ changed: false });
|
|
303
|
+
const deps = renderCommitHook();
|
|
304
|
+
let commitResult: MutationResult | undefined;
|
|
305
|
+
|
|
306
|
+
await act(async () => {
|
|
307
|
+
await deps.api.commitMutation(
|
|
308
|
+
selection,
|
|
309
|
+
{ type: "move-keyframe", fromPercentage: 50, toPercentage: 75 },
|
|
310
|
+
{
|
|
311
|
+
label: "Move keyframe",
|
|
312
|
+
onResult: (result) => {
|
|
313
|
+
commitResult = result;
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(commitResult).toEqual(expect.objectContaining({ ok: true, changed: false }));
|
|
320
|
+
});
|
|
321
|
+
|
|
301
322
|
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
|
|
302
323
|
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
303
324
|
mockFetchResult({ changed: false });
|
|
@@ -338,6 +338,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
338
338
|
mutateGsapScript(pid, targetPath, mutation),
|
|
339
339
|
);
|
|
340
340
|
if (!result) return;
|
|
341
|
+
options.onResult?.(result);
|
|
341
342
|
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
|
|
342
343
|
}, [showToast, finalizeSuccessfulMutation]);
|
|
343
344
|
|
|
@@ -350,6 +351,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
350
351
|
mutateGsapScriptBatch(pid, targetPath, mutations),
|
|
351
352
|
);
|
|
352
353
|
if (!result) return;
|
|
354
|
+
options.onResult?.(result);
|
|
353
355
|
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
|
|
354
356
|
}, [showToast, finalizeSuccessfulMutation]);
|
|
355
357
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
8
|
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
|
|
7
9
|
|
|
8
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -37,8 +39,8 @@ function makeParams(overrides: Partial<Params> = {}): Params {
|
|
|
37
39
|
addKeyframe: vi.fn(),
|
|
38
40
|
addKeyframeBatch: resolved(),
|
|
39
41
|
removeKeyframe: vi.fn(),
|
|
40
|
-
moveKeyframe: vi.fn(),
|
|
41
|
-
resizeKeyframedTween: vi.fn(),
|
|
42
|
+
moveKeyframe: vi.fn().mockResolvedValue(true),
|
|
43
|
+
resizeKeyframedTween: vi.fn().mockResolvedValue(true),
|
|
42
44
|
convertToKeyframes: resolved(),
|
|
43
45
|
removeAllKeyframes: resolved(),
|
|
44
46
|
handleDomManualEditsReset: vi.fn(),
|
|
@@ -79,7 +81,11 @@ describe("useGsapSelectionHandlers save failures", () => {
|
|
|
79
81
|
makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }),
|
|
80
82
|
);
|
|
81
83
|
|
|
82
|
-
|
|
84
|
+
// Braces, not a bare arrow: the handler returns its settlement promise now,
|
|
85
|
+
// and returning a thenable from act() turns it into an un-awaited async act.
|
|
86
|
+
act(() => {
|
|
87
|
+
void rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 });
|
|
88
|
+
});
|
|
83
89
|
await flushRejection();
|
|
84
90
|
|
|
85
91
|
expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
|
|
@@ -114,3 +120,63 @@ describe("useGsapSelectionHandlers save failures", () => {
|
|
|
114
120
|
rendered.unmount();
|
|
115
121
|
});
|
|
116
122
|
});
|
|
123
|
+
|
|
124
|
+
describe("useGsapSelectionHandlers selection override", () => {
|
|
125
|
+
it("aborts on an explicit null override instead of writing to the current selection", () => {
|
|
126
|
+
const removeKeyframe = vi.fn();
|
|
127
|
+
const rendered = renderHandlers(makeParams({ removeKeyframe }));
|
|
128
|
+
|
|
129
|
+
// Explicit null: the caller resolved a selection for its own element and
|
|
130
|
+
// found none, so the write must not land on the selected element.
|
|
131
|
+
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50, undefined, null);
|
|
132
|
+
expect(removeKeyframe).not.toHaveBeenCalled();
|
|
133
|
+
|
|
134
|
+
// Omitted override: falls back to the current selection as before.
|
|
135
|
+
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50);
|
|
136
|
+
expect(removeKeyframe).toHaveBeenCalledOnce();
|
|
137
|
+
rendered.unmount();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("computes the playhead percentage from the passed animation, not the selection's", () => {
|
|
141
|
+
const moveKeyframe = vi.fn();
|
|
142
|
+
const selection = makeSelection();
|
|
143
|
+
// The passed tween runs 2s→6s, so the playhead at 3s is 25% into IT. Without
|
|
144
|
+
// the animation the handler falls back to the selection's own element window
|
|
145
|
+
// (0s→1s here), which reads the same playhead as 100%. Asserting the exact
|
|
146
|
+
// 25 is what separates the two; `expect.any(Number)` even accepts the NaN a
|
|
147
|
+
// missing window would produce.
|
|
148
|
+
const animation = {
|
|
149
|
+
id: "anim-1",
|
|
150
|
+
position: 2,
|
|
151
|
+
resolvedStart: 2,
|
|
152
|
+
duration: 4,
|
|
153
|
+
keyframes: { keyframes: [] },
|
|
154
|
+
} as unknown as GsapAnimation;
|
|
155
|
+
usePlayerStore.setState({ currentTime: 3 });
|
|
156
|
+
const rendered = renderHandlers(makeParams({ moveKeyframe, selectedGsapAnimations: [] }));
|
|
157
|
+
|
|
158
|
+
rendered.handlers().handleGsapMoveKeyframeToPlayhead("anim-1", 50, selection, animation);
|
|
159
|
+
|
|
160
|
+
expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, 25);
|
|
161
|
+
rendered.unmount();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("useGsapSelectionHandlers retime settlement", () => {
|
|
166
|
+
it("returns false without a selection and forwards the mutation result with one", async () => {
|
|
167
|
+
const moveKeyframe = vi.fn().mockResolvedValue(true);
|
|
168
|
+
const withoutSelection = renderHandlers(makeParams({ domEditSelection: null, moveKeyframe }));
|
|
169
|
+
await expect(
|
|
170
|
+
withoutSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75),
|
|
171
|
+
).resolves.toBe(false);
|
|
172
|
+
expect(moveKeyframe).not.toHaveBeenCalled();
|
|
173
|
+
withoutSelection.unmount();
|
|
174
|
+
|
|
175
|
+
const withSelection = renderHandlers(makeParams({ moveKeyframe }));
|
|
176
|
+
await expect(withSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75)).resolves.toBe(
|
|
177
|
+
true,
|
|
178
|
+
);
|
|
179
|
+
expect(moveKeyframe).toHaveBeenCalledOnce();
|
|
180
|
+
withSelection.unmount();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -94,14 +94,14 @@ export function useGsapSelectionHandlers({
|
|
|
94
94
|
animId: string,
|
|
95
95
|
fromPercentage: number,
|
|
96
96
|
toPercentage: number,
|
|
97
|
-
) =>
|
|
97
|
+
) => Promise<boolean>;
|
|
98
98
|
resizeKeyframedTween: (
|
|
99
99
|
sel: DomEditSelection,
|
|
100
100
|
animId: string,
|
|
101
101
|
position: number,
|
|
102
102
|
duration: number,
|
|
103
103
|
pctRemap: Array<{ from: number; to: number }>,
|
|
104
|
-
) =>
|
|
104
|
+
) => Promise<boolean>;
|
|
105
105
|
convertToKeyframes: (
|
|
106
106
|
sel: DomEditSelection,
|
|
107
107
|
animId: string,
|
|
@@ -118,6 +118,19 @@ export function useGsapSelectionHandlers({
|
|
|
118
118
|
const lastSelectionRef = useRef<DomEditSelection | null>(null);
|
|
119
119
|
if (domEditSelection) lastSelectionRef.current = domEditSelection;
|
|
120
120
|
|
|
121
|
+
// `undefined` means the caller passed no override and accepts the current
|
|
122
|
+
// selection. An explicit `null` means the caller RESOLVED a selection for the
|
|
123
|
+
// element it is editing and there is none: falling back to domEditSelection
|
|
124
|
+
// there commits the edit onto whichever element happens to be selected, which
|
|
125
|
+
// is a different element's file. Only `undefined` may fall back.
|
|
126
|
+
const resolveWriteSelection = useCallback(
|
|
127
|
+
(selectionOverride?: DomEditSelection | null): DomEditSelection | null =>
|
|
128
|
+
selectionOverride === undefined
|
|
129
|
+
? (domEditSelection ?? lastSelectionRef.current)
|
|
130
|
+
: selectionOverride,
|
|
131
|
+
[domEditSelection],
|
|
132
|
+
);
|
|
133
|
+
|
|
121
134
|
const trackGsapHandlerFailure = useCallback(
|
|
122
135
|
(error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
|
|
123
136
|
trackStudioSaveFailure({
|
|
@@ -137,12 +150,24 @@ export function useGsapSelectionHandlers({
|
|
|
137
150
|
[showToast],
|
|
138
151
|
);
|
|
139
152
|
|
|
153
|
+
// Resolves to whether the mutation landed. Callers that only fire-and-forget
|
|
154
|
+
// can ignore it (the rejection is always handled here), but a caller that
|
|
155
|
+
// reports a commit result to the UI has to await the real settlement instead
|
|
156
|
+
// of assuming success the moment it dispatched.
|
|
140
157
|
const observeGsapMutation = useCallback(
|
|
141
|
-
(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
158
|
+
(
|
|
159
|
+
mutation: Promise<void>,
|
|
160
|
+
selection: DomEditSelection,
|
|
161
|
+
mutationType: string,
|
|
162
|
+
label: string,
|
|
163
|
+
): Promise<boolean> =>
|
|
164
|
+
mutation.then(
|
|
165
|
+
() => true,
|
|
166
|
+
(error: unknown) => {
|
|
167
|
+
trackGsapHandlerFailure(error, selection, mutationType, label);
|
|
168
|
+
return false;
|
|
169
|
+
},
|
|
170
|
+
),
|
|
146
171
|
[trackGsapHandlerFailure],
|
|
147
172
|
);
|
|
148
173
|
|
|
@@ -160,25 +185,25 @@ export function useGsapSelectionHandlers({
|
|
|
160
185
|
updates: { duration?: number; ease?: string; position?: number },
|
|
161
186
|
selectionOverride?: DomEditSelection | null,
|
|
162
187
|
) => {
|
|
163
|
-
const sel = selectionOverride
|
|
164
|
-
if (!sel) return;
|
|
165
|
-
observeGsapMutation(
|
|
188
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
189
|
+
if (!sel) return Promise.resolve(false);
|
|
190
|
+
return observeGsapMutation(
|
|
166
191
|
updateGsapMeta(sel, animId, updates),
|
|
167
192
|
sel,
|
|
168
193
|
"update-meta",
|
|
169
194
|
"Edit GSAP animation",
|
|
170
195
|
);
|
|
171
196
|
},
|
|
172
|
-
[
|
|
197
|
+
[resolveWriteSelection, observeGsapMutation, updateGsapMeta],
|
|
173
198
|
);
|
|
174
199
|
|
|
175
200
|
const handleGsapDeleteAnimation = useCallback(
|
|
176
|
-
(animId: string) => {
|
|
177
|
-
const sel =
|
|
201
|
+
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
|
202
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
178
203
|
if (!sel) return;
|
|
179
204
|
observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
|
|
180
205
|
},
|
|
181
|
-
[
|
|
206
|
+
[resolveWriteSelection, deleteGsapAnimation, observeGsapMutation],
|
|
182
207
|
);
|
|
183
208
|
|
|
184
209
|
const handleGsapDeleteAllForElement = useCallback(
|
|
@@ -284,12 +309,12 @@ export function useGsapSelectionHandlers({
|
|
|
284
309
|
value: number | string,
|
|
285
310
|
selectionOverride?: DomEditSelection | null,
|
|
286
311
|
) => {
|
|
287
|
-
const sel = selectionOverride
|
|
312
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
288
313
|
if (!sel) return;
|
|
289
314
|
trackStudioEvent("keyframe", { action: "add", property });
|
|
290
315
|
addKeyframe(sel, animId, percentage, property, value);
|
|
291
316
|
},
|
|
292
|
-
[
|
|
317
|
+
[resolveWriteSelection, addKeyframe],
|
|
293
318
|
);
|
|
294
319
|
|
|
295
320
|
const handleGsapAddKeyframeBatch = useCallback(
|
|
@@ -298,19 +323,17 @@ export function useGsapSelectionHandlers({
|
|
|
298
323
|
percentage: number,
|
|
299
324
|
properties: Record<string, number | string>,
|
|
300
325
|
commitOverrides?: Partial<CommitMutationOptions>,
|
|
326
|
+
selectionOverride?: DomEditSelection | null,
|
|
301
327
|
) => {
|
|
302
|
-
|
|
303
|
-
return
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
).catch((error) => {
|
|
310
|
-
trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe");
|
|
311
|
-
});
|
|
328
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
329
|
+
if (!sel) return Promise.resolve();
|
|
330
|
+
return addKeyframeBatch(sel, animId, percentage, properties, commitOverrides).catch(
|
|
331
|
+
(error) => {
|
|
332
|
+
trackGsapHandlerFailure(error, sel, "add-keyframe", "Add keyframe");
|
|
333
|
+
},
|
|
334
|
+
);
|
|
312
335
|
},
|
|
313
|
-
[
|
|
336
|
+
[resolveWriteSelection, addKeyframeBatch, trackGsapHandlerFailure],
|
|
314
337
|
);
|
|
315
338
|
const handleGsapRemoveKeyframe = useCallback(
|
|
316
339
|
(
|
|
@@ -319,26 +342,34 @@ export function useGsapSelectionHandlers({
|
|
|
319
342
|
commitOverrides?: Partial<CommitMutationOptions>,
|
|
320
343
|
selectionOverride?: DomEditSelection | null,
|
|
321
344
|
) => {
|
|
322
|
-
const sel = selectionOverride
|
|
345
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
323
346
|
if (!sel) return;
|
|
324
347
|
trackStudioEvent("keyframe", { action: "remove" });
|
|
325
348
|
removeKeyframe(sel, animId, percentage, commitOverrides);
|
|
326
349
|
},
|
|
327
|
-
[
|
|
350
|
+
[resolveWriteSelection, removeKeyframe],
|
|
328
351
|
);
|
|
329
352
|
|
|
330
353
|
const handleGsapMoveKeyframeToPlayhead = useCallback(
|
|
331
|
-
(
|
|
332
|
-
|
|
354
|
+
(
|
|
355
|
+
animId: string,
|
|
356
|
+
fromPercentage: number,
|
|
357
|
+
selectionOverride?: DomEditSelection | null,
|
|
358
|
+
animationOverride?: GsapAnimation,
|
|
359
|
+
) => {
|
|
360
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
333
361
|
if (!sel) return;
|
|
334
362
|
// Retime the keyframe to the playhead, preserving its value + ease. The
|
|
335
|
-
// playhead's tween-relative percentage is the move target
|
|
336
|
-
|
|
363
|
+
// playhead's tween-relative percentage is the move target, and it has to
|
|
364
|
+
// come from the SAME element the write lands on: reading the animation off
|
|
365
|
+
// the current selection while the percentage came from the clicked element
|
|
366
|
+
// computes the target against one tween and writes it into another.
|
|
367
|
+
const anim = animationOverride ?? selectedGsapAnimations.find((a) => a.id === animId);
|
|
337
368
|
const toPercentage = computeCurrentPercentage(sel, anim);
|
|
338
369
|
trackStudioEvent("keyframe", { action: "move_to_playhead" });
|
|
339
|
-
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
|
370
|
+
void moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
|
340
371
|
},
|
|
341
|
-
[
|
|
372
|
+
[resolveWriteSelection, selectedGsapAnimations, moveKeyframe],
|
|
342
373
|
);
|
|
343
374
|
|
|
344
375
|
const handleGsapMoveKeyframe = useCallback(
|
|
@@ -348,16 +379,16 @@ export function useGsapSelectionHandlers({
|
|
|
348
379
|
toPercentage: number,
|
|
349
380
|
selectionOverride?: DomEditSelection | null,
|
|
350
381
|
) => {
|
|
351
|
-
const sel = selectionOverride
|
|
352
|
-
if (!sel) return;
|
|
382
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
383
|
+
if (!sel) return Promise.resolve(false);
|
|
353
384
|
// Atomic retime: preserves the keyframe's value + per-keyframe ease. Both
|
|
354
385
|
// percentages are tween-relative (the drag handler converts the drop
|
|
355
386
|
// position before calling). No optimistic runtime hold — the soft-reload
|
|
356
387
|
// re-keys the diamond from source.
|
|
357
388
|
trackStudioEvent("keyframe", { action: "retime" });
|
|
358
|
-
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
|
389
|
+
return moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
|
359
390
|
},
|
|
360
|
-
[
|
|
391
|
+
[resolveWriteSelection, moveKeyframe],
|
|
361
392
|
);
|
|
362
393
|
|
|
363
394
|
const handleGsapResizeKeyframedTween = useCallback(
|
|
@@ -368,14 +399,14 @@ export function useGsapSelectionHandlers({
|
|
|
368
399
|
pctRemap: Array<{ from: number; to: number }>,
|
|
369
400
|
selectionOverride?: DomEditSelection | null,
|
|
370
401
|
) => {
|
|
371
|
-
const sel = selectionOverride
|
|
372
|
-
if (!sel) return;
|
|
402
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
403
|
+
if (!sel) return Promise.resolve(false);
|
|
373
404
|
// Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes
|
|
374
405
|
// in place. Distinct telemetry action so resize is separable from in-window move.
|
|
375
406
|
trackStudioEvent("keyframe", { action: "retime_resize" });
|
|
376
|
-
resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
|
407
|
+
return resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
|
377
408
|
},
|
|
378
|
-
[
|
|
409
|
+
[resolveWriteSelection, resizeKeyframedTween],
|
|
379
410
|
);
|
|
380
411
|
|
|
381
412
|
const handleGsapConvertToKeyframes = useCallback(
|
|
@@ -384,37 +415,31 @@ export function useGsapSelectionHandlers({
|
|
|
384
415
|
resolvedFromValues?: Record<string, number | string>,
|
|
385
416
|
duration?: number,
|
|
386
417
|
commitOverrides?: Partial<CommitMutationOptions>,
|
|
418
|
+
selectionOverride?: DomEditSelection | null,
|
|
387
419
|
) => {
|
|
388
|
-
|
|
389
|
-
return
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
).catch((error) => {
|
|
396
|
-
trackGsapHandlerFailure(
|
|
397
|
-
error,
|
|
398
|
-
domEditSelection,
|
|
399
|
-
"convert-to-keyframes",
|
|
400
|
-
"Convert to keyframes",
|
|
401
|
-
);
|
|
402
|
-
});
|
|
420
|
+
const sel = resolveWriteSelection(selectionOverride);
|
|
421
|
+
if (!sel) return Promise.resolve();
|
|
422
|
+
return convertToKeyframes(sel, animId, resolvedFromValues, duration, commitOverrides).catch(
|
|
423
|
+
(error) => {
|
|
424
|
+
trackGsapHandlerFailure(error, sel, "convert-to-keyframes", "Convert to keyframes");
|
|
425
|
+
},
|
|
426
|
+
);
|
|
403
427
|
},
|
|
404
|
-
[
|
|
428
|
+
[resolveWriteSelection, convertToKeyframes, trackGsapHandlerFailure],
|
|
405
429
|
);
|
|
406
430
|
|
|
407
431
|
const handleGsapRemoveAllKeyframes = useCallback(
|
|
408
|
-
(animId: string) => {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
432
|
+
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
|
433
|
+
const selection = resolveWriteSelection(selectionOverride);
|
|
434
|
+
if (!selection) return Promise.resolve(false);
|
|
435
|
+
return observeGsapMutation(
|
|
436
|
+
removeAllKeyframes(selection, animId),
|
|
437
|
+
selection,
|
|
413
438
|
"remove-all-keyframes",
|
|
414
439
|
"Remove all keyframes",
|
|
415
440
|
);
|
|
416
441
|
},
|
|
417
|
-
[
|
|
442
|
+
[resolveWriteSelection, observeGsapMutation, removeAllKeyframes],
|
|
418
443
|
);
|
|
419
444
|
|
|
420
445
|
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
|