fix(studio): label undo history with the track display row

The timeline track key is a fractional z-order sort key: an expanded
sub-composition child gets `host.track + n / (siblings + 2)`. The undo
history entry for the eye toggle interpolated that key directly, so
hiding an expanded child recorded "Hide track 0.16666666666666666".

Give the key-to-display-row conversion a single owner
(timelineTrackDisplay.ts) and route both the track header labels and the
history label through it, so the two cannot drift apart again. The raw
key still routes the callbacks and lookups that need it.

Adds a regression test that toggles a track keyed 1 / 6; a test on track
0 formats cleanly and proves nothing.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 20:37:52 +02:00
parent 1faa0cbdad
commit 477916cbe2
4 changed files with 96 additions and 4 deletions
@@ -4,6 +4,7 @@ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
import { trackDisplayNumber } from "./timelineTrackDisplay";
import { clipTimingStart } from "../../hooks/gsapShared";
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
@@ -105,6 +106,7 @@ export function TimelineLanes({
// in a virtualizer if editorial workflows ever push very high clip counts.
// fallow-ignore-next-line complexity
displayTrackOrder.map((trackNum, row) => {
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
const rowHeight = getTimelineRowHeight(row, rowHeights);
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
@@ -150,8 +152,10 @@ export function TimelineLanes({
trackNumber={trackNum}
// What gets announced. `trackNum` is a fractional z-order sort
// key, so it stays out of every label and in every callback.
trackDisplayNumber={row + 1}
trackLabel={els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${row + 1}`}
trackDisplayNumber={displayNumber}
trackLabel={
els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${displayNumber}`
}
lanesId={lanesId}
contentOrigin={contentOrigin}
keyframeClip={keyframeClip}
@@ -0,0 +1,24 @@
/**
* The one owner of "what track number does the user see".
*
* `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
* sub-composition child gets a synthesized fractional key (`host.track + n /
* (siblings + 2)`, see `useExpandedTimelineElements`), so putting it in a string
* announces "Hide track 0.16666666666666666". Every user-visible track number,
* whether it is rendered by a component or baked into an undo-history label,
* routes through here; the raw key stays in callbacks and lookups only.
*/
/** Ascending distinct track keys, the row order the timeline renders in. */
export function timelineTrackOrder(elements: readonly { track: number }[]): number[] {
return [...new Set(elements.map((element) => element.track))].sort((a, b) => a - b);
}
/**
* A track key's 1-based display row. A key not in `trackOrder` (a drag preview
* onto a brand-new track, say) reads as the row it would land on at the end.
*/
export function trackDisplayNumber(trackOrder: readonly number[], track: number): number {
const row = trackOrder.indexOf(track);
return row < 0 ? trackOrder.length + 1 : row + 1;
}