diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index 94da3b674..d1b9849c3 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -1747,3 +1747,70 @@ describe("TimelineAutomationLane stretch", () => { expect(svg.style.cursor).not.toBe("col-resize"); }); }); + +describe("TimelineAutomationLane — a read-only lane offers nothing to grab", () => { + /** Hover the lane the way a pointer entering it does. */ + function hover(container: HTMLElement): SVGSVGElement { + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + // React implements onPointerEnter through the delegated `pointerover` + // event, not a native `pointerenter` — which does not bubble and so never + // reaches its listener. + fire(svg, "pointerover"); + return svg as SVGSVGElement; + } + + const handles = (container: HTMLElement) => + [...container.querySelectorAll("circle[data-automation-point]")] as SVGCircleElement[]; + + // A grab handle raised on hover is an offer, and a carve lane cannot honour + // it: the analysis rewrites these envelopes on every run, so a point moved + // here is discarded rather than saved. Dimming alone did not say that — the + // handles still came up under the cursor and the drag silently did nothing. + it("keeps its point handles hidden on hover", () => { + const { container } = render( + , + ); + hover(container); + const drawn = handles(container); + expect(drawn.length).toBeGreaterThan(0); + for (const c of drawn) { + expect(c.style.opacity).toBe("0"); + expect(c.style.cursor).toBe("default"); + } + }); + + it("still raises them on an editable lane, so the gate is readOnly and not hover", () => { + const { container } = render(); + hover(container); + const drawn = handles(container); + expect(drawn.length).toBeGreaterThan(0); + for (const c of drawn) { + expect(c.style.opacity).toBe("1"); + expect(c.style.cursor).toBe("grab"); + } + }); + + it("says why, in the lane, once hovered", () => { + const { container } = render( + , + ); + expect(container.querySelector("[data-automation-readonly-note]")).toBeNull(); + hover(container); + expect(container.querySelector("[data-automation-readonly-note]")?.textContent).toContain( + "Owned by the carve.", + ); + }); + + it("says nothing when no reason was given", () => { + const { container } = render( + , + ); + hover(container); + expect(container.querySelector("[data-automation-readonly-note]")).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 061fea8e4..eb8dfa2c7 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -86,6 +86,75 @@ function pointCircleStyle( } /** Pointer shape: a read-only lane can only be selected, a live one edited. */ +/** + * Whether a point's grab handle is up. + * + * A raised handle is an offer to drag, so a read-only lane keeps them down: the + * carve rewrites its envelopes from the analysis on every run, and a point + * moved here is discarded rather than saved. Hidden rather than unmounted + * either way, so the hit area survives — a point dragged past the lane's edge + * fires pointerleave mid-gesture, and a handle that vanished then would drop + * the drag. A live drag and a selected range both keep them up regardless: the + * range is the subject of a pending Delete, and which points it caught cannot + * depend on where the mouse is. + */ +/** The svg's tooltip: what this lane's gestures actually are. */ +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."; +} + +/** + * Why this lane will not take an edit, in the lane itself. + * + * Dimming and a default cursor say "not editable" but not WHY, and the why is + * the part that matters: the carve rewrites these envelopes from its own + * analysis on every run, so a point moved here is discarded rather than saved. + * Only while hovered, so a stack of read-only lanes is not a stack of notices, + * and never over a selection the author is about to act on. + * + * Separate from the gesture `hint` slot, which belongs to handlers that do not + * run on a read-only lane at all. + */ +function ReadOnlyNote({ + readOnly, + note, + hovered, + hasRange, + leftPx, + widthPx, +}: { + readOnly: boolean | undefined; + note: string | undefined; + hovered: boolean; + /** A selection is the subject of a pending action; do not cover it. */ + hasRange: boolean; + leftPx: number; + widthPx: number; +}) { + if (!readOnly || !note || !hovered || hasRange) return null; + return ( +
+ {note} +
+ ); +} + +function pointHandleOpacity(args: { + readOnly: boolean | undefined; + hovered: boolean; + dragging: boolean; + hasRange: boolean; +}): number { + if (args.dragging || args.hasRange) return 1; + return !args.readOnly && args.hovered ? 1 : 0; +} + function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: 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 @@ -120,6 +189,9 @@ export interface TimelineAutomationLaneProps { snapTimes?: readonly number[]; /** Editing writes to the selected element, so an unselected clip is read-only. */ readOnly?: boolean; + /** Why `readOnly`, shown in the lane on hover. Without it the lane only looks + * disabled; the author still has to guess what would let them edit it. */ + readOnlyNote?: string; /** Called when a read-only lane is pressed: selects the clip so it goes live. */ onSelect?(): void; /** Active selection box on THIS lane, or null. */ @@ -142,6 +214,7 @@ export function TimelineAutomationLane({ onCommit, snapTimes, readOnly, + readOnlyNote, onSelect, rangeSelection, onRangeSelect, @@ -372,11 +445,7 @@ export function TimelineAutomationLane({ role="group" aria-label={`${range.label} automation`} > - - {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."} - + {laneTitle(readOnly)} {/* No plate behind the envelope: the lane used to darken its clip's width, which drew a box inside the row and made a stack of lanes read as tiles rather than as rows of one timeline. The row background shows through, and @@ -433,15 +502,14 @@ export function TimelineAutomationLane({ fill={accentColor} stroke={stroke} strokeWidth={strokeWidth} - // Hidden rather than unmounted, so the hit area survives: a point - // dragged past the lane's edge fires pointerleave mid-gesture, and a - // handle that vanishes then would drop the drag. A drag in progress - // and a selected range both keep them up for the same reason — the - // range is the subject of a pending Delete, and which points it - // caught cannot depend on where the mouse is. style={{ cursor: readOnly ? "default" : "grab", - opacity: hovered || dragIndex !== null || rangeSelection ? 1 : 0, + opacity: pointHandleOpacity({ + readOnly, + hovered, + dragging: dragIndex !== null, + hasRange: Boolean(rangeSelection), + }), }} onContextMenu={(e) => { e.preventDefault(); @@ -484,6 +552,15 @@ export function TimelineAutomationLane({ ) : null} + + {menuAt && rangeSelection ? ( - toggleGroupExpanded(group.id)} - // The GROUP's own lanes, not its members'. `∿` is per-row (groups doc - // §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and - // counting the members' here made the group advertise curves it does - // not own and cannot show. - laneCount={groupAutomationLanes([groupElement]).length} - isLaneOpen={isLaneOpen} - onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} - fxChain={group.fxChain} - onFxChainChange={(next) => writeGroupFxChain(next, false)} - onFxChainPreview={(next) => writeGroupFxChain(next, true)} - auditionSpans={memberElements} - 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 - // overhanging header paints opaquely across the rest of its row and - // stays pinned there through horizontal scroll. - columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} - theme={theme} - /> - {/* The group's OWN curves, under the strip. Selected-gated exactly like a + {/* Header and its lane labels in ONE sticky column — the shape + `TimelineTrackHeader` already uses: a fixed TRACK_H line box with the + lane rows absolutely positioned beneath it, the whole thing pinned. + The labels used to be SIBLINGS of the header, so `absolute left-0` + resolved against the ROW, and the row is what scrolls horizontally — + they slid away with the canvas. Sticky lives here rather than on the + header, which keeps its own box inside; a zero-width sticky wrapper + around the labels alone does not work either, because as a flex item + after the header it starts at x = columnWidth, i.e. inside the lanes. */} +
= LABEL_COL_W ? LABEL_COL_W : contentOrigin }} + > + toggleGroupExpanded(group.id)} + // The GROUP's own lanes, not its members'. `∿` is per-row (groups doc + // §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and + // counting the members' here made the group advertise curves it does + // not own and cannot show. + laneCount={groupAutomationLanes([groupElement]).length} + isLaneOpen={isLaneOpen} + onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} + fxChain={group.fxChain} + onFxChainChange={(next) => writeGroupFxChain(next, false)} + onFxChainPreview={(next) => writeGroupFxChain(next, true)} + auditionSpans={memberElements} + 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 + // overhanging header paints opaquely across the rest of its row and + // stays pinned there through horizontal scroll. + columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} + theme={theme} + /> + {/* The group's OWN curves, under the strip. Selected-gated exactly like a clip's: the binder writes through the dom-edit selection, so a lane is editable once the group is selected — which clicking its name does. */} - {/* The label column for those lanes, on the accent rail. Outside the - offset content cell below, because the labels belong to the sticky - gutter the row header occupies, not to the scrolling canvas. */} - {isLaneOpen && ( - = LABEL_COL_W ? LABEL_COL_W : contentOrigin} - gutterBackground={theme.gutterBackground} - accentColor={GROUP_LANE_ACCENT} - /> - )} + {/* The label column for those lanes, on the accent rail — inside the + sticky column above, so they pin with the header. */} + {isLaneOpen && ( + = LABEL_COL_W ? LABEL_COL_W : contentOrigin} + gutterBackground={theme.gutterBackground} + accentColor={GROUP_LANE_ACCENT} + /> + )} +
{isLaneOpen && ( // The same offset content cell a track row wraps its lanes in — the // slot positions absolutely, so mounted straight on the row it resolved