Files
hyperframes/packages/studio/src/hooks/useAppHotkeys.ts
T
Miguel Ángel ec0b23f3ce fix(studio): make Delete remove the whole canvas selection (#3339)
* fix(studio): delete every clip in the selection, not just the first

Select all in the timeline, press Delete, and one clip disappeared while the
rest stayed — still drawn as selected.

The Delete hotkey built the selection set correctly and then called
`elements.find(...)`, which stops at the first match, and handed that single
element to a handler that deletes exactly one. The comment above it claimed the
handler "expands a clip that is part of the multi-selection into an atomic
delete of the whole selection (single undo)" — no such expansion existed
anywhere; `useTimelineEditing` never read `selectedElementIds`.

`handleTimelineElementsDelete` takes the whole selection and removes every
element before saving once, so the delete is a single history entry and a single
undo — what the comment already promised. The hotkey layer now takes only that
plural handler, since it never deletes one element in isolation; the singular
entry point stays for the context menu and clip chrome. The store drops every
deleted key and clears the marquee set, rather than leaving a selection drawn
around clips that no longer exist.

Elements whose `sourceFile` is not the composition being edited are dropped from
the pass rather than written to the wrong file.

Also removes the preview's double-click-to-reset-zoom. It was a document-level
capture listener, so any double-click anywhere over the viewport snapped the
zoom back to fit — including double-clicks meant for the content under it. The
explicit reset control beside the zoom HUD stays.

Reproduced by test: restoring `elements.find` reds the new marquee case.

* fix(studio): delete every canvas element in the selection, not just the primary

Selecting several elements on the canvas and pressing Delete removed one of
them and left the rest — still drawn as selected. The delete path only ever
took the primary selection; the marquee group it belongs to was ignored.

Expand the session-level delete through the group ref, the same way the other
group commits already do, and let the lifecycle op remove every member under a
single save so one Undo restores the whole selection.

* fix(studio): let the canvas selection own Delete instead of its timeline mirror

Marquee-selecting elements on the canvas and pressing Delete removed a
fraction of them. The hotkey routed to the timeline delete whenever the
timeline store held anything, and the timeline's copy of a canvas selection is
derived and lossy by construction — a member with no timeline row of its own is
dropped from it. Selecting 73 elements published 14 ids, so 14 went and 59
stayed, still drawn as selected.

The canvas selection is what the user drew the marquee around, so it owns
Delete whenever it holds something; the timeline path stays as the fallback for
rows with no canvas node to select. Both paths already remove through the same
endpoint, so this is one addressing scheme replacing two.

That makes the canvas delete the path a Delete press normally takes, so it
picks up the same mid-recording refusal the timeline delete has.

* fix(studio): let the marquee see the whole document, not the first 80 elements

Dragging a marquee over the entire canvas selected a fraction of what it
covered, so Delete left most of the page behind. The hit test sourced its
candidates from the layers-panel collector, which stops after 80 items — a
budget for how many rows that panel is willing to render, silently reused as if
it described the document. Everything past the 80th element in document order
was unselectable no matter where the user dragged. The off-canvas indicators
were reading the same truncated list.

The cap now belongs to the panel that wants it; the collector returns
everything. To pay for that, the marquee measures its candidates once when the
drag passes the threshold instead of re-reading layout for every element on
every pointer-move: unbounded plus per-move stalled the tab outright, and the
iframe DOM does not mutate mid-drag, so one pass stays true for the gesture.

On a captured page: one marquee, one Delete, 734 elements down to 81.

* fix(studio): report a no-op delete instead of claiming the elements went

A target the file no longer holds answers `changed: false`, which is normal
for a member nested inside another member already removed. Every target
answering that is not — it means the preview is describing a document the file
does not have, so each removal misses and the file is written back untouched.

The toast still said "Deleted 503 elements. Use Undo to restore them." That is
how a delete that did nothing at all looked from the outside: press Delete, the
page stays, nothing on screen explains it. Say the preview is out of date and
reload it instead.

* fix(studio): keep the canvas hotkeys alive across preview reloads

Pressing Delete with a canvas selection did nothing at all — no removal, no
toast, nothing on screen to explain it. A keypress goes to whichever document
has focus, and clicking the canvas puts focus inside the preview iframe, so the
app's hotkeys have to be forwarded there.

They were, but only from the iframe element's ref callback, which fires when
the element mounts. A preview reload keeps the same element, so the callback
never runs again, and keeps the same WindowProxy, so the forwarder's identity
check saw no change and skipped re-attaching — while the inner window holding
the listeners had been replaced. After the first reload the canvas had no app
hotkeys left. Undo and redo kept working because their forwarder re-attaches on
every load, which is why this read as "only Delete is broken".

Fold the app handler into that per-load forwarder so both attach in the same
place, on every load, and drop the mount-only one. Window only: the history
pair also listens on the document, and capture listeners on both would run the
app handler twice per press.

* perf(studio): stop re-probing every restored selection member on load

The hash carries the whole canvas selection, and restoring it asked the
server whether each member still exists in the source — one request per member,
awaited one after another. A marquee over a captured page puts hundreds of
members in the URL, so every later load of that URL spent hundreds of serial
round trips rebuilding the selection before the canvas answered anything,
keypresses included.

The marquee that produced those members already skips the probe. Restoring them
skips it too; only the primary, whose panel reads the flag, still pays for one.

* fix(studio): delete a canvas selection in one pass and say the key landed

Reproduced with a real, focus-routed keypress instead of a synthetic one: the
press does reach the handler and the delete does run to completion, but at
hundreds of members it takes seconds during which the canvas is unchanged and
nothing acknowledges the key. Silence for that long is indistinguishable from
Delete being broken, and pressing it again or reloading mid-flight lands in a
worse state.

Two things, one per cause. The removal now sends the whole selection in a
single request against a new remove-elements route, which reads the file once,
drops every member and writes once — it was a round trip AND a full rewrite of
the file per element. And a multi-element delete announces itself before the
work starts, so the press is visibly acknowledged instead of leaving the canvas
looking untouched until it finishes.

Measured on a captured page, 84 members: 933ms of serial round trips against
84 rewrites, down to 583ms and one.

* refactor(studio): narrow the SDK delete targets instead of asserting them

The batch SDK path guarded on every member having an hfId and then asserted
it away per member. Narrow once into a string list so the guard and the values
come from the same place, and drop a threaded content variable that never
changed — the SDK owns the document it edits, so every member is removed
against the same starting content.

Also mounts the new forwarding test through the existing harness rather than
repeating its setup.

* fix(studio): stop Delete acting on a canvas selection the user replaced

Two things the reordered Delete arbitration got wrong, both found in review.

A clip with no canvas node left the canvas selection pointing at whatever was
picked before it, and the canvas branch wins whenever that ref is non-null — so
selecting an audio clip and pressing Delete removed the previously selected
canvas element and left the clip, right after the toast said the clip was not
in the preview. The timeline fallback the comment described could not be
reached. Clearing that selection has to stay quiet: the clear is announced to
the timeline, so echoing it would deselect the clip that was just picked.

Expanding the primary to the marquee group also moved out of the delete handler
and up to the Delete key. Cut copies the primary alone, so expanding for every
caller put one element on the clipboard and removed every other member with it
— undo brought them back, paste restored one. The rule is a named function now,
so the two callers can differ without either guessing.

Also throttles the off-canvas indicator rebuild, which the cap had been hiding.
It walks every element in the preview and reads layout for each — measured at
6.5ms on an 825-element captured page against a 16.7ms frame — and what marks
it dirty is a MutationObserver on inline style, which is how animation writes.

* fix(studio): hold the canvas selection inside the timeline selection

The stale-canvas-selection defect survived at the second writer. The
store-driven sync bails when a member has not resolved yet and returned without
touching the canvas, so a pick with no canvas node at all left the previous
selection in place — and Delete acts on the canvas first, so it deleted that.
Reachable from the sidebar audio and asset reveals and from an asset drop, none
of which go through the handler already fixed.

Clearing on every bail would be wrong: the bail exists for a member whose node
is not ready, which a later run resolves, and clearing there would flicker.
Only a canvas anchor that resolves OUTSIDE the current selection goes, which is
the state that is dangerous rather than merely unfinished. Quietly, for the same
reason as the first writer: announcing would deselect the clip just picked.

The invariant is named now, since Delete depends on it: the canvas selection
never points outside the current timeline selection.

Also drops the x-hf-removed header, which nothing read and whose comment
promised a partial-vs-no-op distinction the response cannot make, and pins the
indicator throttle that was measured but uncovered.
2026-08-19 00:22:26 -04:00

567 lines
21 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
import { automationOwnsKey } from "./useAutomationSelectionKeyboard";
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 { isTypingTarget } from "../utils/typingTarget";
import { isEditableTarget } from "../utils/timelineDiscovery";
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
import { canSplitElement } from "../utils/timelineElementSplit";
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 {
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
handleDomEditElementDelete: (
selection: DomEditSelection,
options?: { expandGroup?: boolean },
) => 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 {
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
handleDomEditElementDelete: (
selection: DomEditSelection,
options?: { expandGroup?: boolean },
) => 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;
}
/** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
* arbitration between an automation range and the clip clipboard be asserted
* without standing up the whole hook. */
export 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 && !isTypingTarget(event.target)) {
event.preventDefault();
if (event.shiftKey) cb.onUngroupSelection?.();
else cb.onGroupSelection?.();
return true;
}
if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
// An active automation range owns Cmd+C/Cmd+V, the same way it owns Delete
// below. This listener is on window/capture and runs before
// useAutomationSelectionKeyboard's document/capture handler, so without
// this the clip clipboard also claimed the key: Cmd+V duplicated the clip
// while the automation paste wrote the same file, and Cmd+C armed both
// clipboards and toasted "Copied clip". Return without preventDefault so
// the downstream handler still sees the key.
if (automationOwnsKey(event)) return true;
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
/** Exported for tests: the unmodified-key half of the dispatcher, so the
* Delete arbitration between keyframes, an automation range and the clip can
* be asserted without standing up the whole hook. */
export 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 (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;
}
// An active automation range owns Delete: useAutomationSelectionKeyboard
// empties the range in place, pinning the anchors. Fall through WITHOUT
// preventDefault so that document-level handler still sees the key — this
// listener is on window/capture, so it runs first and everything below
// would otherwise win. Without this the press reaches the clip delete
// below and destroys the whole clip the lane belongs to.
if (usePlayerStore.getState().automationSelection) return;
if (event.key === "Backspace") {
const { selectedElementId, keyframeCache } = usePlayerStore.getState();
if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) {
event.preventDefault();
return;
}
}
// The canvas selection is what the user actually drew a marquee around, so
// it owns Delete whenever it holds something. The timeline mirror of that
// selection is derived and lossy — a member with no timeline row of its own
// is dropped from it — so deleting through the timeline removed the handful
// of clips it knew about and left every other selected element behind,
// still drawn as selected. The timeline path stays as the fallback for rows
// with no canvas node to select (audio, a comp that is not the active one).
const domSel = cb.domEditSelectionRef.current;
if (domSel) {
event.preventDefault();
// The whole marquee group, not just the primary the ref holds.
void cb.handleDomEditElementDelete(domSel, { expandGroup: true });
return;
}
// Takes the WHOLE selection: `find` returned the first match, so selecting
// every clip and pressing Delete removed exactly one of them.
const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
const selectionKeys = new Set(selectedElementIds);
if (selectedElementId) selectionKeys.add(selectedElementId);
const selected = elements.filter((e) => selectionKeys.has(e.key ?? e.id));
if (selected.length > 0) {
event.preventDefault();
void cb.handleTimelineElementsDelete(selected);
}
return;
}
if (event.key === "r" && !event.shiftKey && !event.altKey && cb.onToggleRecording) {
event.preventDefault();
cb.onToggleRecording();
}
}
// ── Hook ──
export function useAppHotkeys({
handleTimelineElementsDelete,
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 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 = {
handleTimelineElementsDelete,
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 (!isTypingTarget(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 handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return;
handleUndoRedoKey(
event,
() => void cbRef.current.handleUndo(),
() => void cbRef.current.handleRedo(),
);
}, []);
/**
* Give the preview iframe the app's hotkeys, because a keypress lands in
* whichever document has focus and clicking the canvas puts focus in there.
*
* Must run on every iframe LOAD, not once when the element mounts: a reload
* keeps the same element (so no ref callback) and the same WindowProxy (so an
* identity check sees no change) while replacing the inner window that holds
* the listeners. Attaching once left Delete dead in the canvas after the first
* reload — press it with a selection and nothing happened, no toast, nothing
* to explain it — while undo/redo kept working because they re-attached here.
*/
const syncPreviewHotkeys = 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;
const appHandler = handleAppKeyDown as EventListener;
safeAddListener(win, "keydown", handler, true);
// Window only: the history pair also listens on the document, and a
// capture listener on both would run the app handler twice per press.
safeAddListener(win, "keydown", appHandler, true);
doc?.addEventListener("keydown", handleHistoryHotkey, true);
previewHistoryCleanupRef.current = () => {
safeRemoveListener(win, "keydown", handler);
safeRemoveListener(win, "keydown", appHandler);
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
};
},
[handleAppKeyDown, handleHistoryHotkey],
);
useEffect(
() => () => {
previewHistoryCleanupRef.current?.();
previewHistoryCleanupRef.current = null;
},
[],
);
return {
handleUndo,
handleRedo,
syncPreviewHotkeys,
};
}