mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user