@hyperframes/studio 0.6.97 → 0.6.99

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 (120) hide show
  1. package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
  2. package/dist/assets/index-B62bDCQv.css +1 -0
  3. package/dist/assets/{index-HveJ0MuV.js → index-C52IT_lp.js} +1 -1
  4. package/dist/assets/index-DOh7E1uj.js +1 -0
  5. package/dist/assets/index-DrwSRbsl.js +252 -0
  6. package/dist/index.html +2 -2
  7. package/package.json +7 -5
  8. package/src/App.tsx +182 -177
  9. package/src/captions/store.ts +11 -11
  10. package/src/components/StudioHeader.tsx +4 -4
  11. package/src/components/StudioLeftSidebar.tsx +2 -2
  12. package/src/components/StudioPreviewArea.tsx +225 -183
  13. package/src/components/StudioRightPanel.tsx +3 -3
  14. package/src/components/TimelineToolbar.tsx +25 -0
  15. package/src/components/editor/DomEditOverlay.tsx +2 -5
  16. package/src/components/editor/EaseCurveSection.tsx +2 -3
  17. package/src/components/editor/GestureTrailOverlay.tsx +4 -3
  18. package/src/components/editor/LayersPanel.tsx +3 -9
  19. package/src/components/editor/PropertyPanel.tsx +20 -61
  20. package/src/components/editor/colorValue.ts +3 -1
  21. package/src/components/editor/domEditOverlayGestures.ts +54 -1
  22. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  23. package/src/components/editor/gradientValue.ts +3 -3
  24. package/src/components/editor/keyframeMove.test.ts +101 -0
  25. package/src/components/editor/keyframeMove.ts +151 -0
  26. package/src/components/editor/manualEditsDom.ts +0 -12
  27. package/src/components/editor/propertyPanelHelpers.ts +10 -38
  28. package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
  29. package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
  30. package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
  31. package/src/components/editor/studioMotionOps.test.ts +1 -1
  32. package/src/components/editor/studioMotionOps.ts +2 -1
  33. package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
  34. package/src/components/nle/NLELayout.tsx +1 -24
  35. package/src/components/sidebar/BlocksTab.tsx +2 -2
  36. package/src/contexts/DomEditContext.tsx +134 -31
  37. package/src/contexts/StudioContext.tsx +90 -40
  38. package/src/contexts/TimelineEditContext.tsx +47 -0
  39. package/src/hooks/domEditCommitTypes.ts +14 -0
  40. package/src/hooks/gsapDragCommit.ts +9 -24
  41. package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
  42. package/src/hooks/gsapKeyframeCommit.ts +5 -15
  43. package/src/hooks/gsapRuntimeBridge.ts +18 -52
  44. package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
  45. package/src/hooks/gsapRuntimeReaders.ts +19 -26
  46. package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
  47. package/src/hooks/gsapScriptCommitTypes.ts +58 -0
  48. package/src/hooks/gsapShared.ts +157 -0
  49. package/src/hooks/timelineEditingHelpers.ts +63 -2
  50. package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
  51. package/src/hooks/useAppHotkeys.ts +299 -377
  52. package/src/hooks/useConsoleErrorCapture.ts +33 -5
  53. package/src/hooks/useDomEditCommits.ts +35 -293
  54. package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
  55. package/src/hooks/useDomEditSession.ts +78 -249
  56. package/src/hooks/useDomEditTextCommits.ts +1 -1
  57. package/src/hooks/useDomEditWiring.ts +255 -0
  58. package/src/hooks/useDomGeometryCommits.ts +181 -0
  59. package/src/hooks/useDomSelection.ts +10 -27
  60. package/src/hooks/useEditorSave.ts +82 -0
  61. package/src/hooks/useElementLifecycleOps.ts +177 -0
  62. package/src/hooks/useEnableKeyframes.ts +10 -15
  63. package/src/hooks/useFileManager.ts +32 -114
  64. package/src/hooks/useFileTree.ts +80 -0
  65. package/src/hooks/useGestureCommit.ts +7 -5
  66. package/src/hooks/useGestureRecording.ts +1 -1
  67. package/src/hooks/useGsapAnimationOps.ts +122 -0
  68. package/src/hooks/useGsapArcPathOps.ts +61 -0
  69. package/src/hooks/useGsapAwareEditing.ts +242 -0
  70. package/src/hooks/useGsapKeyframeOps.ts +167 -0
  71. package/src/hooks/useGsapPropertyDebounce.ts +135 -0
  72. package/src/hooks/useGsapScriptCommits.ts +58 -570
  73. package/src/hooks/useGsapSelectionHandlers.ts +22 -9
  74. package/src/hooks/useGsapTweenCache.ts +35 -29
  75. package/src/hooks/useLintModal.ts +7 -0
  76. package/src/hooks/useMusicBeatAnalysis.ts +152 -0
  77. package/src/hooks/useRazorSplit.ts +1 -1
  78. package/src/hooks/useRenderClipContent.ts +46 -21
  79. package/src/hooks/useTimelineEditing.ts +48 -4
  80. package/src/player/components/AudioWaveform.tsx +29 -4
  81. package/src/player/components/BeatStrip.tsx +166 -0
  82. package/src/player/components/Timeline.tsx +39 -18
  83. package/src/player/components/TimelineCanvas.tsx +52 -12
  84. package/src/player/components/TimelineClipDiamonds.tsx +130 -20
  85. package/src/player/components/TimelinePropertyRows.tsx +8 -2
  86. package/src/player/components/TimelineRuler.tsx +36 -2
  87. package/src/player/components/timelineEditing.ts +30 -5
  88. package/src/player/components/useTimelineClipDrag.ts +155 -4
  89. package/src/player/components/useTimelinePlayhead.ts +30 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +47 -45
  91. package/src/player/lib/mediaProbe.ts +46 -3
  92. package/src/player/lib/playbackScrub.ts +16 -0
  93. package/src/player/lib/timelineDOM.ts +10 -2
  94. package/src/player/lib/timelineIframeHelpers.ts +89 -0
  95. package/src/player/store/playerStore.ts +92 -33
  96. package/src/utils/beatEditActions.ts +109 -0
  97. package/src/utils/beatEditing.ts +136 -0
  98. package/src/utils/clipboardPayload.ts +3 -2
  99. package/src/utils/compositionPatterns.ts +2 -0
  100. package/src/utils/keyframeSelection.test.ts +45 -0
  101. package/src/utils/keyframeSelection.ts +29 -0
  102. package/src/utils/rounding.ts +9 -0
  103. package/src/utils/studioHelpers.ts +5 -2
  104. package/src/utils/studioUrlState.ts +2 -1
  105. package/src/utils/timelineAssetDrop.ts +6 -5
  106. package/src/utils/timelineInspector.ts +15 -100
  107. package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
  108. package/dist/assets/index-B0twsRu0.css +0 -1
  109. package/dist/assets/index-Cfye9xzo.js +0 -251
  110. package/src/components/editor/DopesheetStrip.tsx +0 -141
  111. package/src/components/editor/StaggerControls.tsx +0 -61
  112. package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
  113. package/src/components/editor/TimelineLayerPanel.tsx +0 -15
  114. package/src/components/nle/TimelineEditorNotice.tsx +0 -133
  115. package/src/hooks/gsapRuntimePreview.ts +0 -19
  116. package/src/player/components/timelineUtils.ts +0 -211
  117. package/src/utils/audioBeatDetection.ts +0 -58
  118. package/src/utils/keyframeSnapping.test.ts +0 -74
  119. package/src/utils/keyframeSnapping.ts +0 -63
  120. package/src/utils/timelineInspector.test.ts +0 -79
@@ -9,7 +9,6 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
11
11
 
12
- /** Safely resolves contentWindow for a potentially cross-origin iframe. */
13
12
  function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
14
13
  try {
15
14
  return iframe?.contentWindow ?? null;
@@ -18,10 +17,21 @@ function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
18
17
  }
19
18
  }
20
19
 
21
- /**
22
- * Handles Cmd/Ctrl+Z (undo) and Cmd/Ctrl+Shift+Z / Ctrl+Y (redo) key events.
23
- * Returns true if the event was handled, false otherwise.
24
- */
20
+ function safeAddListener(t: EventTarget | null, type: string, h: EventListener, capture = false) {
21
+ try {
22
+ t?.addEventListener(type, h, capture);
23
+ } catch {
24
+ /* cross-origin */
25
+ }
26
+ }
27
+ function safeRemoveListener(t: EventTarget | null, type: string, h: EventListener) {
28
+ try {
29
+ t?.removeEventListener(type, h);
30
+ } catch {
31
+ /* cross-origin */
32
+ }
33
+ }
34
+
25
35
  // fallow-ignore-next-line complexity
26
36
  function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean {
27
37
  const key = event.key.toLowerCase();
@@ -38,27 +48,50 @@ function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: ()
38
48
  return false;
39
49
  }
40
50
 
51
+ // Beat edits live in an in-memory stack interleaved with file history by
52
+ // timestamp. Undo steps to the NEWER op (beatAt >= fileAt); redo replays the
53
+ // inverse, stepping to the OLDER op (beatAt <= fileAt). Returns true when it
54
+ // handled the keystroke (so the file-history path is skipped).
55
+ // fallow-ignore-next-line complexity
56
+ function tryApplyBeatHistory(
57
+ direction: "undo" | "redo",
58
+ fileState: {
59
+ undo: ReadonlyArray<{ createdAt: number }>;
60
+ redo: ReadonlyArray<{ createdAt: number }>;
61
+ },
62
+ showToast: (message: string, tone?: "error" | "info") => void,
63
+ ): boolean {
64
+ const ps = usePlayerStore.getState();
65
+ const beatStack = direction === "undo" ? ps.beatUndo : ps.beatRedo;
66
+ const beatAt = beatStack[beatStack.length - 1]?.at ?? null;
67
+ if (beatAt === null) return false;
68
+ const fileStack = fileState[direction];
69
+ const fileAt = fileStack[fileStack.length - 1]?.createdAt ?? null;
70
+ if (fileAt !== null && (direction === "undo" ? beatAt < fileAt : beatAt > fileAt)) return false;
71
+ const label = direction === "undo" ? ps.undoBeatEdits() : ps.redoBeatEdits();
72
+ if (label) showToast(`${direction === "undo" ? "Undid" : "Redid"} ${label}`, "info");
73
+ return true;
74
+ }
75
+
41
76
  // ── Types ──
42
77
 
78
+ interface HistoryResult {
79
+ ok: boolean;
80
+ reason?: string;
81
+ label?: string;
82
+ paths?: string[];
83
+ }
84
+ interface HistoryFileCallbacks {
85
+ readFile: (path: string) => Promise<string>;
86
+ writeFile: (path: string, content: string) => Promise<void>;
87
+ }
43
88
  interface EditHistoryHandle {
44
- undo: (callbacks: {
45
- readFile: (path: string) => Promise<string>;
46
- writeFile: (path: string, content: string) => Promise<void>;
47
- }) => Promise<{
48
- ok: boolean;
49
- reason?: string;
50
- label?: string;
51
- paths?: string[];
52
- }>;
53
- redo: (callbacks: {
54
- readFile: (path: string) => Promise<string>;
55
- writeFile: (path: string, content: string) => Promise<void>;
56
- }) => Promise<{
57
- ok: boolean;
58
- reason?: string;
59
- label?: string;
60
- paths?: string[];
61
- }>;
89
+ undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
90
+ redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
91
+ state: {
92
+ undo: ReadonlyArray<{ createdAt: number }>;
93
+ redo: ReadonlyArray<{ createdAt: number }>;
94
+ };
62
95
  }
63
96
 
64
97
  interface UseAppHotkeysParams {
@@ -86,6 +119,156 @@ interface UseAppHotkeysParams {
86
119
  onToggleRecording?: () => void;
87
120
  }
88
121
 
122
+ // ── Extracted keydown dispatch (pure function, no hooks) ──
123
+
124
+ interface HotkeyCallbacks {
125
+ toggleTimelineVisibility: () => void;
126
+ handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
127
+ handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
128
+ handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
129
+ handleUndo: () => Promise<void>;
130
+ handleRedo: () => Promise<void>;
131
+ handleCopy: () => boolean;
132
+ handlePaste: () => Promise<void>;
133
+ handleCut: () => Promise<boolean>;
134
+ onResetKeyframes: () => boolean;
135
+ onDeleteSelectedKeyframes: () => void;
136
+ onToggleRecording?: () => void;
137
+ leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
138
+ domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
139
+ }
140
+
141
+ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean {
142
+ if (
143
+ !shouldIgnoreHistoryShortcut(event.target) &&
144
+ handleUndoRedoKey(
145
+ event,
146
+ () => void cb.handleUndo(),
147
+ () => void cb.handleRedo(),
148
+ )
149
+ )
150
+ return true;
151
+
152
+ if (event.key === "1") {
153
+ event.preventDefault();
154
+ cb.leftSidebarRef.current?.selectTab("compositions");
155
+ return true;
156
+ }
157
+ if (event.key === "2") {
158
+ event.preventDefault();
159
+ cb.leftSidebarRef.current?.selectTab("assets");
160
+ return true;
161
+ }
162
+
163
+ if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
164
+ if (key === "c") {
165
+ if (cb.handleCopy()) event.preventDefault();
166
+ return true;
167
+ }
168
+ if (key === "v") {
169
+ event.preventDefault();
170
+ void cb.handlePaste();
171
+ return true;
172
+ }
173
+ if (key === "x") {
174
+ if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
175
+ event.preventDefault();
176
+ void cb.handleCut();
177
+ }
178
+ return true;
179
+ }
180
+ }
181
+ return false;
182
+ }
183
+
184
+ // fallow-ignore-next-line complexity
185
+ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
186
+ if (key === "f" && !event.shiftKey && !event.altKey) {
187
+ event.preventDefault();
188
+ if (document.fullscreenElement) void document.exitFullscreen();
189
+ else
190
+ document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
191
+ return;
192
+ }
193
+
194
+ if (event.key === "s" && !event.altKey) {
195
+ const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
196
+ if (selectedElementId) {
197
+ const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
198
+ if (
199
+ el &&
200
+ canSplitElement(el) &&
201
+ currentTime > el.start &&
202
+ currentTime < el.start + el.duration
203
+ ) {
204
+ event.preventDefault();
205
+ void cb.handleTimelineElementSplit(el, currentTime);
206
+ return;
207
+ }
208
+ }
209
+ }
210
+
211
+ if (STUDIO_RAZOR_TOOL_ENABLED && key === "b" && !event.shiftKey && !event.altKey) {
212
+ event.preventDefault();
213
+ const { activeTool, setActiveTool } = usePlayerStore.getState();
214
+ setActiveTool(activeTool === "razor" ? "select" : "razor");
215
+ return;
216
+ }
217
+
218
+ if (key === "v" && !event.shiftKey && !event.altKey) {
219
+ event.preventDefault();
220
+ usePlayerStore.getState().setActiveTool("select");
221
+ return;
222
+ }
223
+
224
+ if (event.key === "Escape") {
225
+ const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
226
+ usePlayerStore.getState();
227
+ if (activeTool === "razor") {
228
+ if (selectedElementId) setSelectedElementId(null);
229
+ else setActiveTool("select");
230
+ event.preventDefault();
231
+ return;
232
+ }
233
+ }
234
+
235
+ if ((event.key === "Delete" || event.key === "Backspace") && !event.altKey) {
236
+ if (usePlayerStore.getState().selectedKeyframes.size > 0) {
237
+ cb.onDeleteSelectedKeyframes();
238
+ usePlayerStore.getState().clearSelectedKeyframes();
239
+ event.preventDefault();
240
+ return;
241
+ }
242
+ if (event.key === "Backspace") {
243
+ const { selectedElementId, keyframeCache } = usePlayerStore.getState();
244
+ if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) {
245
+ event.preventDefault();
246
+ return;
247
+ }
248
+ }
249
+ const { selectedElementId, elements } = usePlayerStore.getState();
250
+ if (selectedElementId) {
251
+ const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
252
+ if (el) {
253
+ event.preventDefault();
254
+ void cb.handleTimelineElementDelete(el);
255
+ return;
256
+ }
257
+ }
258
+ const domSel = cb.domEditSelectionRef.current;
259
+ if (domSel) {
260
+ event.preventDefault();
261
+ void cb.handleDomEditElementDelete(domSel);
262
+ }
263
+ return;
264
+ }
265
+
266
+ if (event.key === "r" && !event.shiftKey && !event.altKey && cb.onToggleRecording) {
267
+ event.preventDefault();
268
+ cb.onToggleRecording();
269
+ }
270
+ }
271
+
89
272
  // ── Hook ──
90
273
 
91
274
  export function useAppHotkeys({
@@ -112,10 +295,7 @@ export function useAppHotkeys({
112
295
  onToggleRecording,
113
296
  }: UseAppHotkeysParams) {
114
297
  const previewHotkeyWindowRef = useRef<Window | null>(null);
115
- const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
116
- const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
117
-
118
- // ── Timeline toggle hotkey ──
298
+ const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
119
299
 
120
300
  const handleTimelineToggleHotkey = useCallback(
121
301
  (event: KeyboardEvent) => {
@@ -126,16 +306,14 @@ export function useAppHotkeys({
126
306
  [toggleTimelineVisibility],
127
307
  );
128
308
 
129
- // ── History file read/write helpers ──
309
+ // ── Undo / Redo ──
130
310
 
131
- const readHistoryProjectFile = useCallback(
132
- async (path: string): Promise<string> => {
133
- return path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path);
134
- },
311
+ const readHistoryFile = useCallback(
312
+ (path: string): Promise<string> =>
313
+ path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path),
135
314
  [readOptionalProjectFile, readProjectFile],
136
315
  );
137
-
138
- const writeHistoryProjectFile = useCallback(
316
+ const writeHistoryFile = useCallback(
139
317
  async (path: string, content: string): Promise<void> => {
140
318
  domEditSaveTimestampRef.current = Date.now();
141
319
  await writeProjectFile(path, content);
@@ -143,376 +321,128 @@ export function useAppHotkeys({
143
321
  [domEditSaveTimestampRef, writeProjectFile],
144
322
  );
145
323
 
146
- // ── Undo / Redo ──
147
-
148
- const handleUndo = useCallback(async () => {
149
- await waitForPendingDomEditSaves();
150
- const result = await editHistory.undo({
151
- readFile: readHistoryProjectFile,
152
- writeFile: writeHistoryProjectFile,
153
- });
154
- if (!result.ok && result.reason === "content-mismatch") {
155
- showToast("File changed outside Studio. Undo history was not applied.", "info");
156
- return;
157
- }
158
- if (result.ok && result.label) {
159
- onAfterUndoRedo?.();
160
- await syncHistoryPreviewAfterApply(result.paths);
161
- showToast(`Undid ${result.label}`, "info");
162
- }
163
- }, [
164
- editHistory,
165
- readHistoryProjectFile,
166
- showToast,
167
- syncHistoryPreviewAfterApply,
168
- waitForPendingDomEditSaves,
169
- writeHistoryProjectFile,
170
- onAfterUndoRedo,
171
- ]);
172
-
173
- const handleRedo = useCallback(async () => {
174
- await waitForPendingDomEditSaves();
175
- const result = await editHistory.redo({
176
- readFile: readHistoryProjectFile,
177
- writeFile: writeHistoryProjectFile,
178
- });
179
- if (!result.ok && result.reason === "content-mismatch") {
180
- showToast("File changed outside Studio. Redo history was not applied.", "info");
181
- return;
182
- }
183
- if (result.ok && result.label) {
184
- onAfterUndoRedo?.();
185
- await syncHistoryPreviewAfterApply(result.paths);
186
- showToast(`Redid ${result.label}`, "info");
187
- }
188
- }, [
189
- editHistory,
190
- readHistoryProjectFile,
191
- showToast,
192
- syncHistoryPreviewAfterApply,
193
- waitForPendingDomEditSaves,
194
- writeHistoryProjectFile,
195
- onAfterUndoRedo,
196
- ]);
197
-
198
- // ── Stable refs for the consolidated keydown handler ──
199
-
200
- const handleToggleRef = useRef(handleTimelineToggleHotkey);
201
- handleToggleRef.current = handleTimelineToggleHotkey;
202
- const handleDeleteRef = useRef(handleTimelineElementDelete);
203
- handleDeleteRef.current = handleTimelineElementDelete;
204
- const handleSplitRef = useRef(handleTimelineElementSplit);
205
- handleSplitRef.current = handleTimelineElementSplit;
206
- const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
207
- handleDomEditDeleteRef.current = handleDomEditElementDelete;
208
- const handleUndoRef = useRef(handleUndo);
209
- handleUndoRef.current = handleUndo;
210
- const handleRedoRef = useRef(handleRedo);
211
- handleRedoRef.current = handleRedo;
212
- const handleCopyRef = useRef(handleCopy);
213
- handleCopyRef.current = handleCopy;
214
- const handlePasteRef = useRef(handlePaste);
215
- handlePasteRef.current = handlePaste;
216
- const handleCutRef = useRef(handleCut);
217
- handleCutRef.current = handleCut;
218
- const onResetKeyframesRef = useRef(onResetKeyframes);
219
- onResetKeyframesRef.current = onResetKeyframes;
220
- const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
221
- onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;
222
- const onToggleRecordingRef = useRef(onToggleRecording);
223
- onToggleRecordingRef.current = onToggleRecording;
224
-
225
- // ── Consolidated keydown handler ──
226
-
227
- handleAppKeyDownRef.current = (event: KeyboardEvent) => {
228
- // Shift+T — toggle timeline
229
- handleToggleRef.current(event);
230
-
231
- // Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
232
- if (event.metaKey || event.ctrlKey) {
233
- if (
234
- !shouldIgnoreHistoryShortcut(event.target) &&
235
- handleUndoRedoKey(
236
- event,
237
- () => void handleUndoRef.current(),
238
- () => void handleRedoRef.current(),
239
- )
240
- ) {
324
+ const applyHistory = useCallback(
325
+ async (direction: "undo" | "redo") => {
326
+ // Beat edits interleave with file history by timestamp; handle them first.
327
+ if (tryApplyBeatHistory(direction, editHistory.state, showToast)) return;
328
+
329
+ await waitForPendingDomEditSaves();
330
+ const result = await editHistory[direction]({
331
+ readFile: readHistoryFile,
332
+ writeFile: writeHistoryFile,
333
+ });
334
+ if (!result.ok && result.reason === "content-mismatch") {
335
+ showToast(
336
+ `File changed outside Studio. ${direction === "undo" ? "Undo" : "Redo"} history was not applied.`,
337
+ "info",
338
+ );
241
339
  return;
242
340
  }
243
-
244
- // Cmd/Ctrl+1 — sidebar: Compositions tab
245
- if (event.key === "1") {
246
- event.preventDefault();
247
- leftSidebarRef.current?.selectTab("compositions");
248
- return;
249
- }
250
-
251
- // Cmd/Ctrl+2 — sidebar: Assets tab
252
- if (event.key === "2") {
253
- event.preventDefault();
254
- leftSidebarRef.current?.selectTab("assets");
255
- return;
256
- }
257
-
258
- // Cmd/Ctrl+C — copy (only preventDefault if we actually have something to copy)
259
- const copyPasteKey = event.key.toLowerCase();
260
- if (
261
- copyPasteKey === "c" &&
262
- !event.shiftKey &&
263
- !event.altKey &&
264
- !isEditableTarget(event.target)
265
- ) {
266
- if (handleCopyRef.current()) {
267
- event.preventDefault();
268
- }
269
- return;
341
+ if (result.ok && result.label) {
342
+ onAfterUndoRedo?.();
343
+ await syncHistoryPreviewAfterApply(result.paths);
344
+ showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
270
345
  }
346
+ },
347
+ [
348
+ editHistory,
349
+ readHistoryFile,
350
+ showToast,
351
+ syncHistoryPreviewAfterApply,
352
+ waitForPendingDomEditSaves,
353
+ writeHistoryFile,
354
+ onAfterUndoRedo,
355
+ ],
356
+ );
271
357
 
272
- // Cmd/Ctrl+V paste
273
- if (
274
- copyPasteKey === "v" &&
275
- !event.shiftKey &&
276
- !event.altKey &&
277
- !isEditableTarget(event.target)
278
- ) {
279
- event.preventDefault();
280
- void handlePasteRef.current();
281
- return;
282
- }
358
+ const handleUndo = useCallback(() => applyHistory("undo"), [applyHistory]);
359
+ const handleRedo = useCallback(() => applyHistory("redo"), [applyHistory]);
283
360
 
284
- // Cmd/Ctrl+X cut (only preventDefault if there's a selected element to cut)
285
- if (
286
- copyPasteKey === "x" &&
287
- !event.shiftKey &&
288
- !event.altKey &&
289
- !isEditableTarget(event.target)
290
- ) {
291
- const hasSelection =
292
- !!usePlayerStore.getState().selectedElementId || !!domEditSelectionRef.current;
293
- if (hasSelection) {
294
- event.preventDefault();
295
- void handleCutRef.current();
296
- }
297
- return;
298
- }
299
- }
361
+ // ── Stable callback ref (one ref replaces fifteen) ──
300
362
 
301
- // F toggle fullscreen preview
302
- if (
303
- event.key.toLowerCase() === "f" &&
304
- !event.metaKey &&
305
- !event.ctrlKey &&
306
- !event.altKey &&
307
- !event.shiftKey &&
308
- !isEditableTarget(event.target)
309
- ) {
310
- event.preventDefault();
311
- if (document.fullscreenElement) {
312
- void document.exitFullscreen();
313
- } else {
314
- document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
315
- }
316
- return;
317
- }
363
+ const cbRef = useRef<HotkeyCallbacks>(null!);
364
+ cbRef.current = {
365
+ toggleTimelineVisibility,
366
+ handleTimelineElementDelete,
367
+ handleTimelineElementSplit,
368
+ handleDomEditElementDelete,
369
+ handleUndo,
370
+ handleRedo,
371
+ handleCopy,
372
+ handlePaste,
373
+ handleCut,
374
+ onResetKeyframes,
375
+ onDeleteSelectedKeyframes,
376
+ onToggleRecording,
377
+ leftSidebarRef,
378
+ domEditSelectionRef,
379
+ };
318
380
 
319
- // S split selected clip at playhead
320
- if (
321
- event.key === "s" &&
322
- !event.metaKey &&
323
- !event.ctrlKey &&
324
- !event.altKey &&
325
- !isEditableTarget(event.target)
326
- ) {
327
- const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
328
- if (selectedElementId) {
329
- const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
330
- if (
331
- element &&
332
- canSplitElement(element) &&
333
- currentTime > element.start &&
334
- currentTime < element.start + element.duration
335
- ) {
336
- event.preventDefault();
337
- void handleSplitRef.current(element, currentTime);
338
- return;
339
- }
340
- }
341
- }
381
+ // ── Keydown dispatch ──
342
382
 
343
- // B toggle razor tool
344
- if (
345
- STUDIO_RAZOR_TOOL_ENABLED &&
346
- event.key.toLowerCase() === "b" &&
347
- !event.metaKey &&
348
- !event.ctrlKey &&
349
- !event.altKey &&
350
- !event.shiftKey &&
351
- !isEditableTarget(event.target)
352
- ) {
383
+ const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
384
+ const cb = cbRef.current;
385
+ if (shouldHandleTimelineToggleHotkey(event)) {
353
386
  event.preventDefault();
354
- const { activeTool, setActiveTool } = usePlayerStore.getState();
355
- setActiveTool(activeTool === "razor" ? "select" : "razor");
387
+ cb.toggleTimelineVisibility();
356
388
  return;
357
389
  }
358
-
359
- // V return to selection tool
360
- if (
361
- event.key.toLowerCase() === "v" &&
362
- !event.metaKey &&
363
- !event.ctrlKey &&
364
- !event.altKey &&
365
- !event.shiftKey &&
366
- !isEditableTarget(event.target)
367
- ) {
368
- event.preventDefault();
369
- usePlayerStore.getState().setActiveTool("select");
390
+ const key = event.key.toLowerCase();
391
+ if (event.metaKey || event.ctrlKey) {
392
+ dispatchModifierKey(event, key, cb);
370
393
  return;
371
394
  }
372
-
373
- // Escape — exit razor mode (only when no selection to deselect first)
374
- if (event.key === "Escape" && !isEditableTarget(event.target)) {
375
- const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
376
- usePlayerStore.getState();
377
- if (activeTool === "razor") {
378
- if (selectedElementId) {
379
- setSelectedElementId(null);
380
- } else {
381
- setActiveTool("select");
382
- }
383
- event.preventDefault();
384
- return;
385
- }
386
- }
387
-
388
- // Delete / Backspace — remove selected keyframes > reset keyframes > remove element
389
- if (
390
- (event.key === "Delete" || event.key === "Backspace") &&
391
- !event.metaKey &&
392
- !event.ctrlKey &&
393
- !event.altKey &&
394
- !isEditableTarget(event.target)
395
- ) {
396
- // Priority: selected keyframes take precedence over clip deletion
397
- const { selectedKeyframes } = usePlayerStore.getState();
398
- if (selectedKeyframes.size > 0) {
399
- onDeleteSelectedKeyframesRef.current();
400
- usePlayerStore.getState().clearSelectedKeyframes();
401
- event.preventDefault();
402
- return;
403
- }
404
-
405
- // Backspace: try resetting keyframes first; fall through to delete if none found
406
- if (event.key === "Backspace") {
407
- const { selectedElementId, keyframeCache } = usePlayerStore.getState();
408
- if (selectedElementId && keyframeCache.has(selectedElementId)) {
409
- if (onResetKeyframesRef.current()) {
410
- event.preventDefault();
411
- return;
412
- }
413
- }
414
- }
415
-
416
- const { selectedElementId, elements } = usePlayerStore.getState();
417
- if (selectedElementId) {
418
- const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
419
- if (element) {
420
- event.preventDefault();
421
- void handleDeleteRef.current(element);
422
- return;
423
- }
424
- }
425
- const domSelection = domEditSelectionRef.current;
426
- if (domSelection) {
427
- event.preventDefault();
428
- void handleDomEditDeleteRef.current(domSelection);
429
- }
430
- }
431
-
432
- // R — toggle gesture recording
433
- if (
434
- event.key === "r" &&
435
- !event.metaKey &&
436
- !event.ctrlKey &&
437
- !event.altKey &&
438
- !event.shiftKey &&
439
- !isEditableTarget(event.target) &&
440
- onToggleRecordingRef.current
441
- ) {
442
- event.preventDefault();
443
- onToggleRecordingRef.current();
444
- }
445
- };
446
-
447
- // ── Window keydown listener ──
395
+ if (!isEditableTarget(event.target)) dispatchPlainKey(event, key, cb);
396
+ }, []);
448
397
 
449
398
  // eslint-disable-next-line no-restricted-syntax
450
399
  useEffect(() => {
451
- function handleAppKeyDown(event: KeyboardEvent) {
452
- handleAppKeyDownRef.current?.(event);
453
- }
454
400
  window.addEventListener("keydown", handleAppKeyDown, true);
455
401
  return () => window.removeEventListener("keydown", handleAppKeyDown, true);
456
- }, []);
402
+ }, [handleAppKeyDown]);
457
403
 
458
- // ── Preview iframe keydown forwarding ──
459
-
460
- const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => {
461
- handleAppKeyDownRef.current?.(event);
462
- }, []);
404
+ // ── Preview iframe forwarding ──
463
405
 
464
406
  const syncPreviewTimelineHotkey = useCallback(
465
407
  (iframe: HTMLIFrameElement | null) => {
466
408
  const nextWindow = iframeContentWindow(iframe);
467
409
  if (previewHotkeyWindowRef.current === nextWindow) return;
468
- if (previewHotkeyWindowRef.current) {
469
- try {
470
- previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
471
- } catch {
472
- /* cross-origin iframe */
473
- }
474
- }
410
+ safeRemoveListener(
411
+ previewHotkeyWindowRef.current,
412
+ "keydown",
413
+ handleAppKeyDown as EventListener,
414
+ );
475
415
  previewHotkeyWindowRef.current = nextWindow;
476
- try {
477
- nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
478
- } catch {
479
- /* cross-origin iframe */
480
- }
416
+ safeAddListener(nextWindow, "keydown", handleAppKeyDown as EventListener, true);
481
417
  },
482
- [previewAppKeyDownHandler],
418
+ [handleAppKeyDown],
483
419
  );
484
420
 
485
421
  useEffect(
486
422
  () => () => {
487
- if (previewHotkeyWindowRef.current) {
488
- try {
489
- previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
490
- } catch {
491
- /* cross-origin iframe */
492
- }
493
- previewHotkeyWindowRef.current = null;
494
- }
423
+ safeRemoveListener(
424
+ previewHotkeyWindowRef.current,
425
+ "keydown",
426
+ handleAppKeyDown as EventListener,
427
+ );
428
+ previewHotkeyWindowRef.current = null;
495
429
  },
496
- [previewAppKeyDownHandler],
430
+ [handleAppKeyDown],
497
431
  );
498
432
 
499
- // ── History hotkey for iframe forwarding ──
500
-
501
433
  const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
502
- if (!(event.metaKey || event.ctrlKey)) return;
503
- if (shouldIgnoreHistoryShortcut(event.target)) return;
434
+ if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return;
504
435
  handleUndoRedoKey(
505
436
  event,
506
- () => void handleUndoRef.current(),
507
- () => void handleRedoRef.current(),
437
+ () => void cbRef.current.handleUndo(),
438
+ () => void cbRef.current.handleRedo(),
508
439
  );
509
440
  }, []);
510
441
 
511
442
  const syncPreviewHistoryHotkey = useCallback(
512
443
  (iframe: HTMLIFrameElement | null) => {
513
- previewHistoryHotkeyCleanupRef.current?.();
514
- previewHistoryHotkeyCleanupRef.current = null;
515
-
444
+ previewHistoryCleanupRef.current?.();
445
+ previewHistoryCleanupRef.current = null;
516
446
  const win = iframeContentWindow(iframe);
517
447
  let doc: Document | null = null;
518
448
  try {
@@ -521,19 +451,11 @@ export function useAppHotkeys({
521
451
  doc = null;
522
452
  }
523
453
  if (!win && !doc) return;
524
-
525
- try {
526
- win?.addEventListener("keydown", handleHistoryHotkey, true);
527
- } catch {
528
- /* cross-origin */
529
- }
454
+ const handler = handleHistoryHotkey as EventListener;
455
+ safeAddListener(win, "keydown", handler, true);
530
456
  doc?.addEventListener("keydown", handleHistoryHotkey, true);
531
- previewHistoryHotkeyCleanupRef.current = () => {
532
- try {
533
- win?.removeEventListener("keydown", handleHistoryHotkey, true);
534
- } catch {
535
- /* cross-origin */
536
- }
457
+ previewHistoryCleanupRef.current = () => {
458
+ safeRemoveListener(win, "keydown", handler);
537
459
  doc?.removeEventListener("keydown", handleHistoryHotkey, true);
538
460
  };
539
461
  },
@@ -542,8 +464,8 @@ export function useAppHotkeys({
542
464
 
543
465
  useEffect(
544
466
  () => () => {
545
- previewHistoryHotkeyCleanupRef.current?.();
546
- previewHistoryHotkeyCleanupRef.current = null;
467
+ previewHistoryCleanupRef.current?.();
468
+ previewHistoryCleanupRef.current = null;
547
469
  },
548
470
  [],
549
471
  );