mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): order the timing write after the z-index commit on a diagonal drag
A drag that both moved a clip in time and restacked it fired two writers on the same file via separate queues (a targeted z-index patch and a full-file timing overwrite), so the overwrite could clobber the just-persisted z-index and the restack silently vanished after reload. The move now awaits the z-index commit before persisting timing, giving the file one ordered writer per gesture. Adds a regression test that gates the commit and asserts the timing write waits.
This commit is contained in:
@@ -39,12 +39,12 @@ export function applyTimelineStackingReorder(input: {
|
||||
iframe: HTMLIFrameElement | null;
|
||||
activeCompPath: string | null;
|
||||
commit: TimelineZIndexReorderCommit | null | undefined;
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
// Audio has no visual stacking; a vertical drag on it must never write z-index.
|
||||
if (input.element.tag === "audio") return;
|
||||
if (input.element.tag === "audio") return Promise.resolve();
|
||||
|
||||
const intent = input.stackingReorder ?? null;
|
||||
if (intent == null || intent.zIndexChanges.length === 0) return;
|
||||
if (intent == null || intent.zIndexChanges.length === 0) return Promise.resolve();
|
||||
|
||||
// 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
|
||||
@@ -75,7 +75,7 @@ export function applyTimelineStackingReorder(input: {
|
||||
const selector = change.selector ?? sibling?.selector;
|
||||
const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex;
|
||||
const element = findLive(domId, selector, selectorIndex);
|
||||
if (!isHTMLElement(element)) return;
|
||||
if (!isHTMLElement(element)) return Promise.resolve();
|
||||
if (getElementZIndex(element) === change.zIndex) continue;
|
||||
commitEntries.push({
|
||||
element,
|
||||
@@ -88,12 +88,13 @@ export function applyTimelineStackingReorder(input: {
|
||||
});
|
||||
}
|
||||
|
||||
if (commitEntries.length === 0) return;
|
||||
input.commit?.(commitEntries);
|
||||
if (commitEntries.length === 0) return Promise.resolve();
|
||||
const persisted = input.commit?.(commitEntries) ?? Promise.resolve();
|
||||
const store = usePlayerStore.getState();
|
||||
for (const entry of commitEntries) {
|
||||
store.updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true });
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -170,13 +170,14 @@ export function useElementLifecycleOps({
|
||||
sourceFile: string;
|
||||
}>,
|
||||
) => {
|
||||
if (entries.length === 0) return;
|
||||
if (entries.length === 0) return Promise.resolve();
|
||||
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
|
||||
// the SDK resolves each reordered element — the reorderElements op's targets.
|
||||
onReorderShadow?.(
|
||||
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
|
||||
);
|
||||
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
||||
const saves: Array<Promise<void>> = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
entry.element.style.zIndex = String(entry.zIndex);
|
||||
@@ -192,23 +193,28 @@ export function useElementLifecycleOps({
|
||||
} catch {
|
||||
/* cross-origin or detached — skip */
|
||||
}
|
||||
void commitPositionPatchToHtml(
|
||||
{
|
||||
element: entry.element,
|
||||
id: entry.id ?? null,
|
||||
hfId: readHfId(entry.element),
|
||||
selector: entry.selector,
|
||||
selectorIndex: entry.selectorIndex,
|
||||
sourceFile: entry.sourceFile,
|
||||
} as unknown as DomEditSelection,
|
||||
patches,
|
||||
{
|
||||
label: "Reorder layers",
|
||||
coalesceKey,
|
||||
skipRefresh: i < entries.length - 1,
|
||||
},
|
||||
).catch(() => undefined);
|
||||
saves.push(
|
||||
commitPositionPatchToHtml(
|
||||
{
|
||||
element: entry.element,
|
||||
id: entry.id ?? null,
|
||||
hfId: readHfId(entry.element),
|
||||
selector: entry.selector,
|
||||
selectorIndex: entry.selectorIndex,
|
||||
sourceFile: entry.sourceFile,
|
||||
} as unknown as DomEditSelection,
|
||||
patches,
|
||||
{
|
||||
label: "Reorder layers",
|
||||
coalesceKey,
|
||||
skipRefresh: i < entries.length - 1,
|
||||
},
|
||||
).catch(() => undefined),
|
||||
);
|
||||
}
|
||||
// Resolves once every z-index patch is persisted so a same-file timing write
|
||||
// can be ordered after it (see applyTimelineStackingReorder callers).
|
||||
return Promise.all(saves).then(() => undefined);
|
||||
},
|
||||
[commitPositionPatchToHtml, onReorderShadow],
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ function timelineElement(input: {
|
||||
function renderTimelineEditingHook(input: {
|
||||
timelineElements: TimelineElement[];
|
||||
iframe: HTMLIFrameElement;
|
||||
onZIndexCommit: (entries: ZIndexEntry[]) => void;
|
||||
onZIndexCommit: (entries: ZIndexEntry[]) => Promise<void>;
|
||||
projectId?: string | null;
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
recordEdit?: (input: {
|
||||
@@ -249,7 +249,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn(),
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
@@ -315,7 +315,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const { resize, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn(),
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
@@ -356,7 +356,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
]);
|
||||
const front = timelineElement({ id: "front", track: 0, zIndex: 10 });
|
||||
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [front, back],
|
||||
iframe,
|
||||
@@ -394,7 +394,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
]);
|
||||
const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
|
||||
const music = timelineElement({ id: "music", track: 1, zIndex: 0, tag: "audio" });
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [front, music],
|
||||
iframe,
|
||||
@@ -427,7 +427,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const front = timelineElement({ id: "front", track: 0, zIndex: 2 });
|
||||
const back = timelineElement({ id: "back", track: 1, zIndex: 1 });
|
||||
const dragged = timelineElement({ id: "dragged", track: 2, zIndex: 0 });
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [front, back, dragged],
|
||||
iframe,
|
||||
@@ -496,7 +496,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => {
|
||||
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
const reloadPreview = vi.fn();
|
||||
@@ -546,4 +546,67 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
|
||||
const iframe = createPreviewIframe([
|
||||
{ id: "clip", track: 0, style: "position: relative; z-index: 0" },
|
||||
]);
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||
// Gate the z-index commit so we can observe whether the timing write waits.
|
||||
let releaseCommit!: () => void;
|
||||
const commitGate = new Promise<void>((resolve) => {
|
||||
releaseCommit = resolve;
|
||||
});
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) {
|
||||
return jsonResponse({
|
||||
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: commit,
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
// Diagonal drag: both a time move (start change) and a restack (z-index change).
|
||||
let movePromise!: Promise<unknown>;
|
||||
await act(async () => {
|
||||
movePromise = move(clip, {
|
||||
start: 1.25,
|
||||
track: clip.track,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "onto", layerId: "layer-clip" },
|
||||
zIndexChanges: [{ key: "clip", zIndex: 5 }],
|
||||
},
|
||||
});
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
// The z-index commit is in flight but gated; the full-file timing write must
|
||||
// not have run yet, or it would overwrite the file without the z-index change.
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
expect(writeProjectFile).not.toHaveBeenCalled();
|
||||
|
||||
// Release the z-index commit → the timing write now proceeds, on top of it.
|
||||
await act(async () => {
|
||||
releaseCommit();
|
||||
await movePromise;
|
||||
await flushAsyncWork();
|
||||
});
|
||||
expect(writeProjectFile).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ export function useTimelineEditing({
|
||||
]);
|
||||
}
|
||||
|
||||
applyTimelineStackingReorder({
|
||||
const reorderDone = applyTimelineStackingReorder({
|
||||
element,
|
||||
targetTrack: updates.track,
|
||||
stackingReorder: updates.stackingReorder,
|
||||
@@ -144,7 +144,7 @@ export function useTimelineEditing({
|
||||
commit: handleDomZIndexReorderCommitRef?.current,
|
||||
});
|
||||
|
||||
if (!startChanged) return;
|
||||
if (!startChanged) return reorderDone;
|
||||
|
||||
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
||||
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
|
||||
@@ -171,28 +171,34 @@ export function useTimelineEditing({
|
||||
});
|
||||
});
|
||||
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
|
||||
if (sdkSession && element.hfId && !needsExtension) {
|
||||
return sdkTimingPersist(
|
||||
element.hfId,
|
||||
targetPath,
|
||||
{ start: updates.start },
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
// Capture on-disk bytes as the undo `before` so undoing a timing move
|
||||
// restores the file verbatim, not a normalized full-DOM re-emit.
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Move timeline clip", coalesceKey },
|
||||
).then((handled) => {
|
||||
if (!handled) return moveFallback();
|
||||
});
|
||||
}
|
||||
return moveFallback();
|
||||
// The z-index reorder above and this timing write target the same file on
|
||||
// separate save queues, and the timing write is a full-file overwrite. Order
|
||||
// it after the reorder so it reads disk with the z-index already applied and
|
||||
// can't clobber it — one ordered writer per gesture (diagonal move+restack).
|
||||
return reorderDone.then(() => {
|
||||
if (sdkSession && element.hfId && !needsExtension) {
|
||||
return sdkTimingPersist(
|
||||
element.hfId,
|
||||
targetPath,
|
||||
{ start: updates.start },
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
// Capture on-disk bytes as the undo `before` so undoing a timing move
|
||||
// restores the file verbatim, not a normalized full-DOM re-emit.
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Move timeline clip", coalesceKey },
|
||||
).then((handled) => {
|
||||
if (!handled) return moveFallback();
|
||||
});
|
||||
}
|
||||
return moveFallback();
|
||||
});
|
||||
},
|
||||
[
|
||||
previewIframeRef,
|
||||
|
||||
@@ -10,6 +10,8 @@ interface RecordEditInput {
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
// Resolves once the z-index patches are persisted, so a caller that also writes
|
||||
// the same file (e.g. a timing move) can order its write after this one.
|
||||
export type TimelineZIndexReorderCommit = (
|
||||
entries: Array<{
|
||||
element: HTMLElement;
|
||||
@@ -19,7 +21,7 @@ export type TimelineZIndexReorderCommit = (
|
||||
selectorIndex?: number;
|
||||
sourceFile: string;
|
||||
}>,
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
|
||||
export interface UseTimelineEditingOptions {
|
||||
projectId: string | null;
|
||||
|
||||
Reference in New Issue
Block a user