mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user