chore: merge in the wa-18b-reschedule fallow/typecheck fix

This commit is contained in:
Vance Ingalls
2026-08-13 00:07:27 -07:00
9 changed files with 485 additions and 306 deletions
@@ -64,6 +64,23 @@ const ramp: HfAutomationLane = {
],
};
const curvedRamp: HfAutomationLane = {
target: "volume",
points: [
{ t: 0, v: 0, curve: 1 },
{ t: 2, v: 1 },
],
};
/** Schedule `curvedRamp` and return the fake param it wrote to — the setup
* three tests below share, each checking something different about the
* curve it produces. */
function scheduleCurved(): { target: FxParamTarget; param: FakeParam } {
const { target, param } = fake();
scheduleParamLane([target], curvedRamp, "linear", at(0));
return { target, param };
}
describe("scheduleParamLane", () => {
it("seeds the current value, then ramps to each later point", () => {
const { target, param } = fake();
@@ -133,19 +150,7 @@ describe("scheduleParamLane", () => {
});
it("samples a curved segment rather than ramping through the bend", () => {
const { target, param } = fake();
scheduleParamLane(
[target],
{
target: "volume",
points: [
{ t: 0, v: 0, curve: 1 },
{ t: 2, v: 1 },
],
},
"linear",
at(0),
);
const { param } = scheduleCurved();
const curve = param.calls.find((c) => c.op === "curve");
expect(curve).toBeTruthy();
if (curve?.op !== "curve") throw new Error("expected a curve");
@@ -210,19 +215,7 @@ describe("scheduleParamLane", () => {
});
it("does not seed on top of a curve that starts at the same instant", () => {
const { target, param } = fake();
scheduleParamLane(
[target],
{
target: "volume",
points: [
{ t: 0, v: 0, curve: 1 },
{ t: 2, v: 1 },
],
},
"linear",
at(0),
);
const { param } = scheduleCurved();
// A value curve may not overlap another event, so there is no set at 10.
expect(param.calls.filter((c) => c.op === "set")).toEqual([]);
expect(param.calls[1]?.op).toBe("curve");
+30 -25
View File
@@ -526,6 +526,34 @@ const SNAP_DB = 0.2;
*/
const STEP_DB = 0.5;
/**
* Thin a raw per-frame envelope down to the points where its value actually
* moved by `stepDb`, anchoring the run each drop ends.
*
* Segments interpolate, so dropping a run of equal values does not hold them —
* it draws a straight line from wherever the last kept point was, which turned
* a silent stretch before the run into a slow slide into it. Keeping the last
* value of the run pins the plateau flat and puts the whole move where it
* belongs. Shared by the two envelope builders below, which differ only in how
* `raw` gets built.
*/
function simplifyStepPoints(
raw: readonly { t: number; v: number }[],
stepDb: number,
): { t: number; v: number }[] {
const points: { t: number; v: number }[] = [{ t: 0, v: 0 }];
let lastKept = 0;
let keptIndex = -1;
raw.forEach((pt, i) => {
if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < stepDb) return;
if (i > 0 && keptIndex !== i - 1) points.push(raw[i - 1]!);
points.push(pt);
lastKept = pt.v;
keptIndex = i;
});
return points;
}
export interface HfCarveDynamics {
/** The band this envelope drives, matching a band from `analyseCarveBands`. */
freq: number;
@@ -588,8 +616,6 @@ export function analyseCarveDynamics(
const powers = perBand[b]!;
const peak = Math.max(...powers);
let level = 0;
const points: { t: number; v: number }[] = [{ t: 0, v: 0 }];
let lastKept = 0;
const raw: { t: number; v: number }[] = [];
powers.forEach((p, i) => {
// Relative to this band's own loudest moment, so a quiet band still gets a
@@ -601,19 +627,7 @@ export function analyseCarveDynamics(
const v = Math.abs(scaled) < SNAP_DB ? 0 : Number(scaled.toFixed(1));
raw.push({ t: Number(times[i]!.toFixed(3)), v });
});
let keptIndex = -1;
raw.forEach((pt, i) => {
if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < STEP_DB) return;
// Anchor the run this point ends. Segments interpolate, so dropping a run
// of equal values does not hold them — it draws a straight line from
// wherever the last kept point was, which turned a silent stretch before
// the first word into a slow 2 dB slide into it. Keeping the last value of
// the run pins the plateau flat and puts the whole move where it belongs.
if (i > 0 && keptIndex !== i - 1) points.push(raw[i - 1]!);
points.push(pt);
lastKept = pt.v;
keptIndex = i;
});
const points = simplifyStepPoints(raw, STEP_DB);
// The last point's value is held for the rest of the bed, so it has to be
// no cut and it has to be at the end of the voice.
points.push({ t: Number(Math.max(duration, points.at(-1)!.t).toFixed(3)), v: 0 });
@@ -708,16 +722,7 @@ export function analyseCarveDuck(
});
}
const points: { t: number; v: number }[] = [{ t: 0, v: 0 }];
let lastKept = 0;
let keptIndex = -1;
raw.forEach((pt, i) => {
if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < STEP_DB) return;
if (i > 0 && keptIndex !== i - 1) points.push(raw[i - 1]!);
points.push(pt);
lastKept = pt.v;
keptIndex = i;
});
const points = simplifyStepPoints(raw, STEP_DB);
// Pin the end at no cut, unless the release already got there — a second point
// at the same time and value is just noise in the lane.
const duration = voice.length / sampleRate;
@@ -26,6 +26,7 @@ import type { TimelineElement } from "../player/store/timelineElement";
function wholeAxis<T extends { t0: number; t1: number }>(sel: T): T & { v0: number; v1: number } {
return { ...sel, v0: Number.NEGATIVE_INFINITY, v1: Number.POSITIVE_INFINITY };
}
/** Minimal valid fixture — TimelineElement only requires these five fields. */
const bgmElement: TimelineElement = {
id: "bgm",
@@ -1400,7 +1400,7 @@ describe("TimelineAutomationLane stretch", () => {
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 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 3.3); // off any point, dragged out to 3.3
@@ -1412,7 +1412,7 @@ describe("TimelineAutomationLane stretch", () => {
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).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1));
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1), 0, 1);
});
it("moves the selection with the pointer instead of snapping it on release", () => {
@@ -1422,14 +1422,14 @@ describe("TimelineAutomationLane stretch", () => {
// gesture. The marquee drag fires live for exactly this reason.
const onRangeSelect = vi.fn();
const { svg } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
fire(svg, "pointerdown", at(2.5, 0.5));
fire(svg, "pointermove", at(3, 0.5));
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3, 1));
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3, 1), 0, 1);
fire(svg, "pointermove", at(3.3, 0.5));
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1));
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1), 0, 1);
});
it("a bare click on an edge clears the selection instead of committing a no-op", () => {
@@ -1439,7 +1439,7 @@ describe("TimelineAutomationLane stretch", () => {
const onRangeSelect = vi.fn();
const onRangeClear = vi.fn();
const { svg, props } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
onRangeClear,
});
@@ -1454,7 +1454,7 @@ describe("TimelineAutomationLane stretch", () => {
it("a press that jitters under the threshold still clears rather than retiming", () => {
const onRangeClear = vi.fn();
const { svg, props } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeClear,
});
fire(svg, "pointerdown", at(2.5, 0.5));
@@ -1472,7 +1472,7 @@ describe("TimelineAutomationLane stretch", () => {
// with no way back other than undo.
const onRangeSelect = vi.fn();
const { svg, props } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
fire(svg, "pointerdown", at(2.5, 0.5));
@@ -1485,14 +1485,14 @@ describe("TimelineAutomationLane stretch", () => {
const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]
?.points;
expect(reverted).toEqual(stretchable.lanes[0]?.points);
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5);
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5, 0, 1);
});
it("gives up a stretch whose pointer capture vanished without a cancel", () => {
// A capture taken on a child that unmounts mid-drag is lost silently: no
// pointercancel, no pointerup. Every later hover kept retiming and writing.
const { svg, props } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
});
fire(svg, "pointerdown", at(2.5, 0.5));
fire(svg, "pointermove", { ...at(3.3, 0.5), buttons: 1 });
@@ -1509,7 +1509,7 @@ describe("TimelineAutomationLane stretch", () => {
const onPreview = vi.fn();
const onCommit = vi.fn();
const { svg } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onPreview,
onCommit,
});
@@ -1540,20 +1540,28 @@ describe("TimelineAutomationLane stretch", () => {
],
};
it("the selection's edge wins over a point sitting exactly on it", () => {
// The regression this pins is the feature not being repeatable: stretching
// pins a breakpoint on the edge it just created, so a point-first rule made
// the SECOND stretch of that edge a point-drag every time — at the one
// height (on the envelope) where the user naturally grabs it.
it("a point sitting exactly on the edge is selected content, not the edge", () => {
// #3207's rule was the opposite — the edge won, so a repeated stretch of the
// same edge (which pins a breakpoint there) always resolved to a point-drag.
// A box selection changes the question: with value bounds as well as time,
// a point on the edge is visibly INSIDE the box, and dragging selected
// content has to move it or the box means nothing. So the press below drags
// the group the box already caught — both points inside it — rather than
// stretching the edge; the box travels with them.
const onRangeSelect = vi.fn();
const { svg } = mount(pointOnEdge, {
rangeSelection: { t0: 1, t1: 2 },
const { svg, props } = mount(pointOnEdge, {
rangeSelection: { t0: 1, t1: 2, v0: 0, v1: 1 },
onRangeSelect,
});
fire(svg, "pointerdown", at(2, 0.8)); // 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));
expect(onRangeSelect).toHaveBeenLastCalledWith(1, expect.closeTo(3, 1));
expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1), 0, 1);
const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
const times = (written.lanes[0]?.points ?? []).map((p) => p.t);
// Both points the box caught (t=1.5 and t=2) moved by the same +1 delta.
expect(times.some((t) => Math.abs(t - 2.5) < 0.01)).toBe(true);
expect(times.some((t) => Math.abs(t - 3) < 0.01)).toBe(true);
});
it("leaves that point reachable once the selection is gone", () => {
@@ -1574,19 +1582,19 @@ describe("TimelineAutomationLane stretch", () => {
// right edge, the second grabbing the breakpoint the first one left there.
const onRangeSelect = vi.fn();
const { svg, props, rerender } = mountRerenderable(stretchable, {
rangeSelection: { t0: 0.5, t1: 2 },
rangeSelection: { t0: 0.5, t1: 2, v0: 0, v1: 1 },
onRangeSelect,
});
dragRightEdge(svg, 2, 2.6);
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(2.6, 1));
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(2.6, 1), 0, 1);
const afterFirst = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
// A breakpoint landed on the new edge, which is what used to disarm it.
expect((afterFirst.lanes[0]?.points ?? []).some((p) => Math.abs(p.t - 2.6) < 0.05)).toBe(true);
// The store comes back with the persisted envelope and the new selection.
rerender({ automation: afterFirst, rangeSelection: { t0: 0.5, t1: 2.6 } });
rerender({ automation: afterFirst, rangeSelection: { t0: 0.5, t1: 2.6, v0: 0, v1: 1 } });
dragRightEdge(svg, 2.6, 3.4);
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.4, 1));
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.4, 1), 0, 1);
expect(props.onCommit).toHaveBeenCalledTimes(2);
});
@@ -1598,20 +1606,20 @@ describe("TimelineAutomationLane stretch", () => {
// the rule never comes up.
const onRangeSelect = vi.fn();
const { svg } = mount(stretchable, {
rangeSelection: { t0: 2, t1: 2.08 }, // 8px wide at 100 px/s: both halos overlap
rangeSelection: { t0: 2, t1: 2.08, v0: 0, v1: 1 }, // 8px wide at 100 px/s: both halos overlap
onRangeSelect,
});
fire(svg, "pointerdown", at(2.07, 0.5)); // nearer t1, inside t0's halo too
fire(svg, "pointermove", at(3, 0.5));
fire(svg, "pointerup", at(3, 0.5));
// t0 stayed put and t1 moved out: the press resolved to the right edge.
expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1));
expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1), 0, 1);
});
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 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 0.3); // dragged past the left edge (t0=0.5)
@@ -1622,7 +1630,7 @@ describe("TimelineAutomationLane stretch", () => {
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 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 10); // far past the clip's own duration (4s)
@@ -1639,7 +1647,7 @@ describe("TimelineAutomationLane stretch", () => {
// 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 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onPreview: onPreviewSingle,
});
fire(single.svg, "pointerdown", at(2.5, 0.5));
@@ -1650,7 +1658,7 @@ describe("TimelineAutomationLane stretch", () => {
const onPreviewMulti = vi.fn();
const multi = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onPreview: onPreviewMulti,
});
fire(multi.svg, "pointerdown", at(2.5, 0.5));
@@ -1674,7 +1682,7 @@ describe("TimelineAutomationLane stretch", () => {
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 },
rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 },
onPreview: onPreviewSingle,
});
fire(single.svg, "pointerdown", at(1, 0.5));
@@ -1685,7 +1693,7 @@ describe("TimelineAutomationLane stretch", () => {
const onPreviewMulti = vi.fn();
const multi = mount(stretchable, {
rangeSelection: { t0: 1, t1: 3 },
rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 },
onPreview: onPreviewMulti,
});
fire(multi.svg, "pointerdown", at(1, 0.5));
@@ -1699,13 +1707,13 @@ describe("TimelineAutomationLane stretch", () => {
});
it("shows a resize cursor when hovering an edge with nothing else live", () => {
const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
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 } });
const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
fire(svg, "pointermove", at(2, 0.5)); // middle of the selection, not an edge
expect(svg.style.cursor).not.toBe("col-resize");
});
@@ -76,6 +76,22 @@ function pointInBox(point: HfAutomationPoint, box: SelectionBox | null | undefin
return !!box && pointInSelection(point, box);
}
/** A breakpoint's drawn radius and stroke, pulled out of the render loop so the
* map callback stays a single JSX return — the ternaries below are what pushed
* it over the complexity budget when they lived inline. */
function pointCircleStyle(
inRange: boolean,
dragging: boolean,
): { radius: number; stroke: string; strokeWidth: number } {
return {
radius: POINT_R * (dragging ? 1.3 : inRange ? 1.15 : 1),
// A white ring rather than a different fill: the fill is the parameter's
// own colour, and a lane with two envelopes on it is read by colour first.
stroke: inRange ? "#fff" : "rgba(0,0,0,0.5)",
strokeWidth: inRange ? 1.5 : 1,
};
}
/** Pointer shape: a read-only lane can only be selected, a live one edited. */
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
// A stretch handle wins over everything it might also sit above: the handle is
@@ -412,6 +428,7 @@ export function TimelineAutomationLane({
// the range is exactly what the range will remove. The tinted rectangle
// says where the selection is; this says which points it has.
const inRange = pointInBox(p, rangeSelection);
const { radius, stroke, strokeWidth } = pointCircleStyle(inRange, i === dragIndex);
return (
<circle
key={`${i}-${p.t}`}
@@ -419,13 +436,10 @@ export function TimelineAutomationLane({
{...(inRange ? { "data-automation-point-in-range": "" } : {})}
cx={xOf(p.t)}
cy={yOf(p.v)}
r={POINT_R * (i === dragIndex ? 1.3 : inRange ? 1.15 : 1)}
r={radius}
fill={accentColor}
// A white ring rather than a different fill: the fill is the
// parameter's own colour, and a lane with two envelopes on it is read
// by colour before anything else.
stroke={inRange ? "#fff" : "rgba(0,0,0,0.5)"}
strokeWidth={inRange ? 1.5 : 1}
stroke={stroke}
strokeWidth={strokeWidth}
// Hidden rather than unmounted, so the hit area survives: a point
// dragged past the lane's edge fires pointerleave mid-gesture, and a
// handle that vanishes then would drop the drag. A drag in progress
@@ -0,0 +1,194 @@
/**
* Pure position math for the two multi-point drags on an automation lane: a
* group move (dragging a whole box-selected set by one delta) and a single
* point's move (the modifiers and neighbour clamp one breakpoint honours).
*
* Split out of useAutomationLaneGestures.ts so the hook's own onPointerDown
* and movePoint stay orchestration — read the pointer, call one of these,
* apply the result — rather than carrying this branching themselves.
*/
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
import {
applyShiftConstraint,
dominantDragAxis,
MIN_POINT_GAP_SEC,
snapLaneTime,
} from "./automationLaneGeometry";
import { pointInSelection } from "./automationLaneSelection";
/** Snap radius in clip seconds, shared with the single-point and group drags. */
export const SNAP_SEC = 0.04;
export type AutomationSelectionBox = { t0: number; t1: number; v0: number; v1: number };
/** Which points a press on `pressed` should drag together: the whole box-selected
* set if it contains more than one member, otherwise none (an ordinary
* single-point drag, selection or no). */
function resolveGroupDragIndices(
lane: HfAutomationLane,
pressed: HfAutomationLane["points"][number] | undefined,
rangeSelection: AutomationSelectionBox | null | undefined,
): number[] {
if (!rangeSelection || !pressed || !pointInSelection(pressed, rangeSelection)) return [];
return lane.points.flatMap((p, i) => (pointInSelection(p, rangeSelection) ? [i] : []));
}
export interface GroupDragSnapshot {
points: HfAutomationLane["points"];
indices: number[];
anchor: { t: number; v: number };
selection: AutomationSelectionBox;
}
/** Snapshot to arm a group drag from a press on `pressed`, or null when the
* press does not land on a multi-point selection — an ordinary single-point
* drag, selection or no. */
export function armGroupDrag(
lane: HfAutomationLane,
pressed: HfAutomationLane["points"][number] | undefined,
rangeSelection: AutomationSelectionBox | null | undefined,
): GroupDragSnapshot | null {
const indices = resolveGroupDragIndices(lane, pressed, rangeSelection);
if (indices.length <= 1 || !pressed) return null;
return {
points: lane.points.map((p) => ({ ...p })),
indices,
anchor: { t: pressed.t, v: pressed.v },
selection: rangeSelection ? { ...rangeSelection } : { t0: 0, t1: 0, v0: 0, v1: 0 },
};
}
export interface GroupMoveResult {
points: HfAutomationLane["points"];
selection: AutomationSelectionBox;
hint: string;
}
/**
* Move a selected set by one delta, taken from the point under the pointer.
*
* The whole group has to stop when its first member reaches a boundary, not
* each point on its own: clamping individually squashes the shape flat against
* the edge, and the gesture is meant to preserve it. Deltas are in the
* parameter's own units, so on a logarithmic axis a group moves by Hz rather
* than by octaves — the same as dragging one point does.
*/
export function computeGroupMove(input: {
group: GroupDragSnapshot;
raw: { t: number; v: number };
shiftKey: boolean;
altKey: boolean;
range: AutomationRange;
duration: number;
snapTimes: readonly number[] | undefined;
xOf(t: number): number;
yOf(v: number): number;
}): GroupMoveResult {
const { group, raw, shiftKey, altKey, range, duration, snapTimes, xOf, yOf } = input;
const moving = group.indices.map((i) => group.points[i]!);
let dt = raw.t - group.anchor.t;
let dv = raw.v - group.anchor.v;
if (shiftKey) {
// The axis lock, as a single-point drag has it: keep whichever the pointer
// has travelled further along and drop the other.
const alongTime = Math.abs(dt * (xOf(1) - xOf(0)));
const alongValue = Math.abs((dv * (yOf(range.min) - yOf(range.max))) / (range.max - range.min));
if (alongTime >= alongValue) dv = 0;
else dt = 0;
} else if (!altKey) {
// Snap the point under the pointer, and move the set by that same amount.
const others = group.points.filter((_, i) => !group.indices.includes(i)).map((p) => p.t);
dt =
snapLaneTime(group.anchor.t + dt, [...(snapTimes ?? []), ...others], SNAP_SEC) -
group.anchor.t;
}
const times = moving.map((p) => p.t);
const values = moving.map((p) => p.v);
// No member may cross a point that is staying put. Only stationary
// neighbours constrain: two selected points travel together, so the gap
// between them never changes. Per member rather than per end of the group,
// because a box can select a non-contiguous set — the peaks of an envelope
// and not the dip between them.
const gapTo = (step: 1 | -1): number[] =>
group.indices.flatMap((i) => {
const neighbour = group.points[i + step];
if (!neighbour || group.indices.includes(i + step)) return [];
// Short of the neighbour, not onto it — the lane collapses points that
// share a time, and a group drag must not consume what it runs into.
return [Math.max(0, Math.abs(neighbour.t - group.points[i]!.t) - MIN_POINT_GAP_SEC)];
});
dt = Math.min(
Math.max(dt, -Math.min(Math.min(...times), ...gapTo(-1))),
Math.min(duration - Math.max(...times), ...gapTo(1)),
);
dv = Math.min(Math.max(dv, range.min - Math.min(...values)), range.max - Math.max(...values));
// No re-sort: clamped to the neighbours, the lane's order cannot change
// under a drag, so the point under the pointer keeps its index.
const points = group.points.map((p, i) =>
group.indices.includes(i) ? { ...p, t: p.t + dt, v: p.v + dv } : p,
);
return {
points,
// The box travels with the points — both axes, or a vertical nudge would
// slide its own points out of the box that caught them and a second nudge
// would move fewer of them.
selection: {
t0: group.selection.t0 + dt,
t1: group.selection.t1 + dt,
v0: group.selection.v0 + dv,
v1: group.selection.v1 + dv,
},
hint: `${group.indices.length} points ${dt >= 0 ? "+" : ""}${dt.toFixed(2)}s`,
};
}
/** Where Shift last locked a single-point drag's axis. */
export type ShiftAxis = "time" | "value" | null;
/** The clip-local time and value a single dragged point should land on,
* honouring the modifiers held with it and the neighbours it cannot cross. */
export function computeSinglePointMove(input: {
raw: { t: number; v: number };
origin: { t: number; v: number } | null;
shiftKey: boolean;
altKey: boolean;
shiftAxis: ShiftAxis;
range: AutomationRange;
duration: number;
snapTimes: readonly number[] | undefined;
lane: HfAutomationLane;
dragIndex: number;
xOf(t: number): number;
yOf(v: number): number;
}): { t: number; v: number; shiftAxis: ShiftAxis } {
const { raw, origin, shiftKey, altKey, range, duration, snapTimes, lane, dragIndex, xOf, yOf } =
input;
let shiftAxis = shiftKey ? input.shiftAxis : null;
let { t, v } = raw;
if (shiftKey && origin) {
shiftAxis ??= dominantDragAxis({ origin, raw, xOf, yOf });
({ t, v } = applyShiftConstraint({ range, origin, raw, xOf, yOf, axis: shiftAxis }));
}
// Shift is a deliberate free-hand move as much as Alt is, so neither snaps.
if (!altKey && !shiftKey) {
const neighbours = lane.points.filter((_, i) => i !== dragIndex).map((p) => p.t);
t = snapLaneTime(t, [...(snapTimes ?? []), ...neighbours], SNAP_SEC);
}
// A breakpoint cannot cross another in time, and cannot land exactly on one
// either: the lane collapses points that share a `t`, so arriving on top of a
// neighbour deletes it. It stops a hair short instead, which reads as touching
// and keeps both points. Applied after the snap, which can itself put the
// point on a beat past a neighbour.
const floor = (lane.points[dragIndex - 1]?.t ?? -Infinity) + MIN_POINT_GAP_SEC;
const ceiling = (lane.points[dragIndex + 1]?.t ?? Infinity) - MIN_POINT_GAP_SEC;
const held = lane.points[dragIndex]?.t ?? t;
t =
ceiling >= floor
? Math.min(ceiling, Math.max(floor, Math.min(duration, Math.max(0, t))))
: // Neighbours closer together than the gap leave nowhere to go, so the
// point stays where it is rather than being flung to one side.
held;
return { t, v, shiftAxis };
}
@@ -13,27 +13,22 @@
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
import {
applyShiftConstraint,
dominantDragAxis,
curveForDrag,
formatValue,
GRAB_PX,
MIN_POINT_GAP_SEC,
POINT_MERGE_SEC,
snapLaneTime,
} from "./automationLaneGeometry";
import { curveForDrag, formatValue, GRAB_PX, POINT_MERGE_SEC } from "./automationLaneGeometry";
import { pointInSelection } from "./automationLaneSelection";
import { capturePointer } from "./automationLanePointer";
import { useAutomationEdgeStretch } from "./useAutomationEdgeStretch";
import { useAutomationRangeDrag } from "./useAutomationRangeDrag";
import {
armGroupDrag,
computeGroupMove,
computeSinglePointMove,
type GroupDragSnapshot,
type ShiftAxis,
} from "./automationLaneDragMath";
/** How far a press may travel and still count as a click rather than a drag. */
const CLICK_SLOP_PX = 3;
/** 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;
/** 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 };
@@ -155,23 +150,18 @@ export function useAutomationLaneGestures({
* gesture started, and the deltas have to accumulate from the original
* positions or a rounded move would drift on every pointermove.
*/
const groupDrag = useRef<{
points: HfAutomationLane["points"];
indices: number[];
anchor: { t: number; v: number };
selection: { t0: number; t1: number; v0: number; v1: number };
} | null>(null);
const groupDrag = useRef<GroupDragSnapshot | 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: the two corners of the box it is drawing,
* each a clip-local time and a parameter value. */
const [rangeDrag, setRangeDrag] = useState<{
from: { t: number; v: number };
to: { t: number; v: number };
} | null>(null);
/** Whether the live drag has crossed the pixel threshold that turns a press
* into an actual box, rather than a click that should just clear one. */
const rangeCrossed = useRef(false);
const rangeDrag = useAutomationRangeDrag({
pointAt,
xOf,
yOf,
duration,
snapTimes,
onRangeSelect,
onRangeClear,
});
/** Where a press on a point landed, and whether it has travelled since. A press
* that goes nowhere is a click, which is a different gesture from a drag even
* though both start the same way — see the Shift branch in `endDrag`. */
@@ -182,7 +172,7 @@ export function useAutomationLaneGestures({
* the drag ends or Shift is let go. Deciding per event followed whichever way
* the last move leaned, so a drifting hand unlocked the axis mid-drag.
*/
const shiftAxis = useRef<"time" | "value" | null>(null);
const shiftAxis = useRef<ShiftAxis>(null);
// The value field's own handlers, above the gestures that close it: a press on the
// lane applies whatever was typed, so onPointerDown lists commitEdit as a
@@ -249,43 +239,6 @@ export function useAutomationLaneGestures({
[hitIndex, segmentIndex],
);
/**
* A corner of the selection box under the pointer.
*
* Time is clamped to the clip and snaps to the grid, because the box's span is
* also what a shape insert or a paste acts over and those want beat-aligned
* edges. The value is taken as it lies: there is no grid on a parameter axis,
* and rounding a dB bound would move which points the box catches.
*/
const boxCornerAt = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): { t: number; v: number } => {
const raw = pointAt(e.clientX, e.clientY);
const clamped = Math.min(duration, Math.max(0, raw.t));
return {
t: e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC),
v: raw.v,
};
},
[pointAt, duration, snapTimes],
);
/**
* What a press on the lane's empty background arms: a new selection box, and
* only when a caller wants to hear about one — a read-only lane never reaches
* here at all.
*/
const armRangeDrag = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
if (!onRangeSelect) return;
e.preventDefault();
capturePointer(e);
const corner = boxCornerAt(e);
rangeCrossed.current = false;
setRangeDrag({ from: corner, to: corner });
},
[onRangeSelect, boxCornerAt],
);
const onPointerDown = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
if (e.button !== 0) return;
@@ -306,7 +259,7 @@ export function useAutomationLaneGestures({
// press that only selected made the first drag over any lane do nothing
// visible, so a range took two gestures and looked broken on the first.
onSelect?.();
armRangeDrag(e);
rangeDrag.arm(e);
return;
}
// An active selection's edge outranks a point sitting on it. Every range
@@ -316,7 +269,7 @@ export function useAutomationLaneGestures({
if (stretch.arm(e)) return;
const gesture = gestureAt(e);
if (!gesture) {
armRangeDrag(e);
rangeDrag.arm(e);
return;
}
e.preventDefault();
@@ -328,20 +281,7 @@ export function useAutomationLaneGestures({
dragOrigin.current = originOf(lane.points[gesture.index]);
// Pressing one of a selected set drags the whole set. Pressing a point
// outside the selection is an ordinary single-point drag, selection or no.
const pressed = lane.points[gesture.index];
const indices =
rangeSelection && pressed && pointInSelection(pressed, rangeSelection)
? lane.points.flatMap((p, i) => (pointInSelection(p, rangeSelection) ? [i] : []))
: [];
groupDrag.current =
indices.length > 1 && pressed
? {
points: lane.points.map((p) => ({ ...p })),
indices,
anchor: { t: pressed.t, v: pressed.v },
selection: rangeSelection ? { ...rangeSelection } : { t0: 0, t1: 0, v0: 0, v1: 0 },
}
: null;
groupDrag.current = armGroupDrag(lane, lane.points[gesture.index], rangeSelection);
pressAt.current = { x: e.clientX, y: e.clientY };
pressTravelled.current = false;
setDragIndex(gesture.index);
@@ -351,7 +291,7 @@ export function useAutomationLaneGestures({
lane,
readOnly,
onSelect,
armRangeDrag,
rangeDrag,
editing,
commitEdit,
// The press decides whether it starts a group drag, so it has to see the
@@ -399,64 +339,21 @@ export function useAutomationLaneGestures({
* than by octaves — the same as dragging one point does.
*/
const moveGroup = useCallback(
(e: ReactPointerEvent<SVGSVGElement>, group: NonNullable<typeof groupDrag.current>): void => {
const raw = pointAt(e.clientX, e.clientY);
const moving = group.indices.map((i) => group.points[i]!);
let dt = raw.t - group.anchor.t;
let dv = raw.v - group.anchor.v;
if (e.shiftKey) {
// The axis lock, as a single-point drag has it: keep whichever the pointer
// has travelled further along and drop the other.
const alongTime = Math.abs(dt * (xOf(1) - xOf(0)));
const alongValue = Math.abs(
(dv * (yOf(range.min) - yOf(range.max))) / (range.max - range.min),
);
if (alongTime >= alongValue) dv = 0;
else dt = 0;
} else if (!e.altKey) {
// Snap the point under the pointer, and move the set by that same amount.
const others = group.points.filter((_, i) => !group.indices.includes(i)).map((p) => p.t);
dt =
snapLaneTime(group.anchor.t + dt, [...(snapTimes ?? []), ...others], SNAP_SEC) -
group.anchor.t;
}
const times = moving.map((p) => p.t);
const values = moving.map((p) => p.v);
// No member may cross a point that is staying put. Only stationary
// neighbours constrain: two selected points travel together, so the gap
// between them never changes. Per member rather than per end of the group,
// because a box can select a non-contiguous set — the peaks of an envelope
// and not the dip between them.
const gapTo = (step: 1 | -1): number[] =>
group.indices.flatMap((i) => {
const neighbour = group.points[i + step];
if (!neighbour || group.indices.includes(i + step)) return [];
// Short of the neighbour, not onto it — the lane collapses points that
// share a time, and a group drag must not consume what it runs into.
return [Math.max(0, Math.abs(neighbour.t - group.points[i]!.t) - MIN_POINT_GAP_SEC)];
});
dt = Math.min(
Math.max(dt, -Math.min(Math.min(...times), ...gapTo(-1))),
Math.min(duration - Math.max(...times), ...gapTo(1)),
);
dv = Math.min(Math.max(dv, range.min - Math.min(...values)), range.max - Math.max(...values));
// No re-sort: clamped to the neighbours, the lane's order cannot change
// under a drag, so the point under the pointer keeps its index.
const next = group.points.map((p, i) =>
group.indices.includes(i) ? { ...p, t: p.t + dt, v: p.v + dv } : p,
);
// The box travels with the points — both axes, or a vertical nudge would
// slide its own points out of the box that caught them and a second nudge
// would move fewer of them.
onRangeSelect?.(
group.selection.t0 + dt,
group.selection.t1 + dt,
group.selection.v0 + dv,
group.selection.v1 + dv,
);
setHint(`${group.indices.length} points ${dt >= 0 ? "+" : ""}${dt.toFixed(2)}s`);
commitPoints(next, false);
(e: ReactPointerEvent<SVGSVGElement>, group: GroupDragSnapshot): void => {
const { points, selection, hint } = computeGroupMove({
group,
raw: pointAt(e.clientX, e.clientY),
shiftKey: e.shiftKey,
altKey: e.altKey,
range,
duration,
snapTimes,
xOf,
yOf,
});
onRangeSelect?.(selection.t0, selection.t1, selection.v0, selection.v1);
setHint(hint);
commitPoints(points, false);
},
[commitPoints, duration, onRangeSelect, pointAt, range, snapTimes, xOf, yOf],
);
@@ -470,40 +367,26 @@ export function useAutomationLaneGestures({
moveGroup(e, group);
return;
}
const raw = pointAt(e.clientX, e.clientY);
const origin = dragOrigin.current;
if (!e.shiftKey) shiftAxis.current = null;
let { t, v } = raw;
if (e.shiftKey && origin) {
shiftAxis.current ??= dominantDragAxis({ origin, raw, xOf, yOf });
({ t, v } = applyShiftConstraint({
range,
origin,
raw,
xOf,
yOf,
axis: shiftAxis.current,
}));
}
// Shift is a deliberate free-hand move as much as Alt is, so neither snaps.
if (!e.altKey && !e.shiftKey) {
const neighbours = lane.points.filter((_, i) => i !== dragIndex).map((p) => p.t);
t = snapLaneTime(t, [...(snapTimes ?? []), ...neighbours], SNAP_SEC);
}
// A breakpoint cannot cross another in time, and cannot land exactly on one
// either: the lane collapses points that share a `t`, so arriving on top of a
// neighbour deletes it. It stops a hair short instead, which reads as touching
// and keeps both points. Applied after the snap, which can itself put the
// point on a beat past a neighbour.
const floor = (lane.points[dragIndex - 1]?.t ?? -Infinity) + MIN_POINT_GAP_SEC;
const ceiling = (lane.points[dragIndex + 1]?.t ?? Infinity) - MIN_POINT_GAP_SEC;
const held = lane.points[dragIndex]?.t ?? t;
t =
ceiling >= floor
? Math.min(ceiling, Math.max(floor, Math.min(duration, Math.max(0, t))))
: // Neighbours closer together than the gap leave nowhere to go, so the
// point stays where it is rather than being flung to one side.
held;
const {
t,
v,
shiftAxis: nextAxis,
} = computeSinglePointMove({
raw: pointAt(e.clientX, e.clientY),
origin: dragOrigin.current,
shiftKey: e.shiftKey,
altKey: e.altKey,
shiftAxis: shiftAxis.current,
range,
duration,
snapTimes,
lane,
dragIndex,
xOf,
yOf,
});
shiftAxis.current = nextAxis;
const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p));
setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`);
commitPoints(next, false);
@@ -511,31 +394,6 @@ export function useAutomationLaneGestures({
[dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
);
/** Update the live box-drag as the pointer moves, firing `onRangeSelect` once
* it has covered enough pixels along EITHER axis to count as an actual box
* rather than a click that should just clear one. */
const moveRangeDrag = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
if (rangeDrag === null) return;
const to = boxCornerAt(e);
const { from } = rangeDrag;
setRangeDrag({ from, to });
const travelled = Math.max(
Math.abs(xOf(to.t) - xOf(from.t)),
Math.abs(yOf(to.v) - yOf(from.v)),
);
if (travelled <= 3) return;
rangeCrossed.current = true;
onRangeSelect?.(
Math.min(from.t, to.t),
Math.max(from.t, to.t),
Math.min(from.v, to.v),
Math.max(from.v, to.v),
);
},
[rangeDrag, boxCornerAt, xOf, yOf, onRangeSelect],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
if (stretch.edge !== null) {
@@ -546,9 +404,9 @@ export function useAutomationLaneGestures({
else stretch.move(e);
return;
}
if (rangeDrag !== null) {
if (rangeDrag.dragging) {
e.stopPropagation();
moveRangeDrag(e);
rangeDrag.move(e);
return;
}
if (curveIndex === null && dragIndex === null) {
@@ -563,17 +421,9 @@ export function useAutomationLaneGestures({
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
else movePoint(e);
},
[rangeDrag, moveRangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch],
[rangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch],
);
/** 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<SVGSVGElement>): void => {
if (stretch.edge !== null) {
@@ -581,9 +431,9 @@ export function useAutomationLaneGestures({
stretch.finish();
return;
}
if (rangeDrag !== null) {
if (rangeDrag.dragging) {
e.stopPropagation();
finishRangeDrag();
rangeDrag.finish();
return;
}
if (dragIndex === null && curveIndex === null) return;
@@ -609,7 +459,7 @@ export function useAutomationLaneGestures({
}
commitPoints(lane.points, true);
},
[rangeDrag, finishRangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
[rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
);
/**
@@ -0,0 +1,114 @@
/**
* Drawing a new selection box over an automation lane's empty background.
*
* Its own module for the same reason useAutomationEdgeStretch is: a sixth
* mutually-exclusive gesture on a hook already at the file's complexity
* budget, and one with no use for anything the others snapshot.
*/
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { snapLaneTime } from "./automationLaneGeometry";
import { capturePointer } from "./automationLanePointer";
import { SNAP_SEC } from "./automationLaneDragMath";
export interface UseAutomationRangeDragInput {
pointAt(clientX: number, clientY: number): { t: number; v: number };
xOf(t: number): number;
yOf(v: number): number;
duration: number;
snapTimes?: readonly number[] | undefined;
onRangeSelect?: ((t0: number, t1: number, v0: number, v1: number) => void) | undefined;
onRangeClear?: (() => void) | undefined;
}
export interface UseAutomationRangeDragResult {
/** Whether a box is being drawn right now. */
dragging: boolean;
/** Arms a new box at the press, or declines when no caller wants to hear
* about one (a read-only lane). */
arm(e: ReactPointerEvent<SVGSVGElement>): void;
move(e: ReactPointerEvent<SVGSVGElement>): void;
/** A sub-threshold press clears the selection rather than leaving a
* zero-width one behind. */
finish(): void;
}
/**
* A corner of the selection box under the pointer.
*
* Time is clamped to the clip and snaps to the grid, because the box's span is
* also what a shape insert or a paste acts over and those want beat-aligned
* edges. The value is taken as it lies: there is no grid on a parameter axis,
* and rounding a dB bound would move which points the box catches.
*/
function boxCornerAt(
e: ReactPointerEvent<SVGSVGElement>,
input: Pick<UseAutomationRangeDragInput, "pointAt" | "duration" | "snapTimes">,
): { t: number; v: number } {
const raw = input.pointAt(e.clientX, e.clientY);
const clamped = Math.min(input.duration, Math.max(0, raw.t));
return {
t: e.altKey ? clamped : snapLaneTime(clamped, input.snapTimes ?? [], SNAP_SEC),
v: raw.v,
};
}
export function useAutomationRangeDrag({
pointAt,
xOf,
yOf,
duration,
snapTimes,
onRangeSelect,
onRangeClear,
}: UseAutomationRangeDragInput): UseAutomationRangeDragResult {
const [box, setBox] = useState<{
from: { t: number; v: number };
to: { t: number; v: number };
} | null>(null);
/** Whether the live drag has crossed the pixel threshold that turns a press
* into an actual box, rather than a click that should just clear one. */
const crossed = useRef(false);
const arm = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
if (!onRangeSelect) return;
e.preventDefault();
capturePointer(e);
const corner = boxCornerAt(e, { pointAt, duration, snapTimes });
crossed.current = false;
setBox({ from: corner, to: corner });
},
[onRangeSelect, pointAt, duration, snapTimes],
);
const move = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
if (box === null) return;
const to = boxCornerAt(e, { pointAt, duration, snapTimes });
const { from } = box;
setBox({ from, to });
const travelled = Math.max(
Math.abs(xOf(to.t) - xOf(from.t)),
Math.abs(yOf(to.v) - yOf(from.v)),
);
if (travelled <= 3) return;
crossed.current = true;
onRangeSelect?.(
Math.min(from.t, to.t),
Math.max(from.t, to.t),
Math.min(from.v, to.v),
Math.max(from.v, to.v),
);
},
[box, pointAt, duration, snapTimes, xOf, yOf, onRangeSelect],
);
const finish = useCallback((): void => {
if (!crossed.current) onRangeClear?.();
crossed.current = false;
setBox(null);
}, [onRangeClear]);
return { dragging: box !== null, arm, move, finish };
}
@@ -42,7 +42,7 @@ export function trackShowsBeatStrip(
* counts toward whether a track has anything to disclose. A function rather than
* a map so every caller reads the same cached parse and none can drift.
*/
export function automationLaneCountOf(element: TimelineElement): number {
function automationLaneCountOf(element: TimelineElement): number {
return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0;
}
@@ -51,7 +51,7 @@ export function automationLaneCountOf(element: TimelineElement): number {
* clips on one row share a lane row per property. Counting only the active clip's
* lanes reserved a height that changed with the selection.
*/
export function trackAutomationLaneCount(elements: readonly TimelineElement[]): number {
function trackAutomationLaneCount(elements: readonly TimelineElement[]): number {
return groupAutomationLanes(elements).length;
}