From 7bc1de6840067599f5d05a8f7aa8e6411a8c4659 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 11:01:29 -0700 Subject: [PATCH 1/2] feat(studio): internal clipboard for automation ranges --- .../components/automationClipboard.test.ts | 60 +++++++++++++++++++ .../player/components/automationClipboard.ts | 54 +++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 packages/studio/src/player/components/automationClipboard.test.ts create mode 100644 packages/studio/src/player/components/automationClipboard.ts diff --git a/packages/studio/src/player/components/automationClipboard.test.ts b/packages/studio/src/player/components/automationClipboard.test.ts new file mode 100644 index 0000000000..58d6e2572b --- /dev/null +++ b/packages/studio/src/player/components/automationClipboard.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearAutomationClipboard, + copyRange, + pastePoints, + readClipboard, +} from "./automationClipboard"; +import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation"; +import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; + +const duck: HfAutomationLane = { + target: "volume", + points: [ + { t: 2, v: 1, curve: -0.4 }, + { t: 3, v: 0.25 }, + { t: 4, v: 1 }, + ], +}; + +beforeEach(clearAutomationClipboard); + +describe("automation clipboard", () => { + it("copies the range rebased to zero", () => { + copyRange(duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard(); + expect(entry?.span).toBe(2); + expect(entry?.points.map((p) => p.t)).toEqual([0, 1, 2]); + expect(entry?.points[0]?.curve).toBe(-0.4); + }); + + it("pastes at a new time on the same axis unchanged", () => { + copyRange(duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard(); + expect(entry).not.toBeNull(); + if (!entry) return; + const pts = pastePoints(entry, VOLUME_RANGE, 10); + expect(pts.map((p) => p.t)).toEqual([10, 11, 12]); + expect(pts.map((p) => p.v)).toEqual([1, 0.25, 1]); + }); + + it("maps values through unit space onto a different parameter", () => { + const wet = resolveAutomationRange("fx.r.wet", { + version: 1, + nodes: [{ type: "reverb", id: "r", params: {} }], + }); + expect(wet).toBeTruthy(); + if (!wet) return; + copyRange(duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard(); + if (!entry) return; + const pts = pastePoints(entry, wet, 0); + // volume 1 (unit 1) → wet max; volume 0.25 (unit 0.25) → a quarter up wet's axis + expect(pts[0]?.v).toBeCloseTo(wet.max, 5); + expect(pts[1]?.v).toBeCloseTo(wet.min + 0.25 * (wet.max - wet.min), 5); + }); + + it("reads null when nothing was copied", () => { + expect(readClipboard()).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/automationClipboard.ts b/packages/studio/src/player/components/automationClipboard.ts new file mode 100644 index 0000000000..764ea9c570 --- /dev/null +++ b/packages/studio/src/player/components/automationClipboard.ts @@ -0,0 +1,54 @@ +/** + * Internal clipboard for automation ranges. Module-level, not the OS + * clipboard — points are not text, and useClipboard is already the DOM-element + * channel. Values cross parameters through unit space, so a volume duck + * pasted onto a log-scaled wet knob lands proportionally, not literally. + */ +import type { + AutomationRange, + HfAutomationLane, + HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; +import { fromUnit, toUnit } from "./automationLaneGeometry"; +import { pointsIn } from "./automationLaneSelection"; + +export interface AutomationClipboardEntry { + sourceRange: AutomationRange; + span: number; + points: HfAutomationPoint[]; +} + +let entry: AutomationClipboardEntry | null = null; + +export function copyRange( + lane: HfAutomationLane, + range: AutomationRange, + t0: number, + t1: number, +): void { + entry = { + sourceRange: range, + span: t1 - t0, + points: pointsIn(lane, t0, t1).map((p) => ({ ...p, t: p.t - t0 })), + }; +} + +export function readClipboard(): AutomationClipboardEntry | null { + return entry; +} + +export function pastePoints( + from: AutomationClipboardEntry, + target: AutomationRange, + atT: number, +): HfAutomationPoint[] { + return from.points.map((p) => ({ + ...p, + t: atT + p.t, + v: fromUnit(target, toUnit(from.sourceRange, p.v)), + })); +} + +export function clearAutomationClipboard(): void { + entry = null; +} From 5cc8422da242ef080a445438cf98e0610e7de369 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 11:24:26 -0700 Subject: [PATCH 2/2] 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. --- .../useAutomationSelectionKeyboard.test.tsx | 129 +++++++++-- .../hooks/useAutomationSelectionKeyboard.ts | 211 +++++++++++++++--- 2 files changed, 297 insertions(+), 43 deletions(-) diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx index fd24507356..7966634485 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx @@ -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) => { 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()); + const root = createRoot(host); + act(() => root.render()); + 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(); + }); }); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts index 786a0c683e..4135afedcf 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts @@ -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; 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);