Skip to content

Commit 5cc8422

Browse files
committed
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.
1 parent 7bc1de6 commit 5cc8422

2 files changed

Lines changed: 297 additions & 43 deletions

File tree

packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx

Lines changed: 112 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
// @vitest-environment happy-dom
22
import { act } from "react";
3-
import { describe, expect, it, vi } from "vitest";
4-
import { createRoot } from "react-dom/client";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { createRoot, type Root } from "react-dom/client";
55
import { usePlayerStore } from "../player/store/playerStore";
66
import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard";
7+
import {
8+
clearAutomationClipboard,
9+
copyRange,
10+
readClipboard,
11+
} from "../player/components/automationClipboard";
12+
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
713
import type {
814
AutomationLaneBinding,
915
UseAutomationLanesResult,
@@ -32,25 +38,54 @@ const key = (k: string) => {
3238
act(() => void document.dispatchEvent(e));
3339
};
3440

41+
/** Cmd/Ctrl-modified key combo, returning the event so tests can inspect
42+
* `defaultPrevented` for the "falls through" cases. */
43+
const combo = (k: string) => {
44+
const e = new KeyboardEvent("keydown", {
45+
key: k,
46+
metaKey: true,
47+
bubbles: true,
48+
cancelable: true,
49+
});
50+
act(() => void document.dispatchEvent(e));
51+
return e;
52+
};
53+
3554
describe("useAutomationSelectionKeyboard", () => {
55+
// Each setup() mounts a Host whose effect adds a document-level keydown
56+
// listener. Without unmounting the previous one, listeners from earlier
57+
// tests linger and can consume later tests' events first (stopping
58+
// propagation before the current test's own listener ever runs) — so this
59+
// must run before every test, not just the ones that call setup() twice.
60+
let mountedRoot: { root: Root; host: HTMLElement } | null = null;
61+
afterEach(() => {
62+
if (!mountedRoot) return;
63+
act(() => mountedRoot?.root.unmount());
64+
mountedRoot.host.remove();
65+
mountedRoot = null;
66+
});
67+
3668
const setup = (binding: Partial<AutomationLaneBinding>) => {
3769
const onCommit = vi.fn();
38-
const lanes: UseAutomationLanesResult = {
39-
bind: () => ({
40-
automation: {
41-
version: 1,
42-
lanes: [
43-
{
44-
target: "volume",
45-
points: [
46-
{ t: 0, v: 1 },
47-
{ t: 2, v: 0.5 },
48-
{ t: 4, v: 0 },
49-
],
50-
},
70+
const automation = {
71+
version: 1,
72+
lanes: [
73+
{
74+
target: "volume",
75+
points: [
76+
{ t: 0, v: 1 },
77+
{ t: 2, v: 0.5 },
78+
{ t: 4, v: 0 },
5179
],
5280
},
53-
lanes: [],
81+
],
82+
};
83+
const lanes: UseAutomationLanesResult = {
84+
bind: () => ({
85+
automation,
86+
// Same list as `automation.lanes`, matching useAutomationLanes' real
87+
// binding — the paste fallback (no active selection) reads this.
88+
lanes: automation.lanes,
5489
chain: null,
5590
onPreview: vi.fn(),
5691
onCommit,
@@ -64,7 +99,9 @@ describe("useAutomationSelectionKeyboard", () => {
6499
};
65100
const host = document.createElement("div");
66101
document.body.append(host);
67-
act(() => createRoot(host).render(<Host lanes={lanes} />));
102+
const root = createRoot(host);
103+
act(() => root.render(<Host lanes={lanes} />));
104+
mountedRoot = { root, host };
68105
return { onCommit };
69106
};
70107

@@ -101,4 +138,62 @@ describe("useAutomationSelectionKeyboard", () => {
101138
expect(onCommit).not.toHaveBeenCalled();
102139
input.remove();
103140
});
141+
142+
it("Cmd+C copies the active selection", () => {
143+
clearAutomationClipboard();
144+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
145+
usePlayerStore
146+
.getState()
147+
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
148+
setup({});
149+
combo("c");
150+
const entry = readClipboard();
151+
expect(entry?.span).toBe(2);
152+
expect(entry?.points.map((p) => p.t)).toEqual([0, 2]);
153+
});
154+
155+
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => {
156+
clearAutomationClipboard();
157+
// Duration wide enough that the playhead (5s) is not clamped down by the
158+
// 0..duration-span bound — this is a paste-at-playhead test, not a
159+
// clamp-boundary test.
160+
usePlayerStore.setState({
161+
elements: [{ ...bgmElement, duration: 10 }],
162+
selectedElementId: "bgm",
163+
currentTime: 5,
164+
});
165+
usePlayerStore
166+
.getState()
167+
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
168+
const { onCommit } = setup({});
169+
combo("c");
170+
expect(readClipboard()?.span).toBe(2);
171+
usePlayerStore.getState().clearAutomationSelection();
172+
173+
combo("v");
174+
const written = onCommit.mock.calls.at(-1)?.[0];
175+
const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
176+
expect(times).toContain(5); // playhead 5s − element start 0
177+
expect(times).toContain(7); // + clipboard span 2
178+
179+
// Pasting again immediately should land right after the first paste.
180+
expect(usePlayerStore.getState().automationSelection).toEqual({
181+
elementKey: "bgm",
182+
target: "volume",
183+
t0: 5,
184+
t1: 7,
185+
});
186+
});
187+
188+
it("Cmd+V with clipboard content but no resolvable element falls through", () => {
189+
clearAutomationClipboard();
190+
copyRange({ target: "volume", points: [{ t: 0, v: 1 }] }, VOLUME_RANGE, 0, 1);
191+
expect(readClipboard()).not.toBeNull();
192+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null });
193+
usePlayerStore.getState().clearAutomationSelection();
194+
const { onCommit } = setup({});
195+
const e = combo("v");
196+
expect(e.defaultPrevented).toBe(false);
197+
expect(onCommit).not.toHaveBeenCalled();
198+
});
104199
});

0 commit comments

Comments
 (0)