diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index 12adcd2cd..98b37ad81 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -243,6 +243,45 @@ describe("buildExpandedElements", () => { // The host row is replaced by its children. expect(out.some((e) => e.domId === "scene-host")).toBe(false); }); + + // Regression: DOM-only children were synthesized against the TOP-LEVEL element + // instead of the sub-comp host they actually live in, so every child row read + // the whole top-level window rather than its host's. + it("spans DOM-only children over their nested host's window, not the top-level one", () => { + const elements = [ + el({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }), + ]; + const manifest = [ + clip({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }), + clip({ id: "sub-host", start: 5, duration: 6, compositionSrc: "sub.html" }), + ]; + const parentMap = new Map([ + ["sub-host", "scene-host"], + ["pill-1", "sub-host"], + ]); + const domClipChildren = [ + { + id: "pill-1", + parentId: "sub-host", + hostId: "sub-host", + label: "pill-1", + stackingContextId: "css:0.0", + }, + ]; + + const out = buildExpandedElements( + elements, + manifest, + parentMap, + "scene-host", + "sub-host", + domClipChildren, + ); + const pill = out.find((e) => e.domId === "pill-1")!; + expect(pill.start).toBe(5); + expect(pill.duration).toBe(6); + expect(pill.sourceFile).toBe("sub.html"); + }); }); describe("resolveTimelineExpansionRawId", () => { diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 7ce6c5d66..17309385b 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -209,7 +209,13 @@ function buildChildElements( function domSiblingClips( domClipChildren: DomClipChild[], siblingParentId: string, - host: TimelineElement, + host: { + id: string | null; + start: number; + duration: number; + track: number; + compositionSrc?: string | null; + }, ): ClipManifestClip[] { return domClipChildren .filter((c) => c.parentId === siblingParentId) @@ -243,20 +249,23 @@ export function buildExpandedElements( const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId); if (!topLevelElement) return filterToTopLevel(elements, parentMap); + // The sub-comp host the children actually live in: top-level host for 1-level + // nesting, a nested host for deeper nesting. Its start/file anchor edits. + const parentHost = manifest.find((c) => c.id === siblingParentId); + // Prefer real manifest children; fall back to DOM-only sub-comp children // (groups/pills) that have no data-start and thus never enter the manifest. + // Those are synthesized against the host they actually live in, not the + // top-level element, or every child row reads the whole top-level window. const siblings = (() => { const fromManifest = manifest.filter( (c) => c.id != null && parentMap.get(c.id) === siblingParentId, ); if (fromManifest.length > 0) return fromManifest; - return domSiblingClips(domClipChildren, siblingParentId, topLevelElement); + return domSiblingClips(domClipChildren, siblingParentId, parentHost ?? topLevelElement); })(); if (siblings.length === 0) return filterToTopLevel(elements, parentMap); - // The sub-comp host the children actually live in: top-level host for 1-level - // nesting, a nested host for deeper nesting. Its start/file anchor edits. - const parentHost = manifest.find((c) => c.id === siblingParentId); const editBasis = { start: parentHost?.start ?? topLevelElement.start, sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,