From 3d6fa8f3c820a961569ea5ade61e40f63199afee Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 02:53:56 -0700 Subject: [PATCH 1/7] feat(studio): pure range ops for automation lane selections Add pointsIn() and replaceRange() functions for managing automation envelope edits within a time range. The key invariant: envelope values outside the selection never move. Implemented by anchoring the boundaries at t0 and t1 by sampling the original lane, so cutting middle sections cannot reshape the rest. Inner points from shape generators can suppress redundant anchors at merge distance. --- .../automationLaneSelection.test.ts | 77 +++++++++++++++++++ .../components/automationLaneSelection.ts | 58 ++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 packages/studio/src/player/components/automationLaneSelection.test.ts create mode 100644 packages/studio/src/player/components/automationLaneSelection.ts diff --git a/packages/studio/src/player/components/automationLaneSelection.test.ts b/packages/studio/src/player/components/automationLaneSelection.test.ts new file mode 100644 index 0000000000..7f4a6d77c2 --- /dev/null +++ b/packages/studio/src/player/components/automationLaneSelection.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { pointsIn, replaceRange } from "./automationLaneSelection"; +import { sampleAutomationLane, VOLUME_RANGE } from "@hyperframes/core/audio-automation"; +import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; + +const ramp: HfAutomationLane = { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 0.6 }, + { t: 3, v: 0.4 }, + { t: 6, v: 0 }, + ], +}; + +describe("pointsIn", () => { + it("returns only the points inside the range, endpoints inclusive", () => { + expect(pointsIn(ramp, 2, 3).map((p) => p.t)).toEqual([2, 3]); + expect(pointsIn(ramp, 2.1, 2.9)).toEqual([]); + }); +}); + +describe("replaceRange", () => { + it("never moves the envelope outside the selection", () => { + // THE invariant. Deleting the middle of a ramp must not reshape the rest. + const next: HfAutomationLane = { + target: "volume", + points: replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: [] }), + }; + for (const t of [0, 0.5, 1.0, 1.5, 3.5, 4, 5, 6]) { + expect(sampleAutomationLane(next, t, "linear")).toBeCloseTo( + sampleAutomationLane(ramp, t, "linear"), + 5, + ); + } + }); + + it("pins anchors at both edges when the interior empties", () => { + const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: [] }); + const times = pts.map((p) => p.t); + expect(times).toContain(1.5); + expect(times).toContain(3.5); + expect(times).not.toContain(2); + expect(times).not.toContain(3); + }); + + it("lets inner points at the edges stand in for the anchors", () => { + // A ramp generator emits its own boundary points; pinning a second anchor + // at the same time would fight it. + const pts = replaceRange({ + lane: ramp, + range: VOLUME_RANGE, + t0: 2, + t1: 3, + inner: [ + { t: 2, v: 0 }, + { t: 3, v: 1 }, + ], + }); + expect(pts.filter((p) => p.t === 2)).toHaveLength(1); + expect(pts.find((p) => p.t === 2)?.v).toBe(0); + }); + + it("sorts and respects the point cap", () => { + const dense = Array.from({ length: 600 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 })); + const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense }); + expect(pts.length).toBeLessThanOrEqual(512); + expect([...pts].sort((a, b) => a.t - b.t)).toEqual(pts); + }); + + it("keeps a constant flat when the lane has no points", () => { + const empty: HfAutomationLane = { target: "volume", points: [] }; + const pts = replaceRange({ lane: empty, range: VOLUME_RANGE, t0: 1, t1: 2, inner: [] }); + // Nothing to preserve, nothing to pin: an empty lane stays empty. + expect(pts).toEqual([]); + }); +}); diff --git a/packages/studio/src/player/components/automationLaneSelection.ts b/packages/studio/src/player/components/automationLaneSelection.ts new file mode 100644 index 0000000000..0b32565e3c --- /dev/null +++ b/packages/studio/src/player/components/automationLaneSelection.ts @@ -0,0 +1,58 @@ +/** + * Range operations over one automation lane. + * + * `replaceRange` is the only mutator every range feature (delete, shapes, + * paste, stretch) composes, and it carries the invariant that makes them safe: + * the envelope OUTSIDE the selection never moves. It samples the lane at both + * edges first and pins anchor points there, so cutting the middle out of a + * ramp cannot reshape the rest of the clip. + * + * Exact for linear segments. A curved segment straddling an edge keeps its + * edge VALUE but reshapes slightly between its own start and the anchor — the + * curve exponent now runs over a shorter span. Accepted: the alternative is + * splitting curves analytically for a difference the ear cannot place. + */ + +import { + MAX_AUTOMATION_POINTS, + sampleAutomationLane, + type AutomationRange, + type HfAutomationLane, + type HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; +import { POINT_MERGE_SEC } from "./automationLaneGeometry"; + +/** Points inside [t0, t1], endpoints inclusive. */ +export function pointsIn(lane: HfAutomationLane, t0: number, t1: number): HfAutomationPoint[] { + return lane.points.filter((p) => p.t >= t0 && p.t <= t1); +} + +/** An anchor, unless `inner` already provides the edge within the merge radius. */ +function anchor( + lane: HfAutomationLane, + range: AutomationRange, + t: number, + inner: readonly HfAutomationPoint[], +): HfAutomationPoint[] { + if (inner.some((p) => Math.abs(p.t - t) <= POINT_MERGE_SEC)) return []; + return [{ t, v: sampleAutomationLane(lane, t, range.scale) }]; +} + +export function replaceRange(input: { + lane: HfAutomationLane; + range: AutomationRange; + t0: number; + t1: number; + inner: HfAutomationPoint[]; +}): HfAutomationPoint[] { + const { lane, range, t0, t1, inner } = input; + // An empty lane draws a flat default; there is nothing to preserve, and + // pinning anchors would turn "no automation" into a constant lane. + if (lane.points.length === 0 && inner.length === 0) return []; + const outside = lane.points.filter((p) => p.t < t0 || p.t > t1); + const edges = + lane.points.length === 0 + ? [] + : [...anchor(lane, range, t0, inner), ...anchor(lane, range, t1, inner)]; + return [...outside, ...edges, ...inner].sort((a, b) => a.t - b.t).slice(0, MAX_AUTOMATION_POINTS); +} From 04d9491544ab0e545f1c9bada7ff5bf1c40d0b5c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 03:05:23 -0700 Subject: [PATCH 2/7] fix(studio): budget replaceRange's inner points before capping, not after --- .../automationLaneSelection.test.ts | 19 +++++++++++++++++++ .../components/automationLaneSelection.ts | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/automationLaneSelection.test.ts b/packages/studio/src/player/components/automationLaneSelection.test.ts index 7f4a6d77c2..a7f0296add 100644 --- a/packages/studio/src/player/components/automationLaneSelection.test.ts +++ b/packages/studio/src/player/components/automationLaneSelection.test.ts @@ -74,4 +74,23 @@ describe("replaceRange", () => { // Nothing to preserve, nothing to pin: an empty lane stays empty. expect(pts).toEqual([]); }); + + it("keeps the far anchor and every outside point when inner would overflow the cap", () => { + const dense = Array.from({ length: 600 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 })); + const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense }); + const times = pts.map((p) => p.t); + expect(times).toContain(1.5); // near anchor + expect(times).toContain(3.5); // far anchor — this is what the bug dropped + expect(times).toContain(0); // outside point before the range + expect(times).toContain(6); // outside point after the range + expect(pts.length).toBeLessThanOrEqual(512); + }); + + it("thins the interior evenly rather than dropping its tail", () => { + const dense = Array.from({ length: 2001 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 })); + const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense }); + const innerTimes = pts.map((p) => p.t).filter((t) => t > 1.5 && t < 3.5); + // Evenly spread across the range, not clustered at the start. + expect(Math.max(...innerTimes)).toBeGreaterThan(3.0); + }); }); diff --git a/packages/studio/src/player/components/automationLaneSelection.ts b/packages/studio/src/player/components/automationLaneSelection.ts index 0b32565e3c..3e8e1fb703 100644 --- a/packages/studio/src/player/components/automationLaneSelection.ts +++ b/packages/studio/src/player/components/automationLaneSelection.ts @@ -38,6 +38,20 @@ function anchor( return [{ t, v: sampleAutomationLane(lane, t, range.scale) }]; } +/** Evenly subsample items to a budget, preserving first and last. */ +function decimateEvenly(items: readonly T[], budget: number): T[] { + if (budget <= 0) return []; + if (items.length <= budget) return [...items]; + if (budget === 1) return [items[0]!]; + const out: T[] = []; + const step = (items.length - 1) / (budget - 1); + for (let i = 0; i < budget; i += 1) { + const item = items[Math.round(i * step)]; + if (item) out.push(item); + } + return out; +} + export function replaceRange(input: { lane: HfAutomationLane; range: AutomationRange; @@ -54,5 +68,7 @@ export function replaceRange(input: { lane.points.length === 0 ? [] : [...anchor(lane, range, t0, inner), ...anchor(lane, range, t1, inner)]; - return [...outside, ...edges, ...inner].sort((a, b) => a.t - b.t).slice(0, MAX_AUTOMATION_POINTS); + const budget = Math.max(0, MAX_AUTOMATION_POINTS - outside.length - edges.length); + const cappedInner = inner.length <= budget ? inner : decimateEvenly(inner, budget); + return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t); } From 3bc085d55f82b2f74f47effa5e2acfd1a3765e73 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 03:12:59 -0700 Subject: [PATCH 3/7] feat(studio): automation selection slice --- .../store/automationSelectionSlice.test.ts | 14 +++++++ .../player/store/automationSelectionSlice.ts | 38 +++++++++++++++++++ .../studio/src/player/store/playerStore.ts | 8 +++- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/player/store/automationSelectionSlice.test.ts create mode 100644 packages/studio/src/player/store/automationSelectionSlice.ts diff --git a/packages/studio/src/player/store/automationSelectionSlice.test.ts b/packages/studio/src/player/store/automationSelectionSlice.test.ts new file mode 100644 index 0000000000..cb1af6c155 --- /dev/null +++ b/packages/studio/src/player/store/automationSelectionSlice.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { usePlayerStore } from "./playerStore"; + +describe("automationSelectionSlice", () => { + it("stores one ordered selection and clears it", () => { + const store = usePlayerStore.getState(); + store.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 1 }); + const sel = usePlayerStore.getState().automationSelection; + // Ordered on write, so every consumer can assume t0 < t1. + expect(sel).toEqual({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 }); + usePlayerStore.getState().clearAutomationSelection(); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/store/automationSelectionSlice.ts b/packages/studio/src/player/store/automationSelectionSlice.ts new file mode 100644 index 0000000000..8af1440237 --- /dev/null +++ b/packages/studio/src/player/store/automationSelectionSlice.ts @@ -0,0 +1,38 @@ +/** + * The active time selection on one automation lane. + * + * A store slice, not lane-local state, for the same reason keyframe selection + * is one: Delete/copy/paste handlers and the shape menu live outside the lane + * component and need to read it. Ephemeral by construction — nothing + * serializes store state, and the selection must never survive into a render. + */ +import type { StoreApi } from "zustand"; + +export interface AutomationSelection { + /** TimelineElement key (key ?? id) of the clip that owns the lane. */ + elementKey: string; + /** Lane target: "volume" or "fx..". */ + target: string; + /** Clip-local seconds; always t0 < t1 (ordered on write). */ + t0: number; + t1: number; +} + +export interface AutomationSelectionSlice { + automationSelection: AutomationSelection | null; + setAutomationSelection: (sel: AutomationSelection) => void; + clearAutomationSelection: () => void; +} + +export function createAutomationSelectionSlice( + set: StoreApi["setState"], +): AutomationSelectionSlice { + return { + automationSelection: null, + setAutomationSelection: (sel) => + set({ + automationSelection: sel.t0 <= sel.t1 ? sel : { ...sel, t0: sel.t1, t1: sel.t0 }, + }), + clearAutomationSelection: () => set({ automationSelection: null }), + }; +} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 275021f300..e6274ca347 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -10,6 +10,10 @@ import { } from "../../utils/studioUiPreferences"; import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom"; import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice"; +import { + createAutomationSelectionSlice, + type AutomationSelectionSlice, +} from "./automationSelectionSlice"; export type { KeyframeCacheEntry } from "./keyframeSlice"; @@ -40,7 +44,7 @@ function resolveElementSelection( }; } -interface PlayerState extends KeyframeSlice { +interface PlayerState extends KeyframeSlice, AutomationSelectionSlice { isPlaying: boolean; currentTime: number; duration: number; @@ -296,6 +300,8 @@ export const usePlayerStore = create((set, get) => ({ timelineSessionEpoch: get().timelineSessionEpoch, })), + ...createAutomationSelectionSlice(set), + activeKeyframePct: null, setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), motionPathArmed: false, From 78364e934292bab90654b6a50a96a95955ac31c0 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 03:23:49 -0700 Subject: [PATCH 4/7] feat(studio): drag-select a time range on an automation lane Dragging on an automation lane's empty background now arms a range selection, snapped to the beat grid and clamped to the lane duration; a sub-3px drag counts as a click and clears instead. Point drags and Alt-drag segment bends still take priority, since the range arm only runs where the existing point/segment hit-test already returned null. useAutomationLanes binds the selection slice per element/lane so the rect renders from the store, matching the read pattern the writes already use. --- .../TimelineAutomationLane.test.tsx | 91 ++++++++++++++----- .../components/TimelineAutomationLane.tsx | 42 +++++++++ .../components/useAutomationLaneGestures.ts | 65 ++++++++++++- .../player/components/useAutomationLanes.ts | 23 ++++- 4 files changed, 190 insertions(+), 31 deletions(-) diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index e4d23529c0..1ae4bca663 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -459,33 +459,33 @@ describe("TimelineAutomationLane", () => { }); }); -describe("TimelineAutomationLane modifiers", () => { - /** The lane's own box, so pointer coordinates map to clip time and value. */ - const BOX = { left: 100, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H }; - - /** x for a clip time, y for a 0..1 unit height, in client coordinates. The - * 6px inset and the height have to match the lane's own, or a point sits - * outside the grab radius and a press silently does nothing. */ - const at = (t: number, unit: number) => ({ - clientX: BOX.left + PAD + (t / 4) * 400, - clientY: BOX.top + 6 + (1 - unit) * (AUTOMATION_LANE_H - 12), - }); - - const mount = (automation: HfAutomation, over: Record = {}) => { - const base = laneProps({ automation, ...over }); - // Narrowed once here: laneProps types these as the prop signature, and every - // assertion below reads the calls the lane made. - const props = { - ...base, - onPreview: base.onPreview as ReturnType, - onCommit: base.onCommit as ReturnType, - }; - const { container } = render(); - const svg = container.querySelector("svg")!; - stubBox(svg, BOX); - return { container, svg, props }; +/** The lane's own box, so pointer coordinates map to clip time and value. */ +const BOX = { left: 100, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H }; + +/** x for a clip time, y for a 0..1 unit height, in client coordinates. The + * 6px inset and the height have to match the lane's own, or a point sits + * outside the grab radius and a press silently does nothing. */ +const at = (t: number, unit: number) => ({ + clientX: BOX.left + PAD + (t / 4) * 400, + clientY: BOX.top + 6 + (1 - unit) * (AUTOMATION_LANE_H - 12), +}); + +const mount = (automation: HfAutomation, over: Record = {}) => { + const base = laneProps({ automation, ...over }); + // Narrowed once here: laneProps types these as the prop signature, and every + // assertion below reads the calls the lane made. + const props = { + ...base, + onPreview: base.onPreview as ReturnType, + onCommit: base.onCommit as ReturnType, }; + const { container } = render(); + const svg = container.querySelector("svg")!; + stubBox(svg, BOX); + return { container, svg, props }; +}; +describe("TimelineAutomationLane modifiers", () => { it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => { // `curve` was honoured everywhere it is read — drawn, sampled in preview, // baked into the render — with no gesture that could set it. @@ -608,3 +608,44 @@ describe("TimelineAutomationLane modifiers", () => { expect(committed?.lanes[0]?.points[0]?.v).toBe(VOLUME_RANGE.max); }); }); + +describe("TimelineAutomationLane range selection", () => { + it("drag on the background selects a range, snapped to the grid", () => { + const onRangeSelect = vi.fn(); + const { svg } = mount(ramp, { snapTimes: [1], onRangeSelect }); + fire(svg, "pointerdown", at(0.98, 0.5)); // background: no point within grab radius + fire(svg, "pointermove", at(3, 0.5)); + fire(svg, "pointerup", at(3, 0.5)); + const last = onRangeSelect.mock.calls.at(-1); + expect(last?.[0]).toBe(1); // snapped to the beat + expect(last?.[1]).toBeCloseTo(3, 1); + }); + + it("a sub-threshold click clears instead of selecting", () => { + const onRangeSelect = vi.fn(); + const onRangeClear = vi.fn(); + const { svg } = mount(ramp, { onRangeSelect, onRangeClear }); + fire(svg, "pointerdown", at(1, 0.5)); + fire(svg, "pointerup", at(1.001, 0.5)); + expect(onRangeSelect).not.toHaveBeenCalled(); + expect(onRangeClear).toHaveBeenCalled(); + }); + + it("draws the selection rect between its endpoints", () => { + const { container } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } }); + const rect = container.querySelector("[data-automation-selection]"); + expect(rect).not.toBeNull(); + expect(Number(rect?.getAttribute("x"))).toBeCloseTo(PAD + 100, 0); // xOf(1) at 400px/4s + expect(Number(rect?.getAttribute("width"))).toBeCloseTo(200, 0); + }); + + it("point drags still win over range selection", () => { + const onRangeSelect = vi.fn(); + const { svg, props } = mount(ramp, { onRangeSelect }); + fire(svg, "pointerdown", at(0, 1)); // exactly on a point + fire(svg, "pointermove", at(1, 0.8)); + fire(svg, "pointerup", at(1, 0.8)); + expect(onRangeSelect).not.toHaveBeenCalled(); + expect(props.onCommit).toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index fb5fca3130..8440c14efc 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -72,6 +72,10 @@ export interface TimelineAutomationLaneProps { readOnly?: boolean; /** Called when a read-only lane is pressed: selects the clip so it goes live. */ onSelect?(): void; + /** Active selection on THIS lane, or null. */ + rangeSelection?: { t0: number; t1: number } | null | undefined; + onRangeSelect?: ((t0: number, t1: number) => void) | undefined; + onRangeClear?: (() => void) | undefined; } export function TimelineAutomationLane({ @@ -89,6 +93,9 @@ export function TimelineAutomationLane({ snapTimes, readOnly, onSelect, + rangeSelection, + onRangeSelect, + onRangeClear, }: TimelineAutomationLaneProps) { const stored = laneFor(automation, target); @@ -183,6 +190,9 @@ export function TimelineAutomationLane({ snapTimes, readOnly, onSelect, + onRangeSelect, + onRangeClear, + duration, }); const { dragIndex, curveIndex, hint, editing } = gestures; @@ -255,6 +265,31 @@ export function TimelineAutomationLane({ stroke="rgba(255,255,255,0.08)" strokeDasharray="3 4" /> + {rangeSelection ? ( + <> + + {[rangeSelection.t0, rangeSelection.t1].map((t) => ( + + ))} + + ) : null} bound.onRangeSelect(lane.target, t0, t1)} + onRangeClear={bound.onRangeClear} /> ); })} diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts index 24c12db9d9..cb00c83efe 100644 --- a/packages/studio/src/player/components/useAutomationLaneGestures.ts +++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts @@ -56,6 +56,10 @@ export interface UseAutomationLaneGesturesInput { snapTimes?: readonly number[] | undefined; readOnly?: boolean | undefined; onSelect?: (() => void) | undefined; + /** Live range-select callbacks; absent = background drags do nothing (read-only lanes). */ + onRangeSelect?: ((t0: number, t1: number) => void) | undefined; + onRangeClear?: (() => void) | undefined; + duration: number; // clamp bound for range endpoints } export interface UseAutomationLaneGesturesResult { @@ -90,6 +94,9 @@ export function useAutomationLaneGestures({ snapTimes, readOnly, onSelect, + onRangeSelect, + onRangeClear, + duration, }: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult { const [dragIndex, setDragIndex] = useState(null); const [curveIndex, setCurveIndex] = useState(null); @@ -98,6 +105,11 @@ export function useAutomationLaneGestures({ const dragOrigin = useRef<{ t: number; v: number } | null>(null); /** Point whose value is being typed, and the text so far. */ const [editing, setEditing] = useState<{ index: number; text: string } | null>(null); + /** A background drag in progress: its start and live end, in clip seconds. */ + const [rangeDrag, setRangeDrag] = useState<{ from: number; to: number } | null>(null); + /** Whether the live drag has crossed the pixel threshold that turns a press + * into an actual range, rather than a click that should just clear one. */ + const rangeCrossed = useRef(false); /** Index of a point under the pointer, or null. */ const hitIndex = useCallback( @@ -155,7 +167,22 @@ export function useAutomationLaneGestures({ return; } const gesture = gestureAt(e); - if (!gesture) return; + if (!gesture) { + // Neither a point nor an Alt-held segment: the press landed on the + // lane's empty background. That is a range selection's gesture, not + // nothing — but only when a caller wants to hear about one; a + // read-only lane already returned above, so this is a live one with no + // range feature wired up. + if (!onRangeSelect) return; + e.preventDefault(); + capturePointer(e); + const raw = pointAt(e.clientX, e.clientY).t; + const clamped = Math.min(duration, Math.max(0, raw)); + const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC); + rangeCrossed.current = false; + setRangeDrag({ from: t, to: t }); + return; + } e.preventDefault(); capturePointer(e); if (gesture.curve) { @@ -165,7 +192,7 @@ export function useAutomationLaneGestures({ dragOrigin.current = originOf(lane.points[gesture.index]); setDragIndex(gesture.index); }, - [gestureAt, lane, readOnly, onSelect], + [gestureAt, lane, readOnly, onSelect, onRangeSelect, pointAt, duration, snapTimes], ); /** Bend the segment under the pointer, which is what Alt-dragging the line does. */ @@ -214,16 +241,46 @@ export function useAutomationLaneGestures({ const onPointerMove = useCallback( (e: ReactPointerEvent): void => { + if (rangeDrag !== null) { + e.stopPropagation(); + const raw = pointAt(e.clientX, e.clientY).t; + const clamped = Math.min(duration, Math.max(0, raw)); + const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC); + setRangeDrag({ from: rangeDrag.from, to: t }); + if (Math.abs(xOf(t) - xOf(rangeDrag.from)) > 3) { + rangeCrossed.current = true; + onRangeSelect?.(Math.min(rangeDrag.from, t), Math.max(rangeDrag.from, t)); + } + return; + } if (curveIndex === null && dragIndex === null) return; e.stopPropagation(); if (curveIndex !== null) bendSegment(e.clientX, e.clientY); else movePoint(e); }, - [bendSegment, curveIndex, dragIndex, movePoint], + [ + rangeDrag, + pointAt, + duration, + snapTimes, + xOf, + onRangeSelect, + bendSegment, + curveIndex, + dragIndex, + movePoint, + ], ); const endDrag = useCallback( (e: ReactPointerEvent): void => { + if (rangeDrag !== null) { + e.stopPropagation(); + if (!rangeCrossed.current) onRangeClear?.(); + rangeCrossed.current = false; + setRangeDrag(null); + return; + } if (dragIndex === null && curveIndex === null) return; e.stopPropagation(); setDragIndex(null); @@ -232,7 +289,7 @@ export function useAutomationLaneGestures({ setHint(null); commitPoints(lane.points, true); }, - [curveIndex, dragIndex, lane, commitPoints], + [rangeDrag, onRangeClear, curveIndex, dragIndex, lane, commitPoints], ); const onDoubleClick = useCallback( diff --git a/packages/studio/src/player/components/useAutomationLanes.ts b/packages/studio/src/player/components/useAutomationLanes.ts index e85f1f3b77..1274b12499 100644 --- a/packages/studio/src/player/components/useAutomationLanes.ts +++ b/packages/studio/src/player/components/useAutomationLanes.ts @@ -19,7 +19,9 @@ import { } from "@hyperframes/core/audio-automation"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; -import type { TimelineElement } from "../store/playerStore"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import type { AutomationSelection } from "../store/automationSelectionSlice"; import { elementAutomation, elementFxChain } from "./automationLaneData"; export interface AutomationLaneBinding { @@ -39,6 +41,13 @@ export interface AutomationLaneBinding { */ onSelect(): void; readOnly: boolean; + /** This element's active time selection, or null if none / it belongs to a + * different element. */ + selection: AutomationSelection | null; + /** Live write while dragging a range on the given lane; does not persist — + * the selection is ephemeral store state, not part of the composition. */ + onRangeSelect(target: string, t0: number, t1: number): void; + onRangeClear(): void; } export interface UseAutomationLanesResult { @@ -49,11 +58,15 @@ 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 automationSelection = usePlayerStore((s) => s.automationSelection); + const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection); + const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection); const bind = useCallback( (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => { const chain = elementFxChain(element); const automation = elementAutomation(element); + const elementKey = getTimelineElementIdentity(element); const write = (next: HfAutomation, persist: boolean): void => { if (!domEdit || !isSelected) return; @@ -80,9 +93,15 @@ export function useAutomationLanes(): UseAutomationLanesResult { // Selecting is its own gesture; the lane goes live after it. onSelect: () => void domEdit?.handleTimelineElementSelect(element), readOnly: !domEdit || !isSelected, + selection: automationSelection?.elementKey === elementKey ? automationSelection : null, + onRangeSelect: (target, t0, t1) => { + if (!domEdit || !isSelected) return; + setAutomationSelection({ elementKey, target, t0, t1 }); + }, + onRangeClear: () => clearAutomationSelection(), }; }, - [domEdit], + [domEdit, automationSelection, setAutomationSelection, clearAutomationSelection], ); return useMemo(() => ({ bind }), [bind]); From b341e2b6f1d20fab76e2c418e851758777898ac9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 03:41:01 -0700 Subject: [PATCH 5/7] feat(studio): delete an automation selection from the keyboard Escape clears the active automation-lane time selection; Delete/Backspace empties it via replaceRange(..., inner: []), which pins anchor points at both edges and leaves the envelope outside the selection untouched. Mounted in TimelineLanes.tsx next to the useAutomationLanes() call that already lives there. Also adds a stale-selection guard in TimelineAutomationLaneSlot that clears the selection if its lane's target stops existing on the bound element's automation (e.g. the automated effect was deleted). --- .../useAutomationSelectionKeyboard.test.tsx | 104 ++++++++++++++++++ .../hooks/useAutomationSelectionKeyboard.ts | 79 +++++++++++++ .../components/TimelineAutomationLane.tsx | 10 ++ .../src/player/components/TimelineLanes.tsx | 20 +--- .../player/components/timelineLaneProps.ts | 19 ++++ 5 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx create mode 100644 packages/studio/src/hooks/useAutomationSelectionKeyboard.ts diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx new file mode 100644 index 0000000000..fd24507356 --- /dev/null +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { usePlayerStore } from "../player/store/playerStore"; +import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard"; +import type { + AutomationLaneBinding, + UseAutomationLanesResult, +} from "../player/components/useAutomationLanes"; +import type { TimelineElement } from "../player/store/timelineElement"; + +/** Minimal valid fixture — TimelineElement only requires these five fields. */ +const bgmElement: TimelineElement = { + id: "bgm", + key: "bgm", + tag: "audio", + start: 0, + duration: 6, + track: 0, +}; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function Host({ lanes }: { lanes: UseAutomationLanesResult }) { + useAutomationSelectionKeyboard({ lanes }); + return null; +} + +const key = (k: string) => { + const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }); + act(() => void document.dispatchEvent(e)); +}; + +describe("useAutomationSelectionKeyboard", () => { + 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 }, + ], + }, + ], + }, + lanes: [], + chain: null, + onPreview: vi.fn(), + onCommit, + onSelect: vi.fn(), + readOnly: false, + selection: null, + onRangeSelect: vi.fn(), + onRangeClear: vi.fn(), + ...binding, + }), + }; + const host = document.createElement("div"); + document.body.append(host); + act(() => createRoot(host).render()); + return { onCommit }; + }; + + it("Delete empties the selected range and pins anchors", () => { + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }); + const { onCommit } = setup({}); + key("Delete"); + const written = onCommit.mock.calls.at(-1)?.[0]; + const points = written?.lanes?.[0]?.points ?? []; + expect(points.map((p: { t: number }) => p.t)).toEqual([0, 1, 3, 4]); + }); + + it("Escape clears the selection", () => { + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }); + setup({}); + key("Escape"); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + }); + + it("is inert while a text input has focus", () => { + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }); + const { onCommit } = setup({}); + const input = document.createElement("input"); + document.body.append(input); + input.focus(); + key("Delete"); + expect(onCommit).not.toHaveBeenCalled(); + input.remove(); + }); +}); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts new file mode 100644 index 0000000000..786a0c683e --- /dev/null +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts @@ -0,0 +1,79 @@ +/** + * 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. + */ +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 type { AutomationSelection } from "../player/store/automationSelectionSlice"; +import type { UseAutomationLanesResult } from "../player/components/useAutomationLanes"; + +function isTextInput(el: Element | null): boolean { + if (!el) return false; + const tag = el.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return el instanceof HTMLElement && el.isContentEditable; +} + +/** + * 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 + * resolves to a range, or the lane already has no points in it. Split out of + * the keydown handler so each stays under the complexity a single branch of + * keyboard dispatch should carry. + */ +function resolveDeleteWrite( + state: { elements: TimelineElement[]; selectedElementId: string | null }, + 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: [] }); + return { + onCommit: binding.onCommit, + next: withLane(binding.automation, { target: sel.target, points }), + }; +} + +export function useAutomationSelectionKeyboard({ + lanes, +}: { + lanes: UseAutomationLanesResult; +}): void { + useEffect(() => { + const handler = (e: KeyboardEvent): void => { + if (isTextInput(document.activeElement)) return; + const state = usePlayerStore.getState(); + const sel = state.automationSelection; + if (!sel) return; + + if (e.key === "Escape") { + 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); + }; + document.addEventListener("keydown", handler, true); + return () => document.removeEventListener("keydown", handler, true); + }, [lanes]); +} diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 8440c14efc..eeaf53895e 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -390,6 +390,16 @@ export function TimelineAutomationLaneSlot({ [beatTimes, element.start, element.duration], ); const bound = lanes.bind(element, isSelected); + // Stale-selection guard: the selected lane's target can vanish out from under + // it (e.g. its effect got deleted from the chain, dropping the lane), leaving + // a rectangle selecting nothing. Clear it rather than let it point at a + // target that no longer draws. + useEffect(() => { + const target = bound.selection?.target; + if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) { + bound.onRangeClear(); + } + }, [bound]); if (bound.lanes.length === 0) return null; const inClip = currentTime >= element.start && currentTime <= element.start + element.duration; const top = getTimelineLaneTop(laneCount); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index c04ef3119e..6fe8f530f7 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -5,22 +5,21 @@ import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; import { useAutomationLanes } from "./useAutomationLanes"; +import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { resolveTrackKeyframeClip, trackShowsBeatStrip } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; import { isMultiDragActive, isMultiDragPassenger, multiDragDeltaSeconds, - type MultiDragPreviewInput, multiDragPassengerOffsetPx, } from "./timelineMultiDragPreview"; -import type { TimelineLaneBaseProps } from "./timelineLaneProps"; -import type { TimelineEditCallbacks } from "./timelineCallbacks"; +import type { TimelineLanesProps } from "./timelineLaneProps"; import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; @@ -30,18 +29,6 @@ import { isTimelineClipActive } from "./useTimelineActiveClips"; import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; -interface TimelineLanesProps extends TimelineLaneBaseProps { - /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ - draggedElement: TimelineElement | null; - multiDragPreview: MultiDragPreviewInput | null; - onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; - onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; - onResizeElement: TimelineEditCallbacks["onResizeElement"]; - onMoveElement: TimelineEditCallbacks["onMoveElement"]; - onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; - onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"]; -} - export function TimelineLanes({ pps, contentOrigin, @@ -109,6 +96,7 @@ export function TimelineLanes({ const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const automationLanes = useAutomationLanes(); + useAutomationSelectionKeyboard({ lanes: automationLanes }); const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); const toggleClipExpandedTracked = (key: string) => { const willExpand = !expandedClipIds.has(key); diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts index b16a03b3b3..c28931bf55 100644 --- a/packages/studio/src/player/components/timelineLaneProps.ts +++ b/packages/studio/src/player/components/timelineLaneProps.ts @@ -9,6 +9,8 @@ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./us import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex"; import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineVirtualRow } from "./useTimelineVirtualRows"; +import type { MultiDragPreviewInput } from "./timelineMultiDragPreview"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; /** * Props shared by the scroll container ({@link import("./TimelineCanvas")}) and @@ -92,3 +94,20 @@ export interface TimelineLaneBaseProps { onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void; beatAnalysis?: MusicBeatAnalysis | null; } + +/** + * {@link TimelineLaneBaseProps} plus the handful of props only the lane + * renderer ({@link import("./TimelineLanes")}) itself needs — the drag-preview + * state and the edit callbacks TimelineCanvas does not otherwise touch. + */ +export interface TimelineLanesProps extends TimelineLaneBaseProps { + /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ + draggedElement: TimelineElement | null; + multiDragPreview: MultiDragPreviewInput | null; + onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; + onResizeElement: TimelineEditCallbacks["onResizeElement"]; + onMoveElement: TimelineEditCallbacks["onMoveElement"]; + onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; + onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"]; +} From bc576c69cff4973593ba86c8fe829e9ae7bb9aa0 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 03:50:06 -0700 Subject: [PATCH 6/7] test(studio): cover the automation selection stale-target guard --- .../TimelineAutomationLaneSlot.test.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx new file mode 100644 index 0000000000..c08f25af44 --- /dev/null +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; +import type { AutomationLaneBinding, UseAutomationLanesResult } from "./useAutomationLanes"; +import type { TimelineElement } from "../store/timelineElement"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const element: TimelineElement = { + id: "bgm", + key: "bgm", + tag: "audio", + start: 0, + duration: 6, + track: 0, +}; + +function mountSlot(binding: Partial) { + const onRangeClear = vi.fn(); + const lanes: UseAutomationLanesResult = { + bind: () => ({ + automation: { version: 1, lanes: [] }, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + chain: null, + onPreview: vi.fn(), + onCommit: vi.fn(), + onSelect: vi.fn(), + readOnly: false, + selection: null, + onRangeSelect: vi.fn(), + onRangeClear, + ...binding, + }), + }; + const host = document.createElement("div"); + document.body.append(host); + act(() => { + createRoot(host).render( + , + ); + }); + return { onRangeClear }; +} + +describe("TimelineAutomationLaneSlot stale-selection guard", () => { + it("clears the selection when its lane's target no longer exists", () => { + const { onRangeClear } = mountSlot({ + selection: { elementKey: "bgm", target: "fx.gone.wet", t0: 1, t1: 2 }, + }); + expect(onRangeClear).toHaveBeenCalledTimes(1); + }); + + it("leaves an in-scope selection alone", () => { + const { onRangeClear } = mountSlot({ + selection: { elementKey: "bgm", target: "volume", t0: 1, t1: 2 }, + }); + expect(onRangeClear).not.toHaveBeenCalled(); + }); +}); From 1b4f8cd8d46feeecb02882c0d7e331c15780365a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 14:42:32 -0700 Subject: [PATCH 7/7] fix(studio): let an automation range keep Delete from the clip useAppHotkeys listens on window/capture, so it runs before useAutomationSelectionKeyboard's document/capture handler. With a range selected, Delete fell straight through to the clip-delete branch and destroyed the whole audio clip the lane belongs to; Backspace hit the reset-keyframes branch on the way and wiped the clip's keyframes. Guard both by returning early when automationSelection is set, mirroring the selectedKeyframes precedent six lines above. No preventDefault: the downstream handler still needs the key. dispatchPlainKey is exported so the arbitration between keyframes, an automation range and the clip can be pinned without standing up the hook. Co-Authored-By: Claude Opus 5 (1M context) --- .../studio/src/hooks/useAppHotkeys.test.ts | 113 ++++++++++++++++++ packages/studio/src/hooks/useAppHotkeys.ts | 12 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/hooks/useAppHotkeys.test.ts diff --git a/packages/studio/src/hooks/useAppHotkeys.test.ts b/packages/studio/src/hooks/useAppHotkeys.test.ts new file mode 100644 index 0000000000..7d7d88fc1d --- /dev/null +++ b/packages/studio/src/hooks/useAppHotkeys.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { dispatchPlainKey } from "./useAppHotkeys"; +import { usePlayerStore } from "../player/store/playerStore"; +import type { TimelineElement } from "../player/store/timelineElement"; + +/** Minimal valid fixture — TimelineElement only requires these five fields. */ +const bgmElement: TimelineElement = { + id: "bgm", + key: "bgm", + tag: "audio", + start: 0, + duration: 6, + track: 0, +}; + +/** Every callback dispatchPlainKey can reach, so a test can assert which one + * a key resolved to. Unannotated on purpose: the parameter type is not + * exported, and structural inference checks it at the call site. */ +function callbacks() { + return { + handleTimelineElementDelete: vi.fn(async () => {}), + handleTimelineElementSplit: vi.fn(async () => {}), + handleDomEditElementDelete: vi.fn(async () => {}), + handleUndo: vi.fn(async () => {}), + handleRedo: vi.fn(async () => {}), + handleCopy: vi.fn(() => false), + handlePaste: vi.fn(async () => {}), + handleCut: vi.fn(async () => false), + onResetKeyframes: vi.fn(() => true), + onDeleteSelectedKeyframes: vi.fn(), + showToast: vi.fn(), + leftSidebarRef: { current: null }, + domEditSelectionRef: { current: null }, + }; +} + +const press = (key: string) => + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + +afterEach(() => { + usePlayerStore.getState().clearAutomationSelection(); + usePlayerStore.setState({ + elements: [], + selectedElementId: null, + selectedElementIds: new Set(), + selectedKeyframes: new Set(), + }); +}); + +describe("dispatchPlainKey — Delete arbitration", () => { + const selectBgm = () => + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + + const selectRange = () => + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + + it("deletes the selected clip when no automation range is active", () => { + selectBgm(); + const cb = callbacks(); + const e = press("Delete"); + dispatchPlainKey(e, "delete", cb); + // The pre-existing contract, pinned so the new guard cannot widen. + expect(cb.handleTimelineElementDelete).toHaveBeenCalledTimes(1); + expect(e.defaultPrevented).toBe(true); + }); + + it("leaves the clip alone when an automation range is active", () => { + // The bug: this listener is on window/capture so it runs BEFORE + // useAutomationSelectionKeyboard's document/capture handler. Without the + // guard, clearing a 2s automation range deleted the whole audio clip. + selectBgm(); + selectRange(); + const cb = callbacks(); + const e = press("Delete"); + dispatchPlainKey(e, "delete", cb); + expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled(); + // Must NOT be consumed: the automation handler downstream still needs it. + expect(e.defaultPrevented).toBe(false); + }); + + it("leaves keyframe reset alone when an automation range is active", () => { + // Backspace's reset-keyframes branch sits below the guard, so it has to be + // covered too — otherwise Backspace wiped every keyframe on the clip. + selectBgm(); + usePlayerStore.setState({ + keyframeCache: new Map([["bgm", { targets: [], version: 0 }]]), + }); + selectRange(); + const cb = callbacks(); + const e = press("Backspace"); + dispatchPlainKey(e, "backspace", cb); + expect(cb.onResetKeyframes).not.toHaveBeenCalled(); + expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); + + it("still lets a keyframe selection win over an automation range", () => { + // Ordering: the keyframe guard precedes the automation one, so a keyframe + // selection keeps Delete even with a range showing. + selectBgm(); + selectRange(); + usePlayerStore.setState({ selectedKeyframes: new Set(["bgm:opacity:0"]) }); + const cb = callbacks(); + const e = press("Delete"); + dispatchPlainKey(e, "delete", cb); + expect(cb.onDeleteSelectedKeyframes).toHaveBeenCalledTimes(1); + expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 1cae6af5b5..9998b50d98 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -222,7 +222,10 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba } // fallow-ignore-next-line complexity -function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void { +/** Exported for tests: the unmodified-key half of the dispatcher, so the + * Delete arbitration between keyframes, an automation range and the clip can + * be asserted without standing up the whole hook. */ +export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void { if (key === "f" && !event.shiftKey && !event.altKey) { event.preventDefault(); if (document.fullscreenElement) void document.exitFullscreen(); @@ -288,6 +291,13 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks event.preventDefault(); return; } + // An active automation range owns Delete: useAutomationSelectionKeyboard + // empties the range in place, pinning the anchors. Fall through WITHOUT + // preventDefault so that document-level handler still sees the key — this + // listener is on window/capture, so it runs first and everything below + // would otherwise win. Without this the press reaches the clip delete + // below and destroys the whole clip the lane belongs to. + if (usePlayerStore.getState().automationSelection) return; if (event.key === "Backspace") { const { selectedElementId, keyframeCache } = usePlayerStore.getState(); if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) {