Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -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<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 },
],
},
],
},
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(<Host lanes={lanes} />));
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();
});
});
79 changes: 79 additions & 0 deletions packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => {
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<typeof vi.fn>,
onCommit: base.onCommit as ReturnType<typeof vi.fn>,
};
const { container } = render(<TimelineAutomationLane {...props} />);
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<string, unknown> = {}) => {
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<typeof vi.fn>,
onCommit: base.onCommit as ReturnType<typeof vi.fn>,
};
const { container } = render(<TimelineAutomationLane {...props} />);
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.
Expand Down Expand Up @@ -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();
});
});
52 changes: 52 additions & 0 deletions packages/studio/src/player/components/TimelineAutomationLane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -89,6 +93,9 @@ export function TimelineAutomationLane({
snapTimes,
readOnly,
onSelect,
rangeSelection,
onRangeSelect,
onRangeClear,
}: TimelineAutomationLaneProps) {
const stored = laneFor(automation, target);

Expand Down Expand Up @@ -183,6 +190,9 @@ export function TimelineAutomationLane({
snapTimes,
readOnly,
onSelect,
onRangeSelect,
onRangeClear,
duration,
});
const { dragIndex, curveIndex, hint, editing } = gestures;

Expand Down Expand Up @@ -255,6 +265,31 @@ export function TimelineAutomationLane({
stroke="rgba(255,255,255,0.08)"
strokeDasharray="3 4"
/>
{rangeSelection ? (
<>
<rect
data-automation-selection=""
x={xOf(rangeSelection.t0)}
y={0}
width={Math.max(0, xOf(rangeSelection.t1) - xOf(rangeSelection.t0))}
height={h}
fill={accentColor}
opacity={0.15}
pointerEvents="none"
/>
{[rangeSelection.t0, rangeSelection.t1].map((t) => (
<line
key={t}
x1={xOf(t)}
x2={xOf(t)}
y1={0}
y2={h}
stroke={accentColor}
opacity={0.5}
/>
))}
</>
) : null}
<path
d={path}
fill="none"
Expand Down Expand Up @@ -355,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);
Expand Down Expand Up @@ -382,6 +427,13 @@ export function TimelineAutomationLaneSlot({
onSelect={bound.onSelect}
snapTimes={snapTimes}
readOnly={bound.readOnly}
rangeSelection={
bound.selection?.target === lane.target
? { t0: bound.selection.t0, t1: bound.selection.t1 }
: null
}
onRangeSelect={(t0, t1) => bound.onRangeSelect(lane.target, t0, t1)}
onRangeClear={bound.onRangeClear}
/>
);
})}
Expand Down
Loading
Loading