fix(studio): sub-composition child clips restack via self-contained intent

applyTimelineStackingReorder resolved each z-index change by looking the clip up
in the top-level timelineElements list, but sub-composition children live only
in the expanded list, so the lookup missed them and the reorder silently bailed.
Carry the element's locator (domId/selector/sourceFile) on each z-index change
so the commit resolves the live element directly from the preview DOM. Verified
E2E: dragging a clip inside a sub-composition restacks it and patches the
sub-comp source, while the parent composition is untouched.

Adds timelineEditingHelpers.test.ts (locator-based commit + audio no-op).
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:26 -04:00
parent eef500c890
commit 82fe779bd8
4 changed files with 131 additions and 17 deletions
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { applyTimelineStackingReorder } from "./timelineEditingHelpers";
import type { TimelineElement } from "../player/store/playerStore";
function makeIframeWith(html: string): HTMLIFrameElement {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const doc = iframe.contentDocument;
if (!doc) throw new Error("expected iframe document");
doc.body.innerHTML = html;
return iframe;
}
function el(input: Partial<TimelineElement> & { id: string; tag: string }): TimelineElement {
return {
label: input.id,
start: 0,
duration: 5,
track: 0,
zIndex: 0,
hasExplicitZIndex: false,
stackingContextId: null,
...input,
};
}
describe("applyTimelineStackingReorder", () => {
it("commits via the change's own locator even when the element is not in timelineElements", () => {
// Sub-comp children live in the preview iframe but NOT in the top-level
// timelineElements list — the intent must be self-contained.
const iframe = makeIframeWith(`<div id="chip" style="z-index: 1"></div>`);
const commit = vi.fn<(entries: unknown[]) => void>();
applyTimelineStackingReorder({
element: el({ id: "chip", tag: "div" }),
targetTrack: 0,
stackingReorder: {
contextKey: "scene",
placement: { type: "above", layerId: "layer:scene:x" },
zIndexChanges: [
{
key: "scenes/scene.html#chip",
zIndex: 5,
domId: "chip",
sourceFile: "scenes/scene.html",
},
],
},
timelineElements: [], // element intentionally absent from the top-level list
iframe,
activeCompPath: "index.html",
commit,
});
expect(commit).toHaveBeenCalledTimes(1);
const entries = commit.mock.calls[0]![0] as Array<{
zIndex: number;
id?: string;
sourceFile: string;
}>;
expect(entries).toHaveLength(1);
expect(entries[0]!.zIndex).toBe(5);
expect(entries[0]!.id).toBe("chip");
expect(entries[0]!.sourceFile).toBe("scenes/scene.html");
});
it("never commits when the dragged clip is audio", () => {
const iframe = makeIframeWith(`<audio id="track"></audio>`);
const commit = vi.fn<(entries: unknown[]) => void>();
applyTimelineStackingReorder({
element: el({ id: "track", tag: "audio" }),
targetTrack: 0,
stackingReorder: {
contextKey: "main",
placement: { type: "above", layerId: "layer:main:x" },
zIndexChanges: [{ key: "track", zIndex: 5, domId: "track" }],
},
timelineElements: [],
iframe,
activeCompPath: "index.html",
commit,
});
expect(commit).not.toHaveBeenCalled();
});
});