Skip to content

Commit ef5b167

Browse files
committed
feat(studio): shape and simplify menu on an automation selection
Right-click inside an active time-selection rectangle on an automation lane now opens a menu offering the four utility shapes (Ramp up, Ramp down, Swell, Dip) and Simplify, composing generateShape/simplifyPoints with pointsIn/replaceRange from the prior selection tasks. A point's own right-click still stops propagation and deletes it, unaffected.
1 parent ce5dfb0 commit ef5b167

3 files changed

Lines changed: 167 additions & 1 deletion

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Context menu for a right-click inside an automation time selection: the four
3+
* utility shapes, then Simplify. Portal + dismiss handling mirror
4+
* TrackGapContextMenu; rows never vanish — an inapplicable Simplify dims with
5+
* a reason instead of leaving a shorter menu.
6+
*/
7+
import { memo } from "react";
8+
import { createPortal } from "react-dom";
9+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
10+
import { AUTOMATION_SHAPES, type AutomationShapeId } from "./automationShapes";
11+
12+
interface AutomationSelectionMenuProps {
13+
x: number;
14+
y: number;
15+
onClose(): void;
16+
onInsertShape(shape: AutomationShapeId): void;
17+
onSimplify(): void;
18+
/** At least three points in the range — fewer has nothing to thin. */
19+
canSimplify: boolean;
20+
}
21+
22+
export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
23+
x,
24+
y,
25+
onClose,
26+
onInsertShape,
27+
onSimplify,
28+
canSimplify,
29+
}: AutomationSelectionMenuProps) {
30+
const menuRef = useContextMenuDismiss(onClose);
31+
const row =
32+
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
33+
return createPortal(
34+
<div
35+
ref={menuRef}
36+
className="hf-automation-menu fixed z-50 min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
37+
style={{ left: x, top: y }}
38+
>
39+
{AUTOMATION_SHAPES.map((shape) => (
40+
<button
41+
key={shape.id}
42+
type="button"
43+
className={row}
44+
onClick={() => {
45+
onInsertShape(shape.id);
46+
onClose();
47+
}}
48+
>
49+
{shape.label}
50+
</button>
51+
))}
52+
<div className="my-1 border-t border-panel-border-input" />
53+
<button
54+
type="button"
55+
className={row}
56+
disabled={!canSimplify}
57+
title={canSimplify ? undefined : "Fewer than three points in the selection"}
58+
onClick={() => {
59+
onSimplify();
60+
onClose();
61+
}}
62+
>
63+
Simplify
64+
</button>
65+
</div>,
66+
document.body,
67+
);
68+
});

packages/studio/src/player/components/TimelineAutomationLane.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,3 +649,40 @@ describe("TimelineAutomationLane range selection", () => {
649649
expect(props.onCommit).toHaveBeenCalled();
650650
});
651651
});
652+
653+
describe("TimelineAutomationLane selection menu", () => {
654+
it("right-click inside the selection opens the shape menu", () => {
655+
const { container, svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
656+
fire(svg, "contextmenu", at(2, 0.5));
657+
expect(document.querySelector(".hf-automation-menu")).not.toBeNull();
658+
// The menu portals to document.body, outside `container` — dismiss it via
659+
// Escape before tearing down, or it leaks into the next test's DOM query.
660+
const escape = new Event("keydown", { bubbles: true, cancelable: true });
661+
Object.assign(escape, { key: "Escape" });
662+
act(() => {
663+
document.dispatchEvent(escape);
664+
});
665+
expect(document.querySelector(".hf-automation-menu")).toBeNull();
666+
act(() => container.remove());
667+
});
668+
669+
it("inserting a swell replaces the range and commits once", () => {
670+
const { svg, props } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
671+
fire(svg, "contextmenu", at(2, 0.5));
672+
const swell = Array.from(
673+
document.querySelectorAll<HTMLButtonElement>(".hf-automation-menu button"),
674+
).find((b) => b.textContent === "Swell");
675+
expect(swell).toBeTruthy();
676+
act(() => swell?.click());
677+
expect(props.onCommit).toHaveBeenCalledTimes(1);
678+
const points =
679+
(props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
680+
expect(points.some((p) => p.t === 2 && p.v === 1)).toBe(true); // peak at range.max
681+
});
682+
683+
it("right-click outside the selection does not open it", () => {
684+
const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
685+
fire(svg, "contextmenu", at(3.8, 0.5));
686+
expect(document.querySelector(".hf-automation-menu")).toBeNull();
687+
});
688+
});

packages/studio/src/player/components/TimelineAutomationLane.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@
1414
* same principle the property panel's controls follow.
1515
*/
1616

17-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17+
import {
18+
useCallback,
19+
useEffect,
20+
useMemo,
21+
useRef,
22+
useState,
23+
type MouseEvent as ReactMouseEvent,
24+
} from "react";
1825
import {
1926
resolveAutomationRange,
2027
sampleAutomationLane,
@@ -34,7 +41,11 @@ import {
3441
} from "./automationLaneGeometry";
3542
import { useAutomationLaneGestures } from "./useAutomationLaneGestures";
3643
import { AutomationValueInput } from "./AutomationValueInput";
44+
import { AutomationSelectionMenu } from "./AutomationSelectionMenu";
3745
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
46+
import { generateShape, type AutomationShapeId } from "./automationShapes";
47+
import { simplifyPoints } from "./automationSimplify";
48+
import { pointsIn, replaceRange } from "./automationLaneSelection";
3849
import { getTimelineLaneTop } from "./timelineLayout";
3950
import type { TimelineElement } from "../store/playerStore";
4051
import type { UseAutomationLanesResult } from "./useAutomationLanes";
@@ -207,6 +218,44 @@ export function TimelineAutomationLane({
207218
[lane, commitPoints, readOnly],
208219
);
209220

221+
/** Client-coordinate position of an open selection menu, or null when closed. */
222+
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
223+
224+
const insertShape = useCallback(
225+
(shape: AutomationShapeId): void => {
226+
if (!rangeSelection) return;
227+
const inner = generateShape({
228+
shape,
229+
lane,
230+
range,
231+
t0: rangeSelection.t0,
232+
t1: rangeSelection.t1,
233+
});
234+
commitPoints(replaceRange({ lane, range, ...rangeSelection, inner }), true);
235+
},
236+
[rangeSelection, lane, range, commitPoints],
237+
);
238+
239+
const simplifySelection = useCallback((): void => {
240+
if (!rangeSelection) return;
241+
const inner = simplifyPoints(pointsIn(lane, rangeSelection.t0, rangeSelection.t1), range);
242+
commitPoints(replaceRange({ lane, range, ...rangeSelection, inner }), true);
243+
}, [rangeSelection, lane, range, commitPoints]);
244+
245+
// A point's own right-click already stops propagation and still deletes;
246+
// this only fires when the press lands on the background inside the
247+
// active selection.
248+
const onSvgContextMenu = useCallback(
249+
(e: ReactMouseEvent<SVGSVGElement>): void => {
250+
if (readOnly || !rangeSelection) return;
251+
const { t } = pointAt(e.clientX, e.clientY);
252+
if (t < rangeSelection.t0 || t > rangeSelection.t1) return;
253+
e.preventDefault();
254+
setMenuAt({ x: e.clientX, y: e.clientY });
255+
},
256+
[readOnly, rangeSelection, pointAt],
257+
);
258+
210259
const currentValue =
211260
lane.points.length > 0 && playheadSec !== null
212261
? sampleAutomationLane(lane, playheadSec, range.scale)
@@ -247,6 +296,7 @@ export function TimelineAutomationLane({
247296
onPointerUp={gestures.endDrag}
248297
onPointerCancel={gestures.endDrag}
249298
onDoubleClick={gestures.onDoubleClick}
299+
onContextMenu={onSvgContextMenu}
250300
role="group"
251301
aria-label={`${range.label} automation`}
252302
>
@@ -347,6 +397,17 @@ export function TimelineAutomationLane({
347397
{hint}
348398
</div>
349399
) : null}
400+
401+
{menuAt && rangeSelection ? (
402+
<AutomationSelectionMenu
403+
x={menuAt.x}
404+
y={menuAt.y}
405+
onClose={() => setMenuAt(null)}
406+
onInsertShape={insertShape}
407+
onSimplify={simplifySelection}
408+
canSimplify={pointsIn(lane, rangeSelection.t0, rangeSelection.t1).length >= 3}
409+
/>
410+
) : null}
350411
</div>
351412
);
352413
}

0 commit comments

Comments
 (0)