diff --git a/packages/studio/src/hooks/timelineTrackVisibility.test.ts b/packages/studio/src/hooks/timelineTrackVisibility.test.ts index 8ccb088fe..24d114b98 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.test.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.test.ts @@ -95,7 +95,9 @@ describe("toggleTimelineTrackHidden", () => { expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"])); expect(timestampRef.current).toBeGreaterThan(0); expect(recordEdit).toHaveBeenCalledTimes(1); - expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 0"); + // Display row, not the raw key: track 0 is the first row, so it reads "1", + // the same number the track header announces for that row. + expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 1"); expect(Object.keys(recordEdit.mock.calls[0]?.[0]?.files ?? {}).sort()).toEqual([ "index.html", "scene.html", @@ -134,6 +136,64 @@ describe("toggleTimelineTrackHidden", () => { expect(writes.get("index.html")).not.toContain("data-hidden"); }); + + // An expanded sub-comp child gets a synthesized FRACTIONAL track key + // (`display.track + n / (siblings + 2)`), so a label built from the raw key + // reads "Hide track 0.16666666666666666" in the undo history. Track 0 would + // format cleanly and prove nothing, hence 1 / 6. + it("labels the undo entry with the display row, not the fractional track key", async () => { + const files = new Map([ + ["index.html", `
`], + ]); + stubProjectFiles(files); + + const recordEdit = vi.fn(); + + await toggleTimelineTrackHidden({ + projectId: "project-1", + activeCompPath: "index.html", + timelineElements: [ + element({ id: "host", domId: "host", track: 0 }), + element({ id: "child", domId: "child", track: 1 / 6 }), + ], + track: 1 / 6, + hidden: true, + previewIframe: null, + writeProjectFile: async () => {}, + recordEdit, + domEditSaveTimestampRef: { current: 0 }, + pendingTimelineEditPathRef: { current: new Set() }, + }); + + expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 2"); + }); + + it("labels a show back with the display row too", async () => { + const files = new Map([ + ["index.html", `
`], + ]); + stubProjectFiles(files); + + const recordEdit = vi.fn(); + + await toggleTimelineTrackHidden({ + projectId: "project-1", + activeCompPath: "index.html", + timelineElements: [ + element({ id: "host", domId: "host", track: 0 }), + element({ id: "child", domId: "child", track: 1 / 6, hidden: true }), + ], + track: 1 / 6, + hidden: false, + previewIframe: null, + writeProjectFile: async () => {}, + recordEdit, + domEditSaveTimestampRef: { current: 0 }, + pendingTimelineEditPathRef: { current: new Set() }, + }); + + expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Show track 2"); + }); }); describe("toggleTimelineElementHidden", () => { diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts index f305226c9..634ea6ee6 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { usePlayerStore, type TimelineElement } from "../player"; import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements"; +import { timelineTrackOrder, trackDisplayNumber } from "../player/components/timelineTrackDisplay"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher"; import { @@ -208,12 +209,15 @@ export async function toggleTimelineTrackHidden({ domEditSaveTimestampRef, pendingTimelineEditPathRef, }: ToggleTimelineTrackHiddenInput): Promise { + // `track` is the fractional sort key the callback needs; the history entry is + // read by a human, so it gets the display row instead. + const displayNumber = trackDisplayNumber(timelineTrackOrder(timelineElements), track); return setElementsHidden({ projectId, activeCompPath, elements: timelineElements.filter((element) => element.track === track), hidden, - label: hidden ? `Hide track ${track}` : `Show track ${track}`, + label: hidden ? `Hide track ${displayNumber}` : `Show track ${displayNumber}`, previewIframe, writeProjectFile, recordEdit, diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index c69402557..32e805631 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -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} diff --git a/packages/studio/src/player/components/timelineTrackDisplay.ts b/packages/studio/src/player/components/timelineTrackDisplay.ts new file mode 100644 index 000000000..4bf059e93 --- /dev/null +++ b/packages/studio/src/player/components/timelineTrackDisplay.ts @@ -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; +}