mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
chore: merge in the wa-18b-reschedule fallow/typecheck fix
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user