mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 05:13:47 +00:00
feat(studio): curve, snap and type a value in an automation lane
Four gestures from Ableton's envelope editor, which is the muscle memory an automation lane inherits. Alt-drag the line between two breakpoints to bend it, Alt-double-click to straighten. `curve` was already honoured everywhere it is read — drawn in the lane, sampled in preview, baked into the render through setValueCurveAtTime — with no gesture anywhere that could set it, so every envelope anyone could draw was linear in practice. The curve is solved, not accumulated (x^e = f, so e = ln f / ln x), which keeps the segment under the pointer instead of drifting away over a long drag; the test asserts that by sampling with the renderer's own sampler. Shift locks a drag to one axis and fines the vertical travel to a quarter. Which axis won is decided in pixels — seconds and dB are not comparable numbers, and comparing them would make the lock depend on the zoom. A dragged point snaps to the beat grid and to its neighbouring points, with Alt to ignore it. The radius is tight on purpose: a lane is often a few seconds wide, where a generous radius makes a point unplaceable between two beats. Double-click a point to type its value. -6.0 dB is not a pixel you can find, and there was no way to enter one. The gesture layer moves to useAutomationLaneGestures and the path builder to envelopePath: the component was at the studio's 600-line ceiling, and both are worth testing without a render. trackShowsBeatStrip comes out of TimelineLanes for the same reason.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Typing an exact value into a breakpoint.
|
||||
*
|
||||
* Dragging is how an envelope is shaped, but it cannot land on a number: -6.0 dB
|
||||
* is not a pixel you can find. This is the lane's keyboard route to one value,
|
||||
* kept in its own file so the lane component stays about the pointer.
|
||||
*/
|
||||
|
||||
export interface AutomationValueInputProps {
|
||||
text: string;
|
||||
/** Left edge in the lane's own coordinates. */
|
||||
leftPx: number;
|
||||
label: string;
|
||||
onChange(text: string): void;
|
||||
/** Enter or blur: apply what was typed. */
|
||||
onCommit(): void;
|
||||
/** Escape: leave the point where it was. */
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
export function AutomationValueInput({
|
||||
text,
|
||||
leftPx,
|
||||
label,
|
||||
onChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: AutomationValueInputProps) {
|
||||
return (
|
||||
<input
|
||||
className="hf-automation-value absolute rounded-[3px] border border-panel-border-input bg-panel-bg-2 px-1 font-mono text-[9px] text-panel-text-1"
|
||||
style={{ left: leftPx, top: 1, width: 44, zIndex: 4 }}
|
||||
// The lane is a pointer surface, and the timeline above it owns single-key
|
||||
// shortcuts. Without stopping both, a press lands on the lane instead of
|
||||
// the field and a typed "5" scrubs the transport.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") onCommit();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
value={text}
|
||||
aria-label={`${label} value`}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
||||
import { PAD_X } from "./automationLaneGeometry";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import {
|
||||
resolveAutomationRange,
|
||||
@@ -80,10 +81,24 @@ function renderNested(node: React.ReactElement): {
|
||||
function fire(
|
||||
el: Element,
|
||||
type: string,
|
||||
init: { clientX?: number; clientY?: number; button?: number } = {},
|
||||
init: {
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
button?: number;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
} = {},
|
||||
): void {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.assign(event, { clientX: 0, clientY: 0, button: 0, pointerId: 1, ...init });
|
||||
Object.assign(event, {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
...init,
|
||||
});
|
||||
act(() => {
|
||||
el.dispatchEvent(event);
|
||||
});
|
||||
@@ -443,3 +458,153 @@ describe("TimelineAutomationLane", () => {
|
||||
expect(Math.max(...ys)).toBeGreaterThan(Math.min(...ys) + 30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TimelineAutomationLane modifiers", () => {
|
||||
/** The lane's own box, so pointer coordinates map to clip time and value. */
|
||||
const BOX = { left: 100, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H };
|
||||
|
||||
/** x for a clip time, y for a 0..1 unit height, in client coordinates. The
|
||||
* 6px inset and the height have to match the lane's own, or a point sits
|
||||
* outside the grab radius and a press silently does nothing. */
|
||||
const at = (t: number, unit: number) => ({
|
||||
clientX: BOX.left + PAD + (t / 4) * 400,
|
||||
clientY: BOX.top + 6 + (1 - unit) * (AUTOMATION_LANE_H - 12),
|
||||
});
|
||||
|
||||
const mount = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
|
||||
const base = laneProps({ automation, ...over });
|
||||
// Narrowed once here: laneProps types these as the prop signature, and every
|
||||
// assertion below reads the calls the lane made.
|
||||
const props = {
|
||||
...base,
|
||||
onPreview: base.onPreview as ReturnType<typeof vi.fn>,
|
||||
onCommit: base.onCommit as ReturnType<typeof vi.fn>,
|
||||
};
|
||||
const { container } = render(<TimelineAutomationLane {...props} />);
|
||||
const svg = container.querySelector("svg")!;
|
||||
stubBox(svg, BOX);
|
||||
return { container, svg, props };
|
||||
};
|
||||
|
||||
it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => {
|
||||
// `curve` was honoured everywhere it is read — drawn, sampled in preview,
|
||||
// baked into the render — with no gesture that could set it.
|
||||
const { svg, props } = mount(ramp);
|
||||
fire(svg, "pointerdown", { ...at(2, 0.5), altKey: true });
|
||||
fire(svg, "pointermove", { ...at(2, 0.85), altKey: true });
|
||||
const previewed = props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
|
||||
const points = previewed?.lanes[0]?.points ?? [];
|
||||
expect(points[0]?.curve).toBeDefined();
|
||||
expect(points[0]?.curve).not.toBe(0);
|
||||
// The breakpoints themselves are untouched: only the shape between them moved.
|
||||
expect(points.map((p) => [p.t, p.v])).toEqual([
|
||||
[0, 1],
|
||||
[4, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("straightens a segment on Alt-double-click", () => {
|
||||
const curved: HfAutomation = {
|
||||
version: 1,
|
||||
lanes: [
|
||||
{
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 1, curve: 0.6 },
|
||||
{ t: 4, v: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { svg, props } = mount(curved);
|
||||
fire(svg, "dblclick", { ...at(2, 0.5), altKey: true });
|
||||
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
|
||||
expect(committed?.lanes[0]?.points[0]?.curve).toBeUndefined();
|
||||
});
|
||||
|
||||
it("locks a Shift-drag to one axis", () => {
|
||||
const { svg, props } = mount(ramp);
|
||||
// Grab the point at t=0, v=1 (top left) and pull mostly sideways.
|
||||
fire(svg, "pointerdown", at(0, 1));
|
||||
fire(svg, "pointermove", { ...at(2, 0.9), shiftKey: true });
|
||||
const points =
|
||||
(props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
|
||||
const moved = points.find((p) => p.t > 0.5);
|
||||
expect(moved).toBeDefined();
|
||||
// Value held at exactly where the drag started, despite the vertical travel.
|
||||
expect(moved?.v).toBe(1);
|
||||
});
|
||||
|
||||
it("snaps a dragged point to the beat grid", () => {
|
||||
// By eye, "on the beat" and "20 ms off the beat" look identical.
|
||||
const { svg, props } = mount(ramp, { snapTimes: [2] });
|
||||
fire(svg, "pointerdown", at(0, 1));
|
||||
fire(svg, "pointermove", at(2.02, 1));
|
||||
const points =
|
||||
(props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
|
||||
expect(points.find((p) => p.t > 0.5)?.t).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores the grid while Alt is held", () => {
|
||||
const { svg, props } = mount(ramp, { snapTimes: [2] });
|
||||
fire(svg, "pointerdown", at(0, 1));
|
||||
fire(svg, "pointermove", { ...at(2.02, 1), altKey: true });
|
||||
const points =
|
||||
(props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
|
||||
expect(points.find((p) => p.t > 0.5)?.t).toBeCloseTo(2.02, 2);
|
||||
});
|
||||
|
||||
it("takes a second gesture in the same lane, not just the first", () => {
|
||||
// The panel had exactly this bug: one edit worked and every later one was
|
||||
// swallowed, because the live write skips the resync the next edit reads.
|
||||
const { svg, props } = mount(ramp);
|
||||
fire(svg, "pointerdown", at(0, 1));
|
||||
fire(svg, "pointermove", at(1, 0.8));
|
||||
fire(svg, "pointerup", at(1, 0.8));
|
||||
fire(svg, "pointerdown", at(4, 0));
|
||||
fire(svg, "pointermove", at(3, 0.4));
|
||||
const points =
|
||||
(props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
|
||||
// Both ends moved: the first gesture's point is off t=0, the second's off t=4.
|
||||
expect(points.map((p) => Number(p.t.toFixed(2)))).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("types an exact value into a point", () => {
|
||||
// -6.0 dB is not a pixel you can find by dragging.
|
||||
const { container, svg, props } = mount(ramp);
|
||||
fire(svg, "dblclick", at(0, 1));
|
||||
const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
|
||||
expect(input).not.toBeNull();
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
|
||||
input,
|
||||
"0.25",
|
||||
);
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
fire(input!, "keydown", {});
|
||||
const key = new Event("keydown", { bubbles: true, cancelable: true });
|
||||
Object.assign(key, { key: "Enter" });
|
||||
act(() => {
|
||||
input?.dispatchEvent(key);
|
||||
});
|
||||
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
|
||||
expect(committed?.lanes[0]?.points[0]?.v).toBe(0.25);
|
||||
});
|
||||
|
||||
it("clamps a typed value to the parameter's range", () => {
|
||||
const { container, svg, props } = mount(ramp);
|
||||
fire(svg, "dblclick", at(0, 1));
|
||||
const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "99");
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
// React listens for focusout, not blur — blur does not bubble.
|
||||
act(() => {
|
||||
input?.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
});
|
||||
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
|
||||
expect(committed?.lanes[0]?.points[0]?.v).toBe(VOLUME_RANGE.max);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Breakpoint automation over an audio clip, edited the way a DAW edits it:
|
||||
* double-click the line to add a point, drag one to shape it, right-click a
|
||||
* point to remove it.
|
||||
* point to remove it, Alt-drag the line between two points to bend it, and
|
||||
* double-click a point to type an exact value.
|
||||
*
|
||||
* Modifiers follow Ableton's, because that is the muscle memory an automation
|
||||
* lane inherits: Shift locks a drag to one axis and fines the value down, Alt
|
||||
* over a segment curves it, and Alt during a point drag ignores the grid.
|
||||
*
|
||||
* The lane knows nothing about any particular effect. Which parameters it can
|
||||
* offer, their ranges, units and whether they read logarithmically all come
|
||||
@@ -9,14 +14,7 @@
|
||||
* same principle the property panel's controls follow.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
resolveAutomationRange,
|
||||
sampleAutomationLane,
|
||||
@@ -26,21 +24,27 @@ import {
|
||||
type HfAutomationPoint,
|
||||
} from "@hyperframes/core/audio-automation";
|
||||
import {
|
||||
DRAW_SAMPLES,
|
||||
formatValue,
|
||||
envelopePath,
|
||||
fromUnit,
|
||||
GRAB_PX,
|
||||
laneFor,
|
||||
PAD_X,
|
||||
POINT_MERGE_SEC,
|
||||
toUnit,
|
||||
withLane,
|
||||
} from "./automationLaneGeometry";
|
||||
import { useAutomationLaneGestures } from "./useAutomationLaneGestures";
|
||||
import { AutomationValueInput } from "./AutomationValueInput";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
import { getTimelineLaneTop } from "./timelineLayout";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
||||
|
||||
/** Pointer shape: a read-only lane can only be selected, a live one edited. */
|
||||
function laneCursor(readOnly: boolean | undefined, dragging: boolean): string {
|
||||
if (readOnly) return "pointer";
|
||||
return dragging ? "grabbing" : "crosshair";
|
||||
}
|
||||
|
||||
export interface TimelineAutomationLaneProps {
|
||||
/** Clip-local duration the lane spans. */
|
||||
duration: number;
|
||||
@@ -59,6 +63,11 @@ export interface TimelineAutomationLaneProps {
|
||||
onPreview(automation: HfAutomation): void;
|
||||
/** Gesture-end write; this is the one that persists and lands in undo. */
|
||||
onCommit(automation: HfAutomation): void;
|
||||
/**
|
||||
* Clip-local times a dragged point snaps to — the beat grid, shifted into this
|
||||
* clip's frame. Its own neighbouring points are added on top.
|
||||
*/
|
||||
snapTimes?: readonly number[];
|
||||
/** Editing writes to the selected element, so an unselected clip is read-only. */
|
||||
readOnly?: boolean;
|
||||
/** Called when a read-only lane is pressed: selects the clip so it goes live. */
|
||||
@@ -77,14 +86,13 @@ export function TimelineAutomationLane({
|
||||
playheadSec,
|
||||
onPreview,
|
||||
onCommit,
|
||||
snapTimes,
|
||||
readOnly,
|
||||
onSelect,
|
||||
}: TimelineAutomationLaneProps) {
|
||||
const stored = laneFor(automation, target);
|
||||
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Points as the user is shaping them, before the edit has come back around.
|
||||
@@ -144,37 +152,10 @@ export function TimelineAutomationLane({
|
||||
[duration, inner, range, widthPx],
|
||||
);
|
||||
|
||||
/**
|
||||
* The line the lane draws. A flat line at the current value stands in for a
|
||||
* lane with no points, so the first click has something to land on.
|
||||
*/
|
||||
const path = useMemo(() => {
|
||||
if (lane.points.length === 0) {
|
||||
const y = yOf(range.default ?? (range.min + range.max) / 2);
|
||||
return `M ${PAD_X} ${y} L ${PAD_X + widthPx} ${y}`;
|
||||
}
|
||||
const pts: string[] = [];
|
||||
const first = lane.points[0]!;
|
||||
pts.push(`M ${PAD_X} ${yOf(first.v)}`);
|
||||
pts.push(`L ${xOf(first.t)} ${yOf(first.v)}`);
|
||||
for (let i = 0; i + 1 < lane.points.length; i += 1) {
|
||||
const a = lane.points[i]!;
|
||||
const b = lane.points[i + 1]!;
|
||||
if (!a.curve && range.scale === "linear") {
|
||||
pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
|
||||
continue;
|
||||
}
|
||||
// Curved or log-read: sample it, or the drawing would lie about the
|
||||
// envelope the audio thread is going to play.
|
||||
for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
|
||||
const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
|
||||
pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
|
||||
}
|
||||
}
|
||||
const last = lane.points[lane.points.length - 1]!;
|
||||
pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
|
||||
return pts.join(" ");
|
||||
}, [lane, range, widthPx, xOf, yOf]);
|
||||
const path = useMemo(
|
||||
() => envelopePath({ lane, range, widthPx, xOf, yOf }),
|
||||
[lane, range, widthPx, xOf, yOf],
|
||||
);
|
||||
|
||||
const commitPoints = useCallback(
|
||||
(points: HfAutomationLane["points"], persist: boolean): void => {
|
||||
@@ -187,90 +168,23 @@ export function TimelineAutomationLane({
|
||||
[automation, target, onCommit, onPreview],
|
||||
);
|
||||
|
||||
/** Index of a point under the pointer, or null. */
|
||||
const hitIndex = useCallback(
|
||||
(clientX: number, clientY: number): number | null => {
|
||||
const box = svgRef.current?.getBoundingClientRect();
|
||||
if (!box) return null;
|
||||
const px = clientX - box.left;
|
||||
const py = clientY - box.top;
|
||||
for (let i = 0; i < lane.points.length; i += 1) {
|
||||
const p = lane.points[i]!;
|
||||
if (Math.hypot(xOf(p.t) - px, yOf(p.v) - py) <= GRAB_PX * 1.6) return i;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[lane, xOf, yOf],
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (e.button !== 0) return;
|
||||
// The lane owns this region either way. Letting a press through starts the
|
||||
// timeline's own gesture (scrub / marquee / clip drag), which then eats the
|
||||
// rest of the sequence — including the second half of a double-click.
|
||||
e.stopPropagation();
|
||||
if (readOnly) {
|
||||
// The lane sits below the clip bar, so the timeline's selection handler
|
||||
// never sees this press; selecting here is the only way in.
|
||||
onSelect?.();
|
||||
return;
|
||||
}
|
||||
const index = hitIndex(e.clientX, e.clientY);
|
||||
if (index === null) return;
|
||||
e.preventDefault();
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
setDragIndex(index);
|
||||
},
|
||||
[hitIndex, readOnly, onSelect],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (dragIndex === null) return;
|
||||
e.stopPropagation();
|
||||
const { t, v } = pointAt(e.clientX, e.clientY);
|
||||
const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p));
|
||||
// Re-sort so dragging a point past a neighbour behaves, and keep the
|
||||
// dragged one addressable by following where it landed.
|
||||
const moved = next[dragIndex]!;
|
||||
next.sort((a, b) => a.t - b.t);
|
||||
setDragIndex(next.indexOf(moved));
|
||||
setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`);
|
||||
commitPoints(next, false);
|
||||
},
|
||||
[dragIndex, lane, pointAt, range, commitPoints],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (dragIndex === null) return;
|
||||
e.stopPropagation();
|
||||
setDragIndex(null);
|
||||
setHint(null);
|
||||
commitPoints(lane.points, true);
|
||||
},
|
||||
[dragIndex, lane, commitPoints],
|
||||
);
|
||||
|
||||
const onDoubleClick = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (readOnly) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const { t, v } = pointAt(e.clientX, e.clientY);
|
||||
const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
|
||||
// A lane's first point alone would be a constant, which is not what
|
||||
// clicking an empty lane means: seed the far end at the same value so the
|
||||
// envelope has somewhere to go.
|
||||
const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
|
||||
commitPoints(
|
||||
[...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t),
|
||||
true,
|
||||
);
|
||||
},
|
||||
[lane, pointAt, commitPoints, readOnly],
|
||||
const getBox = useCallback(
|
||||
(): DOMRect | null => svgRef.current?.getBoundingClientRect() ?? null,
|
||||
[],
|
||||
);
|
||||
const gestures = useAutomationLaneGestures({
|
||||
getBox,
|
||||
lane,
|
||||
range,
|
||||
pointAt,
|
||||
xOf,
|
||||
yOf,
|
||||
commitPoints,
|
||||
snapTimes,
|
||||
readOnly,
|
||||
onSelect,
|
||||
});
|
||||
const { dragIndex, curveIndex, hint, editing } = gestures;
|
||||
|
||||
const removeAt = useCallback(
|
||||
(index: number): void => {
|
||||
@@ -312,24 +226,24 @@ export function TimelineAutomationLane({
|
||||
top: 0,
|
||||
width: widthPx + PAD_X * 2,
|
||||
height: h,
|
||||
cursor: readOnly ? "pointer" : dragIndex !== null ? "grabbing" : "crosshair",
|
||||
cursor: laneCursor(readOnly, dragIndex !== null || curveIndex !== null),
|
||||
opacity: readOnly ? 0.55 : 1,
|
||||
touchAction: "none",
|
||||
}}
|
||||
width={widthPx + PAD_X * 2}
|
||||
height={h}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onPointerDown={gestures.onPointerDown}
|
||||
onPointerMove={gestures.onPointerMove}
|
||||
onPointerUp={gestures.endDrag}
|
||||
onPointerCancel={gestures.endDrag}
|
||||
onDoubleClick={gestures.onDoubleClick}
|
||||
role="group"
|
||||
aria-label={`${range.label} automation`}
|
||||
>
|
||||
<title>
|
||||
{readOnly
|
||||
? "Click to select this clip, then double-click to add a point"
|
||||
: "Double-click to add a point, drag to shape, right-click a point to remove"}
|
||||
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click to remove. Alt-drag the line to curve it. Shift locks an axis; Alt ignores the grid."}
|
||||
</title>
|
||||
<rect x={PAD_X} y={0} width={widthPx} height={h} fill="rgba(0,0,0,0.18)" rx={3} />
|
||||
{/* Mid rail, so a value reads against something. */}
|
||||
@@ -379,6 +293,17 @@ export function TimelineAutomationLane({
|
||||
) : null}
|
||||
</svg>
|
||||
|
||||
{editing ? (
|
||||
<AutomationValueInput
|
||||
text={editing.text}
|
||||
leftPx={leftPx + xOf(lane.points[editing.index]?.t ?? 0) - PAD_X - 18}
|
||||
label={range.label}
|
||||
onChange={gestures.setEditingText}
|
||||
onCommit={gestures.commitEdit}
|
||||
onCancel={gestures.cancelEdit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hint ? (
|
||||
<div
|
||||
className="hf-automation-hint pointer-events-none absolute rounded-[3px] bg-black/80 px-1 py-0.5 font-mono text-[9px] text-white"
|
||||
@@ -401,6 +326,8 @@ export interface TimelineAutomationLaneSlotProps {
|
||||
accentColor: string;
|
||||
/** Composition-time playhead; the slot converts it to clip-local. */
|
||||
currentTime: number;
|
||||
/** Composition-time beat grid; the slot converts it to clip-local too. */
|
||||
beatTimes?: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,7 +343,17 @@ export function TimelineAutomationLaneSlot({
|
||||
laneCount,
|
||||
accentColor,
|
||||
currentTime,
|
||||
beatTimes,
|
||||
}: TimelineAutomationLaneSlotProps) {
|
||||
// Beats inside this clip, in the clip's own frame — the lane's times are
|
||||
// clip-local, and a beat outside the clip can never be snapped to anyway.
|
||||
const snapTimes = useMemo(
|
||||
() =>
|
||||
(beatTimes ?? [])
|
||||
.filter((t) => t >= element.start && t <= element.start + element.duration)
|
||||
.map((t) => t - element.start),
|
||||
[beatTimes, element.start, element.duration],
|
||||
);
|
||||
const bound = lanes.bind(element, isSelected);
|
||||
if (bound.lanes.length === 0) return null;
|
||||
const inClip = currentTime >= element.start && currentTime <= element.start + element.duration;
|
||||
@@ -443,6 +380,7 @@ export function TimelineAutomationLaneSlot({
|
||||
onPreview={bound.onPreview}
|
||||
onCommit={bound.onCommit}
|
||||
onSelect={bound.onSelect}
|
||||
snapTimes={snapTimes}
|
||||
readOnly={bound.readOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
|
||||
import { useAutomationLanes } from "./useAutomationLanes";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
||||
import { resolveTrackKeyframeClip, trackShowsBeatStrip } from "./useTimelineTrackLayout";
|
||||
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
||||
@@ -162,11 +162,10 @@ export function TimelineLanes({
|
||||
// The beat-dot strip occupies the top of this track's lane (active track,
|
||||
// or the music track when nothing is selected). When shown, keyframe
|
||||
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
||||
const beatStripOnTrack =
|
||||
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
|
||||
(selectedElementId
|
||||
? els.some((e) => (e.key ?? e.id) === selectedElementId)
|
||||
: els.some(isMusicTrack));
|
||||
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
|
||||
selectedElementId,
|
||||
isMusicTrack,
|
||||
});
|
||||
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
||||
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
|
||||
// The one keyframed element this track shows lanes for (selected, else
|
||||
@@ -555,6 +554,7 @@ export function TimelineLanes({
|
||||
laneCount={laneCounts.get(elementKey) ?? 0}
|
||||
accentColor={clipStyle.accent}
|
||||
currentTime={currentTime}
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { automationTargets, fromUnit, toUnit } from "./automationLaneGeometry";
|
||||
import {
|
||||
applyShiftConstraint,
|
||||
automationTargets,
|
||||
curveForDrag,
|
||||
fromUnit,
|
||||
snapLaneTime,
|
||||
toUnit,
|
||||
} from "./automationLaneGeometry";
|
||||
import { applyCurve, sampleAutomationLane } from "@hyperframes/core/audio-automation";
|
||||
import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
|
||||
@@ -66,3 +74,98 @@ describe("value ↔ lane position", () => {
|
||||
expect(toUnit({ ...VOLUME_RANGE, min: 1, max: 1 }, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("curveForDrag", () => {
|
||||
const a = { t: 0, v: 1 };
|
||||
const b = { t: 4, v: 0 };
|
||||
|
||||
it("puts the curved segment through the point that was dragged", () => {
|
||||
// The whole contract: whatever curve comes back, sampling the segment at the
|
||||
// dragged time has to give the dragged value back — otherwise the line runs
|
||||
// away from the pointer.
|
||||
for (const [t, v] of [
|
||||
[1, 0.9],
|
||||
[2, 0.8],
|
||||
[3, 0.15],
|
||||
] as const) {
|
||||
const curve = curveForDrag({ range: VOLUME_RANGE, a, b, t, v });
|
||||
expect(curve).not.toBeNull();
|
||||
const lane = { target: "volume", points: [{ ...a, curve: curve ?? 0 }, b] };
|
||||
expect(sampleAutomationLane(lane, t, "linear")).toBeCloseTo(v, 2);
|
||||
}
|
||||
});
|
||||
|
||||
it("stays inside the range the model will accept", () => {
|
||||
// Anything outside ±1 is clamped on parse, so a drag past the limit has to
|
||||
// saturate rather than round-trip to something else.
|
||||
const curve = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.05, v: 0.02 });
|
||||
expect(curve).not.toBeNull();
|
||||
expect(Math.abs(curve ?? 0)).toBeLessThanOrEqual(1);
|
||||
expect(applyCurve(0.5, curve ?? 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("declines a segment with no room to bend", () => {
|
||||
// Flat: every curve draws the same line, so there is nothing to solve.
|
||||
expect(curveForDrag({ range: VOLUME_RANGE, a, b: { t: 4, v: 1 }, t: 2, v: 0.5 })).toBeNull();
|
||||
// At the very ends the exponent divides by zero.
|
||||
expect(curveForDrag({ range: VOLUME_RANGE, a, b, t: 0, v: 1 })).toBeNull();
|
||||
expect(curveForDrag({ range: VOLUME_RANGE, a, b, t: 4, v: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyShiftConstraint", () => {
|
||||
const origin = { t: 1, v: 0.5 };
|
||||
const xOf = (t: number) => t * 100;
|
||||
const yOf = (v: number) => (1 - v) * 40;
|
||||
|
||||
it("holds the value when the gesture is mostly sideways", () => {
|
||||
const out = applyShiftConstraint({
|
||||
range: VOLUME_RANGE,
|
||||
origin,
|
||||
raw: { t: 3, v: 0.55 },
|
||||
xOf,
|
||||
yOf,
|
||||
});
|
||||
expect(out).toEqual({ t: 3, v: 0.5 });
|
||||
});
|
||||
|
||||
it("holds the time and fines the value when it is mostly vertical", () => {
|
||||
const out = applyShiftConstraint({
|
||||
range: VOLUME_RANGE,
|
||||
origin,
|
||||
raw: { t: 1.05, v: 0.9 },
|
||||
xOf,
|
||||
yOf,
|
||||
});
|
||||
expect(out.t).toBe(1);
|
||||
// A quarter of the travel: 0.5 + (0.9 - 0.5) / 4.
|
||||
expect(out.v).toBeCloseTo(0.6, 5);
|
||||
});
|
||||
|
||||
it("decides which axis won in pixels, not in units", () => {
|
||||
// 0.2 s against 0.2 of a fader are not comparable numbers; at this zoom the
|
||||
// horizontal move is 20px and the vertical one is 8px.
|
||||
const out = applyShiftConstraint({
|
||||
range: VOLUME_RANGE,
|
||||
origin,
|
||||
raw: { t: 1.2, v: 0.7 },
|
||||
xOf,
|
||||
yOf,
|
||||
});
|
||||
expect(out.v).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapLaneTime", () => {
|
||||
it("takes the nearest target inside the threshold", () => {
|
||||
expect(snapLaneTime(2.02, [1, 2, 3], 0.04)).toBe(2);
|
||||
});
|
||||
|
||||
it("leaves a time alone when nothing is close enough", () => {
|
||||
expect(snapLaneTime(2.5, [1, 2, 3], 0.04)).toBe(2.5);
|
||||
});
|
||||
|
||||
it("has nothing to snap to on an empty grid", () => {
|
||||
expect(snapLaneTime(2.5, [], 0.04)).toBe(2.5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
fxAutomationTarget,
|
||||
resolveAutomationRange,
|
||||
sampleAutomationLane,
|
||||
VOLUME_RANGE,
|
||||
VOLUME_TARGET,
|
||||
type AutomationRange,
|
||||
@@ -96,6 +97,124 @@ export function formatValue(range: AutomationRange, value: number): string {
|
||||
return range.unit ? `${shown} ${range.unit}` : shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `curve` that bends a segment through a dragged point.
|
||||
*
|
||||
* `applyCurve` raises normalised progress to `2^(2*curve)`, so a point the
|
||||
* pointer holds at progress `x` and unit height `f` fixes the exponent:
|
||||
* `x^e = f`, hence `e = ln f / ln x` and `curve = log2(e) / 2`. Solving rather
|
||||
* than accumulating a delta means the segment passes through the pointer
|
||||
* instead of drifting away from it over a long drag.
|
||||
*
|
||||
* Null when the segment cannot express the shape: a flat segment has no room to
|
||||
* bend, and progress or height at the very ends divides by zero.
|
||||
*/
|
||||
export function curveForDrag(input: {
|
||||
range: AutomationRange;
|
||||
a: { t: number; v: number };
|
||||
b: { t: number; v: number };
|
||||
t: number;
|
||||
v: number;
|
||||
}): number | null {
|
||||
const { range, a, b, t, v } = input;
|
||||
const span = b.t - a.t;
|
||||
if (span <= 0) return null;
|
||||
const x = (t - a.t) / span;
|
||||
if (x <= 0.001 || x >= 0.999) return null;
|
||||
const ua = toUnit(range, a.v);
|
||||
const ub = toUnit(range, b.v);
|
||||
if (Math.abs(ub - ua) < 0.001) return null;
|
||||
const f = (toUnit(range, v) - ua) / (ub - ua);
|
||||
if (f <= 0.001 || f >= 0.999) return null;
|
||||
return Math.max(-1, Math.min(1, Math.log2(Math.log(f) / Math.log(x)) / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* A point drag with Shift held: one axis at a time, whichever the gesture
|
||||
* committed to, and a quarter of the vertical travel for a value that has to
|
||||
* land on a number.
|
||||
*
|
||||
* Which axis "won" is decided in pixels, not in seconds and dB — those are
|
||||
* different units and comparing them would make the lock depend on the zoom.
|
||||
*/
|
||||
export function applyShiftConstraint(input: {
|
||||
range: AutomationRange;
|
||||
origin: { t: number; v: number };
|
||||
raw: { t: number; v: number };
|
||||
/** Same projections the lane draws with, so the comparison is on screen. */
|
||||
xOf(t: number): number;
|
||||
yOf(v: number): number;
|
||||
}): { t: number; v: number } {
|
||||
const { range, origin, raw, xOf, yOf } = input;
|
||||
if (Math.abs(xOf(raw.t) - xOf(origin.t)) > Math.abs(yOf(raw.v) - yOf(origin.v))) {
|
||||
return { t: raw.t, v: origin.v };
|
||||
}
|
||||
const from = toUnit(range, origin.v);
|
||||
return { t: origin.t, v: fromUnit(range, from + (toUnit(range, raw.v) - from) * 0.25) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest snap target within the threshold, else the time unchanged.
|
||||
*
|
||||
* A breakpoint is placed by eye, and by eye "on the beat" and "three
|
||||
* milliseconds off the beat" look identical — so the lane snaps to the beat grid
|
||||
* and to its own neighbouring points, the two things an envelope is usually
|
||||
* aligned against.
|
||||
*/
|
||||
export function snapLaneTime(t: number, targets: readonly number[], thresholdSec: number): number {
|
||||
let best = t;
|
||||
let bestDist = thresholdSec;
|
||||
for (const target of targets) {
|
||||
const d = Math.abs(target - t);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = target;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The svg path for one lane's envelope.
|
||||
*
|
||||
* A flat line at the parameter's own default stands in for a lane with no
|
||||
* points, so the first double-click has something to land on. Straight segments
|
||||
* are drawn as one line each; a curved or log-read segment is sampled, because
|
||||
* drawing it straight would lie about the envelope the audio thread is going to
|
||||
* play.
|
||||
*/
|
||||
export function envelopePath(input: {
|
||||
lane: HfAutomationLane;
|
||||
range: AutomationRange;
|
||||
widthPx: number;
|
||||
xOf(t: number): number;
|
||||
yOf(v: number): number;
|
||||
}): string {
|
||||
const { lane, range, widthPx, xOf, yOf } = input;
|
||||
const first = lane.points[0];
|
||||
const last = lane.points[lane.points.length - 1];
|
||||
if (!first || !last) {
|
||||
const y = yOf(range.default ?? (range.min + range.max) / 2);
|
||||
return `M ${PAD_X} ${y} L ${PAD_X + widthPx} ${y}`;
|
||||
}
|
||||
const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`];
|
||||
for (let i = 0; i + 1 < lane.points.length; i += 1) {
|
||||
const a = lane.points[i];
|
||||
const b = lane.points[i + 1];
|
||||
if (!a || !b) continue;
|
||||
if (!a.curve && range.scale === "linear") {
|
||||
pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
|
||||
continue;
|
||||
}
|
||||
for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
|
||||
const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
|
||||
pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
|
||||
}
|
||||
}
|
||||
pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
|
||||
return pts.join(" ");
|
||||
}
|
||||
|
||||
export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
|
||||
return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* The pointer gestures over an automation lane.
|
||||
*
|
||||
* Its own hook because the lane component sits at the studio's file ceiling and
|
||||
* because these are the parts worth testing on their own: which of a press,
|
||||
* a drag and a modifier resolves to moving a point, bending a segment, or
|
||||
* nothing at all.
|
||||
*
|
||||
* Modifiers follow Ableton's, since that is the muscle memory an automation lane
|
||||
* inherits: Shift locks a drag to one axis and fines the value down, Alt over a
|
||||
* segment curves it, and Alt during a point drag ignores the grid.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
|
||||
import {
|
||||
applyShiftConstraint,
|
||||
curveForDrag,
|
||||
formatValue,
|
||||
GRAB_PX,
|
||||
POINT_MERGE_SEC,
|
||||
snapLaneTime,
|
||||
} from "./automationLaneGeometry";
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the rest of the gesture even if the pointer leaves the lane. Without it a
|
||||
* drag that strays outside the svg stops sending moves and the point sticks.
|
||||
*/
|
||||
function capturePointer(e: ReactPointerEvent<SVGSVGElement>): void {
|
||||
const target = e.target;
|
||||
if (target instanceof Element) target.setPointerCapture?.(e.pointerId);
|
||||
}
|
||||
|
||||
export interface UseAutomationLaneGesturesInput {
|
||||
/** The lane's box on screen. A getter, not the ref: the hook only ever needs
|
||||
* the rectangle, and a ref read inside a callback is a lint the rule is right
|
||||
* about — the value is not a dependency it can track. */
|
||||
getBox(): DOMRect | null;
|
||||
lane: HfAutomationLane;
|
||||
range: AutomationRange;
|
||||
/** Pointer position as a clip-local time and a parameter value. */
|
||||
pointAt(clientX: number, clientY: number): { t: number; v: number };
|
||||
xOf(t: number): number;
|
||||
yOf(v: number): number;
|
||||
commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
|
||||
/** Clip-local times a dragged point snaps to, on top of its own neighbours. */
|
||||
snapTimes?: readonly number[] | undefined;
|
||||
readOnly?: boolean | undefined;
|
||||
onSelect?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export interface UseAutomationLaneGesturesResult {
|
||||
/** Point being dragged, for the cursor and the grab circle's size. */
|
||||
dragIndex: number | null;
|
||||
/** Segment being bent, identified by the point that owns its curve. */
|
||||
curveIndex: number | null;
|
||||
/** Value readout to show while a gesture is live. */
|
||||
hint: string | null;
|
||||
hitIndex(clientX: number, clientY: number): number | null;
|
||||
segmentIndex(clientX: number, clientY: number): number | null;
|
||||
onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||
onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||
endDrag(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||
/** Adds a point, opens the value field on one, or straightens a segment. */
|
||||
onDoubleClick(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||
/** The point whose value is being typed, and the text so far. */
|
||||
editing: { index: number; text: string } | null;
|
||||
setEditingText(text: string): void;
|
||||
commitEdit(): void;
|
||||
cancelEdit(): void;
|
||||
}
|
||||
|
||||
export function useAutomationLaneGestures({
|
||||
getBox,
|
||||
lane,
|
||||
range,
|
||||
pointAt,
|
||||
xOf,
|
||||
yOf,
|
||||
commitPoints,
|
||||
snapTimes,
|
||||
readOnly,
|
||||
onSelect,
|
||||
}: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [curveIndex, setCurveIndex] = useState<number | null>(null);
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
/** Where a point drag began, so Shift can lock an axis and fine the value. */
|
||||
const dragOrigin = useRef<{ t: number; v: number } | null>(null);
|
||||
/** Point whose value is being typed, and the text so far. */
|
||||
const [editing, setEditing] = useState<{ index: number; text: string } | null>(null);
|
||||
|
||||
/** Index of a point under the pointer, or null. */
|
||||
const hitIndex = useCallback(
|
||||
(clientX: number, clientY: number): number | null => {
|
||||
const box = getBox();
|
||||
if (!box) return null;
|
||||
const px = clientX - box.left;
|
||||
const py = clientY - box.top;
|
||||
for (let i = 0; i < lane.points.length; i += 1) {
|
||||
const p = lane.points[i];
|
||||
if (p && Math.hypot(xOf(p.t) - px, yOf(p.v) - py) <= GRAB_PX * 1.6) return i;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[getBox, lane, xOf, yOf],
|
||||
);
|
||||
|
||||
/** Index of the point owning the segment under the pointer, or null. */
|
||||
const segmentIndex = useCallback(
|
||||
(clientX: number, clientY: number): number | null => {
|
||||
const { t } = pointAt(clientX, clientY);
|
||||
for (let i = 0; i + 1 < lane.points.length; i += 1) {
|
||||
const a = lane.points[i];
|
||||
const b = lane.points[i + 1];
|
||||
if (a && b && t > a.t && t < b.t) return i;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[lane, pointAt],
|
||||
);
|
||||
|
||||
/** What a press starts: moving a point, or — with Alt on the line — bending it. */
|
||||
const gestureAt = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): { curve: boolean; index: number } | null => {
|
||||
const index = hitIndex(e.clientX, e.clientY);
|
||||
if (index !== null) return { curve: false, index };
|
||||
if (!e.altKey) return null;
|
||||
const segment = segmentIndex(e.clientX, e.clientY);
|
||||
return segment === null ? null : { curve: true, index: segment };
|
||||
},
|
||||
[hitIndex, segmentIndex],
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (e.button !== 0) return;
|
||||
// The lane owns this region either way. Letting a press through starts the
|
||||
// timeline's own gesture (scrub / marquee / clip drag), which then eats the
|
||||
// rest of the sequence — including the second half of a double-click.
|
||||
e.stopPropagation();
|
||||
if (readOnly) {
|
||||
// The lane sits below the clip bar, so the timeline's selection handler
|
||||
// never sees this press; selecting here is the only way in.
|
||||
onSelect?.();
|
||||
return;
|
||||
}
|
||||
const gesture = gestureAt(e);
|
||||
if (!gesture) return;
|
||||
e.preventDefault();
|
||||
capturePointer(e);
|
||||
if (gesture.curve) {
|
||||
setCurveIndex(gesture.index);
|
||||
return;
|
||||
}
|
||||
dragOrigin.current = originOf(lane.points[gesture.index]);
|
||||
setDragIndex(gesture.index);
|
||||
},
|
||||
[gestureAt, lane, readOnly, onSelect],
|
||||
);
|
||||
|
||||
/** Bend the segment under the pointer, which is what Alt-dragging the line does. */
|
||||
const bendSegment = useCallback(
|
||||
(clientX: number, clientY: number): void => {
|
||||
if (curveIndex === null) return;
|
||||
const a = lane.points[curveIndex];
|
||||
const b = lane.points[curveIndex + 1];
|
||||
if (!a || !b) return;
|
||||
const { t, v } = pointAt(clientX, clientY);
|
||||
const curve = curveForDrag({ range, a, b, t, v });
|
||||
if (curve === null) return;
|
||||
setHint(`curve ${curve.toFixed(2)}`);
|
||||
commitPoints(
|
||||
lane.points.map((p, i) => (i === curveIndex ? { ...p, curve } : p)),
|
||||
false,
|
||||
);
|
||||
},
|
||||
[curveIndex, lane, pointAt, range, commitPoints],
|
||||
);
|
||||
|
||||
/** Move the point being dragged, honouring the modifiers held with it. */
|
||||
const movePoint = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (dragIndex === null) return;
|
||||
const raw = pointAt(e.clientX, e.clientY);
|
||||
const origin = dragOrigin.current;
|
||||
let { t, v } =
|
||||
e.shiftKey && origin ? applyShiftConstraint({ range, origin, raw, xOf, yOf }) : raw;
|
||||
// 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);
|
||||
}
|
||||
const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p));
|
||||
// Re-sort so dragging a point past a neighbour behaves, and keep the
|
||||
// dragged one addressable by following where it landed.
|
||||
const moved = next[dragIndex];
|
||||
next.sort((a, b) => a.t - b.t);
|
||||
if (moved) setDragIndex(next.indexOf(moved));
|
||||
setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`);
|
||||
commitPoints(next, false);
|
||||
},
|
||||
[dragIndex, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (curveIndex === null && dragIndex === null) return;
|
||||
e.stopPropagation();
|
||||
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
|
||||
else movePoint(e);
|
||||
},
|
||||
[bendSegment, curveIndex, dragIndex, movePoint],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (dragIndex === null && curveIndex === null) return;
|
||||
e.stopPropagation();
|
||||
setDragIndex(null);
|
||||
setCurveIndex(null);
|
||||
dragOrigin.current = null;
|
||||
setHint(null);
|
||||
commitPoints(lane.points, true);
|
||||
},
|
||||
[curveIndex, dragIndex, lane, commitPoints],
|
||||
);
|
||||
|
||||
const onDoubleClick = useCallback(
|
||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||
if (readOnly) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const onPoint = hitIndex(e.clientX, e.clientY);
|
||||
if (e.altKey) {
|
||||
// Straighten the segment back out — the counterpart to Alt-dragging it.
|
||||
const segment = onPoint ?? segmentIndex(e.clientX, e.clientY);
|
||||
if (segment === null) return;
|
||||
commitPoints(
|
||||
lane.points.map((p, i) => (i === segment ? { t: p.t, v: p.v } : p)),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (onPoint !== null) {
|
||||
// Typing beats dragging when the value has to be exact — -6.0 dB is not
|
||||
// a pixel you can find.
|
||||
const p = lane.points[onPoint];
|
||||
if (p) setEditing({ index: onPoint, text: String(Number(p.v.toFixed(3))) });
|
||||
return;
|
||||
}
|
||||
const { t, v } = pointAt(e.clientX, e.clientY);
|
||||
const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
|
||||
// A lane's first point alone would be a constant, which is not what
|
||||
// clicking an empty lane means: seed the far end at the same value so the
|
||||
// envelope has somewhere to go.
|
||||
const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
|
||||
commitPoints(
|
||||
[...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t),
|
||||
true,
|
||||
);
|
||||
},
|
||||
[lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
|
||||
);
|
||||
|
||||
const setEditingText = useCallback((text: string): void => {
|
||||
setEditing((current) => (current ? { index: current.index, text } : null));
|
||||
}, []);
|
||||
|
||||
const cancelEdit = useCallback((): void => setEditing(null), []);
|
||||
|
||||
/** Apply a typed value, or drop the edit when it is not a number. */
|
||||
const commitEdit = useCallback((): void => {
|
||||
const active = editing;
|
||||
setEditing(null);
|
||||
if (!active) return;
|
||||
const typed = Number(active.text);
|
||||
if (!Number.isFinite(typed)) return;
|
||||
const clamped = Math.min(range.max, Math.max(range.min, typed));
|
||||
commitPoints(
|
||||
lane.points.map((p, i) => (i === active.index ? { ...p, v: clamped } : p)),
|
||||
true,
|
||||
);
|
||||
}, [editing, lane, range, commitPoints]);
|
||||
|
||||
return {
|
||||
dragIndex,
|
||||
curveIndex,
|
||||
hint,
|
||||
hitIndex,
|
||||
segmentIndex,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
endDrag,
|
||||
onDoubleClick,
|
||||
editing,
|
||||
setEditingText,
|
||||
commitEdit,
|
||||
cancelEdit,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,25 @@ import {
|
||||
|
||||
export { getTrackStyle } from "./timelineIcons";
|
||||
|
||||
/**
|
||||
* Whether this track draws the beat-dot strip: only where there are beats to
|
||||
* draw, and only on the track the user is working in — the selected clip's, or
|
||||
* the music track's when nothing is selected.
|
||||
*/
|
||||
export function trackShowsBeatStrip(
|
||||
els: readonly TimelineElement[],
|
||||
beatTimes: readonly number[] | undefined,
|
||||
ctx: {
|
||||
selectedElementId: string | null;
|
||||
isMusicTrack(element: TimelineElement): boolean;
|
||||
},
|
||||
): boolean {
|
||||
if ((beatTimes?.length ?? 0) < 2) return false;
|
||||
return ctx.selectedElementId
|
||||
? els.some((e) => (e.key ?? e.id) === ctx.selectedElementId)
|
||||
: els.some((e) => ctx.isMusicTrack(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Automation lanes on one clip, or 0 for anything that is not audio.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user