@hyperframes/studio 0.7.18 → 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-B7_M3NXS.js → index-CURAsFZX.js} +1 -1
- package/dist/assets/index-DXWeH-EY.js +396 -0
- package/dist/assets/{index-mZiDOLTB.js → index-hueu10iU.js} +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1246 -807
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- 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 +10 -0
- package/src/hooks/useDomEditWiring.ts +17 -0
- 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/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/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
- package/dist/assets/index-D0468l1X.js +0 -375
|
@@ -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
|
}
|
|
@@ -18,6 +18,8 @@ interface KeyframeDiamondContextMenuProps {
|
|
|
18
18
|
onDeleteAll: (elementId: string) => void;
|
|
19
19
|
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
20
20
|
onCopyProperties?: (elementId: string, percentage: number) => void;
|
|
21
|
+
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
|
22
|
+
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
|
@@ -25,11 +27,12 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
25
27
|
onClose,
|
|
26
28
|
onDelete,
|
|
27
29
|
onDeleteAll,
|
|
30
|
+
onMoveToPlayhead,
|
|
28
31
|
}: KeyframeDiamondContextMenuProps) {
|
|
29
32
|
const menuRef = useContextMenuDismiss(onClose);
|
|
30
33
|
|
|
31
34
|
const menuWidth = 200;
|
|
32
|
-
const menuHeight = 70;
|
|
35
|
+
const menuHeight = onMoveToPlayhead ? 100 : 70;
|
|
33
36
|
const overflowY = state.y + menuHeight - window.innerHeight;
|
|
34
37
|
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
|
|
35
38
|
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
|
|
@@ -40,12 +43,28 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
40
43
|
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
41
44
|
style={{ left: adjustedX, top: adjustedY }}
|
|
42
45
|
>
|
|
46
|
+
{onMoveToPlayhead && (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 cursor-pointer text-left"
|
|
50
|
+
onClick={() => {
|
|
51
|
+
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
|
52
|
+
// and returns the tween-% for the mutation. Passing tween-% here would
|
|
53
|
+
// miss the lookup on any tween whose window is shorter than the clip.
|
|
54
|
+
onMoveToPlayhead(state.elementId, state.percentage);
|
|
55
|
+
onClose();
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
Move to Playhead
|
|
59
|
+
</button>
|
|
60
|
+
)}
|
|
61
|
+
|
|
43
62
|
{/* Delete */}
|
|
44
63
|
<button
|
|
45
64
|
type="button"
|
|
46
65
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
47
66
|
onClick={() => {
|
|
48
|
-
onDelete(state.elementId, state.
|
|
67
|
+
onDelete(state.elementId, state.percentage);
|
|
49
68
|
onClose();
|
|
50
69
|
}}
|
|
51
70
|
>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useRef, useCallback, useEffect, memo } from "react";
|
|
2
|
+
import gsap from "gsap";
|
|
3
|
+
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
|
|
2
4
|
import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
|
|
3
5
|
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
|
4
6
|
import { usePlayerStore } from "../store/playerStore";
|
|
@@ -14,20 +16,45 @@ type TimeDisplayMode = "time" | "frame";
|
|
|
14
16
|
|
|
15
17
|
/* ── Icon sub-components ─────────────────────────────────────────── */
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
return (
|
|
19
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
|
20
|
-
<polygon points="6,3 20,12 6,21" />
|
|
21
|
-
</svg>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
19
|
+
gsap.registerPlugin(MorphSVGPlugin);
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
// Play glyph: the right-hand blade from the HyperFrames favicon (points right).
|
|
22
|
+
// Pause glyph: two bars centred in the same coordinate space so MorphSVG can
|
|
23
|
+
// tween one `d` into the other. Both shapes live in the favicon's 0-100 space
|
|
24
|
+
// and the svg viewBox frames the blade's bounding box.
|
|
25
|
+
const PLAY_BLADE_D =
|
|
26
|
+
"M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z";
|
|
27
|
+
const PAUSE_BARS_D = "M56 28H67V71H56Z M73 28H84V71H73Z";
|
|
28
|
+
|
|
29
|
+
// Morph the play blade <-> pause bars on toggle via GSAP MorphSVG. Both glyphs
|
|
30
|
+
// are one path whose `d` tweens; the initial render matches `playing` with no
|
|
31
|
+
// animation, and prefers-reduced-motion snaps instead of tweening.
|
|
32
|
+
function PlayPauseMorphIcon({ playing }: { playing: boolean }) {
|
|
33
|
+
const pathRef = useRef<SVGPathElement>(null);
|
|
34
|
+
const isFirstRun = useRef(true);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const el = pathRef.current;
|
|
37
|
+
if (!el) return;
|
|
38
|
+
const target = playing ? PAUSE_BARS_D : PLAY_BLADE_D;
|
|
39
|
+
const reduceMotion =
|
|
40
|
+
typeof window !== "undefined" &&
|
|
41
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
42
|
+
if (isFirstRun.current || reduceMotion) {
|
|
43
|
+
isFirstRun.current = false;
|
|
44
|
+
gsap.set(el, { morphSVG: target });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const tween = gsap.to(el, { duration: 0.28, ease: "power2.inOut", morphSVG: target });
|
|
48
|
+
return () => {
|
|
49
|
+
tween.kill();
|
|
50
|
+
};
|
|
51
|
+
}, [playing]);
|
|
26
52
|
return (
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
<span className="relative inline-flex h-3 w-3 items-center justify-center" aria-hidden="true">
|
|
54
|
+
<svg width="12" height="12" viewBox="46 21 54 56" fill="#FAFAFA">
|
|
55
|
+
<path ref={pathRef} d={playing ? PAUSE_BARS_D : PLAY_BLADE_D} />
|
|
56
|
+
</svg>
|
|
57
|
+
</span>
|
|
31
58
|
);
|
|
32
59
|
}
|
|
33
60
|
|
|
@@ -420,7 +447,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
420
447
|
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
|
421
448
|
style={{ background: "rgba(255,255,255,0.06)" }}
|
|
422
449
|
>
|
|
423
|
-
{isPlaying
|
|
450
|
+
<PlayPauseMorphIcon playing={isPlaying} />
|
|
424
451
|
</button>
|
|
425
452
|
</Tooltip>
|
|
426
453
|
|
|
@@ -86,6 +86,8 @@ export const Timeline = memo(function Timeline({
|
|
|
86
86
|
onDeleteKeyframe,
|
|
87
87
|
onDeleteAllKeyframes,
|
|
88
88
|
onChangeKeyframeEase,
|
|
89
|
+
onMoveKeyframeToPlayhead,
|
|
90
|
+
onMoveKeyframe,
|
|
89
91
|
} = useResolvedTimelineEditCallbacks({
|
|
90
92
|
onMoveElement: onMoveElementOverride,
|
|
91
93
|
onResizeElement: onResizeElementOverride,
|
|
@@ -479,6 +481,7 @@ export const Timeline = memo(function Timeline({
|
|
|
479
481
|
onShiftClickKeyframe={(elId, pct) => {
|
|
480
482
|
toggleSelectedKeyframe(`${elId}:${pct}`);
|
|
481
483
|
}}
|
|
484
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
482
485
|
onContextMenuKeyframe={(e, elId, pct) => {
|
|
483
486
|
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
|
484
487
|
if (el) {
|
|
@@ -556,6 +559,11 @@ export const Timeline = memo(function Timeline({
|
|
|
556
559
|
onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
|
|
557
560
|
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
|
|
558
561
|
onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
|
|
562
|
+
onMoveToPlayhead={
|
|
563
|
+
onMoveKeyframeToPlayhead
|
|
564
|
+
? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
|
|
565
|
+
: undefined
|
|
566
|
+
}
|
|
559
567
|
onCopyProperties={(elId, pct) => {
|
|
560
568
|
const kfData = keyframeCache.get(elId);
|
|
561
569
|
const kf = kfData?.keyframes.find((k) => k.percentage === pct);
|
|
@@ -92,6 +92,11 @@ interface TimelineCanvasProps {
|
|
|
92
92
|
onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
|
|
93
93
|
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
94
94
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
95
|
+
onMoveKeyframe?: (
|
|
96
|
+
elementId: string,
|
|
97
|
+
fromClipPercentage: number,
|
|
98
|
+
toClipPercentage: number,
|
|
99
|
+
) => void;
|
|
95
100
|
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
96
101
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
97
102
|
}
|
|
@@ -139,6 +144,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
139
144
|
onClickKeyframe,
|
|
140
145
|
onShiftClickKeyframe,
|
|
141
146
|
onContextMenuKeyframe,
|
|
147
|
+
onMoveKeyframe,
|
|
142
148
|
onContextMenuClip,
|
|
143
149
|
beatAnalysis,
|
|
144
150
|
}: TimelineCanvasProps) {
|
|
@@ -439,6 +445,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
439
445
|
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
|
440
446
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
441
447
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
448
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
442
449
|
/>
|
|
443
450
|
)}
|
|
444
451
|
</TimelineClip>
|