diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index f7100997b..4b7a6f600 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -221,6 +221,35 @@ describe("buildExpandedElements", () => { expect(child.key).toBe(expectedStoreKey); }); + // Regression: a child row is built from a manifest clip, which carries none of + // the host element's attributes. Reading hidden off the manifest left every + // expanded row reporting itself visible, so the eye wrote data-hidden a second + // time instead of removing it and the element could never be shown again. + it("inherits hidden and locked state from the flat store element", () => { + const elements = [ + el({ id: "s1", domId: "s1", start: 0, duration: 14 }), + el({ + id: "eyebrow", + key: "index.html#eyebrow", + domId: "eyebrow", + start: 0, + duration: 14, + hidden: true, + timelineLocked: true, + }), + ]; + const manifest = [ + clip({ id: "s1", start: 0, duration: 14 }), + clip({ id: "eyebrow", start: 0, duration: 14 }), + ]; + const parentMap = new Map([["eyebrow", "s1"]]); + + const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1"); + const child = out.find((e) => e.domId === "eyebrow")!; + expect(child.hidden).toBe(true); + expect(child.timelineLocked).toBe(true); + }); + // Sub-comp internals (group + pills) have no data-start, so they're not in the // manifest. They arrive as DOM children and must still expand under their host. it("expands DOM-only sub-comp children (no manifest clip) under the host", () => { diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 8a1c95e58..8cceebcf0 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -134,6 +134,27 @@ interface DisplayBounds { track: number; } +/** + * State that lives on the live host element, not in the clip manifest: + * `data-hidden`, `data-timeline-locked`, `data-timeline-role`. A child row is + * built from a manifest clip with no hostEl to read, so + * createTimelineElementFromManifestClip cannot see any of it. The flat store + * element for the same child WAS built with one, so it is inherited from there. + * + * Without this the eye on an expanded child always reported the row visible, so + * clicking it wrote data-hidden again instead of removing it, and a hidden child + * could never be shown again (not even after a reload, since the attribute is in + * the source). + */ +function hostElementState(flat: TimelineElement | undefined): Partial { + if (!flat) return {}; + return { + hidden: flat.hidden, + timelineLocked: flat.timelineLocked, + timelineRole: flat.timelineRole, + }; +} + // `display` bounds come from the top-level scene clip (where the expanded row is // drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute // start anchors local-time edits and its compositionSrc is the file edits write to. @@ -143,6 +164,7 @@ function buildChildElements( display: DisplayBounds, editBasis: { start: number; sourceFile: string | undefined }, expandedHostKey: string, + elements: readonly TimelineElement[], ): TimelineElement[] { const result: TimelineElement[] = []; for (const child of siblings) { @@ -170,6 +192,7 @@ function buildChildElements( }); result.push({ ...base, + ...hostElementState(elements.find((element) => element.key === key)), key, start: clamped.start, duration: clamped.duration, @@ -281,6 +304,7 @@ export function buildExpandedElements( }, editBasis, parentKey, + elements, ); if (expanded.length === 0) return filterToTopLevel(elements, parentMap);