@hyperframes/studio 0.7.18 → 0.7.20

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.
Files changed (39) hide show
  1. package/dist/assets/hyperframes-player-DNLS_l47.js +459 -0
  2. package/dist/assets/{index-mZiDOLTB.js → index-CUt5jJXy.js} +1 -1
  3. package/dist/assets/{index-B7_M3NXS.js → index-D-KLiCTU.js} +1 -1
  4. package/dist/assets/index-WxXW8fW3.js +396 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1246 -807
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -7
  10. package/src/components/StudioPreviewArea.tsx +92 -17
  11. package/src/components/TimelineToolbar.tsx +8 -0
  12. package/src/components/editor/MotionPathOverlay.tsx +4 -2
  13. package/src/components/editor/keyframeDrag.test.ts +195 -0
  14. package/src/components/editor/keyframeDrag.ts +105 -0
  15. package/src/components/editor/keyframeRetime.test.ts +140 -0
  16. package/src/components/editor/keyframeRetime.ts +121 -0
  17. package/src/contexts/DomEditContext.tsx +12 -0
  18. package/src/contexts/TimelineEditContext.tsx +2 -0
  19. package/src/hooks/gsapDragCommit.ts +28 -1
  20. package/src/hooks/gsapPositionDetection.ts +98 -0
  21. package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
  22. package/src/hooks/gsapRuntimeBridge.ts +41 -97
  23. package/src/hooks/useDomEditSession.ts +10 -0
  24. package/src/hooks/useDomEditWiring.ts +17 -0
  25. package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
  26. package/src/hooks/useGsapKeyframeOps.ts +63 -2
  27. package/src/hooks/useGsapSelectionHandlers.ts +72 -1
  28. package/src/hooks/useGsapTweenCache.ts +16 -7
  29. package/src/hooks/useKeyframeKeyboard.ts +29 -32
  30. package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
  31. package/src/player/components/PlayerControls.tsx +40 -13
  32. package/src/player/components/Timeline.tsx +8 -0
  33. package/src/player/components/TimelineCanvas.tsx +7 -0
  34. package/src/player/components/TimelineClipDiamonds.tsx +109 -17
  35. package/src/player/components/timelineCallbacks.ts +6 -0
  36. package/src/utils/gsapSoftReload.test.ts +29 -0
  37. package/src/utils/gsapSoftReload.ts +19 -0
  38. package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
  39. 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 { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
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: { id: string; keyframes?: unknown }[];
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.method === "set" &&
351
- Object.keys(anim.properties).every((k) => k === "x" || k === "y")
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 set tweens are static holds (created by drag), not
458
- // keyframed animations skip them so they don't show timeline diamonds.
459
- if (anim.method === "set") {
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 (!e.metaKey && !e.ctrlKey) {
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
- if (!e.metaKey && !e.ctrlKey) {
55
- e.preventDefault();
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 (!e.metaKey && !e.ctrlKey && hasSelectedKeyframes) {
62
- e.preventDefault();
63
- onToggleHold?.();
64
- }
66
+ if (onToggleHold && hasSelectedKeyframes) consume(onToggleHold);
65
67
  break;
66
68
  case "u":
67
- if (!e.metaKey && !e.ctrlKey) {
68
- e.preventDefault();
69
- onToggleExpand?.();
70
- }
69
+ if (onToggleExpand) consume(onToggleExpand);
71
70
  break;
72
71
  case "arrowleft":
73
- if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
74
- e.preventDefault();
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 (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
80
- e.preventDefault();
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
- window.addEventListener("keydown", handler);
101
- return () => window.removeEventListener("keydown", handler);
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.tweenPercentage ?? state.percentage);
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
- function PlayIcon() {
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
- function PauseIcon() {
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
- <svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
28
- <rect x="6" y="4" width="4" height="16" rx="1" />
29
- <rect x="14" y="4" width="4" height="16" rx="1" />
30
- </svg>
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 ? <PauseIcon /> : <PlayIcon />}
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>