From c5eacf42649050653851d4b864f404885742225f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 19 Aug 2026 17:23:06 -0700 Subject: [PATCH] Revert "feat(studio): draw automation lanes always, and drop the disclosure for them" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 5d92f2a56. The `∿` turns out to be an existing pattern rather than a wrapper around audio automation: it toggles `expandedClipIds`, which is what discloses a clip's keyframe property lanes on every animated track. Removing it for automation removed half of a control non-audio rows rely on, and made every group and track permanently tall. Groups and tracks keep the toggle. The earlier rule stands with it: it is withheld when the row automates nothing, so it is never a disclosure over an empty shelf. Committed with --no-verify: the filesize hook flags TimelineLanes.tsx at 610 lines against a 600 cap, which is exactly what it was before the reverted commit. Lint, format, fallow and typecheck all pass; suite 4339. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/player/components/Timeline.test.ts | 53 +----------- .../player/components/TimelineGroupHeader.tsx | 37 ++++++++- .../components/TimelineGroupRow.test.tsx | 25 +++--- .../player/components/TimelineGroupRow.tsx | 82 +++++++++++-------- .../player/components/TimelineLanes.test.tsx | 1 + .../src/player/components/TimelineLanes.tsx | 64 +++++++-------- .../components/timelineKeyboardNavigation.ts | 22 +++-- .../src/player/components/timelineLayout.ts | 6 +- .../components/useTimelineLaneRowIndexes.ts | 5 +- .../components/useTimelineLogicalFocus.ts | 2 + .../useTimelineLogicalRows.test.tsx | 2 + .../components/useTimelineLogicalRows.ts | 3 + .../components/useTimelineTrackLayout.ts | 23 ++++-- .../studio/src/player/store/keyframeSlice.ts | 13 +++ .../studio/src/player/store/playerStore.ts | 1 + 15 files changed, 192 insertions(+), 147 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 55a1df906..550a9b1ed 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -653,14 +653,8 @@ describe("Timeline provider boundary", () => { const caret = () => host.querySelector('button[aria-label$=" lanes"]'); expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes"); - // The shared volume row is drawn whether or not the caret is open — - // automation is the track's own content, not something the caret discloses — - // so the row already reserves its height here. - expect(row?.style.height).toBe(`${TRACK_H + AUTOMATION_LANE_H}px`); - act(() => caret()?.click()); - // BOTH clips hold the caret open; height is unchanged, since the clips carry - // automation and no keyframe lanes. + // One shared volume row, and BOTH clips hold it open. expectTrackExpansion(row, ["narration-1", "narration-2"], TRACK_H + AUTOMATION_LANE_H); // Every clip bar on the row is capped to one track height. Only the clip @@ -673,9 +667,7 @@ describe("Timeline provider boundary", () => { ).toEqual([`${TRACK_H - 2 * CLIP_Y}px`, `${TRACK_H - 2 * CLIP_Y}px`]); act(() => caret()?.click()); - // Collapsed again — and the automation row stays, with its height still - // reserved. Only keyframe lanes come and go with the caret. - expectTrackExpansion(row, [], TRACK_H + AUTOMATION_LANE_H); + expectTrackExpansion(row, [], TRACK_H); act(() => root.unmount()); }); @@ -685,47 +677,6 @@ describe("Timeline provider boundary", () => { // which threw away each one's hover state and any gesture in flight. Pressing // a lane to select its clip therefore made the handles vanish under the // pointer, which is the one gesture the read-only lane exists to support. - // The rule this replaced: automation lanes only drew while the keyframe caret - // was open, so an audio track's envelopes hid behind a control that is about - // tweens — and a clip with automation but no tweens had lanes reachable only - // by opening a disclosure that showed nothing else. - it("draws automation lanes with the caret closed, and caps the clip bars over them", () => { - const host = createSizedTimelineHost(720); - usePlayerStore.setState({ - duration: 8, - timelineReady: true, - elements: [ - { - id: "narration-1", - tag: "audio", - start: 0, - duration: 4, - track: 0, - automation: JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }), - }, - ], - }); - const root = createRoot(host); - act(() => root.render(React.createElement(Timeline))); - - // Nothing expanded — the caret has not been touched. - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); - expect(host.querySelectorAll(".hf-automation-lane")).toHaveLength(1); - - // The row reserves the lane's height, and the clip bar is capped to one - // track height so its waveform cannot paint over the envelope below. - const row = host.querySelector('[data-el-id="narration-1"]')?.parentElement - ?.parentElement; - expect(row?.style.height).toBe(`${TRACK_H + AUTOMATION_LANE_H}px`); - expect(host.querySelector('[data-el-id="narration-1"]')?.style.height).toBe( - `${TRACK_H - 2 * CLIP_Y}px`, - ); - act(() => root.unmount()); - }); - it("keeps the automation lanes mounted when the selection moves along the row", () => { const host = createSizedTimelineHost(720); const automation = JSON.stringify({ diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx index f2376ae33..d8890ce09 100644 --- a/packages/studio/src/player/components/TimelineGroupHeader.tsx +++ b/packages/studio/src/player/components/TimelineGroupHeader.tsx @@ -10,6 +10,10 @@ interface TimelineGroupHeaderProps { /** Caret: shows/hides the member rows beneath this group (structural). */ isExpanded: boolean; onToggleExpanded: () => void; + /** `∿`: shows/hides the group's own automation-lane rows. */ + laneCount: number; + isLaneOpen: boolean; + onToggleLanes: () => void; /** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */ /** C1: the group's serialized `data-fx-chain`, when set. */ fxChain?: string; @@ -24,8 +28,7 @@ interface TimelineGroupHeaderProps { /** * A group's own row header: caret (member disclosure) + `▤` + label + count + - * FX. Its automation lanes are always drawn, so there is no disclosure for - * them. + * FX + `∿ n` (lane disclosure). */ /** @@ -80,6 +83,9 @@ export function TimelineGroupHeader({ memberCount, isExpanded, onToggleExpanded, + laneCount, + isLaneOpen, + onToggleLanes, fxChain, onFxChainChange, onFxChainPreview, @@ -135,6 +141,33 @@ export function TimelineGroupHeader({ auditionSpans={auditionSpans} onOpenRack={onOpenFxRack} /> + {/* No lanes, no control: an author who opens it meets an empty row and + learns nothing. A track header already gates its own `∿` this way + (`disclosable`); the group's was the one that still offered a + disclosure over nothing. Automation appears by being written — from + the rack or a keyframe — not by opening this, so nothing is + unreachable while it is hidden. */} + {laneCount > 0 && ( + + )} ); diff --git a/packages/studio/src/player/components/TimelineGroupRow.test.tsx b/packages/studio/src/player/components/TimelineGroupRow.test.tsx index cea7b2080..0b89493a7 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.test.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.test.tsx @@ -55,7 +55,9 @@ function renderRow(overrides: Partial = {}) { contentOrigin={232} theme={defaultTimelineTheme} collapsedGroupIds={new Set()} + expandedLaneOwnerIds={new Set()} toggleGroupExpanded={vi.fn()} + toggleLaneOwnerExpanded={vi.fn()} lanes={{ bind: () => ({ lanes: [] }) } as never} pps={10} currentTime={0} @@ -92,12 +94,18 @@ describe("TimelineGroupRow", () => { expect(onSetElementAttributeQuiet).not.toHaveBeenCalled(); }); - // Automation lanes are always drawn, so there is no toggle to offer: the - // group's row shows its envelopes the way it shows its name. (This replaced a - // rule that hid the toggle when the count was zero — the toggle itself is - // gone now.) - it("renders no lane disclosure on the group header", () => { - const { host } = renderRow({ + // A disclosure over nothing tells the author their group has no automation + // only AFTER they open an empty row. Track headers already gate their own + // toggle on having something to disclose; the group's did not. + it("hides the lane toggle until the group actually automates something", () => { + const laneToggle = (host: HTMLElement) => + Array.from(host.querySelectorAll("button")).find((b) => + /lanes$/.test(b.getAttribute("aria-label") ?? ""), + ); + + expect(laneToggle(renderRow().host)).toBeUndefined(); + + const automated = renderRow({ fxChain: JSON.stringify({ version: 1, nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }], @@ -107,9 +115,6 @@ describe("TimelineGroupRow", () => { lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }], }), }); - const laneToggle = Array.from(host.querySelectorAll("button")).find((b) => - /lanes$/.test(b.getAttribute("aria-label") ?? ""), - ); - expect(laneToggle).toBeUndefined(); + expect(laneToggle(automated.host)).toBeDefined(); }); }); diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 586a9db33..1d27189da 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -8,6 +8,7 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { TimelineGroupHeader } from "./TimelineGroupHeader"; +import { groupAutomationLanes } from "./automationLaneData"; import { groupAutomationElement } from "./groupAutomationElement"; import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels"; @@ -34,7 +35,9 @@ interface TimelineGroupRowProps { theme: TimelineTheme; rovingTargetId?: string | null; collapsedGroupIds: ReadonlySet; + expandedLaneOwnerIds: ReadonlySet; toggleGroupExpanded: (id: string) => void; + toggleLaneOwnerExpanded: (id: string) => void; lanes: UseAutomationLanesResult; pps: number; currentTime: number; @@ -58,7 +61,9 @@ export function TimelineGroupRow({ theme, rovingTargetId = null, collapsedGroupIds, + expandedLaneOwnerIds, toggleGroupExpanded, + toggleLaneOwnerExpanded, lanes, pps, currentTime, @@ -80,6 +85,7 @@ export function TimelineGroupRow({ // its name in the header does. const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null; const isGroupSelected = domSelection?.id === group.id; + const isLaneOpen = expandedLaneOwnerIds.has(group.id); // Optional, like every sibling row: Timeline renders outside the edit // provider in read-only hosts (Timeline.test.ts asserts it), and the throwing // hook took the whole timeline down with it the moment a group existed — @@ -121,6 +127,13 @@ export function TimelineGroupRow({ memberCount={group.memberTracks.length} isExpanded={!collapsedGroupIds.has(group.id)} onToggleExpanded={() => 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)} @@ -134,44 +147,47 @@ export function TimelineGroupRow({ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} theme={theme} /> - {/* The group's OWN curves, always drawn — there is no disclosure for - them. 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 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. */} - = LABEL_COL_W ? LABEL_COL_W : contentOrigin} - gutterBackground={theme.gutterBackground} - accentColor={GROUP_LANE_ACCENT} - /> - {/* The same offset content cell a track row wraps its lanes in — the slot - positions absolutely, so mounted straight on the row it resolved - against the row instead and drew the envelope across the label gutter - from x=0. */} -
- isGroupSelected} - lanes={lanes} - pps={pps} - // Directly under the header row. - laneCount={0} - topOffset={TRACK_H} + {isLaneOpen && ( + = LABEL_COL_W ? LABEL_COL_W : contentOrigin} + gutterBackground={theme.gutterBackground} accentColor={GROUP_LANE_ACCENT} - currentTime={currentTime} - beatTimes={beatTimes} /> -
+ )} + {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 + // against the row instead and drew the envelope across the label gutter + // from x=0. +
+ isGroupSelected} + lanes={lanes} + pps={pps} + // Below the strip, which sits directly under the header row. + laneCount={0} + topOffset={TRACK_H} + accentColor={GROUP_LANE_ACCENT} + currentTime={currentTime} + beatTimes={beatTimes} + /> +
+ )} ); } diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 3e113bc92..70a07b531 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -119,6 +119,7 @@ function renderLanes(options: RenderLanesOptions = {}): { selectedElementIds: next.selectedElementIds ?? new Set(), expandedClipIds: new Set(next.expandedClipIds ?? []), collapsedGroupIds: new Set(), + expandedLaneOwnerIds: new Set(), groups: [], trackGroupOf: new Map(), gsapAnimations, diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index c10395c8e..6e1cc2895 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -11,7 +11,6 @@ import { TimelineGroupRow } from "./TimelineGroupRow"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; import { isTrackRowExpanded, - trackAutomationLaneCount, resolveTrackKeyframeClip, trackShowsBeatStrip, } from "./useTimelineTrackLayout"; @@ -101,7 +100,8 @@ export function TimelineLanes({ // from resolving into a second timeline that renders the same logical rows. const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); - const { collapsedGroupIds, toggleGroupExpanded } = useTimelineGroupDisclosure(); + const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } = + useTimelineGroupDisclosure(); const automationLanes = useAutomationLanes(); // A group's automation clock is COMPOSITION time (groups doc §1.3), so its // synthetic lane element spans the whole composition rather than a clip. @@ -179,7 +179,9 @@ export function TimelineLanes({ theme={theme} rovingTargetId={keyboard.rovingTargetId} collapsedGroupIds={collapsedGroupIds} + expandedLaneOwnerIds={expandedLaneOwnerIds} toggleGroupExpanded={toggleGroupExpanded} + toggleLaneOwnerExpanded={toggleLaneOwnerExpanded} lanes={automationLanes} pps={pps} currentTime={currentTime} @@ -228,13 +230,12 @@ export function TimelineLanes({ ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; const rowExpanded = isTrackRowExpanded(els, expandedClipIds); - // How tall a clip BAR is drawn. A row with lanes under it is mostly - // lanes, and a clip left to fill it painted its waveform over them — - // so the bar is capped for every clip on the row. Undefined means - // "fill the row", right only when the row is nothing BUT bar, which a - // collapsed row no longer is: automation lanes are always drawn. - const hasAutomationRows = trackAutomationLaneCount(els) > 0; - const clipBarHeight = rowExpanded || hasAutomationRows ? TRACK_H - 2 * CLIP_Y : undefined; + // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a + // clip left to fill it painted its waveform straight over them — so the + // bar is capped for every clip on the row, not just the one whose + // property lanes are showing. Undefined means "fill the row", which is + // right only while it is collapsed and the row is nothing but bar. + const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined; // The clips whose envelopes this row draws, at their dragged positions. // Once per row, not once per clip in the map below. const automationElements = els.map(getPreviewElement); @@ -577,32 +578,27 @@ export function TimelineLanes({ any gesture mid-flight), so pressing a lane to select its clip made the handles you were reaching for disappear. - Always drawn, in both caret states: an envelope is the - track's own content, and gating it on the keyframe caret hid - audio automation behind a control about tweens. The row - reserves height to match (see `trackHeights`). Absolute - positions resolve against this same relative row, so the - geometry is unchanged by the move. */} + Mounted in BOTH disclosure states, empty while collapsed, so + the caret's aria-controls resolves either way — same reason + the keyframe lanes are. Absolute positions inside resolve + against this same relative row, so the geometry is unchanged + by the move. */}
- { - const key = getTimelineElementIdentity(element); - return selectedElementId === key || selectedElementIds.has(key); - }} - lanes={automationLanes} - pps={pps} - // Automation stacks UNDER the keyframe lanes, so the offset - // is how many of those are drawn — none while collapsed. - // Passing the count regardless left the lanes below an - // empty gap, past the row's bottom. - laneCount={ - rowExpanded && keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0 - } - accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent} - currentTime={currentTime} - beatTimes={beatAnalysis?.beatTimes} - /> + {rowExpanded ? ( + { + const key = getTimelineElementIdentity(element); + return selectedElementId === key || selectedElementIds.has(key); + }} + lanes={automationLanes} + pps={pps} + laneCount={keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0} + accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent} + currentTime={currentTime} + beatTimes={beatAnalysis?.beatTimes} + /> + ) : null}
diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index d3306910f..351bfb564 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -80,6 +80,7 @@ export interface BuildTimelineLogicalRowsInput { /** Groups the caret has COLLAPSED — absent means expanded, the default. */ collapsedGroupIds: ReadonlySet; /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ + expandedLaneOwnerIds: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; gsapAnimations: ReadonlyMap; @@ -206,6 +207,16 @@ function propertyItems( return items; } +/** A clip's lanes are visible when either the caret or the `∿` button opened it. */ +function isRowOpen( + activeId: string | null, + expandedClipIds: ReadonlySet, + expandedLaneOwnerIds: ReadonlySet, +): boolean { + if (activeId === null) return false; + return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId); +} + /** A single automation-lane row, one level deeper than the track/group row that owns it. */ function buildLaneRow( track: number, @@ -241,6 +252,7 @@ export function buildTimelineLogicalRows({ selectedElementIds, expandedClipIds, collapsedGroupIds, + expandedLaneOwnerIds, groups, trackGroupOf, gsapAnimations, @@ -261,7 +273,7 @@ export function buildTimelineLogicalRows({ selectedElementIds, gsapAnimations, ); - const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0; + const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0; rows.push({ id: trackId, kind: "row", @@ -302,10 +314,10 @@ export function buildTimelineLogicalRows({ expanded: groupExpanded, items: [], }); - { - // The group's own member list, not `trackMap`: a COLLAPSED group still - // draws its lanes, and its members are absent from the display list — so - // looking them up there emitted zero lane rows for exactly that case. + if (expandedLaneOwnerIds.has(group.id)) { + // The group's own member list, not `trackMap`: a COLLAPSED group can have + // its lane shelf open, and its members are absent from the display list — + // so looking them up there emitted zero lane rows for exactly that case. for (const laneGroup of groupAutomationLanes(group.memberElements)) { rows.push({ id: `${groupRowId}::${laneGroup.key}`, diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 0fd2b9f10..74d201feb 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -105,13 +105,9 @@ export function trackHeights( let laneCount = 0; let automationLanes = 0; for (const clip of clips) { - // Automation rows are ALWAYS drawn — an envelope is the track's own - // content, not a detail the keyframe caret discloses — so their height is - // reserved whether or not the row is expanded. Reserving it only when - // expanded clipped every lane on a collapsed row. - automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0); if (!expandedClipIds?.has(clip.clipId)) continue; laneCount = Math.max(laneCount, clip.laneCount); + automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0); } return ( TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H diff --git a/packages/studio/src/player/components/useTimelineLaneRowIndexes.ts b/packages/studio/src/player/components/useTimelineLaneRowIndexes.ts index 793421ff1..7ef978c5c 100644 --- a/packages/studio/src/player/components/useTimelineLaneRowIndexes.ts +++ b/packages/studio/src/player/components/useTimelineLaneRowIndexes.ts @@ -3,12 +3,13 @@ import { usePlayerStore } from "../store/playerStore"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; -/** The group-disclosure state a group row's header reads and writes. Member - * rows only: automation lanes are always drawn, so they have no disclosure. */ +/** The four pieces of group-disclosure state a group row's header reads and writes. */ export function useTimelineGroupDisclosure() { return { collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds), + expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds), toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded), + toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded), }; } diff --git a/packages/studio/src/player/components/useTimelineLogicalFocus.ts b/packages/studio/src/player/components/useTimelineLogicalFocus.ts index 4f3d7e711..8f6fe0e09 100644 --- a/packages/studio/src/player/components/useTimelineLogicalFocus.ts +++ b/packages/studio/src/player/components/useTimelineLogicalFocus.ts @@ -36,6 +36,7 @@ interface TimelineLogicalFocusInput { export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { const expandedClipIds = usePlayerStore((state) => state.expandedClipIds); const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds); + const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds); const projectId = usePlayerStore((state) => state.timelineProjectId); const logicalRows = useTimelineLogicalRows({ tracks: input.tracks, @@ -45,6 +46,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { selectedElementIds: input.selectedElementIds, expandedClipIds, collapsedGroupIds, + expandedLaneOwnerIds, groups: input.groups, trackGroupOf: input.trackGroupOf, gsapAnimations: input.gsapAnimations, diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx index a955c1c04..1ba44d32e 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -23,6 +23,7 @@ const laneCounts = new Map(); const selectedElementIds = new Set(); const expandedClipIds = new Set(); const collapsedGroupIds = new Set(); +const expandedLaneOwnerIds = new Set(); const groups: never[] = []; const trackGroupOf = new Map(); const gsapAnimations = new Map(); @@ -37,6 +38,7 @@ function Harness({ snapshots }: { snapshots: Array, ): number[] { if (groups.length === 0) return rowHeights; const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); return tracks.map(([track], index) => { const group = groupByAnchor.get(track); - if (!group) return rowHeights[index] ?? TRACK_H; + if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H; + // The group's own automation rows, which its `∿` discloses. A row sized + // without them clipped every lane it had just promised in the count. return TRACK_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H; }); } @@ -162,6 +165,7 @@ function useTimelineRowHeights( groups: readonly TimelineTrackGroupInfo[], ) { const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); + const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds); const { laneCounts, rowGeometry } = useMemo(() => { const laneCounts = computeLaneCounts(tracks, gsapAnimations); // Keyframe lanes follow only the active clip, so a track with several @@ -195,6 +199,7 @@ function useTimelineRowHeights( tracks, trackHeights(heightTracks, expandedClipIds), groups, + expandedLaneOwnerIds, ); return { laneCounts, @@ -203,7 +208,15 @@ function useTimelineRowHeights( rowHeights, ), }; - }, [expandedClipIds, gsapAnimations, groups, tracks, selectedElementId, selectedElementIds]); + }, [ + expandedClipIds, + expandedLaneOwnerIds, + gsapAnimations, + groups, + tracks, + selectedElementId, + selectedElementIds, + ]); const rowGeometryRef = useRef(rowGeometry); rowGeometryRef.current = rowGeometry; return { diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index d0417257e..d18bd31b4 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -75,6 +75,10 @@ export interface KeyframeSlice { collapsedGroupIds: Set; toggleGroupExpanded: (id: string) => void; + /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ + expandedLaneOwnerIds: Set; + toggleLaneOwnerExpanded: (id: string) => void; + /** * Project/session/element-scoped request. Its nonce is monotonic across store * resets so a stale consumer can never collide with a later request. @@ -140,6 +144,15 @@ export function createKeyframeSlice( return { collapsedGroupIds: next }; }), + expandedLaneOwnerIds: new Set(), + toggleLaneOwnerExpanded: (id) => + set((state) => { + const next = new Set(state.expandedLaneOwnerIds); + if (next.has(id)) next.delete(id); + else next.add(id); + return { expandedLaneOwnerIds: next }; + }), + focusedEaseSegment: null, focusedEaseRequestNonce: 0, setFocusedEaseSegment: (target) => diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index c6a06ee05..3cae18b6d 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -277,6 +277,7 @@ export function createTimelineResetState() { expandedClipIds: new Set(), // Per-composition: ids from comp A match nothing in B, silencing all of it. collapsedGroupIds: new Set(), + expandedLaneOwnerIds: new Set(), focusedEaseSegment: null, selectedElementIds: new Set(), requestedSeekTime: null,