From 30192257135e14575494064ab5898443b6bcfb93 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 11:01:29 -0700 Subject: [PATCH 1/3] 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 2ac86f1c3b4ce93d457016168e36db7d41754bae Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 11:24:26 -0700 Subject: [PATCH 2/3] 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); From ce90d046b2b299efedf1cc7da66b45adb90b271b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 6 Aug 2026 09:01:39 -0700 Subject: [PATCH 3/3] fix(studio): repair the automation paste path and finish the key arbitration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste was the least safe path in this feature: it resolved its target from the player store but committed through a different, asynchronously-lagging channel. Six review findings against this branch, plus the Cmd+C/Cmd+V half of the arbitration wa-15 started for Delete. - Write channel: resolvePasteTarget bails unless the binding's commitTargetKey equals the element it resolved. useAutomationLanes exposes that key, resolved through resolveTimelineIdForSelection — the same resolver applyDomSelection uses — and read in the same render as the commit handlers, so a handler and the key cannot describe different moments. Before this, clicking clip B then immediately pasting serialized B's automation onto A and left B untouched. - Chaining: the paste anchor comes from sel.t1, not sel.t0, so a second Cmd+V lands after the first instead of on top of it. The old comment claimed the new behaviour while the code did the opposite, and no test pressed Cmd+V twice. - Empty copy: copyRange returns false rather than arming a clipboard whose every paste is a destructive flatten, and samples the range's edges so copying a smooth stretch yields a real segment instead of no points. - Playhead: the playhead branch requires the playhead to be inside the clip rather than silently clamping an out-of-clip playhead to the clip's start. - Keys: one chord helper normalizes with toLowerCase() and gates on !shiftKey && !altKey, matching useAppHotkeys. CapsLock no longer kills the shortcut and Ctrl+Alt+V no longer pastes where the app declines. - Arbitration: useAppHotkeys consults automationOwnsKey before its c/v branch, so an active range keeps Cmd+C/Cmd+V from the clip clipboard the same way it keeps Delete. Without it Cmd+V duplicated the clip while the automation paste wrote the same file, and Cmd+C armed both clipboards. It returns without preventDefault — the downstream handler needs the key — and declines when the automation clipboard is empty so clip paste still works. dispatchModifierKey is exported to pin this, like dispatchPlainKey. - Double-action: the hook now returns early on e.defaultPrevented. useAppHotkeys is on window/capture and deliberately lets a keyframe selection outrank a range on Delete; without this that press deleted the keyframes there AND emptied the range here. - Project scoping: the clipboard scopes itself. Every entry point carries the project it speaks for and a mismatch empties the module, the shape keyframeSlice already uses to discard a request from a previous session. Scoping it inside the module rather than clearing it from the session seam is deliberate: the failure is silent and destructive — a range copied in project A pasted into B is remapped through A's captured sourceRange for an FX node B may not have, and the keystroke is consumed so clip paste never runs — so no future caller should be able to forget the guard. The mark isLastPasteSpan reads is scoped transitively, through the same check. - Session reset: createTimelineResetState clears automationSelection. It is as ephemeral as selectedKeyframes, and a range surviving a project switch can match a same-keyed clip in the new project and redirect a paste through sel.elementKey === paste.elementKey to a stale t0. Five of the six paste fixes above shipped without a test that fails without them, which is how the branch reached review with a comment describing chaining that the code did not do. Each now has one: a second Cmd+V landing after the first, a commit-target mismatch declining, an out-of-clip playhead declining, an empty-lane copy leaving an earlier clipboard intact, and Cmd+V with CapsLock on. All five fail against this branch's parent. --- .../studio/src/contexts/DomEditContext.tsx | 7 + .../studio/src/hooks/useAppHotkeys.test.ts | 87 ++++++++- packages/studio/src/hooks/useAppHotkeys.ts | 18 +- .../useAutomationSelectionKeyboard.test.tsx | 115 +++++++++++- .../hooks/useAutomationSelectionKeyboard.ts | 165 +++++++++++++++--- .../TimelineAutomationLaneSlot.test.tsx | 1 + .../components/automationClipboard.test.ts | 33 +++- .../player/components/automationClipboard.ts | 96 +++++++++- .../player/components/useAutomationLanes.ts | 37 +++- .../src/player/store/playerStore.test.ts | 15 ++ .../studio/src/player/store/playerStore.ts | 4 + 11 files changed, 531 insertions(+), 47 deletions(-) diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index b12134fd84..16778fe8b3 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -116,6 +116,13 @@ export function useDomEditSelectionContext(): DomEditSelectionValue { return ctx; } +/** Optional counterpart to useDomEditActionsContextOptional — same reason: the + * player package's own components mount outside a provider in standalone and + * test trees, where "no dom-edit selection" is the correct answer. */ +export function useDomEditSelectionContextOptional(): DomEditSelectionValue | null { + return useContext(DomEditSelectionContext); +} + /** @deprecated Prefer useDomEditActionsContext or useDomEditSelectionContext. */ export function useDomEditContext(): DomEditValue { return { ...useDomEditActionsContext(), ...useDomEditSelectionContext() }; diff --git a/packages/studio/src/hooks/useAppHotkeys.test.ts b/packages/studio/src/hooks/useAppHotkeys.test.ts index 7d7d88fc1d..2920d0f309 100644 --- a/packages/studio/src/hooks/useAppHotkeys.test.ts +++ b/packages/studio/src/hooks/useAppHotkeys.test.ts @@ -1,7 +1,9 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; -import { dispatchPlainKey } from "./useAppHotkeys"; +import { dispatchModifierKey, dispatchPlainKey } from "./useAppHotkeys"; import { usePlayerStore } from "../player/store/playerStore"; +import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard"; +import { VOLUME_RANGE } from "@hyperframes/core/audio-automation"; import type { TimelineElement } from "../player/store/timelineElement"; /** Minimal valid fixture — TimelineElement only requires these five fields. */ @@ -38,7 +40,11 @@ function callbacks() { const press = (key: string) => new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); +const chord = (key: string) => + new KeyboardEvent("keydown", { key, metaKey: true, bubbles: true, cancelable: true }); + afterEach(() => { + clearAutomationClipboard(); usePlayerStore.getState().clearAutomationSelection(); usePlayerStore.setState({ elements: [], @@ -111,3 +117,82 @@ describe("dispatchPlainKey — Delete arbitration", () => { expect(e.defaultPrevented).toBe(true); }); }); + +describe("dispatchModifierKey — Cmd+C/Cmd+V arbitration", () => { + const clip: TimelineElement = { + id: "bgm", + key: "bgm", + tag: "audio", + start: 0, + duration: 6, + track: 0, + }; + + it("lets the clip clipboard have Cmd+C when no automation range is active", () => { + usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" }); + const cb = callbacks(); + dispatchModifierKey(chord("c"), "c", cb); + expect(cb.handleCopy).toHaveBeenCalled(); + }); + + it("keeps Cmd+C from the clip clipboard when an automation range is active", () => { + // Both clipboards arming on one press double-wrote and toasted "Copied clip". + usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" }); + usePlayerStore.getState().setAutomationSelection({ + elementKey: "bgm", + target: "volume", + t0: 1, + t1: 3, + }); + const cb = callbacks(); + const e = chord("c"); + expect(dispatchModifierKey(e, "c", cb)).toBe(true); + expect(cb.handleCopy).not.toHaveBeenCalled(); + // No preventDefault: the automation handler downstream still needs the key. + expect(e.defaultPrevented).toBe(false); + }); + + it("lets the clip clipboard have Cmd+V when the automation clipboard is empty", () => { + // Nothing to paste means nothing to claim — the clip paste should still run. + clearAutomationClipboard(); + usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" }); + usePlayerStore.getState().setAutomationSelection({ + elementKey: "bgm", + target: "volume", + t0: 1, + t1: 3, + }); + const cb = callbacks(); + dispatchModifierKey(chord("v"), "v", cb); + expect(cb.handlePaste).toHaveBeenCalled(); + }); + + it("keeps Cmd+V from duplicating the clip while an automation paste is pending", () => { + clearAutomationClipboard(); + copyRange( + null, + { + target: "volume", + points: [ + { t: 1, v: 1 }, + { t: 3, v: 0.25 }, + ], + }, + VOLUME_RANGE, + 1, + 3, + ); + usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" }); + usePlayerStore.getState().setAutomationSelection({ + elementKey: "bgm", + target: "volume", + t0: 1, + t1: 3, + }); + const cb = callbacks(); + const e = chord("v"); + dispatchModifierKey(e, "v", cb); + expect(cb.handlePaste).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 9998b50d98..82152a2c1b 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -1,4 +1,5 @@ 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"; @@ -158,7 +159,14 @@ interface HotkeyCallbacks { showToast: (message: string, tone?: "error" | "info") => void; } -function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean { +/** 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( @@ -196,6 +204,14 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba } 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(); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx index 7966634485..a7f57d4b68 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx @@ -91,6 +91,7 @@ describe("useAutomationSelectionKeyboard", () => { onCommit, onSelect: vi.fn(), readOnly: false, + commitTargetKey: "bgm", selection: null, onRangeSelect: vi.fn(), onRangeClear: vi.fn(), @@ -147,7 +148,7 @@ describe("useAutomationSelectionKeyboard", () => { .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); setup({}); combo("c"); - const entry = readClipboard(); + const entry = readClipboard(null); expect(entry?.span).toBe(2); expect(entry?.points.map((p) => p.t)).toEqual([0, 2]); }); @@ -167,7 +168,7 @@ describe("useAutomationSelectionKeyboard", () => { .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); const { onCommit } = setup({}); combo("c"); - expect(readClipboard()?.span).toBe(2); + expect(readClipboard(null)?.span).toBe(2); usePlayerStore.getState().clearAutomationSelection(); combo("v"); @@ -185,10 +186,116 @@ describe("useAutomationSelectionKeyboard", () => { }); }); + it("chains a second Cmd+V after the first instead of overwriting it", () => { + // The regression this pins: paste leaves its own span selected, so anchoring + // at sel.t0 unconditionally made every later press recompute the same atT. + clearAutomationClipboard(); + usePlayerStore.setState({ + elements: [{ ...bgmElement, duration: 10 }], + selectedElementId: "bgm", + }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({}); + combo("c"); + + combo("v"); + const first = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map( + (p: { t: number }) => p.t, + ); + expect(first).toContain(2); + expect(first).toContain(4); + + combo("v"); + const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map( + (p: { t: number }) => p.t, + ); + expect(second).toContain(4); + expect(second).toContain(6); + expect(usePlayerStore.getState().automationSelection).toEqual({ + elementKey: "bgm", + target: "volume", + t0: 4, + t1: 6, + }); + }); + + it("refuses to paste when the dom-edit layer would write to a different clip", () => { + // selectedElementId says "bgm" but the commit channel is still on the + // previously selected clip — writing here would serialize bgm's automation + // onto that other clip and leave bgm untouched. + clearAutomationClipboard(); + copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({ commitTargetKey: "some-other-clip" }); + const e = combo("v"); + expect(e.defaultPrevented).toBe(false); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("does not paste from a playhead outside the clip", () => { + // No selection on this clip, and the playhead is past its end — there is no + // anchor. This used to collapse to the clip's own t=0. + clearAutomationClipboard(); + usePlayerStore.setState({ + elements: [bgmElement], + selectedElementId: "bgm", + currentTime: 2, + }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({}); + combo("c"); + usePlayerStore.getState().clearAutomationSelection(); + usePlayerStore.setState({ currentTime: 50 }); + onCommit.mockClear(); + + const e = combo("v"); + expect(e.defaultPrevented).toBe(false); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("Cmd+C over an empty lane leaves an earlier clipboard alone", () => { + // An empty capture is byte-identical to the Delete payload, so arming the + // clipboard with it turns every later Cmd+V into a destructive flatten. + clearAutomationClipboard(); + copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 3); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 }); + setup({ automation: { version: 1, lanes: [{ target: "volume", points: [] }] } }); + + const e = combo("c"); + expect(e.defaultPrevented).toBe(false); + expect(readClipboard(null)?.span).toBe(3); + }); + + it("pastes with CapsLock on, where e.key is an uppercase V", () => { + clearAutomationClipboard(); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({}); + combo("C"); + expect(readClipboard(null)?.span).toBe(2); + + onCommit.mockClear(); + const e = combo("V"); + expect(e.defaultPrevented).toBe(true); + expect(onCommit).toHaveBeenCalled(); + }); + 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(); + copyRange(null, { target: "volume", points: [{ t: 0, v: 1 }] }, VOLUME_RANGE, 0, 1); + expect(readClipboard(null)).not.toBeNull(); usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null }); usePlayerStore.getState().clearAutomationSelection(); const { onCommit } = setup({}); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts index 4135afedcf..354fd7342b 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts @@ -5,21 +5,32 @@ * 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. + * focus, and a key is only consumed when it does something. + * + * Falling through is NOT enough to keep clip-level copy/paste working: + * useAppHotkeys listens on `window` with capture, so it always runs before this + * document-level listener and `stopImmediatePropagation` here comes too late. + * `automationOwnsKey` below is the arbitration that actually works — the + * central dispatcher asks it first and stands down. */ 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 { copyRange, pastePoints, readClipboard } from "../player/components/automationClipboard"; +import { + copyRange, + isLastPasteSpan, + markLastPaste, + pastePoints, + readClipboard, +} from "../player/components/automationClipboard"; import { resolveAutomationRange, type AutomationRange, type HfAutomation, type HfAutomationLane, } from "@hyperframes/core/audio-automation"; +import { clampNumber } from "../utils/studioHelpers"; import type { AutomationSelection } from "../player/store/automationSelectionSlice"; import type { AutomationLaneBinding, @@ -35,9 +46,15 @@ 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 Cmd/Ctrl+ chord. `e.key` is normalised because CapsLock makes it + * "V"/"C", and useAppHotkeys already lowercases — a raw `e.key === "v"` test + * would silently drop the keystroke here while that dispatcher still acted on + * it. Shift and Alt are excluded for the same parity reason (useAppHotkeys + * gates its own copy/paste on `!shiftKey && !altKey`). + */ +function isChord(e: KeyboardEvent, letter: string): boolean { + return (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === letter; } /** A `TimelineElement`'s identity as the selection and lane bindings key by. */ @@ -113,7 +130,18 @@ function pasteTargetName( /** * 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. + * read-only, it has no automation lane to fall back to, or the dom-edit layer + * would write the result to a DIFFERENT clip. + * + * That last guard is the one with teeth. `binding.onCommit` persists through + * handleDomAttributeQuietCommit, which targets whatever the dom-edit layer + * currently has selected — not the element `bind()` was handed (see the + * doc-comment on `onSelect` in useAutomationLanes). Selecting a clip in the + * timeline sets `selectedElementId` synchronously but resolves the dom-edit + * selection asynchronously, so clicking clip B and immediately pressing Cmd+V + * would serialize B's automation onto A. Every other lane path is a pointer + * gesture on the lane itself, which cannot run before the selection lands; + * paste is the only one that can, so it refuses rather than write blind. */ function resolvePasteTarget( state: PlayerState, @@ -132,6 +160,7 @@ function resolvePasteTarget( const elementKey = elementKeyOf(element); const binding = lanes.bind(element, true); if (binding.readOnly) return null; + if (binding.commitTargetKey !== elementKey) return null; const target = pasteTargetName(binding, elementKey, sel); if (!target) return null; const range = resolveAutomationRange(target, binding.chain ?? undefined); @@ -139,11 +168,58 @@ function resolvePasteTarget( return { elementKey, element, target, binding, lane: laneFor(binding.automation, target), range }; } +/** Is the playhead over this clip? Mirrors TimelineAutomationLaneSlot's own + * in-clip test, which is what decides a lane draws a playhead at all. */ +function playheadInClip(state: PlayerState, element: TimelineElement): boolean { + return ( + state.currentTime >= element.start && state.currentTime <= element.start + element.duration + ); +} + /** - * 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. + * Clip-local seconds a `span`-wide paste should start at, or null when nothing + * can anchor it. + * + * Three refusals, all of which used to be silent mispastes: + * - A span wider than the clip has nowhere to go. Clamping the start to 0 still + * writes breakpoints past the clip's end and leaves a selection whose far + * edge can never be grabbed again. + * - A playhead outside the clip is not an anchor. It used to collapse to the + * clip's own t=0, so a playhead at 0:00 pasted into the head of a clip + * starting at 0:30. + * - No selection on this clip and no in-clip playhead means no anchor at all. + * + * A repeated Cmd+V chains. Paste leaves its own span selected (the user's only + * feedback that it landed), so anchoring at `sel.t0` unconditionally made the + * second press overwrite the first. When the live selection is exactly the mark + * the last paste left, anchor at its END; a selection the user drew themselves + * still pastes at its start. + */ +function pasteAnchor( + state: PlayerState, + element: TimelineElement, + span: number, + sel: AutomationSelection | null, +): number | null { + if (span > element.duration) return null; + const onThisElement = sel !== null && sel.elementKey === elementKeyOf(element); + const raw = onThisElement + ? isLastPasteSpan(sel) + ? sel.t1 + : sel.t0 + : playheadInClip(state, element) + ? state.currentTime - element.start + : null; + if (raw === null) return null; + // Keeps the whole pasted span inside the clip — including a chain that has + // walked to the end — so its own selection stays grabbable. + return clampNumber(raw, 0, element.duration - span); +} + +/** + * Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the active + * selection or the playhead. Returns false (untouched event) when the chord + * doesn't match, there is nothing to paste, or no lane can take it. * Checked ahead of the "no selection" guard in the handler below: paste must * work from the playhead with no active selection at all. */ @@ -152,17 +228,15 @@ function handlePaste( state: PlayerState, lanes: UseAutomationLanesResult, ): boolean { - if (!((e.metaKey || e.ctrlKey) && e.key === "v")) return false; - const clip = readClipboard(); + if (!isChord(e, "v")) return false; + const clip = readClipboard(state.timelineProjectId); if (!clip) return false; const sel = state.automationSelection; const paste = resolvePasteTarget(state, lanes, sel); if (!paste) return false; + const atT = pasteAnchor(state, paste.element, clip.span, sel); + if (atT === null) 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 }); @@ -170,29 +244,63 @@ function handlePaste( 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 }); + // Select the pasted span — the only feedback that it landed — and mark it, so + // an immediate second Cmd+V recognises this selection as the paste's own and + // chains right after it instead of overwriting it. + const mark = { elementKey: paste.elementKey, target: paste.target, t0: atT, t1 }; + state.setAutomationSelection(mark); + markLastPaste(mark); 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. */ +/** Cmd/Ctrl+C on the active selection. Returns false when the chord doesn't + * match, the selection no longer resolves to a copyable lane, or the lane is + * empty so there is no shape to capture. */ function handleCopy( e: KeyboardEvent, state: PlayerState, lanes: UseAutomationLanesResult, sel: AutomationSelection, ): boolean { - if (!((e.metaKey || e.ctrlKey) && e.key === "c")) return false; + if (!isChord(e, "c")) return false; const ctx = resolveSelectionContext(state, lanes, sel); if (!ctx) return false; - copyRange(ctx.lane, ctx.range, sel.t0, sel.t1); + if (!copyRange(state.timelineProjectId, ctx.lane, ctx.range, sel.t0, sel.t1)) return false; e.preventDefault(); e.stopImmediatePropagation(); return true; } +/** + * Will an automation range claim this keystroke? Asked by useAppHotkeys, which + * listens on `window` with capture and therefore always runs BEFORE this + * hook's document listener — so `stopImmediatePropagation` cannot arbitrate and + * the dispatcher has to stand down of its own accord. Without it Cmd+C armed + * both clipboards and Cmd+V ran the whole clip-duplication path (read file → + * insert → save with history → reload preview) alongside the automation write: + * two async read-modify-writes of one file from one keypress. + * + * Store and clipboard only, no lane binding, so the dispatcher can call it + * without holding the binding factory. That leaves one accepted residual: the + * predicate cannot see a read-only lane, an unresolvable target, or the + * dom-edit target mismatch `resolvePasteTarget` guards, so in those rare cases + * the keystroke is a no-op instead of falling through to clip copy/paste. A + * dead key beats today's double write. + */ +export function automationOwnsKey(e: KeyboardEvent): boolean { + if (isTextInput(document.activeElement)) return false; + const state = usePlayerStore.getState(); + if (isChord(e, "c")) return state.automationSelection !== null; + if (!isChord(e, "v")) return false; + const clip = readClipboard(state.timelineProjectId); + if (!clip) return false; + const element = findElement(state.elements, state.selectedElementId); + if (!element) return false; + // Same anchor resolution the handler uses, so predicate and handler cannot + // disagree about whether the paste has somewhere to land. + return pasteAnchor(state, element, clip.span, state.automationSelection) !== null; +} + /** Delete/Backspace on the active selection. Returns false when the key * doesn't match or there is nothing to empty. */ function handleDelete( @@ -219,6 +327,13 @@ export function useAutomationSelectionKeyboard({ useEffect(() => { const handler = (e: KeyboardEvent): void => { if (isTextInput(document.activeElement)) return; + // Somebody upstream already claimed this key. useAppHotkeys is on + // window/capture so it always runs first, and it deliberately lets a + // keyframe selection outrank an automation range on Delete — without + // this, that keystroke deleted the keyframes there AND emptied the range + // here, two edits from one press. preventDefault does not stop + // propagation, so the claim has to be read, not assumed. + if (e.defaultPrevented) return; const state = usePlayerStore.getState(); if (handlePaste(e, state, lanes)) return; diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx index c08f25af44..3684a85524 100644 --- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx @@ -28,6 +28,7 @@ function mountSlot(binding: Partial) { onCommit: vi.fn(), onSelect: vi.fn(), readOnly: false, + commitTargetKey: "bgm", selection: null, onRangeSelect: vi.fn(), onRangeClear, diff --git a/packages/studio/src/player/components/automationClipboard.test.ts b/packages/studio/src/player/components/automationClipboard.test.ts index 58d6e2572b..38527a5ebf 100644 --- a/packages/studio/src/player/components/automationClipboard.test.ts +++ b/packages/studio/src/player/components/automationClipboard.test.ts @@ -21,16 +21,16 @@ beforeEach(clearAutomationClipboard); describe("automation clipboard", () => { it("copies the range rebased to zero", () => { - copyRange(duck, VOLUME_RANGE, 2, 4); - const entry = readClipboard(); + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard("project-a"); 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(); + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard("project-a"); expect(entry).not.toBeNull(); if (!entry) return; const pts = pastePoints(entry, VOLUME_RANGE, 10); @@ -45,8 +45,8 @@ describe("automation clipboard", () => { }); expect(wet).toBeTruthy(); if (!wet) return; - copyRange(duck, VOLUME_RANGE, 2, 4); - const entry = readClipboard(); + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard("project-a"); 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 @@ -55,6 +55,25 @@ describe("automation clipboard", () => { }); it("reads null when nothing was copied", () => { - expect(readClipboard()).toBeNull(); + expect(readClipboard("project-a")).toBeNull(); + }); + + it("does not hand a range copied in one project to another", () => { + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + expect(readClipboard("project-b")).toBeNull(); + }); + + it("drops the entry for good once another project has read past it", () => { + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + readClipboard("project-b"); + // Not merely hidden from B: switching back must not resurrect a shape whose + // source clip may have been edited or deleted while the project was closed. + expect(readClipboard("project-a")).toBeNull(); + }); + + it("keeps serving the entry inside its own project", () => { + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + expect(readClipboard("project-a")?.span).toBe(2); + expect(readClipboard("project-a")?.span).toBe(2); }); }); diff --git a/packages/studio/src/player/components/automationClipboard.ts b/packages/studio/src/player/components/automationClipboard.ts index 764ea9c570..37bd71a198 100644 --- a/packages/studio/src/player/components/automationClipboard.ts +++ b/packages/studio/src/player/components/automationClipboard.ts @@ -3,11 +3,20 @@ * 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. + * + * Project-scoped by itself rather than by a caller, because the failure is + * destructive and silent: a range copied in project A pasted into B is remapped + * through A's captured `sourceRange` for an FX node B may not even have, and the + * keystroke is consumed so clip paste never runs. Every entry point carries the + * project it is speaking for and a mismatch empties the module, so no future + * caller can forget the guard — the same shape `keyframeSlice` uses to discard a + * request from a previous session. */ -import type { - AutomationRange, - HfAutomationLane, - HfAutomationPoint, +import { + sampleAutomationLane, + type AutomationRange, + type HfAutomationLane, + type HfAutomationPoint, } from "@hyperframes/core/audio-automation"; import { fromUnit, toUnit } from "./automationLaneGeometry"; import { pointsIn } from "./automationLaneSelection"; @@ -18,22 +27,73 @@ export interface AutomationClipboardEntry { points: HfAutomationPoint[]; } +/** The span one paste covered, in the same shape as the selection it left. */ +export interface AutomationPasteMark { + elementKey: string; + target: string; + t0: number; + t1: number; +} + +let ownerProjectId: string | null = null; let entry: AutomationClipboardEntry | null = null; +let lastPaste: AutomationPasteMark | null = null; + +/** + * Rebind the module to `projectId`, discarding anything captured under a + * different one. Called by both entry points, so the mark is scoped + * transitively: `isLastPasteSpan` can only ever see a mark left by a paste in + * the project that most recently read the clipboard. + * + * `null` (no timeline session yet) is a project id like any other — it can hold + * an entry, and the first real session id evicts it. + */ +function useProject(projectId: string | null): void { + if (projectId === ownerProjectId) return; + ownerProjectId = projectId; + entry = null; + lastPaste = null; +} +/** + * Capture `[t0, t1]` rebased to zero. Returns false — leaving the clipboard as + * it was — when the lane has nothing to capture. + * + * `pointsIn` reports only explicit breakpoints, so a range drawn over a smooth + * stretch of envelope has none. Storing that as `points: []` would arm a + * clipboard whose paste is byte-identical to the Delete write, so every later + * Cmd+V would erase its destination instead of reproducing the copied shape. + * Over a lane that HAS an envelope the range is a real flat or sloped segment, + * captured by sampling both edges (what `replaceRange`'s own anchors do). Over + * an entirely empty lane there is no shape at all, so nothing is stored — a + * failed copy leaves an earlier clipboard alone rather than destroying it. + */ export function copyRange( + projectId: string | null, lane: HfAutomationLane, range: AutomationRange, t0: number, t1: number, -): void { +): boolean { + const inner = pointsIn(lane, t0, t1).map((p) => ({ ...p, t: p.t - t0 })); + if (inner.length === 0 && lane.points.length === 0) return false; + useProject(projectId); entry = { sourceRange: range, span: t1 - t0, - points: pointsIn(lane, t0, t1).map((p) => ({ ...p, t: p.t - t0 })), + points: + inner.length > 0 + ? inner + : [ + { t: 0, v: sampleAutomationLane(lane, t0, range.scale) }, + { t: t1 - t0, v: sampleAutomationLane(lane, t1, range.scale) }, + ], }; + return true; } -export function readClipboard(): AutomationClipboardEntry | null { +export function readClipboard(projectId: string | null): AutomationClipboardEntry | null { + useProject(projectId); return entry; } @@ -49,6 +109,28 @@ export function pastePoints( })); } +/** + * Remember the span a paste just covered and left selected, so the next Cmd+V + * can tell that selection apart from one the user drew and chain after it + * instead of overwriting it. + */ +export function markLastPaste(mark: AutomationPasteMark): void { + lastPaste = { ...mark }; +} + +/** True when `mark` is exactly the span the last paste left selected. */ +export function isLastPasteSpan(mark: AutomationPasteMark): boolean { + if (!lastPaste) return false; + return ( + lastPaste.elementKey === mark.elementKey && + lastPaste.target === mark.target && + lastPaste.t0 === mark.t0 && + lastPaste.t1 === mark.t1 + ); +} + export function clearAutomationClipboard(): void { + ownerProjectId = null; entry = null; + lastPaste = null; } diff --git a/packages/studio/src/player/components/useAutomationLanes.ts b/packages/studio/src/player/components/useAutomationLanes.ts index 1274b12499..1862d4d33c 100644 --- a/packages/studio/src/player/components/useAutomationLanes.ts +++ b/packages/studio/src/player/components/useAutomationLanes.ts @@ -18,7 +18,11 @@ import { type HfAutomationLane, } from "@hyperframes/core/audio-automation"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; -import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; +import { + useDomEditActionsContextOptional, + useDomEditSelectionContextOptional, +} from "../../contexts/DomEditContext"; +import { resolveTimelineIdForSelection } from "../../utils/studioHelpers"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { AutomationSelection } from "../store/automationSelectionSlice"; @@ -41,6 +45,15 @@ export interface AutomationLaneBinding { */ onSelect(): void; readOnly: boolean; + /** + * The timeline clip `onCommit`/`onPreview` will ACTUALLY persist to, which is + * whatever the dom-edit layer has selected — not necessarily the element this + * binding was made for (see `onSelect` below). Null outside an edit session or + * when the dom-edit selection maps to no clip. Resolved exactly the way + * applyDomSelection resolves it, so in a settled selection it equals the bound + * element's key; it lags only in the window a non-gesture caller can hit. + */ + commitTargetKey: string | null; /** This element's active time selection, or null if none / it belongs to a * different element. */ selection: AutomationSelection | null; @@ -58,10 +71,23 @@ export function useAutomationLanes(): UseAutomationLanesResult { // Optional: the player also runs outside Studio, where there is no edit // session. There the lanes render read-only, which is the right fallback. const domEdit = useDomEditActionsContextOptional(); + const domEditSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null; + const elements = usePlayerStore((s) => s.elements); const automationSelection = usePlayerStore((s) => s.automationSelection); const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection); const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection); + // Read from the SAME render as the commit handlers below: both contexts update + // in one commit, so a handler and this key can never describe different + // moments. activeCompPath is not needed — resolveTimelineIdForSelection only + // uses it as a fallback for a selection with no sourceFile of its own, and + // DomEditSelection always carries one. + const commitTargetKey = useMemo( + () => + domEditSelection ? resolveTimelineIdForSelection(domEditSelection, elements, null) : null, + [domEditSelection, elements], + ); + const bind = useCallback( (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => { const chain = elementFxChain(element); @@ -93,6 +119,7 @@ export function useAutomationLanes(): UseAutomationLanesResult { // Selecting is its own gesture; the lane goes live after it. onSelect: () => void domEdit?.handleTimelineElementSelect(element), readOnly: !domEdit || !isSelected, + commitTargetKey: domEdit ? commitTargetKey : null, selection: automationSelection?.elementKey === elementKey ? automationSelection : null, onRangeSelect: (target, t0, t1) => { if (!domEdit || !isSelected) return; @@ -101,7 +128,13 @@ export function useAutomationLanes(): UseAutomationLanesResult { onRangeClear: () => clearAutomationSelection(), }; }, - [domEdit, automationSelection, setAutomationSelection, clearAutomationSelection], + [ + domEdit, + commitTargetKey, + automationSelection, + setAutomationSelection, + clearAutomationSelection, + ], ); return useMemo(() => ({ bind }), [bind]); diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 799e3c388c..71ee8bfe3d 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -550,6 +550,21 @@ describe("usePlayerStore", () => { expectResettableDefaults(usePlayerStore.getState()); }); + it("drops an automation time selection on reset and on a project switch", () => { + const sel = { elementKey: "bgm", target: "volume", t0: 1, t1: 2 }; + + usePlayerStore.getState().setAutomationSelection(sel); + usePlayerStore.getState().reset(); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + + // The switch matters more than reset(): a stale elementKey can match a + // same-keyed clip in the new project and redirect a paste to its old t0. + usePlayerStore.getState().beginTimelineSession("project-a"); + usePlayerStore.getState().setAutomationSelection(sel); + usePlayerStore.getState().beginTimelineSession("project-b"); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + }); + it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => { const store = usePlayerStore.getState(); store.setPlaybackRate(2); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index e6274ca347..f7d8abe4a2 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -252,6 +252,10 @@ export function createTimelineResetState() { motionPathArmed: false, motionPathCreateAvailable: false, selectedKeyframes: new Set(), + // Ephemeral like every other selection here. A range surviving a project + // switch can match a same-keyed clip in the new project and redirect a + // paste through `sel.elementKey === paste.elementKey` to a stale t0. + automationSelection: null, expandedClipIds: new Set(), focusedEaseSegment: null, selectedElementIds: new Set(),