Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions packages/studio/src/hooks/useAppHotkeys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { dispatchPlainKey } from "./useAppHotkeys";
import { usePlayerStore } from "../player/store/playerStore";
import type { TimelineElement } from "../player/store/timelineElement";

/** Minimal valid fixture — TimelineElement only requires these five fields. */
const bgmElement: TimelineElement = {
id: "bgm",
key: "bgm",
tag: "audio",
start: 0,
duration: 6,
track: 0,
};

/** Every callback dispatchPlainKey can reach, so a test can assert which one
* a key resolved to. Unannotated on purpose: the parameter type is not
* exported, and structural inference checks it at the call site. */
function callbacks() {
return {
handleTimelineElementDelete: vi.fn(async () => {}),
handleTimelineElementSplit: vi.fn(async () => {}),
handleDomEditElementDelete: vi.fn(async () => {}),
handleUndo: vi.fn(async () => {}),
handleRedo: vi.fn(async () => {}),
handleCopy: vi.fn(() => false),
handlePaste: vi.fn(async () => {}),
handleCut: vi.fn(async () => false),
onResetKeyframes: vi.fn(() => true),
onDeleteSelectedKeyframes: vi.fn(),
showToast: vi.fn(),
leftSidebarRef: { current: null },
domEditSelectionRef: { current: null },
};
}

const press = (key: string) =>
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });

afterEach(() => {
usePlayerStore.getState().clearAutomationSelection();
usePlayerStore.setState({
elements: [],
selectedElementId: null,
selectedElementIds: new Set<string>(),
selectedKeyframes: new Set<string>(),
});
});

describe("dispatchPlainKey — Delete arbitration", () => {
const selectBgm = () =>
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });

const selectRange = () =>
usePlayerStore
.getState()
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });

it("deletes the selected clip when no automation range is active", () => {
selectBgm();
const cb = callbacks();
const e = press("Delete");
dispatchPlainKey(e, "delete", cb);
// The pre-existing contract, pinned so the new guard cannot widen.
expect(cb.handleTimelineElementDelete).toHaveBeenCalledTimes(1);
expect(e.defaultPrevented).toBe(true);
});

it("leaves the clip alone when an automation range is active", () => {
// The bug: this listener is on window/capture so it runs BEFORE
// useAutomationSelectionKeyboard's document/capture handler. Without the
// guard, clearing a 2s automation range deleted the whole audio clip.
selectBgm();
selectRange();
const cb = callbacks();
const e = press("Delete");
dispatchPlainKey(e, "delete", cb);
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
// Must NOT be consumed: the automation handler downstream still needs it.
expect(e.defaultPrevented).toBe(false);
});

it("leaves keyframe reset alone when an automation range is active", () => {
// Backspace's reset-keyframes branch sits below the guard, so it has to be
// covered too — otherwise Backspace wiped every keyframe on the clip.
selectBgm();
usePlayerStore.setState({
keyframeCache: new Map([["bgm", { targets: [], version: 0 }]]),
});
selectRange();
const cb = callbacks();
const e = press("Backspace");
dispatchPlainKey(e, "backspace", cb);
expect(cb.onResetKeyframes).not.toHaveBeenCalled();
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
});

it("still lets a keyframe selection win over an automation range", () => {
// Ordering: the keyframe guard precedes the automation one, so a keyframe
// selection keeps Delete even with a range showing.
selectBgm();
selectRange();
usePlayerStore.setState({ selectedKeyframes: new Set(["bgm:opacity:0"]) });
const cb = callbacks();
const e = press("Delete");
dispatchPlainKey(e, "delete", cb);
expect(cb.onDeleteSelectedKeyframes).toHaveBeenCalledTimes(1);
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(true);
});
});
12 changes: 11 additions & 1 deletion packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,10 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
}

// fallow-ignore-next-line complexity
function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
/** 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();
Expand Down Expand Up @@ -288,6 +291,13 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
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()) {
Expand Down
104 changes: 104 additions & 0 deletions packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// @vitest-environment happy-dom
import { act } from "react";
import { describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { usePlayerStore } from "../player/store/playerStore";
import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard";
import type {
AutomationLaneBinding,
UseAutomationLanesResult,
} from "../player/components/useAutomationLanes";
import type { TimelineElement } from "../player/store/timelineElement";

/** Minimal valid fixture — TimelineElement only requires these five fields. */
const bgmElement: TimelineElement = {
id: "bgm",
key: "bgm",
tag: "audio",
start: 0,
duration: 6,
track: 0,
};

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

function Host({ lanes }: { lanes: UseAutomationLanesResult }) {
useAutomationSelectionKeyboard({ lanes });
return null;
}

const key = (k: string) => {
const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true });
act(() => void document.dispatchEvent(e));
};

describe("useAutomationSelectionKeyboard", () => {
const setup = (binding: Partial<AutomationLaneBinding>) => {
const onCommit = vi.fn();
const lanes: UseAutomationLanesResult = {
bind: () => ({
automation: {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 0.5 },
{ t: 4, v: 0 },
],
},
],
},
lanes: [],
chain: null,
onPreview: vi.fn(),
onCommit,
onSelect: vi.fn(),
readOnly: false,
selection: null,
onRangeSelect: vi.fn(),
onRangeClear: vi.fn(),
...binding,
}),
};
const host = document.createElement("div");
document.body.append(host);
act(() => createRoot(host).render(<Host lanes={lanes} />));
return { onCommit };
};

it("Delete empties the selected range and pins anchors", () => {
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 });
const { onCommit } = setup({});
key("Delete");
const written = onCommit.mock.calls.at(-1)?.[0];
const points = written?.lanes?.[0]?.points ?? [];
expect(points.map((p: { t: number }) => p.t)).toEqual([0, 1, 3, 4]);
});

it("Escape clears the selection", () => {
usePlayerStore
.getState()
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 });
setup({});
key("Escape");
expect(usePlayerStore.getState().automationSelection).toBeNull();
});

it("is inert while a text input has focus", () => {
usePlayerStore
.getState()
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 });
const { onCommit } = setup({});
const input = document.createElement("input");
document.body.append(input);
input.focus();
key("Delete");
expect(onCommit).not.toHaveBeenCalled();
input.remove();
});
});
79 changes: 79 additions & 0 deletions packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Keyboard surface for the active automation selection: Escape clears,
* Delete/Backspace empties the range (anchors pinned, envelope outside
* untouched). Sibling of useKeyframeKeyboard and copies its contract:
* capture phase so playback shortcuts cannot swallow keys we act on, inert
* while any text input has focus, and a key is only consumed when it does
* something.
*/
import { useEffect } from "react";
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
import { laneFor, withLane } from "../player/components/automationLaneGeometry";
import { replaceRange } from "../player/components/automationLaneSelection";
import { resolveAutomationRange, type HfAutomation } from "@hyperframes/core/audio-automation";
import type { AutomationSelection } from "../player/store/automationSelectionSlice";
import type { UseAutomationLanesResult } from "../player/components/useAutomationLanes";

function isTextInput(el: Element | null): boolean {
if (!el) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
return el instanceof HTMLElement && el.isContentEditable;
}

/**
* The write that empties the active selection, or null when there is nothing
* to do: the clip is gone, its lane is read-only, the target no longer
* resolves to a range, or the lane already has no points in it. Split out of
* the keydown handler so each stays under the complexity a single branch of
* keyboard dispatch should carry.
*/
function resolveDeleteWrite(
state: { elements: TimelineElement[]; selectedElementId: string | null },
lanes: UseAutomationLanesResult,
sel: AutomationSelection,
): { onCommit(next: HfAutomation): void; next: HfAutomation } | null {
const element = state.elements.find((el) => (el.key ?? el.id) === sel.elementKey);
if (!element) return null;
const binding = lanes.bind(element, sel.elementKey === state.selectedElementId);
if (binding.readOnly) return null;
const lane = laneFor(binding.automation, sel.target);
const range = resolveAutomationRange(sel.target, binding.chain ?? undefined);
if (!range || lane.points.length === 0) return null;
const points = replaceRange({ lane, range, t0: sel.t0, t1: sel.t1, inner: [] });
return {
onCommit: binding.onCommit,
next: withLane(binding.automation, { target: sel.target, points }),
};
}

export function useAutomationSelectionKeyboard({
lanes,
}: {
lanes: UseAutomationLanesResult;
}): void {
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
if (isTextInput(document.activeElement)) return;
const state = usePlayerStore.getState();
const sel = state.automationSelection;
if (!sel) return;

if (e.key === "Escape") {
state.clearAutomationSelection();
return;
}
const isDeleteKey = e.key === "Delete" || e.key === "Backspace";
if (!isDeleteKey || e.metaKey || e.ctrlKey) return;

const write = resolveDeleteWrite(state, lanes, sel);
if (!write) return;

e.preventDefault();
e.stopImmediatePropagation();
write.onCommit(write.next);
};
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);
}, [lanes]);
}
Loading