diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc
index f5b1617118..3a9511683b 100644
--- a/.fallowrc.jsonc
+++ b/.fallowrc.jsonc
@@ -176,22 +176,6 @@
"withLane",
],
},
- // automationShapes is part of the audio-automation stack: its consumer is
- // the UI layer that uses shape generators one PR upstack, so a per-PR audit
- // diffing against the merge base sees these as unused. Consumed for real once
- // the stack merges; safe to drop this entry then.
- {
- "file": "packages/studio/src/player/components/automationShapes.ts",
- "exports": ["AUTOMATION_SHAPES"],
- },
- // automationSimplify is part of the audio-automation stack: its consumer is
- // the UI layer one PR upstack, so a per-PR audit diffing against the merge
- // base sees these as unused. Consumed for real once the stack merges; safe
- // to drop this entry then.
- {
- "file": "packages/studio/src/player/components/automationSimplify.ts",
- "exports": ["simplifyPoints"],
- },
// propertyPanelAutomation is the shared reader for both panel sections; the
// FX group that consumes these two lands one PR upstack, so a per-PR audit
// against the merge base sees them as unused.
diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
index 7966634485..25ab48c2fa 100644
--- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
+++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
@@ -185,6 +185,40 @@ describe("useAutomationSelectionKeyboard", () => {
});
});
+ it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
+ // The playhead branch already clamps to duration - span; the
+ // selection-start branch didn't, so pasting a 2s clip at a selection
+ // sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
+ // past element.duration — and leave the selection itself out of bounds.
+ 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()?.span).toBe(2);
+
+ // A 0.1s-wide selection right near the clip's 6s end.
+ usePlayerStore
+ .getState()
+ .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 });
+ combo("v");
+ const written = onCommit.mock.calls.at(-1)?.[0];
+ const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
+ for (const t of times) {
+ expect(t).toBeGreaterThanOrEqual(0);
+ expect(t).toBeLessThanOrEqual(bgmElement.duration);
+ }
+ // Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
+ expect(usePlayerStore.getState().automationSelection).toEqual({
+ elementKey: "bgm",
+ target: "volume",
+ t0: 4,
+ t1: 6,
+ });
+ });
+
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);
diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
index 4135afedcf..8fe20294ad 100644
--- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
+++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
@@ -161,7 +161,7 @@ function handlePaste(
const atT =
sel && sel.elementKey === paste.elementKey
- ? sel.t0
+ ? clamp(sel.t0, 0, paste.element.duration - clip.span)
: clamp(state.currentTime - paste.element.start, 0, paste.element.duration - clip.span);
const t1 = atT + clip.span;
const inner = pastePoints(clip, paste.range, atT);
diff --git a/packages/studio/src/player/components/AutomationSelectionMenu.tsx b/packages/studio/src/player/components/AutomationSelectionMenu.tsx
index 4a2bd0d057..f157f6e266 100644
--- a/packages/studio/src/player/components/AutomationSelectionMenu.tsx
+++ b/packages/studio/src/player/components/AutomationSelectionMenu.tsx
@@ -30,11 +30,19 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
const menuRef = useContextMenuDismiss(onClose);
const row =
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
+ // Same edge-clamping precedent as TrackGapContextMenu: without it a
+ // right-click near the bottom/right of the timeline renders this menu
+ // partially off-screen.
+ const menuWidth = 140;
+ const menuHeight = AUTOMATION_SHAPES.length * 24 + 32;
+ const overflowY = y + menuHeight - window.innerHeight;
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
return createPortal(
{AUTOMATION_SHAPES.map((shape) => (
{
expect(document.querySelector(".hf-automation-menu")).toBeNull();
});
});
+
+describe("TimelineAutomationLane stretch", () => {
+ // Edges deliberately off any existing point: the lane's hit-priority rule
+ // (a point always wins) means a selection edge sitting exactly on a
+ // breakpoint would resolve to a point-drag, never a stretch — see the
+ // dedicated priority test below for that case instead.
+
+ /** Press, drag and release the right edge of a stretchable selection — the
+ * shape most of this block's tests share, differing only in where the
+ * drag ends up. */
+ function dragRightEdge(svg: Element, from: number, to: number): void {
+ fire(svg, "pointerdown", at(from, 0.5));
+ fire(svg, "pointermove", at(to, 0.5));
+ fire(svg, "pointerup", at(to, 0.5));
+ }
+
+ const stretchable: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 1, v: 0.5 },
+ { t: 2, v: 0.8 },
+ { t: 4, v: 0 },
+ ],
+ },
+ ],
+ };
+
+ it("dragging the right edge retimes the interior and persists on release", () => {
+ const onRangeSelect = vi.fn();
+ const { svg, props } = mount(stretchable, {
+ rangeSelection: { t0: 0.5, t1: 2.5 },
+ onRangeSelect,
+ });
+ dragRightEdge(svg, 2.5, 3.3); // off any point, dragged out to 3.3
+
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
+ const points = written.lanes[0]?.points ?? [];
+ // Interior points (t=1, t=2) scale by the new/old span ratio (2.8 / 2 = 1.4).
+ expect(points.some((p) => Math.abs(p.t - 1.2) < 0.01 && p.v === 0.5)).toBe(true);
+ expect(points.some((p) => Math.abs(p.t - 2.6) < 0.01 && p.v === 0.8)).toBe(true);
+
+ expect(onRangeSelect).toHaveBeenCalledTimes(1);
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1));
+ });
+
+ it("previews the stretch on move without persisting, then commits once on release", () => {
+ const onPreview = vi.fn();
+ const onCommit = vi.fn();
+ const { svg } = mount(stretchable, {
+ rangeSelection: { t0: 0.5, t1: 2.5 },
+ onPreview,
+ onCommit,
+ });
+ fire(svg, "pointerdown", at(2.5, 0.5));
+ fire(svg, "pointermove", at(3, 0.5));
+ fire(svg, "pointermove", at(3.3, 0.5));
+ expect(onPreview).toHaveBeenCalledTimes(2);
+ expect(onCommit).not.toHaveBeenCalled();
+ fire(svg, "pointerup", at(3.3, 0.5));
+ expect(onCommit).toHaveBeenCalledTimes(1);
+ });
+
+ it("a point sitting on the selection's edge wins over the edge-stretch gesture", () => {
+ const sel: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 1.5, v: 0.5 },
+ { t: 2, v: 0.8 },
+ { t: 4, v: 0 },
+ ],
+ },
+ ],
+ };
+ const onRangeSelect = vi.fn();
+ const { svg, props } = mount(sel, {
+ rangeSelection: { t0: 1, t1: 2 },
+ onRangeSelect,
+ });
+ fire(svg, "pointerdown", at(2, 0.8)); // exactly the point at t=2, which is also the right edge
+ fire(svg, "pointermove", at(3, 0.8));
+ fire(svg, "pointerup", at(3, 0.8));
+ // A point-drag moved just that point; the selection itself was untouched.
+ expect(onRangeSelect).not.toHaveBeenCalled();
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
+ const times = (written.lanes[0]?.points ?? []).map((p) => p.t);
+ expect(times).toContain(3);
+ });
+
+ it("clamps the dragged edge so it cannot cross its partner", () => {
+ const onRangeSelect = vi.fn();
+ const { svg } = mount(stretchable, {
+ rangeSelection: { t0: 0.5, t1: 2.5 },
+ onRangeSelect,
+ });
+ dragRightEdge(svg, 2.5, 0.3); // dragged past the left edge (t0=0.5)
+ const [, t1] = onRangeSelect.mock.calls.at(-1) as [number, number];
+ expect(t1).toBeGreaterThan(0.5);
+ });
+
+ it("clamps the dragged edge to the lane's own duration", () => {
+ const onRangeSelect = vi.fn();
+ const { svg } = mount(stretchable, {
+ rangeSelection: { t0: 0.5, t1: 2.5 },
+ onRangeSelect,
+ });
+ dragRightEdge(svg, 2.5, 10); // far past the clip's own duration (4s)
+ const [, t1] = onRangeSelect.mock.calls.at(-1) as [number, number];
+ expect(t1).toBeLessThanOrEqual(4);
+ });
+
+ it("retimes identically whether the right edge arrives in one move or several", () => {
+ // moveEdge must always retime from the points snapshotted at arm time,
+ // never from the live draft — retimeRange is a RELATIVE transform (it
+ // scales the lane's OWN current point positions by newSpan/oldSpan), so
+ // feeding it the live draft on every pointermove compounds the scale
+ // factor instead of applying it once. A real drag fires dozens of moves;
+ // this asserts the FINAL preview is identical regardless of how many.
+ const onPreviewSingle = vi.fn();
+ const single = mount(stretchable, {
+ rangeSelection: { t0: 0.5, t1: 2.5 },
+ onPreview: onPreviewSingle,
+ });
+ fire(single.svg, "pointerdown", at(2.5, 0.5));
+ fire(single.svg, "pointermove", at(3.3, 0.5));
+ const singleShot = (onPreviewSingle.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
+ ?.lanes[0]?.points;
+ expect(singleShot).toBeDefined();
+
+ const onPreviewMulti = vi.fn();
+ const multi = mount(stretchable, {
+ rangeSelection: { t0: 0.5, t1: 2.5 },
+ onPreview: onPreviewMulti,
+ });
+ fire(multi.svg, "pointerdown", at(2.5, 0.5));
+ // At least 3 separate pointermoves crossing the same span, not one jump.
+ fire(multi.svg, "pointermove", at(2.7, 0.5));
+ fire(multi.svg, "pointermove", at(2.9, 0.5));
+ fire(multi.svg, "pointermove", at(3.1, 0.5));
+ fire(multi.svg, "pointermove", at(3.3, 0.5));
+ const afterFourMoves = (onPreviewMulti.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
+ ?.lanes[0]?.points;
+ expect(afterFourMoves).toBeDefined();
+
+ // Both interior points (t=1, t=2) land exactly where a single-shot retime
+ // puts them — not compounded, and not dropped.
+ expect(afterFourMoves).toEqual(singleShot);
+ expect(afterFourMoves?.length).toBe(6);
+ expect(afterFourMoves?.some((p) => Math.abs(p.t - 1.2) < 0.001 && p.v === 0.5)).toBe(true);
+ expect(afterFourMoves?.some((p) => Math.abs(p.t - 2.6) < 0.001 && p.v === 0.8)).toBe(true);
+ });
+
+ it("retimes identically whether the left edge arrives in one move or several", () => {
+ const onPreviewSingle = vi.fn();
+ const single = mount(stretchable, {
+ rangeSelection: { t0: 1, t1: 3 },
+ onPreview: onPreviewSingle,
+ });
+ fire(single.svg, "pointerdown", at(1, 0.5));
+ fire(single.svg, "pointermove", at(0.2, 0.5));
+ const singleShot = (onPreviewSingle.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
+ ?.lanes[0]?.points;
+ expect(singleShot).toBeDefined();
+
+ const onPreviewMulti = vi.fn();
+ const multi = mount(stretchable, {
+ rangeSelection: { t0: 1, t1: 3 },
+ onPreview: onPreviewMulti,
+ });
+ fire(multi.svg, "pointerdown", at(1, 0.5));
+ fire(multi.svg, "pointermove", at(0.7, 0.5));
+ fire(multi.svg, "pointermove", at(0.4, 0.5));
+ fire(multi.svg, "pointermove", at(0.2, 0.5));
+ const afterThreeMoves = (onPreviewMulti.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
+ ?.lanes[0]?.points;
+ expect(afterThreeMoves).toBeDefined();
+ expect(afterThreeMoves).toEqual(singleShot);
+ });
+
+ it("shows a resize cursor when hovering an edge with nothing else live", () => {
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ fire(svg, "pointermove", at(3, 0.5)); // near the right edge, nothing pressed
+ expect(svg.style.cursor).toBe("col-resize");
+ });
+
+ it("keeps the normal cursor away from the selection's edges", () => {
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ fire(svg, "pointermove", at(2, 0.5)); // middle of the selection, not an edge
+ expect(svg.style.cursor).not.toBe("col-resize");
+ });
+});
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx
index 835c8111c5..25d150900e 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx
@@ -50,8 +50,10 @@ import { getTimelineLaneTop } from "./timelineLayout";
import type { TimelineElement } from "../store/playerStore";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
-/** Pointer shape: a read-only lane can only be selected, a live one edited. */
-function laneCursor(readOnly: boolean | undefined, dragging: boolean): string {
+/** Pointer shape: a stretch handle wins over everything else it might also
+ * sit above, a read-only lane can only be selected, a live one edited. */
+function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
+ if (stretching) return "col-resize";
if (readOnly) return "pointer";
return dragging ? "grabbing" : "crosshair";
}
@@ -204,8 +206,9 @@ export function TimelineAutomationLane({
onRangeSelect,
onRangeClear,
duration,
+ rangeSelection,
});
- const { dragIndex, curveIndex, hint, editing } = gestures;
+ const { dragIndex, curveIndex, edgeDrag, edgeHover, hint, editing } = gestures;
const removeAt = useCallback(
(index: number): void => {
@@ -285,7 +288,11 @@ export function TimelineAutomationLane({
top: 0,
width: widthPx + PAD_X * 2,
height: h,
- cursor: laneCursor(readOnly, dragIndex !== null || curveIndex !== null),
+ cursor: laneCursor(
+ readOnly,
+ dragIndex !== null || curveIndex !== null,
+ edgeDrag !== null || edgeHover,
+ ),
opacity: readOnly ? 0.55 : 1,
touchAction: "none",
}}
diff --git a/packages/studio/src/player/components/automationClipboard.test.ts b/packages/studio/src/player/components/automationClipboard.test.ts
index 58d6e2572b..890d03fdb6 100644
--- a/packages/studio/src/player/components/automationClipboard.test.ts
+++ b/packages/studio/src/player/components/automationClipboard.test.ts
@@ -39,19 +39,34 @@ describe("automation clipboard", () => {
});
it("maps values through unit space onto a different parameter", () => {
- const wet = resolveAutomationRange("fx.r.wet", {
+ // fx.n1.frequency (lowpass cutoff) is log-scaled (min:20, max:20000):
+ // linear unit math and a literal copy of the source value would both
+ // read as a passing test on a range that happens to be numerically
+ // identical to VOLUME_RANGE (e.g. fx.r.wet), so this target has to be
+ // genuinely log for the test to discriminate real unit-space mapping.
+ const frequency = resolveAutomationRange("fx.n1.frequency", {
version: 1,
- nodes: [{ type: "reverb", id: "r", params: {} }],
+ nodes: [{ type: "lowpass", id: "n1", params: {} }],
});
- expect(wet).toBeTruthy();
- if (!wet) return;
+ expect(frequency).toBeTruthy();
+ if (!frequency) return;
+ expect(frequency.scale).toBe("log");
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);
+ const pts = pastePoints(entry, frequency, 0);
+ // volume 1 (unit 1) → frequency max; volume 0.25 (unit 0.25) → a quarter
+ // up frequency's LOG axis, i.e. exp(ln(min) + 0.25*(ln(max)-ln(min))) —
+ // NOT the naive linear guess (min + 0.25*(max-min)) and nowhere near a
+ // literal copy of 0.25.
+ expect(pts[0]?.v).toBeCloseTo(frequency.max, 5);
+ const expectedLog = Math.exp(
+ Math.log(frequency.min) + 0.25 * (Math.log(frequency.max) - Math.log(frequency.min)),
+ );
+ const naiveLinear = frequency.min + 0.25 * (frequency.max - frequency.min);
+ expect(pts[1]?.v).toBeCloseTo(expectedLog, 5);
+ expect(pts[1]?.v).not.toBeCloseTo(naiveLinear, 0);
+ expect(pts[1]?.v).not.toBeCloseTo(0.25, 0);
});
it("reads null when nothing was copied", () => {
diff --git a/packages/studio/src/player/components/automationLaneSelection.test.ts b/packages/studio/src/player/components/automationLaneSelection.test.ts
index a7f0296add..e1e2370cd8 100644
--- a/packages/studio/src/player/components/automationLaneSelection.test.ts
+++ b/packages/studio/src/player/components/automationLaneSelection.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { pointsIn, replaceRange } from "./automationLaneSelection";
+import { pointsIn, replaceRange, retimeRange } from "./automationLaneSelection";
import { sampleAutomationLane, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
@@ -94,3 +94,40 @@ describe("replaceRange", () => {
expect(Math.max(...innerTimes)).toBeGreaterThan(3.0);
});
});
+
+describe("retimeRange", () => {
+ it("scales interior points proportionally into the new span", () => {
+ const pts = retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 });
+ const moved = pts.find((p) => p.v === 0.4); // the t=3 point
+ expect(moved?.t).toBe(5);
+ });
+
+ it("preserves the envelope outside the union of old and new spans", () => {
+ const before: HfAutomationLane = { target: "volume", points: ramp.points };
+ const after: HfAutomationLane = {
+ target: "volume",
+ points: retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 }),
+ };
+ // Nothing to the left of t0=2 moved (newT0 === t0 here), so sampled
+ // continuity holds all the way up to the edited region.
+ for (const t of [0, 1, 1.9]) {
+ expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo(
+ sampleAutomationLane(before, t, "linear"),
+ 5,
+ );
+ }
+ // The next real breakpoint past the edited region keeps its own exact
+ // value — growing past it reshapes the transition INTO it, not the point
+ // itself. (Sampling inside that transition, e.g. at t=5.1, is expected to
+ // differ: one of that segment's endpoints moved from t=3 to t=5, even
+ // though this point at t=6 did not move at all.)
+ const farPoint = after.points.find((p) => p.t === 6);
+ expect(farPoint).toEqual({ t: 6, v: 0 });
+ });
+
+ it("rejects a degenerate span", () => {
+ expect(
+ retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 4, newT1: 4 }),
+ ).toEqual(ramp.points);
+ });
+});
diff --git a/packages/studio/src/player/components/automationLaneSelection.ts b/packages/studio/src/player/components/automationLaneSelection.ts
index 3e8e1fb703..3d93afe764 100644
--- a/packages/studio/src/player/components/automationLaneSelection.ts
+++ b/packages/studio/src/player/components/automationLaneSelection.ts
@@ -42,7 +42,10 @@ function anchor(
function decimateEvenly(items: readonly T[], budget: number): T[] {
if (budget <= 0) return [];
if (items.length <= budget) return [...items];
- if (budget === 1) return [items[0]!];
+ if (budget === 1) {
+ const item = items[0];
+ return item ? [item] : [];
+ }
const out: T[] = [];
const step = (items.length - 1) / (budget - 1);
for (let i = 0; i < budget; i += 1) {
@@ -72,3 +75,33 @@ export function replaceRange(input: {
const cappedInner = inner.length <= budget ? inner : decimateEvenly(inner, budget);
return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t);
}
+
+/**
+ * Retime a selection: interior points scale proportionally into the new span,
+ * then replaceRange runs over the UNION of old and new spans — growing eats
+ * whatever it covers, shrinking pins anchors where the envelope re-enters.
+ */
+export function retimeRange(input: {
+ lane: HfAutomationLane;
+ range: AutomationRange;
+ t0: number;
+ t1: number;
+ newT0: number;
+ newT1: number;
+}): HfAutomationPoint[] {
+ const { lane, range, t0, t1, newT0, newT1 } = input;
+ const oldSpan = t1 - t0;
+ const newSpan = newT1 - newT0;
+ if (oldSpan <= 0 || newSpan <= 0) return lane.points;
+ const inner = pointsIn(lane, t0, t1).map((p) => ({
+ ...p,
+ t: newT0 + ((p.t - t0) * newSpan) / oldSpan,
+ }));
+ return replaceRange({
+ lane,
+ range,
+ t0: Math.min(t0, newT0),
+ t1: Math.max(t1, newT1),
+ inner,
+ });
+}
diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts
index cb00c83efe..2eaf4b9cd0 100644
--- a/packages/studio/src/player/components/useAutomationLaneGestures.ts
+++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts
@@ -3,8 +3,8 @@
*
* Its own hook because the lane component sits at the studio's file ceiling and
* because these are the parts worth testing on their own: which of a press,
- * a drag and a modifier resolves to moving a point, bending a segment, or
- * nothing at all.
+ * a drag and a modifier resolves to moving a point, bending a segment,
+ * stretching a selection's edge, or nothing at all.
*
* Modifiers follow Ableton's, since that is the muscle memory an automation lane
* inherits: Shift locks a drag to one axis and fines the value down, Alt over a
@@ -12,7 +12,11 @@
*/
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
-import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
+import type {
+ AutomationRange,
+ HfAutomationLane,
+ HfAutomationPoint,
+} from "@hyperframes/core/audio-automation";
import {
applyShiftConstraint,
curveForDrag,
@@ -21,11 +25,17 @@ import {
POINT_MERGE_SEC,
snapLaneTime,
} from "./automationLaneGeometry";
+import { retimeRange } from "./automationLaneSelection";
/** Snap radius in clip seconds. Tight on purpose: a lane is often a few seconds
* wide, where a generous radius makes a point unplaceable between two beats. */
const SNAP_SEC = 0.04;
+/** Hit radius for grabbing a selection's edge, in screen px — independent of
+ * a point's own grab radius so the two zones can be reasoned about on their
+ * own, even though a point sitting on an edge still wins (see `gestureAt`). */
+const EDGE_GRAB_PX = 8;
+
/** A point's position, or the origin when the index no longer resolves. */
function originOf(point: HfAutomationLane["points"][number] | undefined): { t: number; v: number } {
return point ? { t: point.t, v: point.v } : { t: 0, v: 0 };
@@ -60,6 +70,8 @@ export interface UseAutomationLaneGesturesInput {
onRangeSelect?: ((t0: number, t1: number) => void) | undefined;
onRangeClear?: (() => void) | undefined;
duration: number; // clamp bound for range endpoints
+ /** Active selection on this lane, so its edges have something to grab. */
+ rangeSelection?: { t0: number; t1: number } | null | undefined;
}
export interface UseAutomationLaneGesturesResult {
@@ -67,6 +79,11 @@ export interface UseAutomationLaneGesturesResult {
dragIndex: number | null;
/** Segment being bent, identified by the point that owns its curve. */
curveIndex: number | null;
+ /** Edge being stretched, for the cursor. */
+ edgeDrag: "t0" | "t1" | null;
+ /** Whether the pointer sits over a stretch handle with no gesture live —
+ * the col-resize cursor hint before a press commits to the drag. */
+ edgeHover: boolean;
/** Value readout to show while a gesture is live. */
hint: string | null;
hitIndex(clientX: number, clientY: number): number | null;
@@ -97,6 +114,7 @@ export function useAutomationLaneGestures({
onRangeSelect,
onRangeClear,
duration,
+ rangeSelection,
}: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
const [dragIndex, setDragIndex] = useState(null);
const [curveIndex, setCurveIndex] = useState(null);
@@ -110,6 +128,37 @@ export function useAutomationLaneGestures({
/** 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);
+ /** An edge-stretch drag in progress: which edge, the selection it started
+ * from (kept fixed as the retime's untouched anchor), the edge's own live
+ * position, and the lane's points as they stood at arm time. `retimeRange`
+ * is a RELATIVE transform — it scales a lane's own current point positions
+ * by newSpan/oldSpan — so it must always run against this fixed snapshot,
+ * never against the live draft: retiming from the draft would compound the
+ * scale factor on every pointermove instead of applying it once. */
+ const [edgeDrag, setEdgeDrag] = useState<{
+ edge: "t0" | "t1";
+ origin: { t0: number; t1: number };
+ current: number;
+ points: HfAutomationPoint[];
+ } | null>(null);
+ /** Cursor hint: hovering a stretch handle with nothing else live. */
+ const [edgeHover, setEdgeHover] = useState(false);
+
+ /** Which edge of the active selection, if any, sits within grab range of the
+ * pointer's screen x — full lane height, since the handle spans the rect. */
+ const edgeAt = useCallback(
+ (clientX: number): "t0" | "t1" | null => {
+ if (!rangeSelection) return null;
+ const box = getBox();
+ if (!box) return null;
+ const px = clientX - box.left;
+ const d0 = Math.abs(xOf(rangeSelection.t0) - px);
+ const d1 = Math.abs(xOf(rangeSelection.t1) - px);
+ if (d0 <= EDGE_GRAB_PX && d0 <= d1) return "t0";
+ return d1 <= EDGE_GRAB_PX ? "t1" : null;
+ },
+ [rangeSelection, getBox, xOf],
+ );
/** Index of a point under the pointer, or null. */
const hitIndex = useCallback(
@@ -153,6 +202,39 @@ export function useAutomationLaneGestures({
[hitIndex, segmentIndex],
);
+ /**
+ * What a press on the lane's empty background arms: an edge grab when it
+ * landed within range of an existing selection's edge, else a new range
+ * selection — only when a caller wants to hear about one; a read-only lane
+ * never reaches here at all.
+ */
+ const armBackgroundGesture = useCallback(
+ (e: ReactPointerEvent): void => {
+ const edge = edgeAt(e.clientX);
+ if (edge && rangeSelection) {
+ e.preventDefault();
+ capturePointer(e);
+ setEdgeHover(false);
+ setEdgeDrag({
+ edge,
+ origin: rangeSelection,
+ current: edge === "t0" ? rangeSelection.t0 : rangeSelection.t1,
+ points: lane.points,
+ });
+ return;
+ }
+ 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 });
+ },
+ [edgeAt, rangeSelection, onRangeSelect, pointAt, duration, snapTimes, lane],
+ );
+
const onPointerDown = useCallback(
(e: ReactPointerEvent): void => {
if (e.button !== 0) return;
@@ -168,23 +250,15 @@ export function useAutomationLaneGestures({
}
const gesture = gestureAt(e);
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 });
+ armBackgroundGesture(e);
return;
}
e.preventDefault();
capturePointer(e);
+ // A point can sit close enough to an edge to have set the hover hint
+ // moments ago; winning the press should not leave that stale cursor
+ // showing through the drag that follows.
+ setEdgeHover(false);
if (gesture.curve) {
setCurveIndex(gesture.index);
return;
@@ -192,7 +266,7 @@ export function useAutomationLaneGestures({
dragOrigin.current = originOf(lane.points[gesture.index]);
setDragIndex(gesture.index);
},
- [gestureAt, lane, readOnly, onSelect, onRangeSelect, pointAt, duration, snapTimes],
+ [gestureAt, lane, readOnly, onSelect, armBackgroundGesture],
);
/** Bend the segment under the pointer, which is what Alt-dragging the line does. */
@@ -239,46 +313,132 @@ export function useAutomationLaneGestures({
[dragIndex, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf],
);
+ /** Preview the selection's new bounds as the grabbed edge moves: the other
+ * edge stays put as the retime's anchor, and the dragged one is clamped so
+ * it cannot cross its partner (leaving at least a merge-radius of room) nor
+ * leave the clip's own duration. */
+ const moveEdge = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (edgeDrag === null) return;
+ const { edge, origin, points } = edgeDrag;
+ const raw = pointAt(e.clientX, e.clientY).t;
+ const clamped = Math.min(duration, Math.max(0, raw));
+ const current =
+ edge === "t0"
+ ? Math.min(clamped, origin.t1 - POINT_MERGE_SEC)
+ : Math.max(clamped, origin.t0 + POINT_MERGE_SEC);
+ setEdgeDrag({ edge, origin, current, points });
+ const newT0 = edge === "t0" ? current : origin.t0;
+ const newT1 = edge === "t1" ? current : origin.t1;
+ setHint(`${newT0.toFixed(2)}s → ${newT1.toFixed(2)}s`);
+ // Retime from the snapshot taken at arm time, never from `lane` (the
+ // live draft) — see the state comment above for why.
+ commitPoints(
+ retimeRange({
+ lane: { target: lane.target, points },
+ range,
+ t0: origin.t0,
+ t1: origin.t1,
+ newT0,
+ newT1,
+ }),
+ false,
+ );
+ },
+ [edgeDrag, pointAt, duration, lane.target, range, commitPoints],
+ );
+
+ /** Update the live range-drag as the pointer moves, firing `onRangeSelect`
+ * once it has covered enough pixels to count as an actual range rather
+ * than a click that should just clear one. */
+ const moveRangeDrag = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (rangeDrag === null) return;
+ 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) return;
+ rangeCrossed.current = true;
+ onRangeSelect?.(Math.min(rangeDrag.from, t), Math.max(rangeDrag.from, t));
+ },
+ [rangeDrag, pointAt, duration, snapTimes, xOf, onRangeSelect],
+ );
+
+ /** Cursor hint only: whether the pointer sits over a stretch handle with
+ * nothing else live. Skipped read-only, which never arms a stretch. */
+ const updateEdgeHover = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (!readOnly) setEdgeHover(edgeAt(e.clientX) !== null);
+ },
+ [readOnly, edgeAt],
+ );
+
const onPointerMove = useCallback(
(e: ReactPointerEvent): void => {
+ if (edgeDrag !== null) {
+ e.stopPropagation();
+ moveEdge(e);
+ return;
+ }
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));
- }
+ moveRangeDrag(e);
+ return;
+ }
+ if (curveIndex === null && dragIndex === null) {
+ updateEdgeHover(e);
return;
}
- if (curveIndex === null && dragIndex === null) return;
e.stopPropagation();
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
else movePoint(e);
},
[
+ edgeDrag,
+ moveEdge,
rangeDrag,
- pointAt,
- duration,
- snapTimes,
- xOf,
- onRangeSelect,
- bendSegment,
+ moveRangeDrag,
curveIndex,
dragIndex,
+ updateEdgeHover,
+ bendSegment,
movePoint,
],
);
+ /** Persist the stretch and hand the selection's new bounds back to the
+ * caller — the one point in the gesture that both commits and moves the
+ * selection it grabbed. */
+ const finishEdgeDrag = useCallback((): void => {
+ if (edgeDrag === null) return;
+ const { edge, origin, current } = edgeDrag;
+ const newT0 = edge === "t0" ? current : origin.t0;
+ const newT1 = edge === "t1" ? current : origin.t1;
+ setEdgeDrag(null);
+ setHint(null);
+ commitPoints(lane.points, true);
+ onRangeSelect?.(newT0, newT1);
+ }, [edgeDrag, lane, commitPoints, onRangeSelect]);
+
+ /** A sub-threshold press clears the selection rather than leaving a
+ * zero-width one behind. */
+ const finishRangeDrag = useCallback((): void => {
+ if (!rangeCrossed.current) onRangeClear?.();
+ rangeCrossed.current = false;
+ setRangeDrag(null);
+ }, [onRangeClear]);
+
const endDrag = useCallback(
(e: ReactPointerEvent): void => {
+ if (edgeDrag !== null) {
+ e.stopPropagation();
+ finishEdgeDrag();
+ return;
+ }
if (rangeDrag !== null) {
e.stopPropagation();
- if (!rangeCrossed.current) onRangeClear?.();
- rangeCrossed.current = false;
- setRangeDrag(null);
+ finishRangeDrag();
return;
}
if (dragIndex === null && curveIndex === null) return;
@@ -289,7 +449,16 @@ export function useAutomationLaneGestures({
setHint(null);
commitPoints(lane.points, true);
},
- [rangeDrag, onRangeClear, curveIndex, dragIndex, lane, commitPoints],
+ [
+ edgeDrag,
+ finishEdgeDrag,
+ rangeDrag,
+ finishRangeDrag,
+ curveIndex,
+ dragIndex,
+ lane,
+ commitPoints,
+ ],
);
const onDoubleClick = useCallback(
@@ -352,6 +521,8 @@ export function useAutomationLaneGestures({
return {
dragIndex,
curveIndex,
+ edgeDrag: edgeDrag?.edge ?? null,
+ edgeHover,
hint,
hitIndex,
segmentIndex,