@hyperframes/studio 0.6.97 → 0.6.98
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-DgsMQSvV.js +418 -0
- package/dist/assets/index-B62bDCQv.css +1 -0
- package/dist/assets/index-Ce3pBm_I.js +252 -0
- package/dist/assets/{index-HveJ0MuV.js → index-D-ET9M0b.js} +1 -1
- package/dist/assets/index-D-bS9Dxx.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +7 -5
- package/src/App.tsx +182 -177
- package/src/captions/store.ts +11 -11
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/StudioPreviewArea.tsx +225 -183
- package/src/components/StudioRightPanel.tsx +3 -3
- package/src/components/TimelineToolbar.tsx +25 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -5
- package/src/components/editor/EaseCurveSection.tsx +2 -3
- package/src/components/editor/GestureTrailOverlay.tsx +4 -3
- package/src/components/editor/LayersPanel.tsx +3 -9
- package/src/components/editor/PropertyPanel.tsx +20 -61
- package/src/components/editor/colorValue.ts +3 -1
- package/src/components/editor/domEditOverlayGestures.ts +54 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/gradientValue.ts +3 -3
- package/src/components/editor/keyframeMove.test.ts +101 -0
- package/src/components/editor/keyframeMove.ts +151 -0
- package/src/components/editor/manualEditsDom.ts +0 -12
- package/src/components/editor/propertyPanelHelpers.ts +10 -38
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
- package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
- package/src/components/editor/studioMotionOps.test.ts +1 -1
- package/src/components/editor/studioMotionOps.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
- package/src/components/nle/NLELayout.tsx +1 -24
- package/src/components/sidebar/BlocksTab.tsx +2 -2
- package/src/contexts/DomEditContext.tsx +134 -31
- package/src/contexts/StudioContext.tsx +90 -40
- package/src/contexts/TimelineEditContext.tsx +47 -0
- package/src/hooks/domEditCommitTypes.ts +14 -0
- package/src/hooks/gsapDragCommit.ts +9 -24
- package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
- package/src/hooks/gsapKeyframeCommit.ts +5 -15
- package/src/hooks/gsapRuntimeBridge.ts +18 -52
- package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
- package/src/hooks/gsapRuntimeReaders.ts +19 -26
- package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
- package/src/hooks/gsapScriptCommitTypes.ts +58 -0
- package/src/hooks/gsapShared.ts +157 -0
- package/src/hooks/timelineEditingHelpers.ts +63 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
- package/src/hooks/useAppHotkeys.ts +299 -377
- package/src/hooks/useConsoleErrorCapture.ts +33 -5
- package/src/hooks/useDomEditCommits.ts +35 -293
- package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
- package/src/hooks/useDomEditSession.ts +78 -249
- package/src/hooks/useDomEditTextCommits.ts +1 -1
- package/src/hooks/useDomEditWiring.ts +255 -0
- package/src/hooks/useDomGeometryCommits.ts +181 -0
- package/src/hooks/useDomSelection.ts +10 -27
- package/src/hooks/useEditorSave.ts +82 -0
- package/src/hooks/useElementLifecycleOps.ts +177 -0
- package/src/hooks/useEnableKeyframes.ts +10 -15
- package/src/hooks/useFileManager.ts +32 -114
- package/src/hooks/useFileTree.ts +80 -0
- package/src/hooks/useGestureCommit.ts +7 -5
- package/src/hooks/useGestureRecording.ts +1 -1
- package/src/hooks/useGsapAnimationOps.ts +122 -0
- package/src/hooks/useGsapArcPathOps.ts +61 -0
- package/src/hooks/useGsapAwareEditing.ts +242 -0
- package/src/hooks/useGsapKeyframeOps.ts +167 -0
- package/src/hooks/useGsapPropertyDebounce.ts +135 -0
- package/src/hooks/useGsapScriptCommits.ts +58 -570
- package/src/hooks/useGsapSelectionHandlers.ts +22 -9
- package/src/hooks/useGsapTweenCache.ts +35 -29
- package/src/hooks/useLintModal.ts +7 -0
- package/src/hooks/useMusicBeatAnalysis.ts +152 -0
- package/src/hooks/useRazorSplit.ts +1 -1
- package/src/hooks/useRenderClipContent.ts +46 -21
- package/src/hooks/useTimelineEditing.ts +48 -4
- package/src/player/components/AudioWaveform.tsx +29 -4
- package/src/player/components/BeatStrip.tsx +166 -0
- package/src/player/components/Timeline.tsx +39 -18
- package/src/player/components/TimelineCanvas.tsx +52 -12
- package/src/player/components/TimelineClipDiamonds.tsx +130 -20
- package/src/player/components/TimelinePropertyRows.tsx +8 -2
- package/src/player/components/TimelineRuler.tsx +36 -2
- package/src/player/components/timelineEditing.ts +30 -5
- package/src/player/components/useTimelineClipDrag.ts +155 -4
- package/src/player/components/useTimelinePlayhead.ts +30 -1
- package/src/player/hooks/useTimelinePlayer.ts +47 -45
- package/src/player/lib/mediaProbe.ts +46 -3
- package/src/player/lib/playbackScrub.ts +16 -0
- package/src/player/lib/timelineDOM.ts +10 -2
- package/src/player/lib/timelineIframeHelpers.ts +89 -0
- package/src/player/store/playerStore.ts +92 -33
- package/src/utils/beatEditActions.ts +109 -0
- package/src/utils/beatEditing.ts +136 -0
- package/src/utils/clipboardPayload.ts +3 -2
- package/src/utils/compositionPatterns.ts +2 -0
- package/src/utils/keyframeSelection.test.ts +45 -0
- package/src/utils/keyframeSelection.ts +29 -0
- package/src/utils/rounding.ts +9 -0
- package/src/utils/studioHelpers.ts +5 -2
- package/src/utils/studioUrlState.ts +2 -1
- package/src/utils/timelineAssetDrop.ts +6 -5
- package/src/utils/timelineInspector.ts +15 -100
- package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
- package/dist/assets/index-B0twsRu0.css +0 -1
- package/dist/assets/index-Cfye9xzo.js +0 -251
- package/src/components/editor/DopesheetStrip.tsx +0 -141
- package/src/components/editor/StaggerControls.tsx +0 -61
- package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
- package/src/components/editor/TimelineLayerPanel.tsx +0 -15
- package/src/components/nle/TimelineEditorNotice.tsx +0 -133
- package/src/hooks/gsapRuntimePreview.ts +0 -19
- package/src/player/components/timelineUtils.ts +0 -211
- package/src/utils/audioBeatDetection.ts +0 -58
- package/src/utils/keyframeSnapping.test.ts +0 -74
- package/src/utils/keyframeSnapping.ts +0 -63
- 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
|
-
|
|
23
|
-
|
|
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: (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
// ──
|
|
309
|
+
// ── Undo / Redo ──
|
|
130
310
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
355
|
-
setActiveTool(activeTool === "razor" ? "select" : "razor");
|
|
387
|
+
cb.toggleTimelineVisibility();
|
|
356
388
|
return;
|
|
357
389
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
474
|
-
}
|
|
410
|
+
safeRemoveListener(
|
|
411
|
+
previewHotkeyWindowRef.current,
|
|
412
|
+
"keydown",
|
|
413
|
+
handleAppKeyDown as EventListener,
|
|
414
|
+
);
|
|
475
415
|
previewHotkeyWindowRef.current = nextWindow;
|
|
476
|
-
|
|
477
|
-
nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
|
|
478
|
-
} catch {
|
|
479
|
-
/* cross-origin iframe */
|
|
480
|
-
}
|
|
416
|
+
safeAddListener(nextWindow, "keydown", handleAppKeyDown as EventListener, true);
|
|
481
417
|
},
|
|
482
|
-
[
|
|
418
|
+
[handleAppKeyDown],
|
|
483
419
|
);
|
|
484
420
|
|
|
485
421
|
useEffect(
|
|
486
422
|
() => () => {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
[
|
|
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
|
|
507
|
-
() => void
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
546
|
-
|
|
467
|
+
previewHistoryCleanupRef.current?.();
|
|
468
|
+
previewHistoryCleanupRef.current = null;
|
|
547
469
|
},
|
|
548
470
|
[],
|
|
549
471
|
);
|