mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ function isHTMLElement(element: Element | null): element is HTMLElement {
|
||||
* be resolved. Extracted from StudioApp's timeline hook to keep it under the
|
||||
* studio 600-LOC cap.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function applyTimelineStackingReorder(input: {
|
||||
element: TimelineElement;
|
||||
targetTrack: number;
|
||||
@@ -44,9 +45,19 @@ export function applyTimelineStackingReorder(input: {
|
||||
const intent = input.stackingReorder ?? null;
|
||||
if (intent == null || intent.zIndexChanges.length === 0) return;
|
||||
|
||||
// Resolve each change's live element from the change's OWN locator (the intent
|
||||
// is self-contained), falling back to the top-level element list. Sub-comp
|
||||
// children aren't in `timelineElements`, so a list-only lookup would miss them.
|
||||
const siblingByKey = new Map(
|
||||
input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]),
|
||||
);
|
||||
const doc = input.iframe?.contentDocument ?? null;
|
||||
const findLive = (domId?: string, selector?: string, selectorIndex?: number): Element | null => {
|
||||
if (!doc) return null;
|
||||
if (domId) return doc.getElementById(domId);
|
||||
if (selector) return doc.querySelectorAll(selector)[selectorIndex ?? 0] ?? null;
|
||||
return null;
|
||||
};
|
||||
const commitEntries: Array<{
|
||||
element: HTMLElement;
|
||||
zIndex: number;
|
||||
@@ -59,18 +70,20 @@ export function applyTimelineStackingReorder(input: {
|
||||
|
||||
for (const change of intent.zIndexChanges) {
|
||||
const sibling = siblingByKey.get(change.key);
|
||||
if (!sibling) return;
|
||||
const element = findTimelineElementInIframe(input.iframe, sibling);
|
||||
const domId = change.domId ?? sibling?.domId;
|
||||
const selector = change.selector ?? sibling?.selector;
|
||||
const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex;
|
||||
const element = findLive(domId, selector, selectorIndex);
|
||||
if (!isHTMLElement(element)) return;
|
||||
if (getElementZIndex(element) === change.zIndex) continue;
|
||||
commitEntries.push({
|
||||
element,
|
||||
zIndex: change.zIndex,
|
||||
id: sibling.domId ?? sibling.id,
|
||||
selector: sibling.selector,
|
||||
selectorIndex: sibling.selectorIndex,
|
||||
sourceFile: sibling.sourceFile || input.activeCompPath || "index.html",
|
||||
key: getTimelineElementIdentity(sibling),
|
||||
id: domId ?? sibling?.id ?? change.key,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile: change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html",
|
||||
key: change.key,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,18 @@ function layerContainsElement(layer: StackingTimelineLayer, key: string): boolea
|
||||
|
||||
function addElementChange(
|
||||
changes: TimelineStackingZIndexChange[],
|
||||
key: string,
|
||||
currentZIndex: number,
|
||||
element: TimelineStackingElement,
|
||||
zIndex: number,
|
||||
): void {
|
||||
if (currentZIndex !== zIndex) changes.push({ key, zIndex });
|
||||
if ((element.zIndex ?? 0) === zIndex) return;
|
||||
changes.push({
|
||||
key: getTimelineElementIdentity(element),
|
||||
zIndex,
|
||||
domId: element.domId,
|
||||
selector: element.selector,
|
||||
selectorIndex: element.selectorIndex,
|
||||
sourceFile: element.sourceFile,
|
||||
});
|
||||
}
|
||||
|
||||
function addLayerChanges(
|
||||
@@ -45,7 +52,7 @@ function addLayerChanges(
|
||||
const key = getTimelineElementIdentity(element);
|
||||
if (key === excludedKey) continue;
|
||||
const before = changes.length;
|
||||
addElementChange(changes, key, element.zIndex ?? 0, zIndex);
|
||||
addElementChange(changes, element, zIndex);
|
||||
if (changes.length > before) count += 1;
|
||||
}
|
||||
return count;
|
||||
@@ -70,12 +77,7 @@ function resolvePlacementZIndexChanges(input: {
|
||||
targetZIndex: number;
|
||||
}): TimelineStackingZIndexChange[] {
|
||||
const changes: TimelineStackingZIndexChange[] = [];
|
||||
addElementChange(
|
||||
changes,
|
||||
getTimelineElementIdentity(input.element),
|
||||
input.element.zIndex ?? 0,
|
||||
input.targetZIndex,
|
||||
);
|
||||
addElementChange(changes, input.element, input.targetZIndex);
|
||||
return changes;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ export interface TimelineStackingElement {
|
||||
stackingContextId?: string | null;
|
||||
parentCompositionId?: string | null;
|
||||
compositionAncestors?: string[];
|
||||
// Locator for resolving the live element at commit time (sub-comp children
|
||||
// aren't in the top-level element list, so the reorder intent must be
|
||||
// self-contained rather than re-looked-up by identity).
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile?: string;
|
||||
}
|
||||
|
||||
export interface TimelineStackingOrderItem {
|
||||
@@ -29,6 +36,10 @@ export type TimelineLayerDropPlacement =
|
||||
export interface TimelineStackingZIndexChange {
|
||||
key: string;
|
||||
zIndex: number;
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile?: string;
|
||||
}
|
||||
|
||||
export interface TimelineStackingReorderIntent {
|
||||
|
||||
Reference in New Issue
Block a user