mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
refactor(studio): simplify hooks, split contexts, remove dead code (#1416)
* fix(studio): guard Zustand no-op setters and fix useConsoleErrorCapture memory leak - Guard setIsPlaying to skip set() when value unchanged (eliminates 60 notifications/sec during reverse playback) - Guard caption store selectGroup to bail before set() when group missing (prevents empty Zustand notifications) - Guard clearSelection to skip when already empty - Fix useConsoleErrorCapture: restore original console.error, remove error event listener, and delete __hfErrorCapture flag on cleanup * fix(studio): delete dead files and unused exports Remove 7 dead files (audioBeatDetection, keyframeSnapping, timelineInspector, DopesheetStrip, StaggerControls, TimelineLayerPanel, TimelineEditorNotice) and their test companions. Delete unused computeFitToChildrenSize export from propertyPanelHelpers. Fix re-export indirection: useDomEditCommits and studioMotionOps.test now import patch builders directly from manualEditsDomPatches instead of the re-export passthrough in manualEditsDom. * fix(studio): eliminate effect-chain state mirroring for lint findings, hover, and GSAP fetch Move lint findingsByElement sync from App.tsx into useLintModal where the value is produced, removing the mirroring useEffect. Consolidate 4 hover-clearing effects in useDomSelection into 2 (one unconditional on context change, one conditional combining caption mode, selection match, and disconnected element checks). Fold the GSAP retry effect into the fetch effect in useGsapTweenCache, scheduling a single retry via setTimeout when the initial fetch returns 0 animations. Eliminates 3 unnecessary render cycles from effect chains. * fix(studio): memoize renderQueue, toolbar, and canvas rect to prevent re-render cascade - Wrap renderQueue object in useMemo so StudioContext consumers don't re-render on every App render - Memoize timelineToolbar JSX so NLELayout memo isn't defeated - Move canvasRect getBoundingClientRect() from render-time IIFE to a useLayoutEffect-backed ref, eliminating layout thrashing - Track and clear setTimeout handles in refreshPreviewDocumentVersion to prevent stale timer accumulation on rapid calls and unmount * refactor(studio): consolidate GSAP shared primitives — defaults, iframe access, keyframe parsing Extract duplicated PROPERTY_DEFAULTS, IframeGsap interface, iframe accessors (getIframeGsap, queryIframeElement), percentage keyframe parsing, and toAbsoluteTime into a single gsapShared.ts module. Removes ~120 lines of copy-pasted logic across 8 hook files, reducing drift risk between the duplicate implementations. * fix(studio): remove dead store fields, dead file, duplicate helper, and unsafe assertions * refactor(studio): deduplicate selector helpers, rounding utils, percentage computation, and iframe access * fix(studio): split StudioContext into Shell + Playback to prevent cascade re-renders * refactor(studio): decompose useGsapScriptCommits into focused mutation hooks * refactor(studio): decompose useFileManager into focused file operation hooks Extract useFileTree (tree loading, refresh, derived assets/compositions) and useEditorSave (debounced save with history tracking) from the 508-LOC useFileManager. The parent hook composes both and retains file I/O, click-to-source, upload/import, and CRUD — preserving the same public interface so no consumers change. * refactor(studio): decompose useDomEditCommits into focused commit hooks Extract geometry (path offset, box size, rotation) and element lifecycle (delete, z-index reorder) into useDomGeometryCommits and useElementLifecycleOps. Parent keeps persistDomEditOperations as core and composes all sub-hooks — public interface unchanged. * refactor(studio): simplify useAppHotkeys with declarative command table * refactor(studio): simplify useAppHotkeys with declarative command table Replace 15 individual useRef callback refs with a single cbRef object. Extract keydown dispatch into pure dispatchModifierKey/dispatchPlainKey functions. Merge duplicate undo/redo logic into shared applyHistory. Extract cross-origin listener boilerplate into safeAddListener/safeRemoveListener. Hook body: 204 LOC (down from 445). Public API unchanged. * fix(studio): remove unused getDomEditTargetKey import * refactor(studio): decompose useDomEditSession into focused editing hooks Extract GSAP-aware geometry intercepts (move/resize/rotation) and animated property commit into useGsapAwareEditing, and selection wiring, GSAP cache management, preview sync, and selection handlers into useDomEditWiring. The parent remains a pure composition shell. * style(studio): fix formatting in 5 files * fix(studio): trim App.tsx to 598 lines (under 600 limit) --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
6f677292ae
commit
7bff49ecf0
@@ -9,7 +9,6 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
||||
import { canSplitElement } from "../utils/timelineElementSplit";
|
||||
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
|
||||
/** Safely resolves contentWindow for a potentially cross-origin iframe. */
|
||||
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
||||
try {
|
||||
return iframe?.contentWindow ?? null;
|
||||
@@ -18,10 +17,21 @@ function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Cmd/Ctrl+Z (undo) and Cmd/Ctrl+Shift+Z / Ctrl+Y (redo) key events.
|
||||
* Returns true if the event was handled, false otherwise.
|
||||
*/
|
||||
function safeAddListener(t: EventTarget | null, type: string, h: EventListener, capture = false) {
|
||||
try {
|
||||
t?.addEventListener(type, h, capture);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}
|
||||
function safeRemoveListener(t: EventTarget | null, type: string, h: EventListener) {
|
||||
try {
|
||||
t?.removeEventListener(type, h);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean {
|
||||
const key = event.key.toLowerCase();
|
||||
@@ -40,25 +50,19 @@ function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: ()
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface HistoryResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
label?: string;
|
||||
paths?: string[];
|
||||
}
|
||||
interface HistoryFileCallbacks {
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
}
|
||||
interface EditHistoryHandle {
|
||||
undo: (callbacks: {
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
label?: string;
|
||||
paths?: string[];
|
||||
}>;
|
||||
redo: (callbacks: {
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
label?: string;
|
||||
paths?: string[];
|
||||
}>;
|
||||
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
||||
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
||||
}
|
||||
|
||||
interface UseAppHotkeysParams {
|
||||
@@ -86,6 +90,156 @@ interface UseAppHotkeysParams {
|
||||
onToggleRecording?: () => void;
|
||||
}
|
||||
|
||||
// ── Extracted keydown dispatch (pure function, no hooks) ──
|
||||
|
||||
interface HotkeyCallbacks {
|
||||
toggleTimelineVisibility: () => void;
|
||||
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
||||
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
||||
handleUndo: () => Promise<void>;
|
||||
handleRedo: () => Promise<void>;
|
||||
handleCopy: () => boolean;
|
||||
handlePaste: () => Promise<void>;
|
||||
handleCut: () => Promise<boolean>;
|
||||
onResetKeyframes: () => boolean;
|
||||
onDeleteSelectedKeyframes: () => void;
|
||||
onToggleRecording?: () => void;
|
||||
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
}
|
||||
|
||||
function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean {
|
||||
if (
|
||||
!shouldIgnoreHistoryShortcut(event.target) &&
|
||||
handleUndoRedoKey(
|
||||
event,
|
||||
() => void cb.handleUndo(),
|
||||
() => void cb.handleRedo(),
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
if (event.key === "1") {
|
||||
event.preventDefault();
|
||||
cb.leftSidebarRef.current?.selectTab("compositions");
|
||||
return true;
|
||||
}
|
||||
if (event.key === "2") {
|
||||
event.preventDefault();
|
||||
cb.leftSidebarRef.current?.selectTab("assets");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
|
||||
if (key === "c") {
|
||||
if (cb.handleCopy()) event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (key === "v") {
|
||||
event.preventDefault();
|
||||
void cb.handlePaste();
|
||||
return true;
|
||||
}
|
||||
if (key === "x") {
|
||||
if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
|
||||
event.preventDefault();
|
||||
void cb.handleCut();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
|
||||
if (key === "f" && !event.shiftKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
else
|
||||
document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "s" && !event.altKey) {
|
||||
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
||||
if (selectedElementId) {
|
||||
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
|
||||
if (
|
||||
el &&
|
||||
canSplitElement(el) &&
|
||||
currentTime > el.start &&
|
||||
currentTime < el.start + el.duration
|
||||
) {
|
||||
event.preventDefault();
|
||||
void cb.handleTimelineElementSplit(el, currentTime);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (STUDIO_RAZOR_TOOL_ENABLED && key === "b" && !event.shiftKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
const { activeTool, setActiveTool } = usePlayerStore.getState();
|
||||
setActiveTool(activeTool === "razor" ? "select" : "razor");
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "v" && !event.shiftKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
usePlayerStore.getState().setActiveTool("select");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
|
||||
usePlayerStore.getState();
|
||||
if (activeTool === "razor") {
|
||||
if (selectedElementId) setSelectedElementId(null);
|
||||
else setActiveTool("select");
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && !event.altKey) {
|
||||
if (usePlayerStore.getState().selectedKeyframes.size > 0) {
|
||||
cb.onDeleteSelectedKeyframes();
|
||||
usePlayerStore.getState().clearSelectedKeyframes();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Backspace") {
|
||||
const { selectedElementId, keyframeCache } = usePlayerStore.getState();
|
||||
if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { selectedElementId, elements } = usePlayerStore.getState();
|
||||
if (selectedElementId) {
|
||||
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
|
||||
if (el) {
|
||||
event.preventDefault();
|
||||
void cb.handleTimelineElementDelete(el);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const domSel = cb.domEditSelectionRef.current;
|
||||
if (domSel) {
|
||||
event.preventDefault();
|
||||
void cb.handleDomEditElementDelete(domSel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "r" && !event.shiftKey && !event.altKey && cb.onToggleRecording) {
|
||||
event.preventDefault();
|
||||
cb.onToggleRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useAppHotkeys({
|
||||
@@ -112,10 +266,7 @@ export function useAppHotkeys({
|
||||
onToggleRecording,
|
||||
}: UseAppHotkeysParams) {
|
||||
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
||||
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
|
||||
const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// ── Timeline toggle hotkey ──
|
||||
const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const handleTimelineToggleHotkey = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
@@ -126,16 +277,14 @@ export function useAppHotkeys({
|
||||
[toggleTimelineVisibility],
|
||||
);
|
||||
|
||||
// ── History file read/write helpers ──
|
||||
// ── Undo / Redo ──
|
||||
|
||||
const readHistoryProjectFile = useCallback(
|
||||
async (path: string): Promise<string> => {
|
||||
return path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path);
|
||||
},
|
||||
const readHistoryFile = useCallback(
|
||||
(path: string): Promise<string> =>
|
||||
path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path),
|
||||
[readOptionalProjectFile, readProjectFile],
|
||||
);
|
||||
|
||||
const writeHistoryProjectFile = useCallback(
|
||||
const writeHistoryFile = useCallback(
|
||||
async (path: string, content: string): Promise<void> => {
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await writeProjectFile(path, content);
|
||||
@@ -143,376 +292,125 @@ export function useAppHotkeys({
|
||||
[domEditSaveTimestampRef, writeProjectFile],
|
||||
);
|
||||
|
||||
// ── Undo / Redo ──
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
await waitForPendingDomEditSaves();
|
||||
const result = await editHistory.undo({
|
||||
readFile: readHistoryProjectFile,
|
||||
writeFile: writeHistoryProjectFile,
|
||||
});
|
||||
if (!result.ok && result.reason === "content-mismatch") {
|
||||
showToast("File changed outside Studio. Undo history was not applied.", "info");
|
||||
return;
|
||||
}
|
||||
if (result.ok && result.label) {
|
||||
onAfterUndoRedo?.();
|
||||
await syncHistoryPreviewAfterApply(result.paths);
|
||||
showToast(`Undid ${result.label}`, "info");
|
||||
}
|
||||
}, [
|
||||
editHistory,
|
||||
readHistoryProjectFile,
|
||||
showToast,
|
||||
syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves,
|
||||
writeHistoryProjectFile,
|
||||
onAfterUndoRedo,
|
||||
]);
|
||||
|
||||
const handleRedo = useCallback(async () => {
|
||||
await waitForPendingDomEditSaves();
|
||||
const result = await editHistory.redo({
|
||||
readFile: readHistoryProjectFile,
|
||||
writeFile: writeHistoryProjectFile,
|
||||
});
|
||||
if (!result.ok && result.reason === "content-mismatch") {
|
||||
showToast("File changed outside Studio. Redo history was not applied.", "info");
|
||||
return;
|
||||
}
|
||||
if (result.ok && result.label) {
|
||||
onAfterUndoRedo?.();
|
||||
await syncHistoryPreviewAfterApply(result.paths);
|
||||
showToast(`Redid ${result.label}`, "info");
|
||||
}
|
||||
}, [
|
||||
editHistory,
|
||||
readHistoryProjectFile,
|
||||
showToast,
|
||||
syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves,
|
||||
writeHistoryProjectFile,
|
||||
onAfterUndoRedo,
|
||||
]);
|
||||
|
||||
// ── Stable refs for the consolidated keydown handler ──
|
||||
|
||||
const handleToggleRef = useRef(handleTimelineToggleHotkey);
|
||||
handleToggleRef.current = handleTimelineToggleHotkey;
|
||||
const handleDeleteRef = useRef(handleTimelineElementDelete);
|
||||
handleDeleteRef.current = handleTimelineElementDelete;
|
||||
const handleSplitRef = useRef(handleTimelineElementSplit);
|
||||
handleSplitRef.current = handleTimelineElementSplit;
|
||||
const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
|
||||
handleDomEditDeleteRef.current = handleDomEditElementDelete;
|
||||
const handleUndoRef = useRef(handleUndo);
|
||||
handleUndoRef.current = handleUndo;
|
||||
const handleRedoRef = useRef(handleRedo);
|
||||
handleRedoRef.current = handleRedo;
|
||||
const handleCopyRef = useRef(handleCopy);
|
||||
handleCopyRef.current = handleCopy;
|
||||
const handlePasteRef = useRef(handlePaste);
|
||||
handlePasteRef.current = handlePaste;
|
||||
const handleCutRef = useRef(handleCut);
|
||||
handleCutRef.current = handleCut;
|
||||
const onResetKeyframesRef = useRef(onResetKeyframes);
|
||||
onResetKeyframesRef.current = onResetKeyframes;
|
||||
const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
|
||||
onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;
|
||||
const onToggleRecordingRef = useRef(onToggleRecording);
|
||||
onToggleRecordingRef.current = onToggleRecording;
|
||||
|
||||
// ── Consolidated keydown handler ──
|
||||
|
||||
handleAppKeyDownRef.current = (event: KeyboardEvent) => {
|
||||
// Shift+T — toggle timeline
|
||||
handleToggleRef.current(event);
|
||||
|
||||
// Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
if (
|
||||
!shouldIgnoreHistoryShortcut(event.target) &&
|
||||
handleUndoRedoKey(
|
||||
event,
|
||||
() => void handleUndoRef.current(),
|
||||
() => void handleRedoRef.current(),
|
||||
)
|
||||
) {
|
||||
const applyHistory = useCallback(
|
||||
async (direction: "undo" | "redo") => {
|
||||
await waitForPendingDomEditSaves();
|
||||
const result = await editHistory[direction]({
|
||||
readFile: readHistoryFile,
|
||||
writeFile: writeHistoryFile,
|
||||
});
|
||||
if (!result.ok && result.reason === "content-mismatch") {
|
||||
showToast(
|
||||
`File changed outside Studio. ${direction === "undo" ? "Undo" : "Redo"} history was not applied.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+1 — sidebar: Compositions tab
|
||||
if (event.key === "1") {
|
||||
event.preventDefault();
|
||||
leftSidebarRef.current?.selectTab("compositions");
|
||||
return;
|
||||
if (result.ok && result.label) {
|
||||
onAfterUndoRedo?.();
|
||||
await syncHistoryPreviewAfterApply(result.paths);
|
||||
showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
|
||||
}
|
||||
},
|
||||
[
|
||||
editHistory,
|
||||
readHistoryFile,
|
||||
showToast,
|
||||
syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves,
|
||||
writeHistoryFile,
|
||||
onAfterUndoRedo,
|
||||
],
|
||||
);
|
||||
|
||||
// Cmd/Ctrl+2 — sidebar: Assets tab
|
||||
if (event.key === "2") {
|
||||
event.preventDefault();
|
||||
leftSidebarRef.current?.selectTab("assets");
|
||||
return;
|
||||
}
|
||||
const handleUndo = useCallback(() => applyHistory("undo"), [applyHistory]);
|
||||
const handleRedo = useCallback(() => applyHistory("redo"), [applyHistory]);
|
||||
|
||||
// Cmd/Ctrl+C — copy (only preventDefault if we actually have something to copy)
|
||||
const copyPasteKey = event.key.toLowerCase();
|
||||
if (
|
||||
copyPasteKey === "c" &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
if (handleCopyRef.current()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ── Stable callback ref (one ref replaces fifteen) ──
|
||||
|
||||
// Cmd/Ctrl+V — paste
|
||||
if (
|
||||
copyPasteKey === "v" &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
void handlePasteRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+X — cut (only preventDefault if there's a selected element to cut)
|
||||
if (
|
||||
copyPasteKey === "x" &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
const hasSelection =
|
||||
!!usePlayerStore.getState().selectedElementId || !!domEditSelectionRef.current;
|
||||
if (hasSelection) {
|
||||
event.preventDefault();
|
||||
void handleCutRef.current();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// F — toggle fullscreen preview
|
||||
if (
|
||||
event.key.toLowerCase() === "f" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// S — split selected clip at playhead
|
||||
if (
|
||||
event.key === "s" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
||||
if (selectedElementId) {
|
||||
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
||||
if (
|
||||
element &&
|
||||
canSplitElement(element) &&
|
||||
currentTime > element.start &&
|
||||
currentTime < element.start + element.duration
|
||||
) {
|
||||
event.preventDefault();
|
||||
void handleSplitRef.current(element, currentTime);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// B — toggle razor tool
|
||||
if (
|
||||
STUDIO_RAZOR_TOOL_ENABLED &&
|
||||
event.key.toLowerCase() === "b" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
const { activeTool, setActiveTool } = usePlayerStore.getState();
|
||||
setActiveTool(activeTool === "razor" ? "select" : "razor");
|
||||
return;
|
||||
}
|
||||
|
||||
// V — return to selection tool
|
||||
if (
|
||||
event.key.toLowerCase() === "v" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
usePlayerStore.getState().setActiveTool("select");
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — exit razor mode (only when no selection to deselect first)
|
||||
if (event.key === "Escape" && !isEditableTarget(event.target)) {
|
||||
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
|
||||
usePlayerStore.getState();
|
||||
if (activeTool === "razor") {
|
||||
if (selectedElementId) {
|
||||
setSelectedElementId(null);
|
||||
} else {
|
||||
setActiveTool("select");
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
|
||||
if (
|
||||
(event.key === "Delete" || event.key === "Backspace") &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
// Priority: selected keyframes take precedence over clip deletion
|
||||
const { selectedKeyframes } = usePlayerStore.getState();
|
||||
if (selectedKeyframes.size > 0) {
|
||||
onDeleteSelectedKeyframesRef.current();
|
||||
usePlayerStore.getState().clearSelectedKeyframes();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace: try resetting keyframes first; fall through to delete if none found
|
||||
if (event.key === "Backspace") {
|
||||
const { selectedElementId, keyframeCache } = usePlayerStore.getState();
|
||||
if (selectedElementId && keyframeCache.has(selectedElementId)) {
|
||||
if (onResetKeyframesRef.current()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { selectedElementId, elements } = usePlayerStore.getState();
|
||||
if (selectedElementId) {
|
||||
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
||||
if (element) {
|
||||
event.preventDefault();
|
||||
void handleDeleteRef.current(element);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const domSelection = domEditSelectionRef.current;
|
||||
if (domSelection) {
|
||||
event.preventDefault();
|
||||
void handleDomEditDeleteRef.current(domSelection);
|
||||
}
|
||||
}
|
||||
|
||||
// R — toggle gesture recording
|
||||
if (
|
||||
event.key === "r" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target) &&
|
||||
onToggleRecordingRef.current
|
||||
) {
|
||||
event.preventDefault();
|
||||
onToggleRecordingRef.current();
|
||||
}
|
||||
const cbRef = useRef<HotkeyCallbacks>(null!);
|
||||
cbRef.current = {
|
||||
toggleTimelineVisibility,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineElementSplit,
|
||||
handleDomEditElementDelete,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handleCopy,
|
||||
handlePaste,
|
||||
handleCut,
|
||||
onResetKeyframes,
|
||||
onDeleteSelectedKeyframes,
|
||||
onToggleRecording,
|
||||
leftSidebarRef,
|
||||
domEditSelectionRef,
|
||||
};
|
||||
|
||||
// ── Window keydown listener ──
|
||||
// ── Keydown dispatch ──
|
||||
|
||||
const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
const cb = cbRef.current;
|
||||
if (shouldHandleTimelineToggleHotkey(event)) {
|
||||
event.preventDefault();
|
||||
cb.toggleTimelineVisibility();
|
||||
return;
|
||||
}
|
||||
const key = event.key.toLowerCase();
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
dispatchModifierKey(event, key, cb);
|
||||
return;
|
||||
}
|
||||
if (!isEditableTarget(event.target)) dispatchPlainKey(event, key, cb);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
function handleAppKeyDown(event: KeyboardEvent) {
|
||||
handleAppKeyDownRef.current?.(event);
|
||||
}
|
||||
window.addEventListener("keydown", handleAppKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleAppKeyDown, true);
|
||||
}, []);
|
||||
}, [handleAppKeyDown]);
|
||||
|
||||
// ── Preview iframe keydown forwarding ──
|
||||
|
||||
const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => {
|
||||
handleAppKeyDownRef.current?.(event);
|
||||
}, []);
|
||||
// ── Preview iframe forwarding ──
|
||||
|
||||
const syncPreviewTimelineHotkey = useCallback(
|
||||
(iframe: HTMLIFrameElement | null) => {
|
||||
const nextWindow = iframeContentWindow(iframe);
|
||||
if (previewHotkeyWindowRef.current === nextWindow) return;
|
||||
if (previewHotkeyWindowRef.current) {
|
||||
try {
|
||||
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
|
||||
} catch {
|
||||
/* cross-origin iframe */
|
||||
}
|
||||
}
|
||||
safeRemoveListener(
|
||||
previewHotkeyWindowRef.current,
|
||||
"keydown",
|
||||
handleAppKeyDown as EventListener,
|
||||
);
|
||||
previewHotkeyWindowRef.current = nextWindow;
|
||||
try {
|
||||
nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
|
||||
} catch {
|
||||
/* cross-origin iframe */
|
||||
}
|
||||
safeAddListener(nextWindow, "keydown", handleAppKeyDown as EventListener, true);
|
||||
},
|
||||
[previewAppKeyDownHandler],
|
||||
[handleAppKeyDown],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (previewHotkeyWindowRef.current) {
|
||||
try {
|
||||
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
|
||||
} catch {
|
||||
/* cross-origin iframe */
|
||||
}
|
||||
previewHotkeyWindowRef.current = null;
|
||||
}
|
||||
safeRemoveListener(
|
||||
previewHotkeyWindowRef.current,
|
||||
"keydown",
|
||||
handleAppKeyDown as EventListener,
|
||||
);
|
||||
previewHotkeyWindowRef.current = null;
|
||||
},
|
||||
[previewAppKeyDownHandler],
|
||||
[handleAppKeyDown],
|
||||
);
|
||||
|
||||
// ── History hotkey for iframe forwarding ──
|
||||
|
||||
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) return;
|
||||
if (shouldIgnoreHistoryShortcut(event.target)) return;
|
||||
if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return;
|
||||
handleUndoRedoKey(
|
||||
event,
|
||||
() => void handleUndoRef.current(),
|
||||
() => void handleRedoRef.current(),
|
||||
() => void cbRef.current.handleUndo(),
|
||||
() => void cbRef.current.handleRedo(),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const syncPreviewHistoryHotkey = useCallback(
|
||||
(iframe: HTMLIFrameElement | null) => {
|
||||
previewHistoryHotkeyCleanupRef.current?.();
|
||||
previewHistoryHotkeyCleanupRef.current = null;
|
||||
|
||||
previewHistoryCleanupRef.current?.();
|
||||
previewHistoryCleanupRef.current = null;
|
||||
const win = iframeContentWindow(iframe);
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
@@ -521,19 +419,11 @@ export function useAppHotkeys({
|
||||
doc = null;
|
||||
}
|
||||
if (!win && !doc) return;
|
||||
|
||||
try {
|
||||
win?.addEventListener("keydown", handleHistoryHotkey, true);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
const handler = handleHistoryHotkey as EventListener;
|
||||
safeAddListener(win, "keydown", handler, true);
|
||||
doc?.addEventListener("keydown", handleHistoryHotkey, true);
|
||||
previewHistoryHotkeyCleanupRef.current = () => {
|
||||
try {
|
||||
win?.removeEventListener("keydown", handleHistoryHotkey, true);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
previewHistoryCleanupRef.current = () => {
|
||||
safeRemoveListener(win, "keydown", handler);
|
||||
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
||||
};
|
||||
},
|
||||
@@ -542,8 +432,8 @@ export function useAppHotkeys({
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
previewHistoryHotkeyCleanupRef.current?.();
|
||||
previewHistoryHotkeyCleanupRef.current = null;
|
||||
previewHistoryCleanupRef.current?.();
|
||||
previewHistoryCleanupRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user