mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +00:00
543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
import { useCallback, useEffect, useRef } from "react";
|
|
import { usePlayerStore } from "../player";
|
|
import type { TimelineElement } from "../player";
|
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
|
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
import { isEditableTarget } from "../utils/timelineDiscovery";
|
|
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
|
|
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
|
try {
|
|
return iframe?.contentWindow ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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();
|
|
if (key === "z" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
onUndo();
|
|
return true;
|
|
}
|
|
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
|
event.preventDefault();
|
|
onRedo();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Beat edits live in an in-memory stack interleaved with file history by
|
|
// timestamp. Undo steps to the NEWER op (beatAt >= fileAt); redo replays the
|
|
// inverse, stepping to the OLDER op (beatAt <= fileAt). Returns true when it
|
|
// handled the keystroke (so the file-history path is skipped).
|
|
// fallow-ignore-next-line complexity
|
|
function tryApplyBeatHistory(
|
|
direction: "undo" | "redo",
|
|
fileState: {
|
|
undo: ReadonlyArray<{ createdAt: number }>;
|
|
redo: ReadonlyArray<{ createdAt: number }>;
|
|
},
|
|
showToast: (message: string, tone?: "error" | "info") => void,
|
|
): boolean {
|
|
const ps = usePlayerStore.getState();
|
|
const beatStack = direction === "undo" ? ps.beatUndo : ps.beatRedo;
|
|
const beatAt = beatStack[beatStack.length - 1]?.at ?? null;
|
|
if (beatAt === null) return false;
|
|
const fileStack = fileState[direction];
|
|
const fileAt = fileStack[fileStack.length - 1]?.createdAt ?? null;
|
|
if (fileAt !== null && (direction === "undo" ? beatAt < fileAt : beatAt > fileAt)) return false;
|
|
const label = direction === "undo" ? ps.undoBeatEdits() : ps.redoBeatEdits();
|
|
if (label) showToast(`${direction === "undo" ? "Undid" : "Redid"} ${label}`, "info");
|
|
return true;
|
|
}
|
|
|
|
// ── Types ──
|
|
|
|
interface HistoryResult {
|
|
ok: boolean;
|
|
reason?: string;
|
|
label?: string;
|
|
paths?: string[];
|
|
/** Per-file restored/previous content, used to soft-apply the preview. */
|
|
files?: Record<string, { previous: string; restored: string }>;
|
|
}
|
|
interface HistoryFileCallbacks {
|
|
readFile: (path: string) => Promise<string>;
|
|
writeFile: (path: string, content: string) => Promise<void>;
|
|
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
}
|
|
interface EditHistoryHandle {
|
|
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
state: {
|
|
undo: ReadonlyArray<{ createdAt: number }>;
|
|
redo: ReadonlyArray<{ createdAt: number }>;
|
|
};
|
|
}
|
|
|
|
interface UseAppHotkeysParams {
|
|
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
|
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
clearDomSelectionRef: React.MutableRefObject<() => void>;
|
|
editHistory: EditHistoryHandle;
|
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
readProjectFile: (path: string) => Promise<string>;
|
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
syncHistoryPreviewAfterApply: (restore: {
|
|
paths?: string[];
|
|
files?: Record<string, { previous: string; restored: string }>;
|
|
}) => Promise<void>;
|
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
|
handleCopy: () => boolean;
|
|
handlePaste: () => Promise<void>;
|
|
handleCut: () => Promise<boolean>;
|
|
onResetKeyframes: () => boolean;
|
|
onDeleteSelectedKeyframes: () => void;
|
|
onAfterUndoRedo?: () => void;
|
|
onToggleRecording?: () => void;
|
|
/** Group the current multi-selection into a data-hf-group wrapper (⌘G). */
|
|
onGroupSelection?: () => void;
|
|
/** Ungroup the selected group wrapper (⌘⇧G). */
|
|
onUngroupSelection?: () => void;
|
|
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
|
|
activeCompPath?: string | null;
|
|
/**
|
|
* Force-reload the SDK session after undo/redo reverts the active comp file,
|
|
* bypassing the self-write suppress window. Without this, the suppress window
|
|
* blocks the file-change reload and the SDK session stays on pre-undo content.
|
|
*/
|
|
forceReloadSdkSession?: () => void;
|
|
}
|
|
|
|
// ── Extracted keydown dispatch (pure function, no hooks) ──
|
|
|
|
interface HotkeyCallbacks {
|
|
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;
|
|
onGroupSelection?: () => void;
|
|
onUngroupSelection?: () => void;
|
|
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
}
|
|
|
|
function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean {
|
|
if (
|
|
!shouldIgnoreHistoryShortcut(event.target) &&
|
|
handleUndoRedoKey(
|
|
event,
|
|
() => {
|
|
trackStudioEvent("keyboard_shortcut", { action: "undo" });
|
|
void cb.handleUndo();
|
|
},
|
|
() => {
|
|
trackStudioEvent("keyboard_shortcut", { action: "redo" });
|
|
void cb.handleRedo();
|
|
},
|
|
)
|
|
)
|
|
return true;
|
|
|
|
if (event.key === "1") {
|
|
event.preventDefault();
|
|
trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
|
|
cb.leftSidebarRef.current?.selectTab("compositions");
|
|
return true;
|
|
}
|
|
if (event.key === "2") {
|
|
event.preventDefault();
|
|
trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
|
|
cb.leftSidebarRef.current?.selectTab("assets");
|
|
return true;
|
|
}
|
|
|
|
if (key === "g" && !event.altKey && !isEditableTarget(event.target)) {
|
|
event.preventDefault();
|
|
if (event.shiftKey) cb.onUngroupSelection?.();
|
|
else cb.onGroupSelection?.();
|
|
return true;
|
|
}
|
|
|
|
if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
|
|
if (key === "c") {
|
|
if (cb.handleCopy()) {
|
|
event.preventDefault();
|
|
trackStudioEvent("keyboard_shortcut", { action: "copy" });
|
|
}
|
|
return true;
|
|
}
|
|
if (key === "v") {
|
|
event.preventDefault();
|
|
trackStudioEvent("keyboard_shortcut", { action: "paste" });
|
|
void cb.handlePaste();
|
|
return true;
|
|
}
|
|
if (key === "x") {
|
|
if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
|
|
event.preventDefault();
|
|
trackStudioEvent("keyboard_shortcut", { action: "cut" });
|
|
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) {
|
|
// Reserve bare `s` for Split even when the current selection cannot split,
|
|
// so secondary listeners do not reinterpret the same key as Snap toggle.
|
|
event.preventDefault();
|
|
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
|
|
) {
|
|
void cb.handleTimelineElementSplit(el, currentTime);
|
|
return;
|
|
}
|
|
// Expanded sub-comp children carry a qualified `sourceFile#id` selection
|
|
// that isn't in the raw `elements` list, so the s-key can't resolve them.
|
|
// Nudge toward the razor tool instead of failing silently.
|
|
if (!el && selectedElementId.includes("#")) {
|
|
cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
|
|
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;
|
|
}
|
|
}
|
|
// Delete acts on the primary selection OR the marquee multi-selection —
|
|
// the delete handler expands a clip that is part of the multi-selection
|
|
// into an atomic delete of the whole selection (single undo).
|
|
const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
|
|
const selectionKeys = new Set(selectedElementIds);
|
|
if (selectedElementId) selectionKeys.add(selectedElementId);
|
|
if (selectionKeys.size > 0) {
|
|
const el = elements.find((e) => selectionKeys.has(e.key ?? e.id));
|
|
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({
|
|
handleTimelineElementDelete,
|
|
handleTimelineElementSplit,
|
|
handleDomEditElementDelete,
|
|
domEditSelectionRef,
|
|
editHistory,
|
|
readOptionalProjectFile,
|
|
readProjectFile,
|
|
writeProjectFile,
|
|
domEditSaveTimestampRef,
|
|
showToast,
|
|
syncHistoryPreviewAfterApply,
|
|
waitForPendingDomEditSaves,
|
|
leftSidebarRef,
|
|
handleCopy,
|
|
handlePaste,
|
|
handleCut,
|
|
onResetKeyframes,
|
|
onDeleteSelectedKeyframes,
|
|
onAfterUndoRedo,
|
|
onToggleRecording,
|
|
onGroupSelection,
|
|
onUngroupSelection,
|
|
activeCompPath,
|
|
forceReloadSdkSession,
|
|
}: UseAppHotkeysParams) {
|
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
|
|
|
|
// ── Undo / Redo ──
|
|
|
|
const readHistoryFile = useCallback(
|
|
(path: string): Promise<string> =>
|
|
path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path),
|
|
[readOptionalProjectFile, readProjectFile],
|
|
);
|
|
const writeHistoryFile = useCallback(
|
|
async (path: string, content: string): Promise<void> => {
|
|
domEditSaveTimestampRef.current = Date.now();
|
|
await writeProjectFile(path, content);
|
|
},
|
|
[domEditSaveTimestampRef, writeProjectFile],
|
|
);
|
|
const serializeHistoryFiles = useCallback(
|
|
<T>(paths: readonly string[], task: () => Promise<T>) =>
|
|
serializeStudioFileMutations(writeProjectFile, paths, task),
|
|
[writeProjectFile],
|
|
);
|
|
|
|
const applyHistory = useCallback(
|
|
async (direction: "undo" | "redo") => {
|
|
// Beat edits interleave with file history by timestamp; handle them first.
|
|
if (tryApplyBeatHistory(direction, editHistory.state, showToast)) return;
|
|
|
|
await waitForPendingDomEditSaves();
|
|
const result = await editHistory[direction]({
|
|
readFile: readHistoryFile,
|
|
writeFile: writeHistoryFile,
|
|
serialize: serializeHistoryFiles,
|
|
});
|
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
showToast(
|
|
`File changed outside Studio. ${direction === "undo" ? "Undo" : "Redo"} history was not applied.`,
|
|
"info",
|
|
);
|
|
return;
|
|
}
|
|
if (result.ok && result.label) {
|
|
onAfterUndoRedo?.();
|
|
// If the active composition was among the written files, force-reload
|
|
// the SDK session so its in-memory doc matches the reverted content.
|
|
// writeHistoryFile sets domEditSaveTimestampRef which activates the
|
|
// 2 s suppress window — without this call the file-change event would
|
|
// be swallowed and the SDK session would stay on stale pre-undo content.
|
|
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
forceReloadSdkSession?.();
|
|
}
|
|
await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
|
|
showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
|
|
}
|
|
},
|
|
[
|
|
editHistory,
|
|
readHistoryFile,
|
|
showToast,
|
|
syncHistoryPreviewAfterApply,
|
|
waitForPendingDomEditSaves,
|
|
writeHistoryFile,
|
|
serializeHistoryFiles,
|
|
onAfterUndoRedo,
|
|
activeCompPath,
|
|
forceReloadSdkSession,
|
|
],
|
|
);
|
|
|
|
const handleUndo = useCallback(() => applyHistory("undo"), [applyHistory]);
|
|
const handleRedo = useCallback(() => applyHistory("redo"), [applyHistory]);
|
|
|
|
// ── Stable callback ref (one ref replaces fifteen) ──
|
|
|
|
const cbRef = useRef<HotkeyCallbacks>(null!);
|
|
cbRef.current = {
|
|
handleTimelineElementDelete,
|
|
handleTimelineElementSplit,
|
|
handleDomEditElementDelete,
|
|
handleUndo,
|
|
handleRedo,
|
|
handleCopy,
|
|
handlePaste,
|
|
handleCut,
|
|
onResetKeyframes,
|
|
onDeleteSelectedKeyframes,
|
|
onToggleRecording,
|
|
onGroupSelection,
|
|
onUngroupSelection,
|
|
leftSidebarRef,
|
|
domEditSelectionRef,
|
|
showToast,
|
|
};
|
|
|
|
// ── Keydown dispatch ──
|
|
|
|
const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
|
|
const cb = cbRef.current;
|
|
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(() => {
|
|
window.addEventListener("keydown", handleAppKeyDown, true);
|
|
return () => window.removeEventListener("keydown", handleAppKeyDown, true);
|
|
}, [handleAppKeyDown]);
|
|
|
|
// ── Preview iframe forwarding ──
|
|
|
|
const syncPreviewTimelineHotkey = useCallback(
|
|
(iframe: HTMLIFrameElement | null) => {
|
|
const nextWindow = iframeContentWindow(iframe);
|
|
if (previewHotkeyWindowRef.current === nextWindow) return;
|
|
safeRemoveListener(
|
|
previewHotkeyWindowRef.current,
|
|
"keydown",
|
|
handleAppKeyDown as EventListener,
|
|
);
|
|
previewHotkeyWindowRef.current = nextWindow;
|
|
safeAddListener(nextWindow, "keydown", handleAppKeyDown as EventListener, true);
|
|
},
|
|
[handleAppKeyDown],
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
safeRemoveListener(
|
|
previewHotkeyWindowRef.current,
|
|
"keydown",
|
|
handleAppKeyDown as EventListener,
|
|
);
|
|
previewHotkeyWindowRef.current = null;
|
|
},
|
|
[handleAppKeyDown],
|
|
);
|
|
|
|
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
|
if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return;
|
|
handleUndoRedoKey(
|
|
event,
|
|
() => void cbRef.current.handleUndo(),
|
|
() => void cbRef.current.handleRedo(),
|
|
);
|
|
}, []);
|
|
|
|
const syncPreviewHistoryHotkey = useCallback(
|
|
(iframe: HTMLIFrameElement | null) => {
|
|
previewHistoryCleanupRef.current?.();
|
|
previewHistoryCleanupRef.current = null;
|
|
const win = iframeContentWindow(iframe);
|
|
let doc: Document | null = null;
|
|
try {
|
|
doc = iframe?.contentDocument ?? null;
|
|
} catch {
|
|
doc = null;
|
|
}
|
|
if (!win && !doc) return;
|
|
const handler = handleHistoryHotkey as EventListener;
|
|
safeAddListener(win, "keydown", handler, true);
|
|
doc?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
previewHistoryCleanupRef.current = () => {
|
|
safeRemoveListener(win, "keydown", handler);
|
|
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
};
|
|
},
|
|
[handleHistoryHotkey],
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
previewHistoryCleanupRef.current?.();
|
|
previewHistoryCleanupRef.current = null;
|
|
},
|
|
[],
|
|
);
|
|
|
|
return {
|
|
handleUndo,
|
|
handleRedo,
|
|
syncPreviewTimelineHotkey,
|
|
syncPreviewHistoryHotkey,
|
|
};
|
|
}
|