Revert "feat(studio): draw automation lanes always, and drop the disclosure for them"

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) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:19 -07:00
co-authored by Claude Opus 5
parent a014e00a48
commit dc7187bf4a
15 changed files with 192 additions and 147 deletions
@@ -653,14 +653,8 @@ describe("Timeline provider boundary", () => {
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]'); const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]');
expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 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()); act(() => caret()?.click());
// BOTH clips hold the caret open; height is unchanged, since the clips carry // One shared volume row, and BOTH clips hold it open.
// automation and no keyframe lanes.
expectTrackExpansion(row, ["narration-1", "narration-2"], TRACK_H + AUTOMATION_LANE_H); 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 // 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`]); ).toEqual([`${TRACK_H - 2 * CLIP_Y}px`, `${TRACK_H - 2 * CLIP_Y}px`]);
act(() => caret()?.click()); act(() => caret()?.click());
// Collapsed again — and the automation row stays, with its height still expectTrackExpansion(row, [], TRACK_H);
// reserved. Only keyframe lanes come and go with the caret.
expectTrackExpansion(row, [], TRACK_H + AUTOMATION_LANE_H);
act(() => root.unmount()); 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 // 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 // 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. // 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<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
?.parentElement;
expect(row?.style.height).toBe(`${TRACK_H + AUTOMATION_LANE_H}px`);
expect(host.querySelector<HTMLElement>('[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", () => { it("keeps the automation lanes mounted when the selection moves along the row", () => {
const host = createSizedTimelineHost(720); const host = createSizedTimelineHost(720);
const automation = JSON.stringify({ const automation = JSON.stringify({
@@ -10,6 +10,10 @@ interface TimelineGroupHeaderProps {
/** Caret: shows/hides the member rows beneath this group (structural). */ /** Caret: shows/hides the member rows beneath this group (structural). */
isExpanded: boolean; isExpanded: boolean;
onToggleExpanded: () => void; 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. */ /** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
/** C1: the group's serialized `data-fx-chain`, when set. */ /** C1: the group's serialized `data-fx-chain`, when set. */
fxChain?: string; fxChain?: string;
@@ -24,8 +28,7 @@ interface TimelineGroupHeaderProps {
/** /**
* A group's own row header: caret (member disclosure) + `▤` + label + count + * A group's own row header: caret (member disclosure) + `▤` + label + count +
* FX. Its automation lanes are always drawn, so there is no disclosure for * FX + `∿ n` (lane disclosure).
* them.
*/ */
/** /**
@@ -80,6 +83,9 @@ export function TimelineGroupHeader({
memberCount, memberCount,
isExpanded, isExpanded,
onToggleExpanded, onToggleExpanded,
laneCount,
isLaneOpen,
onToggleLanes,
fxChain, fxChain,
onFxChainChange, onFxChainChange,
onFxChainPreview, onFxChainPreview,
@@ -135,6 +141,33 @@ export function TimelineGroupHeader({
auditionSpans={auditionSpans} auditionSpans={auditionSpans}
onOpenRack={onOpenFxRack} 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 && (
<button
type="button"
tabIndex={-1}
aria-expanded={isLaneOpen}
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
// Anchored right, matching every other header's lane toggle.
className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleLanes();
}}
>
<span aria-hidden="true"></span>
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
</button>
)}
</div> </div>
</div> </div>
); );
@@ -55,7 +55,9 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
contentOrigin={232} contentOrigin={232}
theme={defaultTimelineTheme} theme={defaultTimelineTheme}
collapsedGroupIds={new Set()} collapsedGroupIds={new Set()}
expandedLaneOwnerIds={new Set()}
toggleGroupExpanded={vi.fn()} toggleGroupExpanded={vi.fn()}
toggleLaneOwnerExpanded={vi.fn()}
lanes={{ bind: () => ({ lanes: [] }) } as never} lanes={{ bind: () => ({ lanes: [] }) } as never}
pps={10} pps={10}
currentTime={0} currentTime={0}
@@ -92,12 +94,18 @@ describe("TimelineGroupRow", () => {
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled(); expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
}); });
// Automation lanes are always drawn, so there is no toggle to offer: the // A disclosure over nothing tells the author their group has no automation
// group's row shows its envelopes the way it shows its name. (This replaced a // only AFTER they open an empty row. Track headers already gate their own
// rule that hid the toggle when the count was zero — the toggle itself is // toggle on having something to disclose; the group's did not.
// gone now.) it("hides the lane toggle until the group actually automates something", () => {
it("renders no lane disclosure on the group header", () => { const laneToggle = (host: HTMLElement) =>
const { host } = renderRow({ Array.from(host.querySelectorAll("button")).find((b) =>
/lanes$/.test(b.getAttribute("aria-label") ?? ""),
);
expect(laneToggle(renderRow().host)).toBeUndefined();
const automated = renderRow({
fxChain: JSON.stringify({ fxChain: JSON.stringify({
version: 1, version: 1,
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 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 }] }], lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
}), }),
}); });
const laneToggle = Array.from(host.querySelectorAll("button")).find((b) => expect(laneToggle(automated.host)).toBeDefined();
/lanes$/.test(b.getAttribute("aria-label") ?? ""),
);
expect(laneToggle).toBeUndefined();
}); });
}); });
@@ -8,6 +8,7 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { TimelineTrackRow } from "./TimelineTrackRow"; import { TimelineTrackRow } from "./TimelineTrackRow";
import { TimelineGroupHeader } from "./TimelineGroupHeader"; import { TimelineGroupHeader } from "./TimelineGroupHeader";
import { groupAutomationLanes } from "./automationLaneData";
import { groupAutomationElement } from "./groupAutomationElement"; import { groupAutomationElement } from "./groupAutomationElement";
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels"; import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels";
@@ -34,7 +35,9 @@ interface TimelineGroupRowProps {
theme: TimelineTheme; theme: TimelineTheme;
rovingTargetId?: string | null; rovingTargetId?: string | null;
collapsedGroupIds: ReadonlySet<string>; collapsedGroupIds: ReadonlySet<string>;
expandedLaneOwnerIds: ReadonlySet<string>;
toggleGroupExpanded: (id: string) => void; toggleGroupExpanded: (id: string) => void;
toggleLaneOwnerExpanded: (id: string) => void;
lanes: UseAutomationLanesResult; lanes: UseAutomationLanesResult;
pps: number; pps: number;
currentTime: number; currentTime: number;
@@ -58,7 +61,9 @@ export function TimelineGroupRow({
theme, theme,
rovingTargetId = null, rovingTargetId = null,
collapsedGroupIds, collapsedGroupIds,
expandedLaneOwnerIds,
toggleGroupExpanded, toggleGroupExpanded,
toggleLaneOwnerExpanded,
lanes, lanes,
pps, pps,
currentTime, currentTime,
@@ -80,6 +85,7 @@ export function TimelineGroupRow({
// its name in the header does. // its name in the header does.
const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null; const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
const isGroupSelected = domSelection?.id === group.id; const isGroupSelected = domSelection?.id === group.id;
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
// Optional, like every sibling row: Timeline renders outside the edit // Optional, like every sibling row: Timeline renders outside the edit
// provider in read-only hosts (Timeline.test.ts asserts it), and the throwing // 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 — // hook took the whole timeline down with it the moment a group existed —
@@ -121,6 +127,13 @@ export function TimelineGroupRow({
memberCount={group.memberTracks.length} memberCount={group.memberTracks.length}
isExpanded={!collapsedGroupIds.has(group.id)} isExpanded={!collapsedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(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} fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)} onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)} onFxChainPreview={(next) => writeGroupFxChain(next, true)}
@@ -134,44 +147,47 @@ export function TimelineGroupRow({
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme} theme={theme}
/> />
{/* The group's OWN curves, always drawn — there is no disclosure for {/* The group's OWN curves, under the strip. Selected-gated exactly like a
them. Selected-gated exactly like a clip's: the binder writes through clip's: the binder writes through the dom-edit selection, so a lane is
the dom-edit selection, so a lane is editable once the group is editable once the group is selected — which clicking its name does. */}
selected, which clicking its name does. */}
{/* The label column for those lanes, on the accent rail. Outside the {/* The label column for those lanes, on the accent rail. Outside the
offset content cell below, because the labels belong to the sticky offset content cell below, because the labels belong to the sticky
gutter the row header occupies, not to the scrolling canvas. */} gutter the row header occupies, not to the scrolling canvas. */}
<TimelineGroupLaneLabels {isLaneOpen && (
groupElement={groupElement} <TimelineGroupLaneLabels
groupLabel={group.label} groupElement={groupElement}
top={TRACK_H} groupLabel={group.label}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} top={TRACK_H}
gutterBackground={theme.gutterBackground} columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
accentColor={GROUP_LANE_ACCENT} gutterBackground={theme.gutterBackground}
/>
{/* 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. */}
<div
role="gridcell"
aria-colindex={2}
style={{ width: trackContentWidth, marginLeft: contentGutter }}
className="relative"
>
<TimelineAutomationLaneSlot
elements={[groupElement]}
isSelected={() => isGroupSelected}
lanes={lanes}
pps={pps}
// Directly under the header row.
laneCount={0}
topOffset={TRACK_H}
accentColor={GROUP_LANE_ACCENT} accentColor={GROUP_LANE_ACCENT}
currentTime={currentTime}
beatTimes={beatTimes}
/> />
</div> )}
{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.
<div
role="gridcell"
aria-colindex={2}
style={{ width: trackContentWidth, marginLeft: contentGutter }}
className="relative"
>
<TimelineAutomationLaneSlot
elements={[groupElement]}
isSelected={() => 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}
/>
</div>
)}
</TimelineTrackRow> </TimelineTrackRow>
); );
} }
@@ -119,6 +119,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
selectedElementIds: next.selectedElementIds ?? new Set(), selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []), expandedClipIds: new Set(next.expandedClipIds ?? []),
collapsedGroupIds: new Set(), collapsedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [], groups: [],
trackGroupOf: new Map(), trackGroupOf: new Map(),
gsapAnimations, gsapAnimations,
@@ -11,7 +11,6 @@ import { TimelineGroupRow } from "./TimelineGroupRow";
import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
import { import {
isTrackRowExpanded, isTrackRowExpanded,
trackAutomationLaneCount,
resolveTrackKeyframeClip, resolveTrackKeyframeClip,
trackShowsBeatStrip, trackShowsBeatStrip,
} from "./useTimelineTrackLayout"; } from "./useTimelineTrackLayout";
@@ -101,7 +100,8 @@ export function TimelineLanes({
// from resolving into a second timeline that renders the same logical rows. // from resolving into a second timeline that renders the same logical rows.
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const { collapsedGroupIds, toggleGroupExpanded } = useTimelineGroupDisclosure(); const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
useTimelineGroupDisclosure();
const automationLanes = useAutomationLanes(); const automationLanes = useAutomationLanes();
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its // 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. // synthetic lane element spans the whole composition rather than a clip.
@@ -179,7 +179,9 @@ export function TimelineLanes({
theme={theme} theme={theme}
rovingTargetId={keyboard.rovingTargetId} rovingTargetId={keyboard.rovingTargetId}
collapsedGroupIds={collapsedGroupIds} collapsedGroupIds={collapsedGroupIds}
expandedLaneOwnerIds={expandedLaneOwnerIds}
toggleGroupExpanded={toggleGroupExpanded} toggleGroupExpanded={toggleGroupExpanded}
toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
lanes={automationLanes} lanes={automationLanes}
pps={pps} pps={pps}
currentTime={currentTime} currentTime={currentTime}
@@ -228,13 +230,12 @@ export function TimelineLanes({
); );
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
const rowExpanded = isTrackRowExpanded(els, expandedClipIds); const rowExpanded = isTrackRowExpanded(els, expandedClipIds);
// How tall a clip BAR is drawn. A row with lanes under it is mostly // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a
// lanes, and a clip left to fill it painted its waveform over them — // clip left to fill it painted its waveform straight over them — so the
// so the bar is capped for every clip on the row. Undefined means // bar is capped for every clip on the row, not just the one whose
// "fill the row", right only when the row is nothing BUT bar, which a // property lanes are showing. Undefined means "fill the row", which is
// collapsed row no longer is: automation lanes are always drawn. // right only while it is collapsed and the row is nothing but bar.
const hasAutomationRows = trackAutomationLaneCount(els) > 0; const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
const clipBarHeight = rowExpanded || hasAutomationRows ? TRACK_H - 2 * CLIP_Y : undefined;
// The clips whose envelopes this row draws, at their dragged positions. // The clips whose envelopes this row draws, at their dragged positions.
// Once per row, not once per clip in the map below. // Once per row, not once per clip in the map below.
const automationElements = els.map(getPreviewElement); const automationElements = els.map(getPreviewElement);
@@ -577,32 +578,27 @@ export function TimelineLanes({
any gesture mid-flight), so pressing a lane to select its any gesture mid-flight), so pressing a lane to select its
clip made the handles you were reaching for disappear. clip made the handles you were reaching for disappear.
Always drawn, in both caret states: an envelope is the Mounted in BOTH disclosure states, empty while collapsed, so
track's own content, and gating it on the keyframe caret hid the caret's aria-controls resolves either way same reason
audio automation behind a control about tweens. The row the keyframe lanes are. Absolute positions inside resolve
reserves height to match (see `trackHeights`). Absolute against this same relative row, so the geometry is unchanged
positions resolve against this same relative row, so the by the move. */}
geometry is unchanged by the move. */}
<div id={automationLanesId}> <div id={automationLanesId}>
<TimelineAutomationLaneSlot {rowExpanded ? (
elements={automationElements} <TimelineAutomationLaneSlot
isSelected={(element) => { elements={automationElements}
const key = getTimelineElementIdentity(element); isSelected={(element) => {
return selectedElementId === key || selectedElementIds.has(key); const key = getTimelineElementIdentity(element);
}} return selectedElementId === key || selectedElementIds.has(key);
lanes={automationLanes} }}
pps={pps} lanes={automationLanes}
// Automation stacks UNDER the keyframe lanes, so the offset pps={pps}
// is how many of those are drawn — none while collapsed. laneCount={keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0}
// Passing the count regardless left the lanes below an accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent}
// empty gap, past the row's bottom. currentTime={currentTime}
laneCount={ beatTimes={beatAnalysis?.beatTimes}
rowExpanded && keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0 />
} ) : null}
accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent}
currentTime={currentTime}
beatTimes={beatAnalysis?.beatTimes}
/>
</div> </div>
</div> </div>
</TimelineTrackRow> </TimelineTrackRow>
@@ -80,6 +80,7 @@ export interface BuildTimelineLogicalRowsInput {
/** Groups the caret has COLLAPSED — absent means expanded, the default. */ /** Groups the caret has COLLAPSED — absent means expanded, the default. */
collapsedGroupIds: ReadonlySet<string>; collapsedGroupIds: ReadonlySet<string>;
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
expandedLaneOwnerIds: ReadonlySet<string>;
groups: readonly TimelineTrackGroupInfo[]; groups: readonly TimelineTrackGroupInfo[];
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>; trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>; gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
@@ -206,6 +207,16 @@ function propertyItems(
return items; return items;
} }
/** A clip's lanes are visible when either the caret or the `∿` button opened it. */
function isRowOpen(
activeId: string | null,
expandedClipIds: ReadonlySet<string>,
expandedLaneOwnerIds: ReadonlySet<string>,
): 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. */ /** A single automation-lane row, one level deeper than the track/group row that owns it. */
function buildLaneRow( function buildLaneRow(
track: number, track: number,
@@ -241,6 +252,7 @@ export function buildTimelineLogicalRows({
selectedElementIds, selectedElementIds,
expandedClipIds, expandedClipIds,
collapsedGroupIds, collapsedGroupIds,
expandedLaneOwnerIds,
groups, groups,
trackGroupOf, trackGroupOf,
gsapAnimations, gsapAnimations,
@@ -261,7 +273,7 @@ export function buildTimelineLogicalRows({
selectedElementIds, selectedElementIds,
gsapAnimations, gsapAnimations,
); );
const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0; const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
rows.push({ rows.push({
id: trackId, id: trackId,
kind: "row", kind: "row",
@@ -302,10 +314,10 @@ export function buildTimelineLogicalRows({
expanded: groupExpanded, expanded: groupExpanded,
items: [], items: [],
}); });
{ if (expandedLaneOwnerIds.has(group.id)) {
// The group's own member list, not `trackMap`: a COLLAPSED group still // The group's own member list, not `trackMap`: a COLLAPSED group can have
// draws its lanes, and its members are absent from the display list — so // its lane shelf open, and its members are absent from the display list —
// looking them up there emitted zero lane rows for exactly that case. // so looking them up there emitted zero lane rows for exactly that case.
for (const laneGroup of groupAutomationLanes(group.memberElements)) { for (const laneGroup of groupAutomationLanes(group.memberElements)) {
rows.push({ rows.push({
id: `${groupRowId}::${laneGroup.key}`, id: `${groupRowId}::${laneGroup.key}`,
@@ -105,13 +105,9 @@ export function trackHeights(
let laneCount = 0; let laneCount = 0;
let automationLanes = 0; let automationLanes = 0;
for (const clip of clips) { 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; if (!expandedClipIds?.has(clip.clipId)) continue;
laneCount = Math.max(laneCount, clip.laneCount); laneCount = Math.max(laneCount, clip.laneCount);
automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0);
} }
return ( return (
TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H
@@ -3,12 +3,13 @@ import { usePlayerStore } from "../store/playerStore";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
/** The group-disclosure state a group row's header reads and writes. Member /** The four pieces of group-disclosure state a group row's header reads and writes. */
* rows only: automation lanes are always drawn, so they have no disclosure. */
export function useTimelineGroupDisclosure() { export function useTimelineGroupDisclosure() {
return { return {
collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds), collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds),
expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded), toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
}; };
} }
@@ -36,6 +36,7 @@ interface TimelineLogicalFocusInput {
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds); const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds); const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds);
const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
const projectId = usePlayerStore((state) => state.timelineProjectId); const projectId = usePlayerStore((state) => state.timelineProjectId);
const logicalRows = useTimelineLogicalRows({ const logicalRows = useTimelineLogicalRows({
tracks: input.tracks, tracks: input.tracks,
@@ -45,6 +46,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
selectedElementIds: input.selectedElementIds, selectedElementIds: input.selectedElementIds,
expandedClipIds, expandedClipIds,
collapsedGroupIds, collapsedGroupIds,
expandedLaneOwnerIds,
groups: input.groups, groups: input.groups,
trackGroupOf: input.trackGroupOf, trackGroupOf: input.trackGroupOf,
gsapAnimations: input.gsapAnimations, gsapAnimations: input.gsapAnimations,
@@ -23,6 +23,7 @@ const laneCounts = new Map<string, number>();
const selectedElementIds = new Set<string>(); const selectedElementIds = new Set<string>();
const expandedClipIds = new Set<string>(); const expandedClipIds = new Set<string>();
const collapsedGroupIds = new Set<string>(); const collapsedGroupIds = new Set<string>();
const expandedLaneOwnerIds = new Set<string>();
const groups: never[] = []; const groups: never[] = [];
const trackGroupOf = new Map(); const trackGroupOf = new Map();
const gsapAnimations = new Map(); const gsapAnimations = new Map();
@@ -37,6 +38,7 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
selectedElementIds, selectedElementIds,
expandedClipIds, expandedClipIds,
collapsedGroupIds, collapsedGroupIds,
expandedLaneOwnerIds,
groups, groups,
trackGroupOf, trackGroupOf,
gsapAnimations, gsapAnimations,
@@ -15,6 +15,7 @@ export function useTimelineLogicalRows({
selectedElementIds, selectedElementIds,
expandedClipIds, expandedClipIds,
collapsedGroupIds, collapsedGroupIds,
expandedLaneOwnerIds,
groups, groups,
trackGroupOf, trackGroupOf,
gsapAnimations, gsapAnimations,
@@ -29,6 +30,7 @@ export function useTimelineLogicalRows({
selectedElementIds, selectedElementIds,
expandedClipIds, expandedClipIds,
collapsedGroupIds, collapsedGroupIds,
expandedLaneOwnerIds,
groups, groups,
trackGroupOf, trackGroupOf,
gsapAnimations, gsapAnimations,
@@ -37,6 +39,7 @@ export function useTimelineLogicalRows({
displayTrackOrder, displayTrackOrder,
expandedClipIds, expandedClipIds,
collapsedGroupIds, collapsedGroupIds,
expandedLaneOwnerIds,
groups, groups,
trackGroupOf, trackGroupOf,
gsapAnimations, gsapAnimations,
@@ -59,7 +59,7 @@ function automationLaneCountOf(element: TimelineElement): number {
* clips on one row share a lane row per property. Counting only the active clip's * clips on one row share a lane row per property. Counting only the active clip's
* lanes reserved a height that changed with the selection. * lanes reserved a height that changed with the selection.
*/ */
export function trackAutomationLaneCount(elements: readonly TimelineElement[]): number { function trackAutomationLaneCount(elements: readonly TimelineElement[]): number {
return groupAutomationLanes(elements).length; return groupAutomationLanes(elements).length;
} }
@@ -138,18 +138,21 @@ function computeLaneCounts(
/** Group anchor rows have no elements of their own (`groupTimelineTracks` /** Group anchor rows have no elements of their own (`groupTimelineTracks`
* pushes them as `[anchorKey, []]`), so `trackHeights` which only ever * pushes them as `[anchorKey, []]`), so `trackHeights` which only ever
* looks at a row's clips always gives them TRACK_H. Override those * looks at a row's clips always gives them TRACK_H. Override those
* specific rows post-hoc: TRACK_H plus the group's own automation rows, * specific rows post-hoc: TRACK_H while collapsed, plus the group's own
* which are always drawn. A row sized without them clips every lane. */ * automation rows once its `` is open. */
function applyGroupStripHeights( function applyGroupStripHeights(
tracks: readonly (readonly [number, readonly TimelineElement[]])[], tracks: readonly (readonly [number, readonly TimelineElement[]])[],
rowHeights: number[], rowHeights: number[],
groups: readonly TimelineTrackGroupInfo[], groups: readonly TimelineTrackGroupInfo[],
expandedLaneOwnerIds: ReadonlySet<string>,
): number[] { ): number[] {
if (groups.length === 0) return rowHeights; if (groups.length === 0) return rowHeights;
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
return tracks.map(([track], index) => { return tracks.map(([track], index) => {
const group = groupByAnchor.get(track); 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; return TRACK_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H;
}); });
} }
@@ -162,6 +165,7 @@ function useTimelineRowHeights(
groups: readonly TimelineTrackGroupInfo[], groups: readonly TimelineTrackGroupInfo[],
) { ) {
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
const { laneCounts, rowGeometry } = useMemo(() => { const { laneCounts, rowGeometry } = useMemo(() => {
const laneCounts = computeLaneCounts(tracks, gsapAnimations); const laneCounts = computeLaneCounts(tracks, gsapAnimations);
// Keyframe lanes follow only the active clip, so a track with several // Keyframe lanes follow only the active clip, so a track with several
@@ -195,6 +199,7 @@ function useTimelineRowHeights(
tracks, tracks,
trackHeights(heightTracks, expandedClipIds), trackHeights(heightTracks, expandedClipIds),
groups, groups,
expandedLaneOwnerIds,
); );
return { return {
laneCounts, laneCounts,
@@ -203,7 +208,15 @@ function useTimelineRowHeights(
rowHeights, rowHeights,
), ),
}; };
}, [expandedClipIds, gsapAnimations, groups, tracks, selectedElementId, selectedElementIds]); }, [
expandedClipIds,
expandedLaneOwnerIds,
gsapAnimations,
groups,
tracks,
selectedElementId,
selectedElementIds,
]);
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry); const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
rowGeometryRef.current = rowGeometry; rowGeometryRef.current = rowGeometry;
return { return {
@@ -75,6 +75,10 @@ export interface KeyframeSlice {
collapsedGroupIds: Set<string>; collapsedGroupIds: Set<string>;
toggleGroupExpanded: (id: string) => void; toggleGroupExpanded: (id: string) => void;
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
expandedLaneOwnerIds: Set<string>;
toggleLaneOwnerExpanded: (id: string) => void;
/** /**
* Project/session/element-scoped request. Its nonce is monotonic across store * Project/session/element-scoped request. Its nonce is monotonic across store
* resets so a stale consumer can never collide with a later request. * resets so a stale consumer can never collide with a later request.
@@ -140,6 +144,15 @@ export function createKeyframeSlice(
return { collapsedGroupIds: next }; 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, focusedEaseSegment: null,
focusedEaseRequestNonce: 0, focusedEaseRequestNonce: 0,
setFocusedEaseSegment: (target) => setFocusedEaseSegment: (target) =>
@@ -277,6 +277,7 @@ export function createTimelineResetState() {
expandedClipIds: new Set<string>(), expandedClipIds: new Set<string>(),
// Per-composition: ids from comp A match nothing in B, silencing all of it. // Per-composition: ids from comp A match nothing in B, silencing all of it.
collapsedGroupIds: new Set<string>(), collapsedGroupIds: new Set<string>(),
expandedLaneOwnerIds: new Set<string>(),
focusedEaseSegment: null, focusedEaseSegment: null,
selectedElementIds: new Set<string>(), selectedElementIds: new Set<string>(),
requestedSeekTime: null, requestedSeekTime: null,