Merge pull request #2068 from heygen-com/worktree-fix-timeline-zindex-reorder

feat(studio): lane-model timeline — vertical drag restacks via z-index
This commit is contained in:
Miguel Ángel
2026-07-09 17:37:46 -04:00
committed by GitHub
59 changed files with 4510 additions and 628 deletions
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyTimelineStackingReorder, extendRootDurationIfNeeded } from "./timelineEditingHelpers";
import type { TimelineElement } from "../player/store/playerStore";
import { usePlayerStore } from "../player/store/playerStore";
afterEach(() => {
usePlayerStore.getState().reset();
});
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" }),
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" }),
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();
});
});
describe("extendRootDurationIfNeeded", () => {
it("extends the player duration only when the new end is larger", () => {
usePlayerStore.getState().setDuration(4);
expect(extendRootDurationIfNeeded(5)).toBe(true);
expect(usePlayerStore.getState().duration).toBe(5);
expect(extendRootDurationIfNeeded(5)).toBe(false);
expect(extendRootDurationIfNeeded(3)).toBe(false);
expect(usePlayerStore.getState().duration).toBe(5);
});
});
@@ -1,8 +1,121 @@
import type { TimelineElement } from "../player/store/playerStore";
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import {
formatTimelineAttributeNumber,
type TimelineStackingReorderIntent,
} from "../player/components/timelineEditing";
import { getElementZIndex } from "../player/lib/layerOrdering";
import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
import type { EditHistoryKind } from "../utils/editHistory";
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
import { extendRootDurationInSource } from "../utils/rootDuration";
function isHTMLElement(element: Element | null): element is HTMLElement {
if (!element) return false;
// Use the element's OWN realm's HTMLElement: timeline clips live in the preview
// iframe, and cross-realm `element instanceof HTMLElement` (main window) is
// always false — which silently dropped every timeline z-index commit.
const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
return element instanceof Ctor;
}
/**
* Resolve a timeline vertical move to a z-index stacking reorder and commit it
* through the shared layers-panel reorder path. Reads live sibling z-index from
* the preview DOM, remaps with the dup-preserving reorder math, and writes only
* z-index (never data-track-index). No-op when the move isn't a reorder, the
* dragged clip is audio (no visual layer to restack), or the live siblings can't
* 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;
stackingReorder: TimelineStackingReorderIntent | null | undefined;
timelineElements: readonly TimelineElement[];
iframe: HTMLIFrameElement | null;
activeCompPath: string | null;
commit: TimelineZIndexReorderCommit | null | undefined;
}): Promise<void> {
// Audio has no visual stacking; a vertical drag on it must never write z-index.
if (input.element.tag === "audio") return Promise.resolve();
const intent = input.stackingReorder ?? null;
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
// 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;
id?: string;
selector?: string;
selectorIndex?: number;
sourceFile: string;
key: string;
}> = [];
for (const change of intent.zIndexChanges) {
const sibling = siblingByKey.get(change.key);
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 Promise.resolve();
if (getElementZIndex(element) === change.zIndex) continue;
commitEntries.push({
element,
zIndex: change.zIndex,
id: domId ?? sibling?.id ?? change.key,
selector,
selectorIndex,
sourceFile: change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html",
key: change.key,
});
}
if (commitEntries.length === 0) return Promise.resolve();
return input.commit?.(commitEntries) ?? Promise.resolve();
}
/**
* Remove the keyframes currently selected in the player store from the active
* element's GSAP animation. Reads selection lazily so it stays correct when
* invoked from a ref callback. Extracted from StudioApp to keep it under the
* studio 600-LOC cap.
*/
export function deleteSelectedKeyframes(session: {
selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[];
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
}): void {
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
if (!animation) return;
// Only the active element's keyframes; a stale cross-element selection must not delete here.
for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
session.handleGsapRemoveKeyframe(animation.id, pct);
}
}
export function extendRootDurationIfNeeded(newEnd: number): boolean {
const store = usePlayerStore.getState();
if (newEnd <= store.duration) return false;
store.setDuration(newEnd);
return true;
}
// ── Types ──
@@ -72,8 +185,25 @@ export function patchIframeDomTiming(
}
}
function postRootDurationToPreview(
iframe: HTMLIFrameElement | null,
durationSeconds: number,
): void {
const duration = Number(durationSeconds);
if (!Number.isFinite(duration) || duration <= 0) return;
iframe?.contentWindow?.postMessage(
{
source: "hf-parent",
type: "control",
action: "set-root-duration",
durationSeconds: duration,
},
"*",
);
}
// fallow-ignore-next-line complexity
export function resolveResizePlaybackStart(
function resolveResizePlaybackStart(
original: string,
target: PatchTarget,
element: TimelineElement,
@@ -99,6 +229,47 @@ export function resolveResizePlaybackStart(
};
}
export function buildTimelineMoveTimingPatch(
original: string,
target: PatchTarget,
start: number,
duration: number,
): string {
const patched = applyPatchByTarget(original, target, {
type: "attribute",
property: "start",
value: formatTimelineAttributeNumber(start),
});
return extendRootDurationInSource(patched, start + duration);
}
export function buildTimelineResizeTimingPatch(
original: string,
target: PatchTarget,
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
): string {
const pbs = resolveResizePlaybackStart(original, target, element, updates);
let patched = applyPatchByTarget(original, target, {
type: "attribute",
property: "start",
value: formatTimelineAttributeNumber(updates.start),
});
patched = applyPatchByTarget(patched, target, {
type: "attribute",
property: "duration",
value: formatTimelineAttributeNumber(updates.duration),
});
if (pbs) {
patched = applyPatchByTarget(patched, target, {
type: "attribute",
property: pbs.attrName,
value: formatTimelineAttributeNumber(pbs.value),
});
}
return extendRootDurationInSource(patched, updates.start + updates.duration);
}
export interface PersistTimelineEditInput {
projectId: string;
element: TimelineElement;
@@ -158,6 +329,47 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
return data.content;
}
export type GsapMutationStatus = { mutated: boolean };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readMutationStatus(value: unknown): GsapMutationStatus {
if (!isRecord(value)) return { mutated: false };
return { mutated: value.mutated === true || value.changed === true };
}
function readMutationError(value: unknown, fallback: string): string {
if (isRecord(value) && typeof value.error === "string") return value.error;
return fallback;
}
export async function finishTimelineTimingFallback(input: {
iframe: HTMLIFrameElement | null;
needsExtension: boolean;
rootDurationSeconds: number;
reloadPreview: () => void;
gsapMutation?: () => Promise<GsapMutationStatus>;
onGsapError: (error: unknown) => void;
}): Promise<void> {
let gsapMutated = false;
if (input.gsapMutation) {
try {
gsapMutated = (await input.gsapMutation()).mutated;
} catch (error) {
input.onGsapError(error);
return;
}
}
if (input.needsExtension) {
postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
if (gsapMutated) input.reloadPreview();
return;
}
input.reloadPreview();
}
/**
* Shift all GSAP animation positions targeting a given element by a time delta.
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
@@ -167,8 +379,8 @@ export async function shiftGsapPositions(
filePath: string,
elementId: string,
delta: number,
): Promise<void> {
if (delta === 0 || !elementId) return;
): Promise<GsapMutationStatus> {
if (delta === 0 || !elementId) return { mutated: false };
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
@@ -183,8 +395,9 @@ export async function shiftGsapPositions(
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
throw new Error(readMutationError(err, "shift-positions failed"));
}
return readMutationStatus(await res.json().catch(() => null));
}
export async function scaleGsapPositions(
@@ -195,9 +408,9 @@ export async function scaleGsapPositions(
oldDuration: number,
newStart: number,
newDuration: number,
): Promise<void> {
if (!elementId || oldDuration <= 0 || newDuration <= 0) return;
if (oldStart === newStart && oldDuration === newDuration) return;
): Promise<GsapMutationStatus> {
if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
@@ -215,8 +428,9 @@ export async function scaleGsapPositions(
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err as { error?: string })?.error ?? "scale-positions failed");
throw new Error(readMutationError(err, "scale-positions failed"));
}
return readMutationStatus(await res.json().catch(() => null));
}
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
@@ -160,6 +160,7 @@ export function useElementLifecycleOps({
// persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle.
// No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
const handleDomZIndexReorderCommit = useCallback(
// fallow-ignore-next-line complexity
(
entries: Array<{
element: HTMLElement;
@@ -168,17 +169,26 @@ export function useElementLifecycleOps({
selector?: string;
selectorIndex?: number;
sourceFile: string;
key?: 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>> = [];
const rollbacks: Array<() => void> = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const priorZIndex = entry.element.style.zIndex;
const priorPosition = entry.element.style.position;
const priorStoreEntry = entry.key
? usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === entry.key)
: undefined;
let positionChanged = false;
entry.element.style.zIndex = String(entry.zIndex);
const patches: Array<{ type: "inline-style"; property: string; value: string }> = [
{ type: "inline-style", property: "z-index", value: String(entry.zIndex) },
@@ -187,28 +197,58 @@ export function useElementLifecycleOps({
const win = entry.element.ownerDocument?.defaultView;
if (win && win.getComputedStyle(entry.element).position === "static") {
entry.element.style.position = "relative";
positionChanged = true;
patches.push({ type: "inline-style", property: "position", value: "relative" });
}
} 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);
if (entry.key) {
usePlayerStore
.getState()
.updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true });
}
rollbacks.push(() => {
entry.element.style.zIndex = priorZIndex;
if (positionChanged) entry.element.style.position = priorPosition;
if (entry.key && priorStoreEntry) {
usePlayerStore.getState().updateElement(entry.key, {
zIndex: priorStoreEntry.zIndex,
hasExplicitZIndex: priorStoreEntry.hasExplicitZIndex,
});
}
});
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,
},
),
);
}
// Resolves once every z-index patch is persisted so a same-file timing write
// can be ordered after it (see applyTimelineStackingReorder callers).
return Promise.allSettled(saves).then((settled) => {
const rejected = settled.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (rejected) {
for (const rollback of rollbacks) rollback();
return Promise.reject(rejected.reason);
}
return undefined;
});
},
[commitPositionPatchToHtml, onReorderShadow],
);
@@ -0,0 +1,742 @@
// @vitest-environment happy-dom
import React, { act, useRef } from "react";
import { createRoot } from "react-dom/client";
import { openComposition } from "@hyperframes/sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore, type TimelineElement } from "../player";
import { useElementLifecycleOps } from "./useElementLifecycleOps";
import { useTimelineEditing } from "./useTimelineEditing";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type ZIndexEntry = {
element: HTMLElement;
zIndex: number;
id?: string;
selector?: string;
selectorIndex?: number;
sourceFile: string;
};
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.getState().reset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function createPreviewIframe(
clips: Array<{
id: string;
track: number;
style?: string;
}> = [
{ id: "front", track: 0 },
{ id: "back", track: 1 },
],
): 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 = clips
.map(
(clip) =>
`<div id="${clip.id}" data-start="0" data-duration="2" data-track-index="${clip.track}"${
clip.style ? ` style="${clip.style}"` : ""
}></div>`,
)
.join("\n");
return iframe;
}
function timelineElement(input: {
id: string;
track: number;
zIndex: number;
tag?: string;
}): TimelineElement {
return {
id: input.id,
domId: input.id,
hfId: `hf-${input.id}`,
tag: input.tag ?? "div",
start: 0,
duration: 2,
track: input.track,
zIndex: input.zIndex,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
sourceFile: "index.html",
timingSource: "authored",
};
}
function renderTimelineEditingHook(input: {
timelineElements: TimelineElement[];
iframe: HTMLIFrameElement;
onZIndexCommit: (entries: ZIndexEntry[]) => Promise<void>;
projectId?: string | null;
writeProjectFile?: (path: string, content: string) => Promise<void>;
recordEdit?: (input: {
label: string;
kind: string;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
reloadPreview?: () => void;
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
forceReloadSdkSession?: () => void;
}): {
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
unmount: () => void;
} {
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
function Harness() {
const commitRef = useRef(input.onZIndexCommit);
commitRef.current = input.onZIndexCommit;
const hook = useTimelineEditing({
projectId: input.projectId ?? null,
activeCompPath: "index.html",
timelineElements: input.timelineElements,
showToast: vi.fn(),
writeProjectFile: input.writeProjectFile ?? vi.fn(),
recordEdit: input.recordEdit ?? vi.fn(),
domEditSaveTimestampRef: { current: 0 },
reloadPreview: input.reloadPreview ?? vi.fn(),
previewIframeRef: { current: input.iframe },
pendingTimelineEditPathRef: { current: new Set<string>() },
uploadProjectFiles: vi.fn(),
sdkSession: input.sdkSession,
forceReloadSdkSession: input.forceReloadSdkSession,
handleDomZIndexReorderCommitRef: commitRef,
});
move = hook.handleTimelineElementMove;
resize = hook.handleTimelineElementResize;
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
if (!move) throw new Error("Expected hook to expose move handler");
if (!resize) throw new Error("Expected hook to expose resize handler");
return {
move,
resize,
unmount: () => {
act(() => root.unmount());
},
};
}
function renderTimelineEditingHookWithLifecycle(input: {
timelineElements: TimelineElement[];
iframe: HTMLIFrameElement;
commitPositionPatchToHtml: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
}): {
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
unmount: () => void;
} {
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
function Harness() {
const lifecycle = useElementLifecycleOps({
activeCompPath: "index.html",
showToast: vi.fn(),
writeProjectFile: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: vi.fn() },
projectIdRef: { current: "p1" },
reloadPreview: vi.fn(),
clearDomSelection: vi.fn(),
commitPositionPatchToHtml: input.commitPositionPatchToHtml,
});
const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);
commitRef.current = lifecycle.handleDomZIndexReorderCommit;
const hook = useTimelineEditing({
projectId: null,
activeCompPath: "index.html",
timelineElements: input.timelineElements,
showToast: vi.fn(),
writeProjectFile: vi.fn(),
recordEdit: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
reloadPreview: vi.fn(),
previewIframeRef: { current: input.iframe },
pendingTimelineEditPathRef: { current: new Set<string>() },
uploadProjectFiles: vi.fn(),
handleDomZIndexReorderCommitRef: commitRef,
});
move = hook.handleTimelineElementMove;
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
if (!move) throw new Error("Expected hook to expose move handler");
return {
move,
unmount: () => {
act(() => root.unmount());
},
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}
function requestUrl(input: Parameters<typeof fetch>[0]): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
async function flushAsyncWork(): Promise<void> {
for (let i = 0; i < 8; i += 1) {
await Promise.resolve();
}
}
describe("useTimelineEditing timeline z-index reorder", () => {
it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => {
const source = [
`<div data-composition-id="main" data-duration="4">`,
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
`</div>`,
].join("\n");
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
const sdkSession = await openComposition(source);
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const forceReloadSdkSession = vi.fn();
const reloadPreview = vi.fn();
const iframeWindow = iframe.contentWindow;
if (!iframeWindow) throw new Error("Expected iframe window");
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
if (url.includes("/api/projects/p1/gsap-mutations/")) {
return jsonResponse({ ok: true, mutated: false });
}
throw new Error(`Unexpected fetch: ${url}`);
}),
);
usePlayerStore.getState().setDuration(4);
const { move, unmount } = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit,
sdkSession,
forceReloadSdkSession,
reloadPreview,
});
await act(async () => {
await move(clip, { start: 3, track: clip.track });
});
expect(setTimingSpy).not.toHaveBeenCalled();
expect(writeProjectFile.mock.calls[0]![1]).toContain(
'data-composition-id="main" data-duration="5"',
);
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
expect(usePlayerStore.getState().duration).toBe(5);
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
expect(reloadPreview).not.toHaveBeenCalled();
expect(postMessageSpy).toHaveBeenCalledWith(
{
source: "hf-parent",
type: "control",
action: "set-root-duration",
durationSeconds: 5,
},
"*",
);
unmount();
});
it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => {
const source = [
`<div data-composition-id="main" data-duration="4">`,
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
`</div>`,
].join("\n");
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
const sdkSession = await openComposition(source);
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const forceReloadSdkSession = vi.fn();
const reloadPreview = vi.fn();
const iframeWindow = iframe.contentWindow;
if (!iframeWindow) throw new Error("Expected iframe window");
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
if (url.includes("/api/projects/p1/gsap-mutations/")) {
return jsonResponse({ ok: true, mutated: false });
}
throw new Error(`Unexpected fetch: ${url}`);
}),
);
usePlayerStore.getState().setDuration(4);
const { resize, unmount } = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit,
sdkSession,
forceReloadSdkSession,
reloadPreview,
});
await act(async () => {
await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
});
expect(setTimingSpy).not.toHaveBeenCalled();
expect(writeProjectFile.mock.calls[0]![1]).toContain(
'data-composition-id="main" data-duration="5"',
);
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
expect(usePlayerStore.getState().duration).toBe(5);
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
expect(reloadPreview).not.toHaveBeenCalled();
expect(postMessageSpy).toHaveBeenCalledWith(
{
source: "hf-parent",
type: "control",
action: "set-root-duration",
durationSeconds: 5,
},
"*",
);
unmount();
});
it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
const iframe = createPreviewIframe([
{ id: "front", track: 0, style: "position: relative; z-index: 10" },
{ id: "back", track: 2, style: "position: relative; z-index: 1" },
]);
const front = timelineElement({ id: "front", track: 0, zIndex: 10 });
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
const { move, unmount } = renderTimelineEditingHook({
timelineElements: [front, back],
iframe,
onZIndexCommit: commit,
});
await act(async () => {
await move(back, {
start: back.start,
track: back.track,
stackingReorder: {
contextKey: "root",
placement: { type: "onto", layerId: "layer-front" },
zIndexChanges: [{ key: "back", zIndex: 10 }],
},
});
});
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe document");
expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
["back", 10],
]);
expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2");
unmount();
});
it("never writes z-index when the dragged clip is audio (no visual layer)", async () => {
const iframe = createPreviewIframe([
{ id: "front", track: 0 },
{ id: "music", track: 1 },
]);
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[]) => Promise<void>>().mockResolvedValue(undefined);
const { move, unmount } = renderTimelineEditingHook({
timelineElements: [front, music],
iframe,
onZIndexCommit: commit,
});
await act(async () => {
await move(music, {
start: music.start,
track: music.track,
stackingReorder: {
contextKey: "root",
placement: { type: "onto", layerId: "layer-front" },
zIndexChanges: [{ key: "music", zIndex: 2 }],
},
});
});
expect(commit).not.toHaveBeenCalled();
unmount();
});
it("commits only the minimum z-index changes resolved by the timeline drag", async () => {
const iframe = createPreviewIframe([
{ id: "front", track: 0, style: "position: relative; z-index: 2" },
{ id: "back", track: 1, style: "position: relative; z-index: 1" },
{ id: "dragged", track: 2, style: "position: relative; z-index: 0" },
]);
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[]) => Promise<void>>().mockResolvedValue(undefined);
const { move, unmount } = renderTimelineEditingHook({
timelineElements: [front, back, dragged],
iframe,
onZIndexCommit: commit,
});
await act(async () => {
await move(dragged, {
start: dragged.start,
track: dragged.track,
stackingReorder: {
contextKey: "root",
placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
zIndexChanges: [
{ key: "dragged", zIndex: 2 },
{ key: "front", zIndex: 3 },
],
},
});
});
expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
["dragged", 2],
["front", 3],
]);
unmount();
});
it("uses the shared lifecycle commit so static clips receive position relative", async () => {
const iframe = createPreviewIframe([
{ id: "front", track: 0, style: "position: static" },
{ id: "back", track: 1, style: "position: static" },
]);
const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
timelineElements: [front, back],
iframe,
commitPositionPatchToHtml,
});
await act(async () => {
await move(back, {
start: back.start,
track: back.track,
stackingReorder: {
contextKey: "root",
placement: { type: "above", layerId: "front" },
zIndexChanges: [{ key: "back", zIndex: 2 }],
},
});
await flushAsyncWork();
});
expect(commitPositionPatchToHtml).toHaveBeenCalled();
expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
{ type: "inline-style", property: "z-index", value: "2" },
{ type: "inline-style", property: "position", value: "relative" },
]);
unmount();
});
it("rejects and rolls back DOM and store z-index changes when a reorder save fails", async () => {
const iframe = createPreviewIframe([
{ id: "front", track: 0, style: "position: relative; z-index: 7" },
{ id: "back", track: 1, style: "position: static" },
]);
const front = timelineElement({ id: "front", track: 0, zIndex: 7 });
const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
usePlayerStore.getState().setElements([
{ ...front, hasExplicitZIndex: true },
{ ...back, hasExplicitZIndex: false },
]);
const saveError = new Error("save failed");
const commitPositionPatchToHtml = vi
.fn<(...args: unknown[]) => Promise<void>>()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(saveError);
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
timelineElements: [front, back],
iframe,
commitPositionPatchToHtml,
});
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe document");
const frontElement = doc.getElementById("front") as HTMLElement | null;
const backElement = doc.getElementById("back") as HTMLElement | null;
if (!frontElement || !backElement) throw new Error("Expected reordered elements");
let rejection: unknown;
await act(async () => {
try {
await move(back, {
start: back.start,
track: back.track,
stackingReorder: {
contextKey: "root",
placement: { type: "above", layerId: "front" },
zIndexChanges: [
{ key: "front", zIndex: 2 },
{ key: "back", zIndex: 5 },
],
},
});
} catch (error) {
rejection = error;
}
await flushAsyncWork();
});
expect(rejection).toBe(saveError);
expect(frontElement.style.zIndex).toBe("7");
expect(frontElement.style.position).toBe("relative");
expect(backElement.style.zIndex).toBe("");
expect(backElement.style.position).toBe("static");
const storeEntries = usePlayerStore.getState().elements;
expect(storeEntries.find((entry) => entry.id === "front")).toMatchObject({
zIndex: 7,
hasExplicitZIndex: true,
});
expect(storeEntries.find((entry) => entry.id === "back")).toMatchObject({
zIndex: 0,
hasExplicitZIndex: false,
});
unmount();
});
it("waits for every lifecycle z-index save before resolving a reorder", async () => {
const iframe = createPreviewIframe([
{ id: "front", track: 0, style: "position: relative; z-index: 1" },
{ id: "back", track: 1, style: "position: relative; z-index: 0" },
]);
const front = timelineElement({ id: "front", track: 0, zIndex: 1 });
const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
let releaseFirst!: () => void;
let releaseSecond!: () => void;
const firstSave = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const secondSave = new Promise<void>((resolve) => {
releaseSecond = resolve;
});
const commitPositionPatchToHtml = vi
.fn<(...args: unknown[]) => Promise<void>>()
.mockReturnValueOnce(firstSave)
.mockReturnValueOnce(secondSave);
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
timelineElements: [front, back],
iframe,
commitPositionPatchToHtml,
});
let settled = false;
let movePromise!: Promise<void>;
await act(async () => {
movePromise = move(back, {
start: back.start,
track: back.track,
stackingReorder: {
contextKey: "root",
placement: { type: "above", layerId: "front" },
zIndexChanges: [
{ key: "front", zIndex: 2 },
{ key: "back", zIndex: 3 },
],
},
}).then(() => {
settled = true;
});
await flushAsyncWork();
});
expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(2);
expect(settled).toBe(false);
await act(async () => {
releaseFirst();
await flushAsyncWork();
});
expect(settled).toBe(false);
await act(async () => {
releaseSecond();
await movePromise;
await flushAsyncWork();
});
expect(settled).toBe(true);
unmount();
});
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[]) => Promise<void>>().mockResolvedValue(undefined);
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const reloadPreview = vi.fn();
const fetchMock = vi.fn(
async (
input: Parameters<typeof fetch>[0],
_init?: Parameters<typeof fetch>[1],
): 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,
reloadPreview,
});
await act(async () => {
await move(clip, { start: 1.25, track: clip.track });
});
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe document");
expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25");
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0");
expect(commit).not.toHaveBeenCalled();
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
expect(
fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
).toBe(true);
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();
});
});
+102 -87
View File
@@ -21,17 +21,21 @@ import {
resolveDroppedAssetDuration,
} from "../utils/studioHelpers";
import {
applyTimelineStackingReorder,
buildPatchTarget,
patchIframeDomTiming,
resolveResizePlaybackStart,
persistTimelineEdit,
readFileContent,
applyPatchByTarget,
formatTimelineAttributeNumber,
shiftGsapPositions,
scaleGsapPositions,
finishTimelineTimingFallback,
extendRootDurationIfNeeded,
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
} from "./timelineEditingHelpers";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import {
useTimelineElementVisibilityEditing,
useTimelineTrackVisibilityEditing,
@@ -39,6 +43,10 @@ import {
import { sdkTimingPersist } from "../utils/sdkCutover";
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
};
// ── Hook ──
export function useTimelineEditing({
@@ -56,6 +64,7 @@ export function useTimelineEditing({
isRecordingRef,
sdkSession,
forceReloadSdkSession,
handleDomZIndexReorderCommitRef,
}: UseTimelineEditingOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
@@ -115,64 +124,80 @@ export function useTimelineEditing({
// fallow-ignore-next-line complexity
const handleTimelineElementMove = useCallback(
// fallow-ignore-next-line complexity
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
patchIframeDomTiming(previewIframeRef.current, element, [
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-track-index", String(updates.track)],
]);
(element: TimelineElement, updates: TimelineMoveUpdates) => {
const targetPath = element.sourceFile || activeCompPath || "index.html";
const startChanged = updates.start !== element.start;
if (startChanged) {
patchIframeDomTiming(previewIframeRef.current, element, [
["data-start", formatTimelineAttributeNumber(updates.start)],
]);
}
const reorderDone = applyTimelineStackingReorder({
element,
stackingReorder: updates.stackingReorder,
timelineElements,
iframe: previewIframeRef.current,
activeCompPath,
commit: handleDomZIndexReorderCommitRef?.current,
});
if (!startChanged) return reorderDone;
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
let patched = applyPatchByTarget(original, target, {
type: "attribute",
property: "start",
value: formatTimelineAttributeNumber(updates.start),
});
return applyPatchByTarget(patched, target, {
type: "attribute",
property: "track-index",
value: String(updates.track),
});
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
};
// Server-path fallback (no SDK session): persist the attr patch, then
// shift GSAP tween positions on the server and reload the preview — the
// SDK path folds both into setTiming, but the fallback must do them
// explicitly or the clip moves while its GSAP tweens stay put + the
// preview never refreshes. coalesceKey mirrors the SDK branch so undo
// granularity is identical on either path.
// shift GSAP tween positions on the server. Extending edits can keep the
// iframe live unless a GSAP source rewrite needs a fresh run.
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
const moveFallback = () =>
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
const pid = projectIdRef.current;
const delta = updates.start - element.start;
if (delta !== 0 && element.domId && pid) {
return shiftGsapPositions(pid, targetPath, element.domId, delta)
.then(() => reloadPreview())
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
}
return reloadPreview();
});
if (sdkSession && element.hfId) {
return sdkTimingPersist(
element.hfId,
targetPath,
{ start: updates.start, trackIndex: updates.track },
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
const domId = element.domId;
return finishTimelineTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: updates.start + element.duration,
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();
gsapMutation:
delta !== 0 && domId && pid
? () => shiftGsapPositions(pid, targetPath, domId, delta)
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
});
});
}
return moveFallback();
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
// 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,
@@ -183,6 +208,8 @@ export function useTimelineEditing({
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
timelineElements,
handleDomZIndexReorderCommitRef,
],
);
@@ -210,25 +237,7 @@ export function useTimelineEditing({
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
const targetPath = element.sourceFile || activeCompPath || "index.html";
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
const pbs = resolveResizePlaybackStart(original, target, element, updates);
let patched = applyPatchByTarget(original, target, {
type: "attribute",
property: "start",
value: formatTimelineAttributeNumber(updates.start),
});
patched = applyPatchByTarget(patched, target, {
type: "attribute",
property: "duration",
value: formatTimelineAttributeNumber(updates.duration),
});
if (pbs) {
patched = applyPatchByTarget(patched, target, {
type: "attribute",
property: pbs.attrName,
value: formatTimelineAttributeNumber(pbs.value),
});
}
return patched;
return buildTimelineResizeTimingPatch(original, target, element, updates);
};
// SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
// The second clause fires because trimming the start of a clip that has a
@@ -238,32 +247,38 @@ export function useTimelineEditing({
updates.playbackStart != null ||
(updates.start !== element.start && element.playbackStart != null);
// Server-path fallback: after persisting the attr patch, scale GSAP tween
// positions/durations on the server and reload the preview. The SDK path
// folds both into setTiming; the fallback must do them explicitly or the
// clip resizes while its GSAP tweens keep their old timing + the preview
// never refreshes. coalesceKey mirrors the SDK branch for undo parity.
// positions/durations on the server. Extending edits can keep the iframe
// live unless a GSAP source rewrite needs a fresh run.
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
const timingChanged =
updates.start !== element.start || updates.duration !== element.duration;
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
const resizeFallback = () =>
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
const pid = projectIdRef.current;
if (timingChanged && element.domId && pid) {
return scaleGsapPositions(
pid,
targetPath,
element.domId,
element.start,
element.duration,
updates.start,
updates.duration,
)
.then(() => reloadPreview())
.catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
}
return reloadPreview();
const domId = element.domId;
return finishTimelineTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: updates.start + updates.duration,
reloadPreview,
gsapMutation:
timingChanged && domId && pid
? () =>
scaleGsapPositions(
pid,
targetPath,
domId,
element.start,
element.duration,
updates.start,
updates.duration,
)
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
});
});
if (sdkSession && element.hfId && !hasPbsAdjustment) {
if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
return sdkTimingPersist(
element.hfId,
targetPath,
@@ -10,6 +10,20 @@ 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;
zIndex: number;
id?: string;
selector?: string;
selectorIndex?: number;
sourceFile: string;
key?: string;
}>,
) => Promise<void>;
export interface UseTimelineEditingOptions {
projectId: string | null;
activeCompPath: string | null;
@@ -27,4 +41,5 @@ export interface UseTimelineEditingOptions {
sdkSession?: Composition | null;
/** Resync the SDK session after a server-authoritative timeline write. */
forceReloadSdkSession?: () => void;
handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
}