mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
co-authored by
Claude Opus 5
parent
5f3423a944
commit
c5eacf4264
@@ -653,14 +653,8 @@ describe("Timeline provider boundary", () => {
|
||||
const caret = () => host.querySelector<HTMLButtonElement>('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<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", () => {
|
||||
const host = createSizedTimelineHost(720);
|
||||
const automation = JSON.stringify({
|
||||
|
||||
@@ -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 && (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,9 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>;
|
||||
expandedLaneOwnerIds: ReadonlySet<string>;
|
||||
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. */}
|
||||
<TimelineGroupLaneLabels
|
||||
groupElement={groupElement}
|
||||
groupLabel={group.label}
|
||||
top={TRACK_H}
|
||||
columnWidth={contentOrigin >= 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. */}
|
||||
<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}
|
||||
{isLaneOpen && (
|
||||
<TimelineGroupLaneLabels
|
||||
groupElement={groupElement}
|
||||
groupLabel={group.label}
|
||||
top={TRACK_H}
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
gutterBackground={theme.gutterBackground}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */}
|
||||
<div id={automationLanesId}>
|
||||
<TimelineAutomationLaneSlot
|
||||
elements={automationElements}
|
||||
isSelected={(element) => {
|
||||
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 ? (
|
||||
<TimelineAutomationLaneSlot
|
||||
elements={automationElements}
|
||||
isSelected={(element) => {
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
</TimelineTrackRow>
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface BuildTimelineLogicalRowsInput {
|
||||
/** Groups the caret has COLLAPSED — absent means expanded, the default. */
|
||||
collapsedGroupIds: ReadonlySet<string>;
|
||||
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
|
||||
expandedLaneOwnerIds: ReadonlySet<string>;
|
||||
groups: readonly TimelineTrackGroupInfo[];
|
||||
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
|
||||
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
||||
@@ -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<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. */
|
||||
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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ const laneCounts = new Map<string, number>();
|
||||
const selectedElementIds = new Set<string>();
|
||||
const expandedClipIds = new Set<string>();
|
||||
const collapsedGroupIds = new Set<string>();
|
||||
const expandedLaneOwnerIds = new Set<string>();
|
||||
const groups: never[] = [];
|
||||
const trackGroupOf = new Map();
|
||||
const gsapAnimations = new Map();
|
||||
@@ -37,6 +38,7 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
|
||||
selectedElementIds,
|
||||
expandedClipIds,
|
||||
collapsedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
|
||||
@@ -15,6 +15,7 @@ export function useTimelineLogicalRows({
|
||||
selectedElementIds,
|
||||
expandedClipIds,
|
||||
collapsedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
@@ -29,6 +30,7 @@ export function useTimelineLogicalRows({
|
||||
selectedElementIds,
|
||||
expandedClipIds,
|
||||
collapsedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
@@ -37,6 +39,7 @@ export function useTimelineLogicalRows({
|
||||
displayTrackOrder,
|
||||
expandedClipIds,
|
||||
collapsedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
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
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -138,18 +138,21 @@ function computeLaneCounts(
|
||||
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
|
||||
* pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
|
||||
* 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,
|
||||
* which are always drawn. A row sized without them clips every lane. */
|
||||
* specific rows post-hoc: TRACK_H while collapsed, plus the group's own
|
||||
* automation rows once its `∿` is open. */
|
||||
function applyGroupStripHeights(
|
||||
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
|
||||
rowHeights: number[],
|
||||
groups: readonly TimelineTrackGroupInfo[],
|
||||
expandedLaneOwnerIds: ReadonlySet<string>,
|
||||
): 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<TimelineRowGeometry>(rowGeometry);
|
||||
rowGeometryRef.current = rowGeometry;
|
||||
return {
|
||||
|
||||
@@ -75,6 +75,10 @@ export interface KeyframeSlice {
|
||||
collapsedGroupIds: Set<string>;
|
||||
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
|
||||
* 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) =>
|
||||
|
||||
@@ -277,6 +277,7 @@ export function createTimelineResetState() {
|
||||
expandedClipIds: new Set<string>(),
|
||||
// Per-composition: ids from comp A match nothing in B, silencing all of it.
|
||||
collapsedGroupIds: new Set<string>(),
|
||||
expandedLaneOwnerIds: new Set<string>(),
|
||||
focusedEaseSegment: null,
|
||||
selectedElementIds: new Set<string>(),
|
||||
requestedSeekTime: null,
|
||||
|
||||
Reference in New Issue
Block a user