feat(studio): copy and paste automation ranges across lanes

Extends the automation-selection keyboard hook with Cmd/Ctrl+C (copy the
active range) and Cmd/Ctrl+V (paste onto the selected clip's lane, at the
selection's start or the playhead, chaining the selection to the pasted
span so a second paste lands right after the first). Paste falls through
untouched when no target lane resolves, so clip-level paste keeps working.

Also fixes a latent test-isolation bug: setup() never unmounted the
previous test's Host, so document keydown listeners leaked across tests
and could consume later events before the current test's own listener ran.
This commit is contained in:
Vance Ingalls
2026-08-12 00:47:32 -07:00
parent c9a1fd17e3
commit 6cd3798716
2 changed files with 297 additions and 43 deletions
@@ -1,9 +1,15 @@
// @vitest-environment happy-dom
import { act } from "react";
import { describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { usePlayerStore } from "../player/store/playerStore";
import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard";
import {
clearAutomationClipboard,
copyRange,
readClipboard,
} from "../player/components/automationClipboard";
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
import type {
AutomationLaneBinding,
UseAutomationLanesResult,
@@ -32,25 +38,54 @@ const key = (k: string) => {
act(() => void document.dispatchEvent(e));
};
/** Cmd/Ctrl-modified key combo, returning the event so tests can inspect
* `defaultPrevented` for the "falls through" cases. */
const combo = (k: string) => {
const e = new KeyboardEvent("keydown", {
key: k,
metaKey: true,
bubbles: true,
cancelable: true,
});
act(() => void document.dispatchEvent(e));
return e;
};
describe("useAutomationSelectionKeyboard", () => {
// Each setup() mounts a Host whose effect adds a document-level keydown
// listener. Without unmounting the previous one, listeners from earlier
// tests linger and can consume later tests' events first (stopping
// propagation before the current test's own listener ever runs) — so this
// must run before every test, not just the ones that call setup() twice.
let mountedRoot: { root: Root; host: HTMLElement } | null = null;
afterEach(() => {
if (!mountedRoot) return;
act(() => mountedRoot?.root.unmount());
mountedRoot.host.remove();
mountedRoot = null;
});
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 },
],
},
const automation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 0.5 },
{ t: 4, v: 0 },
],
},
lanes: [],
],
};
const lanes: UseAutomationLanesResult = {
bind: () => ({
automation,
// Same list as `automation.lanes`, matching useAutomationLanes' real
// binding — the paste fallback (no active selection) reads this.
lanes: automation.lanes,
chain: null,
onPreview: vi.fn(),
onCommit,
@@ -64,7 +99,9 @@ describe("useAutomationSelectionKeyboard", () => {
};
const host = document.createElement("div");
document.body.append(host);
act(() => createRoot(host).render(<Host lanes={lanes} />));
const root = createRoot(host);
act(() => root.render(<Host lanes={lanes} />));
mountedRoot = { root, host };
return { onCommit };
};
@@ -101,4 +138,62 @@ describe("useAutomationSelectionKeyboard", () => {
expect(onCommit).not.toHaveBeenCalled();
input.remove();
});
it("Cmd+C copies the active selection", () => {
clearAutomationClipboard();
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
setup({});
combo("c");
const entry = readClipboard();
expect(entry?.span).toBe(2);
expect(entry?.points.map((p) => p.t)).toEqual([0, 2]);
});
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => {
clearAutomationClipboard();
// Duration wide enough that the playhead (5s) is not clamped down by the
// 0..duration-span bound — this is a paste-at-playhead test, not a
// clamp-boundary test.
usePlayerStore.setState({
elements: [{ ...bgmElement, duration: 10 }],
selectedElementId: "bgm",
currentTime: 5,
});
usePlayerStore
.getState()
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
const { onCommit } = setup({});
combo("c");
expect(readClipboard()?.span).toBe(2);
usePlayerStore.getState().clearAutomationSelection();
combo("v");
const written = onCommit.mock.calls.at(-1)?.[0];
const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
expect(times).toContain(5); // playhead 5s − element start 0
expect(times).toContain(7); // + clipboard span 2
// Pasting again immediately should land right after the first paste.
expect(usePlayerStore.getState().automationSelection).toEqual({
elementKey: "bgm",
target: "volume",
t0: 5,
t1: 7,
});
});
it("Cmd+V with clipboard content but no resolvable element falls through", () => {
clearAutomationClipboard();
copyRange({ target: "volume", points: [{ t: 0, v: 1 }] }, VOLUME_RANGE, 0, 1);
expect(readClipboard()).not.toBeNull();
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null });
usePlayerStore.getState().clearAutomationSelection();
const { onCommit } = setup({});
const e = combo("v");
expect(e.defaultPrevented).toBe(false);
expect(onCommit).not.toHaveBeenCalled();
});
});
@@ -1,18 +1,32 @@
/**
* 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.
* untouched), Cmd/Ctrl+C copies it, Cmd/Ctrl+V pastes at the selection's
* start (or the playhead) onto the selected clip's lane. 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 — paste in
* particular must fall through untouched when no lane can take it, so
* clip-level paste keeps working.
*/
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 { copyRange, pastePoints, readClipboard } from "../player/components/automationClipboard";
import {
resolveAutomationRange,
type AutomationRange,
type HfAutomation,
type HfAutomationLane,
} from "@hyperframes/core/audio-automation";
import type { AutomationSelection } from "../player/store/automationSelectionSlice";
import type { UseAutomationLanesResult } from "../player/components/useAutomationLanes";
import type {
AutomationLaneBinding,
UseAutomationLanesResult,
} from "../player/components/useAutomationLanes";
type PlayerState = ReturnType<typeof usePlayerStore.getState>;
function isTextInput(el: Element | null): boolean {
if (!el) return false;
@@ -21,6 +35,40 @@ function isTextInput(el: Element | null): boolean {
return el instanceof HTMLElement && el.isContentEditable;
}
/** Clamp `v` to `[min, max]`, tolerating an inverted range (max < min). */
function clamp(v: number, min: number, max: number): number {
return Math.min(Math.max(v, min), Math.max(min, max));
}
/** A `TimelineElement`'s identity as the selection and lane bindings key by. */
function elementKeyOf(element: TimelineElement): string {
return element.key ?? element.id;
}
function findElement(elements: TimelineElement[], key: string | null): TimelineElement | null {
if (!key) return null;
return elements.find((el) => elementKeyOf(el) === key) ?? null;
}
/**
* A selection's element, binding, lane and range — the resolution Delete and
* copy both need. Null when the clip is gone, its lane is read-only, or the
* target no longer resolves to a range.
*/
function resolveSelectionContext(
state: PlayerState,
lanes: UseAutomationLanesResult,
sel: AutomationSelection,
): { binding: AutomationLaneBinding; lane: HfAutomationLane; range: AutomationRange } | null {
const element = findElement(state.elements, sel.elementKey);
if (!element) return null;
const binding = lanes.bind(element, sel.elementKey === state.selectedElementId);
if (binding.readOnly) return null;
const range = resolveAutomationRange(sel.target, binding.chain ?? undefined);
if (!range) return null;
return { binding, lane: laneFor(binding.automation, sel.target), range };
}
/**
* 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
@@ -29,24 +77,140 @@ function isTextInput(el: Element | null): boolean {
* keyboard dispatch should carry.
*/
function resolveDeleteWrite(
state: { elements: TimelineElement[]; selectedElementId: string | null },
state: PlayerState,
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: [] });
const ctx = resolveSelectionContext(state, lanes, sel);
if (!ctx || ctx.lane.points.length === 0) return null;
const points = replaceRange({
lane: ctx.lane,
range: ctx.range,
t0: sel.t0,
t1: sel.t1,
inner: [],
});
return {
onCommit: binding.onCommit,
next: withLane(binding.automation, { target: sel.target, points }),
onCommit: ctx.binding.onCommit,
next: withLane(ctx.binding.automation, { target: sel.target, points }),
};
}
/**
* The lane target Cmd+V writes to: the active selection's, when the
* selection belongs to the same clip the paste is landing on, else the
* clip's first automation lane. A selection left over on a different clip
* does not redirect the paste.
*/
function pasteTargetName(
binding: AutomationLaneBinding,
elementKey: string,
sel: AutomationSelection | null,
): string | undefined {
if (sel && sel.elementKey === elementKey) return sel.target;
return binding.lanes[0]?.target;
}
/**
* Where Cmd+V lands, or null when nothing is selected, the clip's lanes are
* read-only, or it has no automation lane to fall back to.
*/
function resolvePasteTarget(
state: PlayerState,
lanes: UseAutomationLanesResult,
sel: AutomationSelection | null,
): {
elementKey: string;
element: TimelineElement;
target: string;
binding: AutomationLaneBinding;
lane: HfAutomationLane;
range: AutomationRange;
} | null {
const element = findElement(state.elements, state.selectedElementId);
if (!element) return null;
const elementKey = elementKeyOf(element);
const binding = lanes.bind(element, true);
if (binding.readOnly) return null;
const target = pasteTargetName(binding, elementKey, sel);
if (!target) return null;
const range = resolveAutomationRange(target, binding.chain ?? undefined);
if (!range) return null;
return { elementKey, element, target, binding, lane: laneFor(binding.automation, target), range };
}
/**
* Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the
* active selection's start or the playhead. Returns false (untouched event)
* when the combo doesn't match, there is nothing to paste, or no lane can
* take it — clip-level paste needs the fall-through in that last case.
* Checked ahead of the "no selection" guard in the handler below: paste must
* work from the playhead with no active selection at all.
*/
function handlePaste(
e: KeyboardEvent,
state: PlayerState,
lanes: UseAutomationLanesResult,
): boolean {
if (!((e.metaKey || e.ctrlKey) && e.key === "v")) return false;
const clip = readClipboard();
if (!clip) return false;
const sel = state.automationSelection;
const paste = resolvePasteTarget(state, lanes, sel);
if (!paste) return false;
const atT =
sel && sel.elementKey === paste.elementKey
? sel.t0
: clamp(state.currentTime - paste.element.start, 0, paste.element.duration - clip.span);
const t1 = atT + clip.span;
const inner = pastePoints(clip, paste.range, atT);
const points = replaceRange({ lane: paste.lane, range: paste.range, t0: atT, t1, inner });
e.preventDefault();
e.stopImmediatePropagation();
paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points }));
// Covers the pasted span so an immediate second Cmd+V chains right after
// this one instead of overwriting it.
state.setAutomationSelection({ elementKey: paste.elementKey, target: paste.target, t0: atT, t1 });
return true;
}
/** Cmd/Ctrl+C on the active selection. Returns false when the combo doesn't
* match or the selection no longer resolves to a copyable lane. */
function handleCopy(
e: KeyboardEvent,
state: PlayerState,
lanes: UseAutomationLanesResult,
sel: AutomationSelection,
): boolean {
if (!((e.metaKey || e.ctrlKey) && e.key === "c")) return false;
const ctx = resolveSelectionContext(state, lanes, sel);
if (!ctx) return false;
copyRange(ctx.lane, ctx.range, sel.t0, sel.t1);
e.preventDefault();
e.stopImmediatePropagation();
return true;
}
/** Delete/Backspace on the active selection. Returns false when the key
* doesn't match or there is nothing to empty. */
function handleDelete(
e: KeyboardEvent,
state: PlayerState,
lanes: UseAutomationLanesResult,
sel: AutomationSelection,
): boolean {
const isDeleteKey = e.key === "Delete" || e.key === "Backspace";
if (!isDeleteKey || e.metaKey || e.ctrlKey) return false;
const write = resolveDeleteWrite(state, lanes, sel);
if (!write) return false;
e.preventDefault();
e.stopImmediatePropagation();
write.onCommit(write.next);
return true;
}
export function useAutomationSelectionKeyboard({
lanes,
}: {
@@ -56,6 +220,8 @@ export function useAutomationSelectionKeyboard({
const handler = (e: KeyboardEvent): void => {
if (isTextInput(document.activeElement)) return;
const state = usePlayerStore.getState();
if (handlePaste(e, state, lanes)) return;
const sel = state.automationSelection;
if (!sel) return;
@@ -63,15 +229,8 @@ export function useAutomationSelectionKeyboard({
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);
if (handleCopy(e, state, lanes, sel)) return;
handleDelete(e, state, lanes, sel);
};
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);