= {}) => {
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx
index eb8dfa2c7a..2c3570fbe7 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx
@@ -1,21 +1,15 @@
/**
* 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 or
- * Shift+click a point to remove it, Alt-drag the line between two points to bend
- * it, and double-click a point to type an exact value.
+ * Shift+click a point to remove it, drag a line segment to move both endpoints,
+ * Alt-drag the line 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.
+ * Ableton-style modifiers apply: Shift locks/fines a drag, while Alt bends a
+ * segment or ignores the grid during a point drag. Background drags select a
+ * set that can be moved, deleted, or shaped together.
*
- * Drag the background to draw a selection box around a set of breakpoints, then
- * Delete to remove them, drag any one of them to move the whole set, or
- * right-click inside the box for shapes over its span.
- *
- * The lane knows nothing about any particular effect. Which parameters it can
- * offer, their ranges, units and whether they read logarithmically all come
- * from the FX registry, so an effect gained upstream needs no change here — the
- * same principle the property panel's controls follow.
+ * Effect parameters, ranges, units, and scaling come from the FX registry, so
+ * an upstream effect needs no lane-specific code here.
*/
import {
@@ -42,6 +36,7 @@ import { generateShape, type AutomationShapeId } from "./automationShapes";
import { simplifyPoints } from "./automationSimplify";
import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
import { defaultTimelineTheme } from "./timelineTheme";
+import { AutomationEnvelopePaths } from "./AutomationEnvelopePaths";
/**
* Drawn radius of a breakpoint.
@@ -102,7 +97,7 @@ function pointCircleStyle(
function laneTitle(readOnly: boolean | undefined): string {
return readOnly
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
- : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
+ : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag a line segment to move both endpoints. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
}
/**
@@ -155,13 +150,19 @@ function pointHandleOpacity(args: {
return !args.readOnly && args.hovered ? 1 : 0;
}
-function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
+function laneCursor(
+ readOnly: boolean | undefined,
+ dragging: boolean,
+ stretching: boolean,
+ segmentHovering: boolean,
+): string {
// A stretch handle wins over everything it might also sit above: the handle is
// a few px wide and always overlaps whatever is under the selection edge, so
// any other cursor there would advertise a gesture the press will not start.
if (stretching) return "col-resize";
if (readOnly) return "pointer";
- return dragging ? "grabbing" : "crosshair";
+ if (dragging) return "grabbing";
+ return segmentHovering ? "grab" : "crosshair";
}
export interface TimelineAutomationLaneProps {
@@ -318,7 +319,16 @@ export function TimelineAutomationLane({
duration,
rangeSelection,
});
- const { dragIndex, curveIndex, edgeDrag, edgeHover, hint, editing } = gestures;
+ const {
+ dragIndex,
+ curveIndex,
+ segmentDragIndex,
+ segmentHoverIndex,
+ edgeDrag,
+ edgeHover,
+ hint,
+ editing,
+ } = gestures;
const removeAt = useCallback(
(index: number): void => {
@@ -426,8 +436,9 @@ export function TimelineAutomationLane({
height: h,
cursor: laneCursor(
readOnly,
- dragIndex !== null || curveIndex !== null,
+ dragIndex !== null || curveIndex !== null || segmentDragIndex !== null,
edgeDrag !== null || edgeHover,
+ segmentHoverIndex !== null,
),
opacity: readOnly ? 0.55 : 1,
touchAction: "none",
@@ -435,7 +446,10 @@ export function TimelineAutomationLane({
width={widthPx + PAD_X * 2}
height={h}
onPointerEnter={() => setHovered(true)}
- onPointerLeave={() => setHovered(false)}
+ onPointerLeave={() => {
+ setHovered(false);
+ gestures.onPointerLeave();
+ }}
onPointerDown={gestures.onPointerDown}
onPointerMove={gestures.onPointerMove}
onPointerUp={gestures.endDrag}
@@ -478,12 +492,14 @@ export function TimelineAutomationLane({
pointerEvents="none"
/>
) : null}
-
{lane.points.map((p, i) => {
// Endpoint-inclusive, the same rule Delete uses, so what looks caught by
diff --git a/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx b/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx
index 6eb77512e0..2a4dc51008 100644
--- a/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx
+++ b/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx
@@ -25,6 +25,7 @@ export function TimelineGroupLaneLabels({
columnWidth,
gutterBackground,
accentColor,
+ onReveal,
}: {
/** The group wearing a clip's shape — see `groupAutomationElement`. */
groupElement: TimelineElement;
@@ -34,6 +35,8 @@ export function TimelineGroupLaneLabels({
columnWidth: number;
gutterBackground: string;
accentColor: string;
+ /** Select the bus and reveal this lane's exact parameter in its rack. */
+ onReveal?: (target: string) => void;
}) {
// The LIVE playhead, not the row's `currentTime` prop — that one only moves
// on seek, so the readout sat frozen while the curve was audibly working,
@@ -55,10 +58,13 @@ export function TimelineGroupLaneLabels({
// clip-local rebase here — unlike a clip's lane.
const value = sampleAutomationLane(lane, currentTime);
return (
- event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation();
+ onReveal?.(lane.target);
+ }}
>
▤
@@ -80,7 +91,7 @@ export function TimelineGroupLaneLabels({
{value.toFixed(2)}
-
+
);
})}
>
diff --git a/packages/studio/src/player/components/TimelineGroupRow.test.tsx b/packages/studio/src/player/components/TimelineGroupRow.test.tsx
index 7ecc34f84c..e72d54ba51 100644
--- a/packages/studio/src/player/components/TimelineGroupRow.test.tsx
+++ b/packages/studio/src/player/components/TimelineGroupRow.test.tsx
@@ -6,14 +6,23 @@ import { TimelineGroupRow } from "./TimelineGroupRow";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
-import type { TimelineElement } from "../store/playerStore";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("../../telemetry/canary", () => ({ isCanaryEnabled: () => true }));
+const domEditMocks = vi.hoisted(() => ({
+ handleTimelineElementSelect: vi.fn(async () => undefined),
+}));
+vi.mock("../../contexts/DomEditContext", () => ({
+ useDomEditSelectionContextOptional: () => null,
+ useDomEditActionsContextOptional: () => domEditMocks,
+}));
afterEach(() => {
document.body.innerHTML = "";
+ domEditMocks.handleTimelineElementSelect.mockClear();
+ usePlayerStore.setState({ revealedAudioFxTarget: null });
});
const member = (id: string, track: number): TimelineElement => ({
@@ -36,7 +45,10 @@ const GROUP: TimelineTrackGroupInfo = {
hidden: false,
};
-function renderRow(overrides: Partial = {}) {
+function renderRow(
+ overrides: Partial = {},
+ expandedLaneOwnerIds = new Set(),
+) {
const onSetAudioGroupAttributeQuiet = vi.fn();
const onSetElementAttributeQuiet = vi.fn();
const host = document.createElement("div");
@@ -55,10 +67,31 @@ function renderRow(overrides: Partial = {}) {
contentOrigin={232}
theme={defaultTimelineTheme}
collapsedGroupIds={new Set()}
- expandedLaneOwnerIds={new Set()}
+ expandedLaneOwnerIds={expandedLaneOwnerIds}
toggleGroupExpanded={vi.fn()}
toggleLaneOwnerExpanded={vi.fn()}
- lanes={{ bind: () => ({ lanes: [] }) } as never}
+ lanes={
+ {
+ bind: (element: TimelineElement) => {
+ const automation = element.automation
+ ? JSON.parse(element.automation)
+ : { version: 1, lanes: [] };
+ return {
+ automation,
+ lanes: automation.lanes,
+ chain: element.fxChain ? JSON.parse(element.fxChain) : null,
+ onPreview: vi.fn(),
+ onCommit: vi.fn(),
+ onSelect: vi.fn(),
+ readOnly: true,
+ commitTargetKey: null,
+ selection: null,
+ onRangeSelect: vi.fn(),
+ onRangeClear: vi.fn(),
+ };
+ },
+ } as never
+ }
pps={10}
currentTime={0}
compositionDuration={60}
@@ -72,6 +105,49 @@ function renderRow(overrides: Partial = {}) {
}
describe("TimelineGroupRow", () => {
+ it("routes the group title through the guarded selection path", () => {
+ const { host } = renderRow();
+ const title = host.querySelector(
+ 'button[aria-label="Open Voiceover effects"]',
+ );
+
+ act(() => title?.click());
+
+ expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "voiceover", domId: "voiceover" }),
+ );
+ });
+
+ it("opens a group automation lane on its exact rack parameter", async () => {
+ const { host } = renderRow(
+ {
+ fxChain: JSON.stringify({
+ version: 1,
+ nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
+ }),
+ automation: JSON.stringify({
+ version: 1,
+ lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
+ }),
+ },
+ new Set(["voiceover"]),
+ );
+ const laneTitle = host.querySelector('[data-group-lane-label="fx.p1.gain"]');
+
+ await act(async () => {
+ laneTitle?.click();
+ await Promise.resolve();
+ });
+
+ expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "voiceover", domId: "voiceover" }),
+ );
+ expect(usePlayerStore.getState().revealedAudioFxTarget).toMatchObject({
+ elementKey: "voiceover",
+ automationTarget: "fx.p1.gain",
+ });
+ });
+
// C1 names this as the step's own definition of done: "opening the popover on
// a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
// on the group element, and zero writes on members". A group IS a bus — a
diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx
index b41f982d14..427857bedd 100644
--- a/packages/studio/src/player/components/TimelineGroupRow.tsx
+++ b/packages/studio/src/player/components/TimelineGroupRow.tsx
@@ -17,6 +17,7 @@ import type { UseAutomationLanesResult } from "./useAutomationLanes";
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
+import { usePlayerStore } from "../store/playerStore";
/** Accent rail on a group-owned lane — the same green the member rail uses, so
* "this belongs to the group" reads the same in both places (groups doc §5). */
@@ -92,19 +93,27 @@ export function TimelineGroupRow({
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional();
+ const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
};
- const openGroupFxRack = () => {
- const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
- group.id,
+ const openGroupFxRack = (automationTarget?: string) => {
+ // Use the guarded timeline-selection path even though the bus is synthetic:
+ // it invalidates an older clip selection that may still be resolving. The
+ // old direct build/apply path let that late clip reclaim the rack.
+ const selection = domEditActions?.handleTimelineElementSelect(groupElement);
+ if (!selection || !automationTarget) return;
+ // Bus selection clears the clip selection, which intentionally retires any
+ // old reveal request. Publish this one afterwards so the newly mounted bus
+ // rack can consume it rather than losing it during that clear.
+ void selection.then(() =>
+ revealAudioFx({
+ elementKey: group.id,
+ automationTarget,
+ }),
);
- if (!target) return;
- void domEditActions
- ?.buildDomSelectionFromTarget(target)
- .then((selection) => selection && domEditActions.applyDomSelection(selection));
};
return (
writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements}
- onOpenFxRack={openGroupFxRack}
+ onOpenFxRack={() => openGroupFxRack()}
// Same width as every other row's header. The group row needs a real
// label column, but it gets one by turning `labelMode` on for the whole
// timeline (see Timeline.tsx) rather than by overhanging alone — an
@@ -172,6 +181,7 @@ export function TimelineGroupRow({
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
gutterBackground={theme.gutterBackground}
accentColor={GROUP_LANE_ACCENT}
+ onReveal={openGroupFxRack}
/>
)}
diff --git a/packages/studio/src/player/components/automationLaneDragMath.ts b/packages/studio/src/player/components/automationLaneDragMath.ts
index 79b029a66d..394ac1c702 100644
--- a/packages/studio/src/player/components/automationLaneDragMath.ts
+++ b/packages/studio/src/player/components/automationLaneDragMath.ts
@@ -59,6 +59,34 @@ export function armGroupDrag(
};
}
+/**
+ * Snapshot the two endpoints owned by a segment drag.
+ *
+ * The pointer itself is the anchor: a line can be grabbed anywhere along its
+ * span, and the segment must not jump so either endpoint takes the pointer's
+ * place when the gesture starts.
+ */
+export function armSegmentDrag(
+ lane: HfAutomationLane,
+ index: number,
+ anchor: { t: number; v: number },
+): GroupDragSnapshot | null {
+ const a = lane.points[index];
+ const b = lane.points[index + 1];
+ if (!a || !b) return null;
+ return {
+ points: lane.points.map((p) => ({ ...p })),
+ indices: [index, index + 1],
+ anchor: { ...anchor },
+ selection: {
+ t0: a.t,
+ t1: b.t,
+ v0: Math.min(a.v, b.v),
+ v1: Math.max(a.v, b.v),
+ },
+ };
+}
+
export interface GroupMoveResult {
points: HfAutomationLane["points"];
selection: AutomationSelectionBox;
diff --git a/packages/studio/src/player/components/automationLaneGeometry.ts b/packages/studio/src/player/components/automationLaneGeometry.ts
index ef9966f2b9..c1dd990737 100644
--- a/packages/studio/src/player/components/automationLaneGeometry.ts
+++ b/packages/studio/src/player/components/automationLaneGeometry.ts
@@ -35,6 +35,8 @@ export const POINT_MERGE_SEC = 0.02;
export const MIN_POINT_GAP_SEC = 0.001;
/** Hit radius for grabbing a point, in px. */
export const GRAB_PX = 7;
+/** Distance from the drawn envelope that offers a segment drag, in px. */
+export const SEGMENT_GRAB_PX = 5;
/** Samples used to draw a segment the eye should see as curved. */
export const DRAW_SAMPLES = 64;
/**
@@ -222,6 +224,29 @@ export function snapLaneTime(t: number, targets: readonly number[], thresholdSec
return best;
}
+/** Draw commands from one breakpoint to the next, sampled when it is curved. */
+function segmentLineCommands(input: {
+ lane: HfAutomationLane;
+ range: AutomationRange;
+ index: number;
+ xOf(t: number): number;
+ yOf(v: number): number;
+}): string[] {
+ const { lane, range, index, xOf, yOf } = input;
+ const a = lane.points[index];
+ const b = lane.points[index + 1];
+ if (!a || !b) return [];
+ // A via point bends the segment with no `curve` of its own, so the
+ // straight-line shortcut has to rule out both.
+ if (!a.curve && a.viaX === undefined && range.scale === "linear") {
+ return [`L ${xOf(b.t)} ${yOf(b.v)}`];
+ }
+ return Array.from({ length: DRAW_SAMPLES }, (_, sample) => {
+ const t = a.t + ((b.t - a.t) * (sample + 1)) / DRAW_SAMPLES;
+ return `L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`;
+ });
+}
+
/**
* The svg path for one lane's envelope.
*
@@ -247,24 +272,38 @@ export function envelopePath(input: {
}
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;
- // A via point bends the segment with no `curve` of its own, so the
- // straight-line shortcut has to rule out both.
- if (!a.curve && a.viaX === undefined && 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(...segmentLineCommands({ lane, range, index: i, xOf, yOf }));
}
pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
return pts.join(" ");
}
+/**
+ * The visible path for one segment, without the lane's constant extensions.
+ *
+ * Used for the hover/drag affordance: only the segment under the pointer grows
+ * heavier, rather than making the entire envelope look selected. It samples by
+ * the same rule as `envelopePath`, so a curved or logarithmic segment's hover
+ * stroke sits exactly on the line the audio model draws.
+ */
+export function envelopeSegmentPath(input: {
+ lane: HfAutomationLane;
+ range: AutomationRange;
+ index: number;
+ xOf(t: number): number;
+ yOf(v: number): number;
+}): string | null {
+ const { lane, range, index, xOf, yOf } = input;
+ const a = lane.points[index];
+ const b = lane.points[index + 1];
+ if (!a || !b) return null;
+ const pts = [
+ `M ${xOf(a.t)} ${yOf(a.v)}`,
+ ...segmentLineCommands({ lane, range, index, xOf, yOf }),
+ ];
+ return pts.join(" ");
+}
+
export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
}
diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts
index de166cf5e9..f9bd6cdf38 100644
--- a/packages/studio/src/player/components/useAutomationLaneGestures.ts
+++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts
@@ -25,6 +25,7 @@ import {
type GroupDragSnapshot,
type ShiftAxis,
} from "./automationLaneDragMath";
+import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
/** How far a press may travel and still count as a click rather than a drag. */
const CLICK_SLOP_PX = 3;
@@ -63,10 +64,16 @@ export interface UseAutomationLaneGesturesResult {
dragIndex: number | null;
/** Segment being bent, identified by the point that owns its curve. */
curveIndex: number | null;
+ /** Segment whose two endpoints are being translated together. */
+ segmentDragIndex: number | null;
+ /** Segment close enough to the pointer to offer that translation. */
+ segmentHoverIndex: 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;
+ /** Clear hover feedback when the pointer leaves the lane. */
+ onPointerLeave(): void;
onPointerDown(e: ReactPointerEvent): void;
onPointerMove(e: ReactPointerEvent): void;
/** Edge being stretched, for the cursor. Null when no stretch is live. */
@@ -227,16 +234,37 @@ export function useAutomationLaneGestures({
[lane, pointAt],
);
- /** What a press starts: moving a point, or — with Alt on the line — bending it. */
+ const segmentDrag = useAutomationSegmentDrag({
+ getBox,
+ lane,
+ range,
+ pointAt,
+ xOf,
+ yOf,
+ segmentIndex,
+ commitPoints,
+ duration,
+ snapTimes,
+ readOnly,
+ onHint: setHint,
+ });
+
+ /** What a press starts: point move, segment move, or Alt segment bend. */
const gestureAt = useCallback(
- (e: ReactPointerEvent): { curve: boolean; index: number } | null => {
+ (
+ e: ReactPointerEvent,
+ ): { kind: "point" | "segment" | "curve"; 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 };
+ if (index !== null) return { kind: "point", index };
+ // Alt is an explicit bend gesture and retains its span-wide hit target.
+ // The unmodified translation is offered only close to the drawn line.
+ const segment = e.altKey
+ ? segmentIndex(e.clientX, e.clientY)
+ : segmentDrag.hitIndex(e.clientX, e.clientY);
+ if (segment === null) return null;
+ return { kind: e.altKey ? "curve" : "segment", index: segment };
},
- [hitIndex, segmentIndex],
+ [hitIndex, segmentDrag, segmentIndex],
);
const onPointerDown = useCallback(
@@ -274,10 +302,15 @@ export function useAutomationLaneGestures({
}
e.preventDefault();
capturePointer(e);
- if (gesture.curve) {
+ segmentDrag.clearHover();
+ if (gesture.kind === "curve") {
setCurveIndex(gesture.index);
return;
}
+ if (gesture.kind === "segment") {
+ segmentDrag.arm(gesture.index, e.clientX, e.clientY);
+ return;
+ }
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.
@@ -299,6 +332,7 @@ export function useAutomationLaneGestures({
// selecting a range read the previous selection, or none.
rangeSelection,
stretch,
+ segmentDrag,
],
);
@@ -394,6 +428,20 @@ export function useAutomationLaneGestures({
[dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
);
+ /** Route a live point, bend, or segment gesture after global gestures stand down. */
+ const moveLiveGesture = useCallback(
+ (e: ReactPointerEvent): void => {
+ const from = pressAt.current;
+ if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
+ pressTravelled.current = true;
+ }
+ if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
+ else if (segmentDrag.dragIndex !== null) segmentDrag.move(e);
+ else movePoint(e);
+ },
+ [bendSegment, curveIndex, movePoint, segmentDrag],
+ );
+
const onPointerMove = useCallback(
(e: ReactPointerEvent): void => {
if (stretch.edge !== null) {
@@ -409,19 +457,15 @@ export function useAutomationLaneGestures({
rangeDrag.move(e);
return;
}
- if (curveIndex === null && dragIndex === null) {
+ if (curveIndex === null && dragIndex === null && segmentDrag.dragIndex === null) {
stretch.updateHover(e);
+ segmentDrag.updateHover(e.clientX, e.clientY);
return;
}
e.stopPropagation();
- const from = pressAt.current;
- if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
- pressTravelled.current = true;
- }
- if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
- else movePoint(e);
+ moveLiveGesture(e);
},
- [rangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch],
+ [rangeDrag, curveIndex, dragIndex, segmentDrag, moveLiveGesture, stretch],
);
const endDrag = useCallback(
@@ -436,12 +480,13 @@ export function useAutomationLaneGestures({
rangeDrag.finish();
return;
}
- if (dragIndex === null && curveIndex === null) return;
+ if (dragIndex === null && curveIndex === null && segmentDrag.dragIndex === null) return;
e.stopPropagation();
const index = dragIndex;
const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current;
setDragIndex(null);
setCurveIndex(null);
+ segmentDrag.finish();
dragOrigin.current = null;
groupDrag.current = null;
shiftAxis.current = null;
@@ -459,7 +504,7 @@ export function useAutomationLaneGestures({
}
commitPoints(lane.points, true);
},
- [rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
+ [rangeDrag, curveIndex, dragIndex, segmentDrag, lane, commitPoints, stretch],
);
/**
@@ -519,12 +564,15 @@ export function useAutomationLaneGestures({
return {
dragIndex,
curveIndex,
+ segmentDragIndex: segmentDrag.dragIndex,
+ segmentHoverIndex: segmentDrag.hoverIndex,
edgeDrag: stretch.edge,
edgeHover: stretch.hover,
cancelDrag,
hint,
hitIndex,
segmentIndex,
+ onPointerLeave: segmentDrag.clearHover,
onPointerDown,
onPointerMove,
endDrag,
diff --git a/packages/studio/src/player/components/useAutomationSegmentDrag.ts b/packages/studio/src/player/components/useAutomationSegmentDrag.ts
new file mode 100644
index 0000000000..69d452df35
--- /dev/null
+++ b/packages/studio/src/player/components/useAutomationSegmentDrag.ts
@@ -0,0 +1,110 @@
+/**
+ * The grab target and translation gesture for one automation segment.
+ *
+ * Kept separate from the lane's gesture router: proximity is measured against
+ * the sampled envelope, while movement reuses the same shape-preserving group
+ * math as a box selection.
+ */
+
+import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
+import {
+ sampleAutomationLane,
+ type AutomationRange,
+ type HfAutomationLane,
+} from "@hyperframes/core/audio-automation";
+import { SEGMENT_GRAB_PX } from "./automationLaneGeometry";
+import { armSegmentDrag, computeGroupMove, type GroupDragSnapshot } from "./automationLaneDragMath";
+
+interface UseAutomationSegmentDragInput {
+ getBox(): DOMRect | null;
+ lane: HfAutomationLane;
+ range: AutomationRange;
+ pointAt(clientX: number, clientY: number): { t: number; v: number };
+ xOf(t: number): number;
+ yOf(v: number): number;
+ segmentIndex(clientX: number, clientY: number): number | null;
+ commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
+ duration: number;
+ snapTimes: readonly number[] | undefined;
+ readOnly: boolean | undefined;
+ onHint(hint: string | null): void;
+}
+
+export function useAutomationSegmentDrag({
+ getBox,
+ lane,
+ range,
+ pointAt,
+ xOf,
+ yOf,
+ segmentIndex,
+ commitPoints,
+ duration,
+ snapTimes,
+ readOnly,
+ onHint,
+}: UseAutomationSegmentDragInput) {
+ const [dragIndex, setDragIndex] = useState(null);
+ const [hoverIndex, setHoverIndex] = useState(null);
+ const snapshot = useRef(null);
+
+ /** Segment whose drawn line is close enough to grab, or null. */
+ const hitIndex = useCallback(
+ (clientX: number, clientY: number): number | null => {
+ const box = getBox();
+ if (!box) return null;
+ const index = segmentIndex(clientX, clientY);
+ if (index === null) return null;
+ const { t } = pointAt(clientX, clientY);
+ const value = sampleAutomationLane(lane, t, range.scale);
+ return Math.abs(yOf(value) - (clientY - box.top)) <= SEGMENT_GRAB_PX ? index : null;
+ },
+ [getBox, lane, pointAt, range.scale, segmentIndex, yOf],
+ );
+
+ const arm = useCallback(
+ (index: number, clientX: number, clientY: number): void => {
+ snapshot.current = armSegmentDrag(lane, index, pointAt(clientX, clientY));
+ setHoverIndex(null);
+ setDragIndex(index);
+ },
+ [lane, pointAt],
+ );
+
+ const move = useCallback(
+ (e: ReactPointerEvent): void => {
+ const group = snapshot.current;
+ if (!group) return;
+ const moved = computeGroupMove({
+ group,
+ raw: pointAt(e.clientX, e.clientY),
+ shiftKey: e.shiftKey,
+ altKey: e.altKey,
+ range,
+ duration,
+ snapTimes,
+ xOf,
+ yOf,
+ });
+ onHint(moved.hint);
+ commitPoints(moved.points, false);
+ },
+ [commitPoints, duration, onHint, pointAt, range, snapTimes, xOf, yOf],
+ );
+
+ const finish = useCallback((): void => {
+ snapshot.current = null;
+ setDragIndex(null);
+ }, []);
+
+ const updateHover = useCallback(
+ (clientX: number, clientY: number): void => {
+ setHoverIndex(readOnly ? null : hitIndex(clientX, clientY));
+ },
+ [hitIndex, readOnly],
+ );
+
+ const clearHover = useCallback((): void => setHoverIndex(null), []);
+
+ return { dragIndex, hoverIndex, hitIndex, arm, move, finish, updateHover, clearHover };
+}