mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): drag automation segments (#3465)
* feat(studio): drag automation segments * fix(studio): clear clip selection for group effects * fix(studio): replace clip selection with audio bus * fix(studio): make audio bus selection authoritative
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { SelectElementOptions, TimelineElement } from "../player";
|
||||
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
||||
import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { logSelect } from "../utils/selectDebug";
|
||||
@@ -60,9 +61,17 @@ export function announceTimelineSelection(
|
||||
anchor,
|
||||
anchorPublished: anchor != null && publishedMembers.has(anchor),
|
||||
});
|
||||
// A canvas target can be editable without owning a timeline row. Preserve that
|
||||
// canvas-only selection when the timeline has nothing truthful to represent.
|
||||
if (!timelineAnchor) return;
|
||||
// A canvas target can be editable without owning a timeline row. Most such
|
||||
// targets live inside a clip and must not erase its timeline context. A mixer
|
||||
// bus is different: it is itself the editing target, so its title replaces any
|
||||
// selected clips even though the bus has no clip row of its own.
|
||||
if (!timelineAnchor) {
|
||||
if (primary.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
|
||||
setTimelineSelectionSet(new Set());
|
||||
setSelectedTimelineElementId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A late async primary that already belongs to the live set must preserve the
|
||||
// group. A fresh single click does not belong to it, so publish the singleton
|
||||
// first; otherwise `preserveSet` clears the set and sync wipes the canvas.
|
||||
|
||||
@@ -130,6 +130,121 @@ describe("useDomSelection — Variables tab preservation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDomSelection — canvas-only targets replace timeline clips", () => {
|
||||
beforeEach(() => {
|
||||
deferreds.clear();
|
||||
usePlayerStore.getState().clearSelection();
|
||||
});
|
||||
afterEach(() => {
|
||||
deferreds.clear();
|
||||
usePlayerStore.getState().clearSelection();
|
||||
});
|
||||
|
||||
it("deselects every clip when an audio bus is selected", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelectedElementId("voice-1");
|
||||
store.setSelectedElementIds(new Set(["voice-1", "voice-2"]));
|
||||
|
||||
const bus = document.createElement("hf-audio-group");
|
||||
bus.id = "voiceover";
|
||||
const harness = renderHarness({
|
||||
rightPanelTab: "design",
|
||||
setRightPanelTab: vi.fn(),
|
||||
iframe: null,
|
||||
timelineElements: [
|
||||
{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 },
|
||||
{ id: "voice-2", tag: "audio", start: 1, duration: 1, track: 1 },
|
||||
],
|
||||
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
||||
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
||||
});
|
||||
|
||||
act(() => harness.current().applyDomSelection(makeSelection("Voiceover", bus)));
|
||||
|
||||
expect(harness.current().domEditSelection?.id).toBe("voiceover");
|
||||
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
||||
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
|
||||
harness.cleanup();
|
||||
});
|
||||
|
||||
it("lets a bus supersede a clip selection that is still resolving", async () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument!;
|
||||
const clipNode = doc.createElement("audio");
|
||||
clipNode.id = "voice-1";
|
||||
const busNode = doc.createElement("hf-audio-group");
|
||||
busNode.id = "voiceover";
|
||||
doc.body.append(clipNode, busNode);
|
||||
|
||||
const clip: TimelineElement = {
|
||||
id: "voice-1",
|
||||
domId: "voice-1",
|
||||
tag: "audio",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
};
|
||||
const bus: TimelineElement = {
|
||||
id: "voiceover",
|
||||
domId: "voiceover",
|
||||
tag: "audio",
|
||||
start: 0,
|
||||
duration: 10,
|
||||
track: -0.5,
|
||||
};
|
||||
const harness = renderHarness({
|
||||
rightPanelTab: "design",
|
||||
setRightPanelTab: vi.fn(),
|
||||
iframe,
|
||||
// The bus is a synthetic row target, not a clip in the store.
|
||||
timelineElements: [clip],
|
||||
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
||||
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
||||
});
|
||||
|
||||
let pendingClip = Promise.resolve();
|
||||
let pendingBus = Promise.resolve();
|
||||
act(() => {
|
||||
pendingClip = harness.current().handleTimelineElementSelect(clip);
|
||||
pendingBus = harness.current().handleTimelineElementSelect(bus);
|
||||
});
|
||||
await act(async () => {
|
||||
deferreds.get("voiceover")?.resolve();
|
||||
await pendingBus;
|
||||
deferreds.get("voice-1")?.resolve();
|
||||
await pendingClip;
|
||||
});
|
||||
|
||||
expect(harness.current().domEditSelection?.id).toBe("voiceover");
|
||||
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
||||
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
|
||||
harness.cleanup();
|
||||
iframe.remove();
|
||||
});
|
||||
|
||||
it("preserves clip context for a non-bus canvas-only selection", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelectedElementId("voice-1");
|
||||
const decoration = document.createElement("div");
|
||||
decoration.id = "decoration";
|
||||
const harness = renderHarness({
|
||||
rightPanelTab: "design",
|
||||
setRightPanelTab: vi.fn(),
|
||||
iframe: null,
|
||||
timelineElements: [{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }],
|
||||
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
||||
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
||||
});
|
||||
|
||||
act(() => harness.current().applyDomSelection(makeSelection("Decoration", decoration)));
|
||||
|
||||
expect(usePlayerStore.getState().selectedElementId).toBe("voice-1");
|
||||
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["voice-1"]));
|
||||
harness.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDomSelection — timeline-select race guard", () => {
|
||||
beforeEach(() => deferreds.clear());
|
||||
afterEach(() => deferreds.clear());
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/** The base automation envelope plus the heavier segment grab affordance. */
|
||||
|
||||
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
|
||||
import { envelopeSegmentPath } from "./automationLaneGeometry";
|
||||
|
||||
interface AutomationEnvelopePathsProps {
|
||||
path: string;
|
||||
lane: HfAutomationLane;
|
||||
range: AutomationRange;
|
||||
accentColor: string;
|
||||
activeSegment: number | null;
|
||||
xOf(t: number): number;
|
||||
yOf(v: number): number;
|
||||
}
|
||||
|
||||
export function AutomationEnvelopePaths({
|
||||
path,
|
||||
lane,
|
||||
range,
|
||||
accentColor,
|
||||
activeSegment,
|
||||
xOf,
|
||||
yOf,
|
||||
}: AutomationEnvelopePathsProps) {
|
||||
const activePath =
|
||||
activeSegment === null
|
||||
? null
|
||||
: envelopeSegmentPath({ lane, range, index: activeSegment, xOf, yOf });
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
data-automation-envelope=""
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth={1.5}
|
||||
opacity={lane.points.length === 0 ? 0.35 : 0.95}
|
||||
/>
|
||||
{activePath ? (
|
||||
<path
|
||||
data-automation-segment-active={activeSegment ?? undefined}
|
||||
d={activePath}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
opacity={1}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
||||
import {
|
||||
normalizeAutomation,
|
||||
resolveAutomationRange,
|
||||
sampleAutomationLane,
|
||||
VOLUME_RANGE,
|
||||
type HfAutomation,
|
||||
} from "@hyperframes/core/audio-automation";
|
||||
@@ -668,6 +669,118 @@ describe("TimelineAutomationLane point visibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TimelineAutomationLane segment drag", () => {
|
||||
const four: HfAutomation = {
|
||||
version: 1,
|
||||
lanes: [
|
||||
{
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 1 },
|
||||
{ t: 1, v: 0.8 },
|
||||
{ t: 2, v: 0.6 },
|
||||
{ t: 3.5, v: 0.2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const previewedPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
|
||||
props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as {
|
||||
t: number;
|
||||
v: number;
|
||||
viaX?: number;
|
||||
viaY?: number;
|
||||
}[];
|
||||
|
||||
it("thickens the segment and offers a grab cursor only within its hit proximity", () => {
|
||||
const { container, svg } = mount(ramp);
|
||||
const envelope = container.querySelector<SVGPathElement>("[data-automation-envelope]");
|
||||
expect(envelope?.getAttribute("stroke-width")).toBe("1.5");
|
||||
|
||||
fire(svg, "pointermove", at(2, 0.5));
|
||||
const active = container.querySelector<SVGPathElement>("[data-automation-segment-active]");
|
||||
expect(active).not.toBeNull();
|
||||
expect(active?.getAttribute("stroke-width")).toBe("3");
|
||||
expect(svg.style.cursor).toBe("grab");
|
||||
|
||||
// Same time span, but far enough above the drawn ramp to be background.
|
||||
fire(svg, "pointermove", at(2, 0.9));
|
||||
expect(container.querySelector("[data-automation-segment-active]")).toBeNull();
|
||||
expect(svg.style.cursor).toBe("crosshair");
|
||||
});
|
||||
|
||||
it("moves both segment endpoints by the same time and value delta", () => {
|
||||
const { svg, props } = mount(four);
|
||||
// Midpoint of the segment from (1, .8) to (2, .6).
|
||||
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
|
||||
fire(svg, "pointermove", { ...at(2, 0.5), buttons: 1 });
|
||||
|
||||
const points = previewedPoints(props);
|
||||
expect(points[0]).toEqual({ t: 0, v: 1 });
|
||||
expect(points[1]!.t).toBeCloseTo(1.5, 2);
|
||||
expect(points[2]!.t).toBeCloseTo(2.5, 2);
|
||||
expect(points[1]!.v).toBeCloseTo(0.6, 2);
|
||||
expect(points[2]!.v).toBeCloseTo(0.4, 2);
|
||||
expect(points[3]).toEqual({ t: 3.5, v: 0.2 });
|
||||
});
|
||||
|
||||
it("treats a press outside the line's proximity as a background range drag", () => {
|
||||
const onRangeSelect = vi.fn();
|
||||
const { svg, props } = mount(four, { onRangeSelect });
|
||||
fire(svg, "pointerdown", { ...at(1.5, 0.95), buttons: 1 });
|
||||
fire(svg, "pointermove", { ...at(2.5, 0.95), buttons: 1 });
|
||||
expect(onRangeSelect).toHaveBeenCalled();
|
||||
expect(props.onPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the segment's curve while translating its endpoints", () => {
|
||||
const curved: HfAutomation = {
|
||||
version: 1,
|
||||
lanes: [
|
||||
{
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 1 },
|
||||
{ t: 1, v: 0.8, viaX: 0.4, viaY: 0.7 },
|
||||
{ t: 2, v: 0.6 },
|
||||
{ t: 3.5, v: 0.2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { svg, props } = mount(curved);
|
||||
const lineValue = sampleAutomationLane(curved.lanes[0]!, 1.7, "linear");
|
||||
fire(svg, "pointerdown", { ...at(1.7, lineValue), buttons: 1 });
|
||||
fire(svg, "pointermove", { ...at(2.1, lineValue - 0.1), buttons: 1 });
|
||||
const points = previewedPoints(props);
|
||||
expect(points[1]?.viaX).toBe(0.4);
|
||||
expect(points[1]?.viaY).toBe(0.7);
|
||||
});
|
||||
|
||||
it("stops both endpoints together before the next breakpoint", () => {
|
||||
const { svg, props } = mount(four);
|
||||
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
|
||||
fire(svg, "pointermove", { ...at(4, 0.7), buttons: 1, altKey: true });
|
||||
const points = previewedPoints(props);
|
||||
expect(points[2]!.t).toBeLessThan(points[3]!.t);
|
||||
expect(points[3]!.t - points[2]!.t).toBeCloseTo(0.001, 4);
|
||||
expect(points[2]!.t - points[1]!.t).toBeCloseTo(1, 4);
|
||||
});
|
||||
|
||||
it("previews every move and persists the segment once on release", () => {
|
||||
const { svg, props } = mount(four);
|
||||
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
|
||||
for (const t of [1.7, 1.9, 2.1]) {
|
||||
fire(svg, "pointermove", { ...at(t, 0.6), buttons: 1 });
|
||||
}
|
||||
expect(props.onPreview).toHaveBeenCalledTimes(3);
|
||||
expect(props.onCommit).not.toHaveBeenCalled();
|
||||
fire(svg, "pointerup", { ...at(2.1, 0.6), buttons: 0 });
|
||||
expect(props.onCommit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/** `mount`, plus the re-render a real store update causes — the persisted
|
||||
* automation and the new selection coming back down as props. */
|
||||
const mountRerenderable = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
|
||||
|
||||
@@ -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}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth={1.5}
|
||||
opacity={lane.points.length === 0 ? 0.35 : 0.95}
|
||||
<AutomationEnvelopePaths
|
||||
path={path}
|
||||
lane={lane}
|
||||
range={range}
|
||||
accentColor={accentColor}
|
||||
activeSegment={segmentDragIndex ?? segmentHoverIndex}
|
||||
xOf={xOf}
|
||||
yOf={yOf}
|
||||
/>
|
||||
{lane.points.map((p, i) => {
|
||||
// Endpoint-inclusive, the same rule Delete uses, so what looks caught by
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
key={lane.target}
|
||||
data-group-lane-label={lane.target}
|
||||
className="absolute left-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[10px] text-white/65"
|
||||
aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
|
||||
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-white/65 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
style={{
|
||||
top: top + index * AUTOMATION_LANE_H,
|
||||
width: columnWidth,
|
||||
@@ -67,6 +73,11 @@ export function TimelineGroupLaneLabels({
|
||||
borderLeft: `2px solid ${accentColor}`,
|
||||
}}
|
||||
title={`${groupLabel} · ${parts.param ? `${parts.name} · ${parts.param}` : parts.name}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onReveal?.(lane.target);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
|
||||
▤
|
||||
@@ -80,7 +91,7 @@ export function TimelineGroupLaneLabels({
|
||||
<span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
|
||||
{value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -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<TimelineTrackGroupInfo> = {}) {
|
||||
function renderRow(
|
||||
overrides: Partial<TimelineTrackGroupInfo> = {},
|
||||
expandedLaneOwnerIds = new Set<string>(),
|
||||
) {
|
||||
const onSetAudioGroupAttributeQuiet = vi.fn();
|
||||
const onSetElementAttributeQuiet = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
@@ -55,10 +67,31 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
|
||||
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<TimelineTrackGroupInfo> = {}) {
|
||||
}
|
||||
|
||||
describe("TimelineGroupRow", () => {
|
||||
it("routes the group title through the guarded selection path", () => {
|
||||
const { host } = renderRow();
|
||||
const title = host.querySelector<HTMLButtonElement>(
|
||||
'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<HTMLButtonElement>('[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
|
||||
|
||||
@@ -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 (
|
||||
<TimelineTrackRow
|
||||
@@ -150,7 +159,7 @@ export function TimelineGroupRow({
|
||||
onFxChainChange={(next) => 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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<SVGSVGElement>): void;
|
||||
onPointerMove(e: ReactPointerEvent<SVGSVGElement>): 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<SVGSVGElement>): { curve: boolean; index: number } | null => {
|
||||
(
|
||||
e: ReactPointerEvent<SVGSVGElement>,
|
||||
): { 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<SVGSVGElement>): 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<SVGSVGElement>): 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,
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const snapshot = useRef<GroupDragSnapshot | null>(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<SVGSVGElement>): 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user