@hyperframes/studio 0.7.17 → 0.7.19
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-DNLS_l47.js +459 -0
- package/dist/assets/{index-BDx28x9R.js → index-CURAsFZX.js} +1 -1
- package/dist/assets/index-DXWeH-EY.js +396 -0
- package/dist/assets/{index-BCAdHPz_.js → index-hueu10iU.js} +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1446 -866
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +2 -1
- package/src/components/StudioPreviewArea.tsx +92 -17
- package/src/components/TimelineToolbar.tsx +8 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -2
- package/src/components/editor/keyframeDrag.test.ts +195 -0
- package/src/components/editor/keyframeDrag.ts +105 -0
- package/src/components/editor/keyframeRetime.test.ts +140 -0
- package/src/components/editor/keyframeRetime.ts +121 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/gsapDragCommit.ts +28 -1
- package/src/hooks/gsapPositionDetection.ts +98 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
- package/src/hooks/gsapRuntimeBridge.ts +41 -97
- package/src/hooks/useDomEditSession.ts +22 -0
- package/src/hooks/useDomEditWiring.ts +17 -0
- package/src/hooks/useDomSelection.ts +4 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
- package/src/hooks/useGsapKeyframeOps.ts +63 -2
- package/src/hooks/useGsapSelectionHandlers.ts +72 -1
- package/src/hooks/useGsapTweenCache.ts +16 -7
- package/src/hooks/useKeyframeKeyboard.ts +29 -32
- package/src/hooks/useStudioSelectionPublisher.ts +102 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
- package/src/player/components/PlayerControls.tsx +40 -13
- package/src/player/components/Timeline.tsx +8 -0
- package/src/player/components/TimelineCanvas.tsx +7 -0
- package/src/player/components/TimelineClipDiamonds.tsx +109 -17
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +19 -0
- package/src/utils/studioSelectionSnapshot.test.ts +47 -0
- package/src/utils/studioSelectionSnapshot.ts +72 -0
- package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
- package/dist/assets/index-hiqLDAtz.js +0 -375
|
@@ -18,6 +18,7 @@ import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
|
18
18
|
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
19
19
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
20
20
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
21
|
+
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
|
21
22
|
|
|
22
23
|
// ── Types ──
|
|
23
24
|
|
|
@@ -54,6 +55,7 @@ export interface UseDomEditSessionParams {
|
|
|
54
55
|
projectIdRef: React.MutableRefObject<string | null>;
|
|
55
56
|
previewIframe: HTMLIFrameElement | null;
|
|
56
57
|
refreshKey: number;
|
|
58
|
+
previewDocumentVersion: number;
|
|
57
59
|
rightPanelTab: RightPanelTab;
|
|
58
60
|
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
|
59
61
|
(iframe: HTMLIFrameElement) => Promise<void>
|
|
@@ -96,6 +98,7 @@ export function useDomEditSession({
|
|
|
96
98
|
projectIdRef,
|
|
97
99
|
previewIframe,
|
|
98
100
|
refreshKey,
|
|
101
|
+
previewDocumentVersion,
|
|
99
102
|
rightPanelTab,
|
|
100
103
|
applyStudioManualEditsToPreviewRef,
|
|
101
104
|
syncPreviewHistoryHotkey,
|
|
@@ -168,6 +171,15 @@ export function useDomEditSession({
|
|
|
168
171
|
domEditSelection,
|
|
169
172
|
});
|
|
170
173
|
|
|
174
|
+
useStudioSelectionPublisher({
|
|
175
|
+
projectId,
|
|
176
|
+
domEditSelection,
|
|
177
|
+
domEditSelectionRef,
|
|
178
|
+
refreshKey,
|
|
179
|
+
previewDocumentVersion,
|
|
180
|
+
refreshDomEditSelectionFromPreview,
|
|
181
|
+
});
|
|
182
|
+
|
|
171
183
|
// ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
|
|
172
184
|
|
|
173
185
|
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
|
@@ -189,6 +201,8 @@ export function useDomEditSession({
|
|
|
189
201
|
addKeyframe,
|
|
190
202
|
addKeyframeBatch,
|
|
191
203
|
removeKeyframe,
|
|
204
|
+
moveKeyframe,
|
|
205
|
+
resizeKeyframedTween,
|
|
192
206
|
convertToKeyframes,
|
|
193
207
|
removeAllKeyframes,
|
|
194
208
|
setArcPath,
|
|
@@ -344,6 +358,9 @@ export function useDomEditSession({
|
|
|
344
358
|
handleGsapAddKeyframe,
|
|
345
359
|
handleGsapAddKeyframeBatch,
|
|
346
360
|
handleGsapRemoveKeyframe,
|
|
361
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
362
|
+
handleGsapMoveKeyframe,
|
|
363
|
+
handleGsapResizeKeyframedTween,
|
|
347
364
|
handleGsapConvertToKeyframes,
|
|
348
365
|
handleGsapRemoveAllKeyframes,
|
|
349
366
|
handleResetSelectedElementKeyframes,
|
|
@@ -381,6 +398,8 @@ export function useDomEditSession({
|
|
|
381
398
|
addKeyframe,
|
|
382
399
|
addKeyframeBatch,
|
|
383
400
|
removeKeyframe,
|
|
401
|
+
moveKeyframe,
|
|
402
|
+
resizeKeyframedTween,
|
|
384
403
|
convertToKeyframes,
|
|
385
404
|
removeAllKeyframes,
|
|
386
405
|
handleDomManualEditsReset,
|
|
@@ -541,6 +560,9 @@ export function useDomEditSession({
|
|
|
541
560
|
handleGsapAddKeyframe,
|
|
542
561
|
handleGsapAddKeyframeBatch,
|
|
543
562
|
handleGsapRemoveKeyframe,
|
|
563
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
564
|
+
handleGsapMoveKeyframe,
|
|
565
|
+
handleGsapResizeKeyframedTween,
|
|
544
566
|
handleGsapConvertToKeyframes,
|
|
545
567
|
handleGsapRemoveAllKeyframes,
|
|
546
568
|
handleResetSelectedElementKeyframes,
|
|
@@ -87,6 +87,19 @@ export interface UseDomEditWiringParams {
|
|
|
87
87
|
properties: Record<string, number | string>,
|
|
88
88
|
) => Promise<void>;
|
|
89
89
|
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
|
|
90
|
+
moveKeyframe: (
|
|
91
|
+
sel: DomEditSelection,
|
|
92
|
+
animId: string,
|
|
93
|
+
fromPercentage: number,
|
|
94
|
+
toPercentage: number,
|
|
95
|
+
) => void;
|
|
96
|
+
resizeKeyframedTween: (
|
|
97
|
+
sel: DomEditSelection,
|
|
98
|
+
animId: string,
|
|
99
|
+
position: number,
|
|
100
|
+
duration: number,
|
|
101
|
+
pctRemap: Array<{ from: number; to: number }>,
|
|
102
|
+
) => void;
|
|
90
103
|
convertToKeyframes: (
|
|
91
104
|
sel: DomEditSelection,
|
|
92
105
|
animId: string,
|
|
@@ -131,6 +144,8 @@ export function useDomEditWiring({
|
|
|
131
144
|
addKeyframe,
|
|
132
145
|
addKeyframeBatch,
|
|
133
146
|
removeKeyframe,
|
|
147
|
+
moveKeyframe,
|
|
148
|
+
resizeKeyframedTween,
|
|
134
149
|
convertToKeyframes,
|
|
135
150
|
removeAllKeyframes,
|
|
136
151
|
handleDomManualEditsReset,
|
|
@@ -224,6 +239,8 @@ export function useDomEditWiring({
|
|
|
224
239
|
addKeyframe,
|
|
225
240
|
addKeyframeBatch,
|
|
226
241
|
removeKeyframe,
|
|
242
|
+
moveKeyframe,
|
|
243
|
+
resizeKeyframedTween,
|
|
227
244
|
convertToKeyframes,
|
|
228
245
|
removeAllKeyframes,
|
|
229
246
|
handleDomManualEditsReset,
|
|
@@ -381,7 +381,10 @@ export function useDomSelection({
|
|
|
381
381
|
if (!doc) return;
|
|
382
382
|
|
|
383
383
|
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
384
|
-
if (!element)
|
|
384
|
+
if (!element) {
|
|
385
|
+
applyDomSelection(null, { revealPanel: false });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
385
388
|
|
|
386
389
|
const nextSelection = await buildDomSelectionFromTarget(element);
|
|
387
390
|
if (nextSelection) {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Tell React this is an act-capable environment so act(...) flushes effects.
|
|
7
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
+
|
|
9
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
|
+
import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
|
|
11
|
+
|
|
12
|
+
type HookApi = ReturnType<typeof useGsapKeyframeOps>;
|
|
13
|
+
|
|
14
|
+
let cleanup: (() => void) | null = null;
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanup?.();
|
|
17
|
+
cleanup = null;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
|
|
21
|
+
|
|
22
|
+
function renderKeyframeOps(over: {
|
|
23
|
+
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
|
24
|
+
trackGsapSaveFailure: (...args: unknown[]) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const captured: { api: HookApi | null } = { api: null };
|
|
27
|
+
function Probe() {
|
|
28
|
+
captured.api = useGsapKeyframeOps({
|
|
29
|
+
activeCompPath: "index.html",
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
31
|
+
commitMutation: over.commitMutation as any,
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
33
|
+
commitMutationSafely: (() => {}) as any,
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
35
|
+
trackGsapSaveFailure: over.trackGsapSaveFailure as any,
|
|
36
|
+
sdkSession: null,
|
|
37
|
+
sdkDeps: null,
|
|
38
|
+
});
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const container = document.createElement("div");
|
|
42
|
+
const root = createRoot(container);
|
|
43
|
+
act(() => {
|
|
44
|
+
root.render(<Probe />);
|
|
45
|
+
});
|
|
46
|
+
cleanup = () => act(() => root.unmount());
|
|
47
|
+
if (!captured.api) throw new Error("hook did not initialize");
|
|
48
|
+
return captured.api;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
52
|
+
it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
|
|
53
|
+
const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
|
|
54
|
+
ok: true,
|
|
55
|
+
}));
|
|
56
|
+
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
|
57
|
+
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
|
58
|
+
|
|
59
|
+
const pctRemap = [
|
|
60
|
+
{ from: 0, to: 0 },
|
|
61
|
+
{ from: 100, to: 100 },
|
|
62
|
+
];
|
|
63
|
+
await act(async () => {
|
|
64
|
+
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
68
|
+
const [sel, mutation] = commitMutation.mock.calls[0]!;
|
|
69
|
+
expect(sel).toBe(selection);
|
|
70
|
+
expect(mutation).toEqual({
|
|
71
|
+
type: "resize-keyframed-tween",
|
|
72
|
+
animationId: "box-to-0-opacity",
|
|
73
|
+
position: 0.2,
|
|
74
|
+
duration: 2,
|
|
75
|
+
pctRemap,
|
|
76
|
+
});
|
|
77
|
+
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => {
|
|
81
|
+
const error = new Error("network down");
|
|
82
|
+
const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => {
|
|
83
|
+
throw error;
|
|
84
|
+
});
|
|
85
|
+
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
|
86
|
+
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
|
87
|
+
|
|
88
|
+
await act(async () => {
|
|
89
|
+
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [{ from: 100, to: 100 }]);
|
|
90
|
+
// let the rejected commit promise settle inside act
|
|
91
|
+
await Promise.resolve();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1);
|
|
95
|
+
const [errArg, selArg, mutationArg, labelArg] = trackGsapSaveFailure.mock.calls[0]!;
|
|
96
|
+
expect(errArg).toBe(error);
|
|
97
|
+
expect(selArg).toBe(selection);
|
|
98
|
+
expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween");
|
|
99
|
+
expect(labelArg).toBe("Retime keyframe (resize tween)");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
} from "../utils/sdkCutover";
|
|
13
13
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
14
14
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
clearKeyframeCacheForElement,
|
|
17
|
+
readKeyframeSnapshot,
|
|
18
|
+
writeKeyframeCache,
|
|
19
|
+
} from "./gsapKeyframeCacheHelpers";
|
|
16
20
|
import type {
|
|
17
21
|
CommitMutation,
|
|
18
22
|
SafeGsapCommitMutation,
|
|
@@ -201,6 +205,56 @@ export function useGsapKeyframeOps({
|
|
|
201
205
|
[activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
|
|
202
206
|
);
|
|
203
207
|
|
|
208
|
+
const moveKeyframe = useCallback(
|
|
209
|
+
(
|
|
210
|
+
selection: DomEditSelection,
|
|
211
|
+
animationId: string,
|
|
212
|
+
fromPercentage: number,
|
|
213
|
+
toPercentage: number,
|
|
214
|
+
) => {
|
|
215
|
+
const mutation = { type: "move-keyframe", animationId, fromPercentage, toPercentage };
|
|
216
|
+
// No SDK persist helper exists for retime — server path only. The post-commit
|
|
217
|
+
// updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no
|
|
218
|
+
// optimistic cache write is needed (mapping the tween-% to clip-% here would
|
|
219
|
+
// duplicate that math). softReload mirrors remove-keyframe.
|
|
220
|
+
void commitMutation(selection, mutation, {
|
|
221
|
+
label: `Move keyframe to ${toPercentage}%`,
|
|
222
|
+
softReload: true,
|
|
223
|
+
}).catch((error) => {
|
|
224
|
+
trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`);
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
[commitMutation, trackGsapSaveFailure],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const resizeKeyframedTween = useCallback(
|
|
231
|
+
(
|
|
232
|
+
selection: DomEditSelection,
|
|
233
|
+
animationId: string,
|
|
234
|
+
position: number,
|
|
235
|
+
duration: number,
|
|
236
|
+
pctRemap: Array<{ from: number; to: number }>,
|
|
237
|
+
) => {
|
|
238
|
+
const mutation = {
|
|
239
|
+
type: "resize-keyframed-tween",
|
|
240
|
+
animationId,
|
|
241
|
+
position,
|
|
242
|
+
duration,
|
|
243
|
+
pctRemap,
|
|
244
|
+
};
|
|
245
|
+
// Boundary drag-to-retime: the server re-keys keyframes in place + grows the
|
|
246
|
+
// tween window, preserving _auto / per-keyframe ease / easeEach / outer ease.
|
|
247
|
+
// softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe).
|
|
248
|
+
void commitMutation(selection, mutation, {
|
|
249
|
+
label: "Retime keyframe (resize tween)",
|
|
250
|
+
softReload: true,
|
|
251
|
+
}).catch((error) => {
|
|
252
|
+
trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)");
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
[commitMutation, trackGsapSaveFailure],
|
|
256
|
+
);
|
|
257
|
+
|
|
204
258
|
const convertToKeyframes = useCallback(
|
|
205
259
|
async (
|
|
206
260
|
selection: DomEditSelection,
|
|
@@ -233,8 +287,13 @@ export function useGsapKeyframeOps({
|
|
|
233
287
|
|
|
234
288
|
const removeAllKeyframes = useCallback(
|
|
235
289
|
async (selection: DomEditSelection, animationId: string) => {
|
|
290
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
291
|
+
// remove-all-keyframes collapses the tween to a static hold and the commit
|
|
292
|
+
// path doesn't return parsed animations, so the keyframe cache is never
|
|
293
|
+
// refreshed — clear it here so the timeline diamonds disappear immediately.
|
|
294
|
+
const elementId = selection.id ?? selection.selector?.match(/^#([\w-]+)/)?.[1] ?? null;
|
|
295
|
+
if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
|
|
236
296
|
if (sdkSession && sdkDeps) {
|
|
237
|
-
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
238
297
|
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
|
239
298
|
targetPath,
|
|
240
299
|
animationId,
|
|
@@ -267,6 +326,8 @@ export function useGsapKeyframeOps({
|
|
|
267
326
|
addKeyframe,
|
|
268
327
|
addKeyframeBatch,
|
|
269
328
|
removeKeyframe,
|
|
329
|
+
moveKeyframe,
|
|
330
|
+
resizeKeyframedTween,
|
|
270
331
|
convertToKeyframes,
|
|
271
332
|
removeAllKeyframes,
|
|
272
333
|
commitKeyframeAtTime,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
3
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
4
|
import { usePlayerStore } from "../player";
|
|
5
|
+
import { computeCurrentPercentage } from "./gsapDragCommit";
|
|
4
6
|
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
5
7
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
8
|
|
|
@@ -25,6 +27,8 @@ export function useGsapSelectionHandlers({
|
|
|
25
27
|
addKeyframe,
|
|
26
28
|
addKeyframeBatch,
|
|
27
29
|
removeKeyframe,
|
|
30
|
+
moveKeyframe,
|
|
31
|
+
resizeKeyframedTween,
|
|
28
32
|
convertToKeyframes,
|
|
29
33
|
removeAllKeyframes,
|
|
30
34
|
handleDomManualEditsReset,
|
|
@@ -73,6 +77,19 @@ export function useGsapSelectionHandlers({
|
|
|
73
77
|
properties: Record<string, number | string>,
|
|
74
78
|
) => Promise<void>;
|
|
75
79
|
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
|
|
80
|
+
moveKeyframe: (
|
|
81
|
+
sel: DomEditSelection,
|
|
82
|
+
animId: string,
|
|
83
|
+
fromPercentage: number,
|
|
84
|
+
toPercentage: number,
|
|
85
|
+
) => void;
|
|
86
|
+
resizeKeyframedTween: (
|
|
87
|
+
sel: DomEditSelection,
|
|
88
|
+
animId: string,
|
|
89
|
+
position: number,
|
|
90
|
+
duration: number,
|
|
91
|
+
pctRemap: Array<{ from: number; to: number }>,
|
|
92
|
+
) => void;
|
|
76
93
|
convertToKeyframes: (
|
|
77
94
|
sel: DomEditSelection,
|
|
78
95
|
animId: string,
|
|
@@ -82,7 +99,7 @@ export function useGsapSelectionHandlers({
|
|
|
82
99
|
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
|
|
83
100
|
|
|
84
101
|
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
|
85
|
-
selectedGsapAnimations:
|
|
102
|
+
selectedGsapAnimations: GsapAnimation[];
|
|
86
103
|
}) {
|
|
87
104
|
const lastSelectionRef = useRef<DomEditSelection | null>(null);
|
|
88
105
|
if (domEditSelection) lastSelectionRef.current = domEditSelection;
|
|
@@ -233,6 +250,57 @@ export function useGsapSelectionHandlers({
|
|
|
233
250
|
[domEditSelection, removeKeyframe],
|
|
234
251
|
);
|
|
235
252
|
|
|
253
|
+
const handleGsapMoveKeyframeToPlayhead = useCallback(
|
|
254
|
+
(animId: string, fromPercentage: number, selectionOverride?: DomEditSelection | null) => {
|
|
255
|
+
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
|
256
|
+
if (!sel) return;
|
|
257
|
+
// Retime the keyframe to the playhead, preserving its value + ease. The
|
|
258
|
+
// playhead's tween-relative percentage is the move target.
|
|
259
|
+
const anim = selectedGsapAnimations.find((a) => a.id === animId);
|
|
260
|
+
const toPercentage = computeCurrentPercentage(sel, anim);
|
|
261
|
+
trackStudioEvent("keyframe", { action: "move_to_playhead" });
|
|
262
|
+
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
|
263
|
+
},
|
|
264
|
+
[domEditSelection, selectedGsapAnimations, moveKeyframe],
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const handleGsapMoveKeyframe = useCallback(
|
|
268
|
+
(
|
|
269
|
+
animId: string,
|
|
270
|
+
fromPercentage: number,
|
|
271
|
+
toPercentage: number,
|
|
272
|
+
selectionOverride?: DomEditSelection | null,
|
|
273
|
+
) => {
|
|
274
|
+
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
|
275
|
+
if (!sel) return;
|
|
276
|
+
// Atomic retime: preserves the keyframe's value + per-keyframe ease. Both
|
|
277
|
+
// percentages are tween-relative (the drag handler converts the drop
|
|
278
|
+
// position before calling). No optimistic runtime hold — the soft-reload
|
|
279
|
+
// re-keys the diamond from source.
|
|
280
|
+
trackStudioEvent("keyframe", { action: "retime" });
|
|
281
|
+
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
|
282
|
+
},
|
|
283
|
+
[domEditSelection, moveKeyframe],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const handleGsapResizeKeyframedTween = useCallback(
|
|
287
|
+
(
|
|
288
|
+
animId: string,
|
|
289
|
+
position: number,
|
|
290
|
+
duration: number,
|
|
291
|
+
pctRemap: Array<{ from: number; to: number }>,
|
|
292
|
+
selectionOverride?: DomEditSelection | null,
|
|
293
|
+
) => {
|
|
294
|
+
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
|
295
|
+
if (!sel) return;
|
|
296
|
+
// Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes
|
|
297
|
+
// in place. Distinct telemetry action so resize is separable from in-window move.
|
|
298
|
+
trackStudioEvent("keyframe", { action: "retime_resize" });
|
|
299
|
+
resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
|
300
|
+
},
|
|
301
|
+
[domEditSelection, resizeKeyframedTween],
|
|
302
|
+
);
|
|
303
|
+
|
|
236
304
|
const handleGsapConvertToKeyframes = useCallback(
|
|
237
305
|
(animId: string, resolvedFromValues?: Record<string, number | string>, duration?: number) => {
|
|
238
306
|
if (!domEditSelection) return Promise.resolve();
|
|
@@ -280,6 +348,9 @@ export function useGsapSelectionHandlers({
|
|
|
280
348
|
handleGsapAddKeyframe,
|
|
281
349
|
handleGsapAddKeyframeBatch,
|
|
282
350
|
handleGsapRemoveKeyframe,
|
|
351
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
352
|
+
handleGsapMoveKeyframe,
|
|
353
|
+
handleGsapResizeKeyframedTween,
|
|
283
354
|
handleGsapConvertToKeyframes,
|
|
284
355
|
handleGsapRemoveAllKeyframes,
|
|
285
356
|
handleResetSelectedElementKeyframes,
|
|
@@ -128,7 +128,7 @@ export async function fetchParsedAnimations(
|
|
|
128
128
|
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
|
129
129
|
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
|
130
130
|
*/
|
|
131
|
-
function resolveClipTimingBasis(
|
|
131
|
+
export function resolveClipTimingBasis(
|
|
132
132
|
elementId: string,
|
|
133
133
|
sourceFile: string,
|
|
134
134
|
elements: ReadonlyArray<{
|
|
@@ -346,9 +346,16 @@ export function useGsapAnimationsForElement(
|
|
|
346
346
|
let ease: string | undefined;
|
|
347
347
|
let easeEach: string | undefined;
|
|
348
348
|
for (const anim of animations) {
|
|
349
|
+
// A static position hold (only x/y, no real motion) is a `set`, not a
|
|
350
|
+
// keyframe — don't synthesize a diamond for it. Covers both `tl.set(...)`
|
|
351
|
+
// and the `tl.to({ duration: 0, immediateRender: true })` hold that
|
|
352
|
+
// remove-all-keyframes collapses to (which is otherwise shown as a stray
|
|
353
|
+
// 0% keyframe).
|
|
349
354
|
if (
|
|
350
|
-
anim.
|
|
351
|
-
Object.keys(anim.properties).
|
|
355
|
+
!anim.keyframes &&
|
|
356
|
+
Object.keys(anim.properties).length > 0 &&
|
|
357
|
+
Object.keys(anim.properties).every((k) => k === "x" || k === "y") &&
|
|
358
|
+
(anim.method === "set" || (anim.duration ?? 0) === 0)
|
|
352
359
|
)
|
|
353
360
|
continue;
|
|
354
361
|
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
@@ -454,11 +461,13 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
454
461
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
455
462
|
for (const anim of parsed.animations) {
|
|
456
463
|
if (anim.hasUnresolvedKeyframes) continue;
|
|
457
|
-
// Position-only
|
|
458
|
-
//
|
|
459
|
-
|
|
464
|
+
// Position-only static holds are not keyframed animations — skip them so
|
|
465
|
+
// they don't draw a timeline diamond. Covers both a `tl.set(...)` and the
|
|
466
|
+
// `tl.to({ duration: 0, immediateRender: true })` that remove-all-keyframes
|
|
467
|
+
// collapses a keyframed tween to.
|
|
468
|
+
if (!anim.keyframes && (anim.method === "set" || (anim.duration ?? 0) === 0)) {
|
|
460
469
|
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
|
|
461
|
-
if (propKeys.every((k) => k === "x" || k === "y")) {
|
|
470
|
+
if (propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y")) {
|
|
462
471
|
continue;
|
|
463
472
|
}
|
|
464
473
|
}
|
|
@@ -33,53 +33,48 @@ export function useKeyframeKeyboard({
|
|
|
33
33
|
(e: KeyboardEvent) => {
|
|
34
34
|
if (!enabled) return;
|
|
35
35
|
if (isTextInput(document.activeElement)) return;
|
|
36
|
+
if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
|
|
36
37
|
|
|
37
38
|
const hasSelectedKeyframes = usePlayerStore.getState().selectedKeyframes.size > 0;
|
|
38
39
|
|
|
40
|
+
// Only consume a key we can actually act on. The fall-through matters:
|
|
41
|
+
// these keys (k/j/arrows) double as JKL playback shortcuts in
|
|
42
|
+
// usePlaybackKeyboard, so when a handler is absent we must let the event
|
|
43
|
+
// continue. When we DO act, stopImmediatePropagation prevents the playback
|
|
44
|
+
// handler from also firing (e.g. K pausing instead of adding a keyframe).
|
|
45
|
+
// The listener is registered in the capture phase so it runs first.
|
|
46
|
+
const consume = (run: () => void) => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
e.stopImmediatePropagation();
|
|
49
|
+
run();
|
|
50
|
+
};
|
|
51
|
+
|
|
39
52
|
switch (e.key.toLowerCase()) {
|
|
40
53
|
case "k":
|
|
41
|
-
if (
|
|
42
|
-
e.preventDefault();
|
|
43
|
-
onAddKeyframe?.();
|
|
44
|
-
}
|
|
54
|
+
if (onAddKeyframe) consume(onAddKeyframe);
|
|
45
55
|
break;
|
|
46
56
|
case "delete":
|
|
47
57
|
case "backspace":
|
|
48
|
-
if (hasSelectedKeyframes)
|
|
49
|
-
e.preventDefault();
|
|
50
|
-
onDeleteKeyframe?.();
|
|
51
|
-
}
|
|
58
|
+
if (onDeleteKeyframe && hasSelectedKeyframes) consume(onDeleteKeyframe);
|
|
52
59
|
break;
|
|
53
|
-
case "j":
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (e.shiftKey) onNextKeyframe?.();
|
|
57
|
-
else onPrevKeyframe?.();
|
|
58
|
-
}
|
|
60
|
+
case "j": {
|
|
61
|
+
const nav = e.shiftKey ? onNextKeyframe : onPrevKeyframe;
|
|
62
|
+
if (nav) consume(nav);
|
|
59
63
|
break;
|
|
64
|
+
}
|
|
60
65
|
case "h":
|
|
61
|
-
if (
|
|
62
|
-
e.preventDefault();
|
|
63
|
-
onToggleHold?.();
|
|
64
|
-
}
|
|
66
|
+
if (onToggleHold && hasSelectedKeyframes) consume(onToggleHold);
|
|
65
67
|
break;
|
|
66
68
|
case "u":
|
|
67
|
-
if (
|
|
68
|
-
e.preventDefault();
|
|
69
|
-
onToggleExpand?.();
|
|
70
|
-
}
|
|
69
|
+
if (onToggleExpand) consume(onToggleExpand);
|
|
71
70
|
break;
|
|
72
71
|
case "arrowleft":
|
|
73
|
-
if (
|
|
74
|
-
e.
|
|
75
|
-
onNudgeKeyframe?.(-1, e.shiftKey);
|
|
76
|
-
}
|
|
72
|
+
if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
|
|
73
|
+
consume(() => onNudgeKeyframe(-1, e.shiftKey));
|
|
77
74
|
break;
|
|
78
75
|
case "arrowright":
|
|
79
|
-
if (
|
|
80
|
-
e.
|
|
81
|
-
onNudgeKeyframe?.(1, e.shiftKey);
|
|
82
|
-
}
|
|
76
|
+
if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
|
|
77
|
+
consume(() => onNudgeKeyframe(1, e.shiftKey));
|
|
83
78
|
break;
|
|
84
79
|
}
|
|
85
80
|
},
|
|
@@ -97,7 +92,9 @@ export function useKeyframeKeyboard({
|
|
|
97
92
|
|
|
98
93
|
useEffect(() => {
|
|
99
94
|
if (!enabled) return;
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
// Capture phase: run before usePlaybackKeyboard's (bubble-phase) JKL handler
|
|
96
|
+
// so an active keyframe shortcut can claim the key.
|
|
97
|
+
window.addEventListener("keydown", handler, { capture: true });
|
|
98
|
+
return () => window.removeEventListener("keydown", handler, { capture: true });
|
|
102
99
|
}, [enabled, handler]);
|
|
103
100
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useEffect, useRef, type MutableRefObject } from "react";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
import { usePlayerStore } from "../player";
|
|
4
|
+
import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
|
+
|
|
7
|
+
interface UseStudioSelectionPublisherParams {
|
|
8
|
+
projectId: string | null;
|
|
9
|
+
domEditSelection: DomEditSelection | null;
|
|
10
|
+
domEditSelectionRef: MutableRefObject<DomEditSelection | null>;
|
|
11
|
+
refreshKey: number;
|
|
12
|
+
previewDocumentVersion: number;
|
|
13
|
+
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function reportSelectionPublishError(error: unknown): void {
|
|
17
|
+
if (error instanceof Error && error.name === "AbortError") return;
|
|
18
|
+
const errorName = error instanceof Error ? error.name : typeof error;
|
|
19
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
20
|
+
trackStudioEvent("studio_selection_publish_failed", {
|
|
21
|
+
error_name: errorName,
|
|
22
|
+
error_message: errorMessage.slice(0, 500),
|
|
23
|
+
});
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.warn("[Studio] Failed to update agent selection context", error);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise<void> {
|
|
29
|
+
return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
|
|
30
|
+
method: "PUT",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ selection }),
|
|
33
|
+
signal,
|
|
34
|
+
}).then(() => undefined);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useStudioSelectionPublisher({
|
|
38
|
+
projectId,
|
|
39
|
+
domEditSelection,
|
|
40
|
+
domEditSelectionRef,
|
|
41
|
+
refreshKey,
|
|
42
|
+
previewDocumentVersion,
|
|
43
|
+
refreshDomEditSelectionFromPreview,
|
|
44
|
+
}: UseStudioSelectionPublisherParams): void {
|
|
45
|
+
const lastSelectionRefreshKeyRef = useRef(refreshKey);
|
|
46
|
+
const pendingSelectionRefreshKeyRef = useRef<number | null>(null);
|
|
47
|
+
|
|
48
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!projectId) return;
|
|
51
|
+
const selection = domEditSelection?.element.isConnected
|
|
52
|
+
? buildStudioSelectionSnapshot({
|
|
53
|
+
projectId,
|
|
54
|
+
selection: domEditSelection,
|
|
55
|
+
currentTime: usePlayerStore.getState().currentTime,
|
|
56
|
+
})
|
|
57
|
+
: null;
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
void putSelection(projectId, selection, controller.signal).catch(reportSelectionPublishError);
|
|
60
|
+
return () => controller.abort();
|
|
61
|
+
}, [domEditSelection, projectId]);
|
|
62
|
+
|
|
63
|
+
// Clear server-side agent context when Studio leaves a project. Without this,
|
|
64
|
+
// a long-running multi-project preview server can keep serving the last
|
|
65
|
+
// selected element for a project after its tab/session unmounts.
|
|
66
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!projectId) return;
|
|
69
|
+
return () => {
|
|
70
|
+
void putSelection(projectId, null).catch(reportSelectionPublishError);
|
|
71
|
+
};
|
|
72
|
+
}, [projectId]);
|
|
73
|
+
|
|
74
|
+
// On external file edits, the iframe reloads while React keeps the previous
|
|
75
|
+
// DOM selection object alive. Clear the agent-facing snapshot immediately so
|
|
76
|
+
// `preview --context` never serves a detached or stale target, then let the
|
|
77
|
+
// post-load preview document refresh below re-resolve the selection if it
|
|
78
|
+
// still exists in the new document.
|
|
79
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (lastSelectionRefreshKeyRef.current === refreshKey) return;
|
|
82
|
+
lastSelectionRefreshKeyRef.current = refreshKey;
|
|
83
|
+
pendingSelectionRefreshKeyRef.current = domEditSelectionRef.current ? refreshKey : null;
|
|
84
|
+
if (!projectId || !domEditSelectionRef.current) return;
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
void putSelection(projectId, null, controller.signal).catch(reportSelectionPublishError);
|
|
87
|
+
return () => controller.abort();
|
|
88
|
+
}, [domEditSelectionRef, projectId, refreshKey]);
|
|
89
|
+
|
|
90
|
+
// `refreshPreviewDocumentVersion` ticks after iframe load and shortly after.
|
|
91
|
+
// Consume one pending refresh per external reload: enough to re-resolve the
|
|
92
|
+
// selected element once the new document is queryable, without republishing
|
|
93
|
+
// the same snapshot on every follow-up 80/300ms tick.
|
|
94
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (pendingSelectionRefreshKeyRef.current === null) return;
|
|
97
|
+
pendingSelectionRefreshKeyRef.current = null;
|
|
98
|
+
const selection = domEditSelectionRef.current;
|
|
99
|
+
if (!selection) return;
|
|
100
|
+
void refreshDomEditSelectionFromPreview(selection);
|
|
101
|
+
}, [domEditSelectionRef, previewDocumentVersion, refreshDomEditSelectionFromPreview]);
|
|
102
|
+
}
|