Merge pull request #2347 from heygen-com/fix/studio-restore-golden-behaviors

fix(studio): restore golden-branch timeline behaviors dropped by the stack rebuild
This commit is contained in:
Ular Kimsanov
2026-07-13 17:13:37 -07:00
committed by GitHub
59 changed files with 4737 additions and 1896 deletions
@@ -39,6 +39,44 @@ describe("collectRuntimeTimelinePayload", () => {
expect(result.clips[0].id).toBe("hf-headline");
});
// Regression: the authored data-track-index must round-trip verbatim, even
// when clips of DIFFERENT kinds (video vs element) share a track. The old
// mixed-kind renumber split them onto separate tracks, which made the
// written track drift from the displayed one on every editor move.
it("honors authored track indices verbatim for mixed-kind tracks", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const video = document.createElement("video");
video.id = "clip-video";
video.setAttribute("data-start", "1");
video.setAttribute("data-duration", "3");
video.setAttribute("data-track-index", "1");
root.appendChild(video);
const caption = document.createElement("div");
caption.id = "clip-caption";
caption.setAttribute("data-start", "8");
caption.setAttribute("data-duration", "3");
caption.setAttribute("data-track-index", "1");
root.appendChild(caption);
const other = document.createElement("div");
other.id = "clip-other";
other.setAttribute("data-start", "0");
other.setAttribute("data-duration", "3");
other.setAttribute("data-track-index", "2");
root.appendChild(other);
const result = collectRuntimeTimelinePayload(defaultParams);
const trackOf = (id: string) => result.clips.find((c) => c.id === id)?.track;
expect(trackOf("clip-video")).toBe(1);
expect(trackOf("clip-caption")).toBe(1);
expect(trackOf("clip-other")).toBe(2);
});
it("collects clips from elements with data-start and data-duration", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+17 -70
View File
@@ -52,57 +52,15 @@ function maxDefinedNumber(...values: Array<number | null>): number | null {
}
/**
* When multiple content kinds share the same track number, split them
* onto separate tracks so the timeline UI shows distinct rows.
*
* Preferred kind order (top → bottom): composition, video, image, element, audio.
* Tracks that contain only one kind are left untouched.
* Parse an authored track attribute, honoring 0 (a valid top-lane index).
* `parseInt(...) || fallback` silently replaced authored track 0 with the
* synthetic fallback, so track-0 clips drifted to the bottom of the timeline.
*/
const KIND_ORDER: Record<string, number> = {
composition: 0,
video: 1,
image: 2,
element: 3,
audio: 4,
};
function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
if (clips.length === 0) return;
// Group clips by their raw track number and detect which tracks have mixed kinds
const trackKinds = new Map<number, Set<string>>();
for (const clip of clips) {
const kinds = trackKinds.get(clip.track) ?? new Set();
kinds.add(clip.kind);
trackKinds.set(clip.track, kinds);
}
const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
if (!hasMixedTracks) return;
// Build new contiguous track numbers, splitting mixed tracks by kind
let nextTrack = 0;
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack
const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
for (const track of sortedTracks) {
const kinds = trackKinds.get(track)!;
if (kinds.size === 1) {
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
} else {
// Split by kind in preferred order
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
for (const kind of sorted) {
newTrackMap.set(`${track}:${kind}`, nextTrack++);
}
}
}
for (const clip of clips) {
const key = `${clip.track}:${clip.kind}`;
const newTrack = newTrackMap.get(key);
if (newTrack != null) clip.track = newTrack;
}
function parseAuthoredTrack(el: Element, fallback: number): number {
const raw = el.getAttribute("data-track-index") ?? el.getAttribute("data-track");
if (raw == null) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
@@ -441,11 +399,7 @@ export function collectRuntimeTimelinePayload(params: {
label: buildTimelineClipLabel(node, kind, clips.length),
start,
duration,
track:
Number.parseInt(
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
10,
) || 0,
track: parseAuthoredTrack(node, i),
zIndex: readInlineZIndex(node),
stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId,
kind,
@@ -554,11 +508,7 @@ export function collectRuntimeTimelinePayload(params: {
el.id,
start: range.start,
duration: clampedDuration,
track:
Number.parseInt(
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
10,
) || gsapTrack,
track: parseAuthoredTrack(el, gsapTrack),
zIndex: readInlineZIndex(el),
stackingContextId: rootCompositionIdForGsap,
kind: "element",
@@ -613,11 +563,7 @@ export function collectRuntimeTimelinePayload(params: {
el.id,
start: 0,
duration: clampedDuration,
track:
Number.parseInt(
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
10,
) || overlayTrack,
track: parseAuthoredTrack(el, overlayTrack),
zIndex: readInlineZIndex(el),
stackingContextId: rootCompositionIdForGsap,
kind: "element",
@@ -637,11 +583,12 @@ export function collectRuntimeTimelinePayload(params: {
}
}
// ── Track normalization ────────────────────────────────────────────────
// When multiple content kinds (composition, audio, video, …) share the same
// data-track-index value, split them onto separate tracks so the timeline UI
// shows distinct rows for each kind.
normalizeTrackAssignments(clips);
// Track assignment honors the authored data-track-index verbatim: a clip stays
// on the track it was placed on, regardless of kind. (Previously mixed-kind
// tracks were split onto separate rows, but that renumbered tracks — breaking
// "drop a clip onto an existing track" and causing the written track to drift
// from the displayed one on every move. Track index is display-only; render
// never reads it, so honoring it verbatim is the correct NLE behavior.)
for (const compositionNode of compositionNodes) {
if (compositionNode === root) continue;
@@ -3,7 +3,11 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
import { resolveZIndexEntries } from "../nle/PreviewOverlays";
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
import { CanvasContextMenu } from "./CanvasContextMenu";
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
import type { DomEditSelection } from "./domEditing";
installReactActEnvironment();
@@ -24,7 +28,8 @@ afterEach(() => {
function renderMenu(props: {
selection: DomEditSelection;
onApplyZIndex?: () => void;
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
onDelete?: (selection: DomEditSelection) => void;
}) {
root = createRoot(host);
@@ -36,6 +41,7 @@ function renderMenu(props: {
selection: props.selection,
onClose: () => {},
onApplyZIndex: props.onApplyZIndex,
onZOrderCrossed: props.onZOrderCrossed,
onDelete: props.onDelete,
}),
);
@@ -113,3 +119,148 @@ describe("CanvasContextMenu — handler gating", () => {
expect(document.body.querySelector(".border-t")).toBeNull();
});
});
// ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──
function pressMenuItem(label: string) {
const button = zOrderButtons().find((b) => b.textContent === label);
expect(button).toBeDefined();
act(() => {
button!.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
);
});
}
/** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */
function makeStaticFamily() {
const parent = document.createElement("div");
const target = document.createElement("div");
target.id = "target";
// In happy-dom an unset computed position is "" (not "static"), which would
// skip the commit hook's static-position injection; declare it explicitly so
// the test exercises the browser default.
target.style.position = "static";
const other = document.createElement("div");
other.id = "other";
parent.append(target, other);
document.body.append(parent);
return { parent, target, other };
}
interface CapturedBatchCall {
batches: DomEditPatchBatch[];
options: { label: string; coalesceKey: string };
}
/** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */
function renderCommitHook(captured: CapturedBatchCall[]) {
type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
let commit: Commit | undefined;
function Harness() {
({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps({
activeCompPath: "index.html",
showToast: vi.fn(),
writeProjectFile: vi.fn(async () => {}),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: vi.fn(async () => {}) },
projectIdRef: { current: null },
reloadPreview: vi.fn(),
clearDomSelection: vi.fn(),
commitDomEditPatchBatches: async (batches, options) => {
captured.push({ batches, options });
},
}));
return null;
}
const hookHost = document.createElement("div");
document.body.append(hookHost);
const hookRoot = createRoot(hookHost);
act(() => hookRoot.render(<Harness />));
return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
}
describe("CanvasContextMenu — z-action commit path", () => {
it("never mutates live styles itself and persists the position patch for a static element", async () => {
const { target } = makeStaticFamily();
const selection = makeSelection("Target", target);
const captured: CapturedBatchCall[] = [];
const { commit, cleanup } = renderCommitHook(captured);
// Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
// hook), asserting the menu has NOT touched the DOM when it fires — the
// hook must capture true pre-change styles for its rollback.
const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
renderMenu({
selection,
onApplyZIndex: (patches, action) => {
stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
const { entries } = resolveZIndexEntries(selection, patches);
void commit(entries, undefined, action);
},
});
await act(async () => pressMenuItem("Bring forward"));
// The menu left the element pristine; only the commit hook wrote styles.
expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]);
expect(target.style.zIndex).toBe("1");
expect(target.style.position).toBe("relative");
// The persisted payload carries BOTH the z-index and the injected position,
// so the reorder survives the post-commit reloadPreview().
expect(captured).toHaveLength(1);
const targetPatch = captured[0]?.batches
.flatMap((batch) => batch.patches)
.find((patch) => patch.target.id === "target");
expect(targetPatch?.operations).toEqual(
expect.arrayContaining([
{ type: "inline-style", property: "z-index", value: "1" },
{ type: "inline-style", property: "position", value: "relative" },
]),
);
// F7: the action kind is part of the default undo coalesce key, so two
// different menu actions never merge into one undo step.
expect(captured[0]?.options.coalesceKey).toContain("bring-forward");
cleanup();
});
it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => {
// target (earlier in DOM) and other are tied — bring-forward steps over
// `other`, and the flash callback must receive exactly that element, after
// onApplyZIndex ran (call order lets the host measure post-commit rects).
const { target, other } = makeStaticFamily();
const selection = makeSelection("Target", target);
const calls: Array<{ kind: string; crossed?: HTMLElement }> = [];
renderMenu({
selection,
onApplyZIndex: () => calls.push({ kind: "apply" }),
onZOrderCrossed: (crossed, action) => {
expect(action).toBe("bring-forward");
calls.push({ kind: "crossed", crossed });
},
});
await act(async () => pressMenuItem("Bring forward"));
expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]);
expect(calls[1]?.crossed).toBe(other);
});
it("does not call onZOrderCrossed for bring-to-front", async () => {
const { target } = makeStaticFamily();
const onZOrderCrossed = vi.fn();
renderMenu({
selection: makeSelection("Target", target),
onApplyZIndex: vi.fn(),
onZOrderCrossed,
});
await act(async () => pressMenuItem("Bring to front"));
expect(onZOrderCrossed).not.toHaveBeenCalled();
});
});
@@ -7,10 +7,13 @@
* useContextMenuDismiss.
*
* ── Wiring (z-order persistence) ─────────────────────────────────────────────
* Z-index changes are applied optimistically to the live iframe element(s) via
* Z-index changes are resolved against the live iframe DOM via
* `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
* moving a target past an equal-z sibling can require renumbering the affected
* set). The patches are surfaced through the `onApplyZIndex` prop.
* set). The patches are surfaced through the `onApplyZIndex` prop; the menu
* itself never mutates element styles — handleDomZIndexReorderCommit applies
* the live z-index (and injects position when needed) in the same synchronous
* flow, and captures the TRUE prior styles for its failure rollback.
*
* The prop MUST be wired at the call site to route through the full persist
* path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
@@ -26,7 +29,9 @@ import type { DomEditSelection } from "./domEditing";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import {
isZOrderActionEnabled,
resolveCrossedNeighbor,
resolveZOrderChange,
type ZOrderAction,
type ZOrderPatch,
} from "./canvasContextMenuZOrder";
@@ -38,12 +43,22 @@ interface CanvasContextMenuProps {
selection: DomEditSelection;
onClose: () => void;
/**
* Called with the resolved z-order patch list after an optimistic DOM update.
* Each patch is an { element, zIndex } pair (the target and, when a renumber
* is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
* module-level wiring comment).
* Called with the resolved z-order patch list and the menu action that
* produced it (the action feeds the undo coalesce key, so two DIFFERENT
* actions never merge into one undo step). Each patch is an
* { element, zIndex } pair (the target and, when a renumber is needed,
* affected siblings). The menu does NOT touch the live DOM — wire to
* handleDomZIndexReorderCommit, which applies the live styles itself
* (see module-level wiring comment).
*/
onApplyZIndex?: (patches: ZOrderPatch[]) => void;
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
/**
* Called after a successful bring-forward / send-backward with the sibling
* the target stepped over (resolved from the SAME pre-mutation state as the
* patches), so the host can flash a highlight on it in the studio overlay.
* Never called for front/back or no-op actions.
*/
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
/**
* Delete the selected element. Wire to handleDomEditElementDelete from
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
@@ -68,6 +83,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
selection,
onClose,
onApplyZIndex,
onZOrderCrossed,
onDelete,
}: CanvasContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
@@ -93,20 +109,19 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
const el = selection.element;
function handleZAction(action: ZAction) {
// No persist handler → do NOT touch the live iframe DOM. An optimistic
// write with nothing to persist just reverts on the next reload.
if (!onApplyZIndex) return;
const patches = resolveZOrderChange(el, action);
if (patches === null) return;
// Optimistic update — visible immediately even before persist completes.
for (const patch of patches) {
patch.element.style.zIndex = String(patch.zIndex);
const view = patch.element.ownerDocument?.defaultView;
if (view && view.getComputedStyle(patch.element).position === "static") {
patch.element.style.position = "relative";
}
}
onApplyZIndex(patches);
// Resolve the crossed neighbor BEFORE the commit path mutates live styles —
// both resolvers must read the same pre-change render order.
const crossed = onZOrderCrossed ? resolveCrossedNeighbor(el, action) : null;
// Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
// live z-index (and injects position:relative for static elements) in the
// same synchronous flow, so feedback is still instant — and it must read
// the PRE-change styles itself, both to capture true rollback values and
// to detect a static position that needs persisting.
onApplyZIndex(patches, action);
if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
onClose();
}
@@ -1,9 +1,11 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
import { useMarqueeGestures } from "./marqueeCommit";
import { MarqueeOverlay } from "./MarqueeOverlay";
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFlash";
import { useCanvasContextMenuState } from "./useCanvasContextMenuState";
import {
type BlockedMoveState,
type DomEditGroupPathOffsetCommit,
@@ -27,7 +29,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
import { useMountEffect } from "../../hooks/useMountEffect";
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
import { CanvasContextMenu } from "./CanvasContextMenu";
import type { ZOrderPatch } from "./canvasContextMenuZOrder";
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
// Re-exports for external consumers — preserving existing import paths.
@@ -91,12 +93,17 @@ interface DomEditOverlayProps {
*/
onDeleteSelection?: (selection: DomEditSelection) => void;
/**
* Called with the resolved z-order patch list after an optimistic DOM update.
* The patch list is tie-aware and may include sibling elements (see
* canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from
* Called with the resolved z-order patch list and the menu action that
* produced it (feeds the undo coalesce key). The patch list is tie-aware and
* may include sibling elements (see canvasContextMenuZOrder); the live DOM is
* NOT yet mutated. Wire to handleDomZIndexReorderCommit from
* useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
*/
onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void;
onApplyZIndex?: (
selection: DomEditSelection,
patches: ZOrderPatch[],
action: ZOrderAction,
) => void;
}
// fallow-ignore-next-line complexity
@@ -139,30 +146,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const snapGuidesRef = useRef<SnapGuidesState | null>(null);
const rafPausedRef = useRef(false);
// Context menu state: position of the right-click that opened it.
// contextMenuSelection is the element the menu targets — captured at right-click
// time so the menu can open even before the React selection state settles.
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
sel: DomEditSelection;
} | null>(null);
const selectionRef = useRef(selection);
selectionRef.current = selection;
// Close the context menu whenever the selection moves off the element the menu
// targets (a click that reselects elsewhere, a deselect, or a preview reload
// that rebuilds the selection). Without this the menu can linger — orphaned —
// over a stale target after the underlying element is gone. A right-click that
// OPENS the menu also selects its target, so the common open path keeps the
// menu (same element) rather than immediately dismissing it.
useEffect(() => {
if (!contextMenu) return;
if (!selection || selection.element !== contextMenu.sel.element) {
setContextMenu(null);
}
}, [selection, contextMenu]);
// Brief highlight on the sibling a forward/backward z step crossed — drawn
// in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash).
const { zOrderFlashRect, handleZOrderCrossed } = useZOrderCrossedFlash({ overlayRef, iframeRef });
const activeCompositionPathRef = useRef(activeCompositionPath);
activeCompositionPathRef.current = activeCompositionPath;
@@ -428,37 +417,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
e.stopPropagation();
};
// Right-click: select element first (if not already selected), then open menu.
const handleContextMenu = useCallback(
async (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
// If no element is selected yet, resolve it from the pointer position first.
const currentSel = selectionRef.current;
let activeSel: DomEditSelection | null = currentSel;
if (!currentSel) {
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
if (!resolved) return; // Nothing under the cursor — skip menu.
onSelectionChangeRef.current(resolved, { revealPanel: true });
// Use `resolved` directly: React state (and therefore selectionRef) won't
// update synchronously after onSelectionChange — we'd be reading stale null.
activeSel = resolved;
} else {
// Check if the user right-clicked on an unselected element (hover target).
const hover = hoverSelectionRef.current;
if (hover && hover.element !== currentSel.element) {
onSelectionChangeRef.current(hover, { revealPanel: true });
activeSel = hover;
}
}
if (!activeSel) return;
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// Right-click state + handler: select the element under the pointer (if
// needed), then open the menu; closes when the selection moves off-target.
const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
selection,
selectionRef,
hoverSelectionRef,
onCanvasPointerMoveRef,
onSelectionChangeRef,
});
return (
<div
@@ -551,24 +518,26 @@ export const DomEditOverlay = memo(function DomEditOverlay({
x={contextMenu.x}
y={contextMenu.y}
selection={contextMenu.sel}
onClose={() => setContextMenu(null)}
onClose={closeContextMenu}
onDelete={
onDeleteSelection
? (sel) => {
setContextMenu(null);
closeContextMenu();
onDeleteSelection(sel);
}
: undefined
}
onApplyZIndex={
onApplyZIndex
? (patches) => {
onApplyZIndex(contextMenu.sel, patches);
? (patches, action) => {
onApplyZIndex(contextMenu.sel, patches, action);
}
: undefined
}
onZOrderCrossed={handleZOrderCrossed}
/>
)}
<ZOrderCrossedFlash rect={zOrderFlashRect} />
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
@@ -15,6 +15,7 @@ import {
import { Layers } from "../../icons/SystemIcons";
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
const TAG_ICONS: Record<string, string> = {
video: "Vi",
@@ -280,9 +281,17 @@ export const LayersPanel = memo(function LayersPanel() {
selector: layer.selector,
selectorIndex: layer.selectorIndex,
sourceFile: layer.sourceFile,
key: deriveTimelineStoreKey({
domId: layer.id,
selector: layer.selector,
selectorIndex: layer.selectorIndex,
sourceFile: layer.sourceFile,
}),
}));
handleDomZIndexReorderCommit(entries);
// "layer-drag" keeps consecutive drops of the same sibling set coalescing
// into one undo step, without merging with a context-menu z action.
handleDomZIndexReorderCommit(entries, undefined, "layer-drag");
},
[handleDomZIndexReorderCommit],
);
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
isElementVisibleForZOrder,
isZOrderActionEnabled,
parseZIndex,
resolveCrossedNeighbor,
resolveZOrderChange,
type ZOrderAction,
type ZOrderPatch,
@@ -48,8 +50,12 @@ function makeFamily(
}
/** Resolve a z-order change and assert it produced patches (fails otherwise). */
function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] {
const patches = resolveZOrderChange(target, action);
function resolveZOrderPatches(
target: HTMLElement,
action: ZOrderAction,
options?: { isVisible?: (el: HTMLElement) => boolean },
): ZOrderPatch[] {
const patches = resolveZOrderChange(target, action, options);
expect(patches).not.toBeNull();
if (!patches) throw new Error("expected z-order patches");
return patches;
@@ -375,6 +381,27 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
});
it("ignores <template>/<noscript> siblings in the family", () => {
// A renumber fallback once wrote z-index/position into <template> source
// markup because templates entered the sibling family. They never paint —
// exclude them like audio/script/style.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const template = document.createElement("template");
template.style.zIndex = "2";
const noscript = document.createElement("noscript");
const target = makeEl("target", "0");
parent.append(a, template, noscript, target);
const patches = resolveZOrderPatches(target, "send-to-back");
for (const p of patches) {
expect(p.element).not.toBe(template);
expect(p.element).not.toBe(noscript);
}
const order = renderOrderIds(parent, { a, target }, patches);
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
});
it("a lone painting element beside only non-painting siblings has no family → null", () => {
const parent = document.createElement("div");
const target = makeEl("target", "1");
@@ -392,6 +419,163 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
});
});
// ── visibility scoping (forward/backward step over VISIBLE siblings only) ──────
//
// The visibility probe is injectable (ZOrderResolveOptions.isVisible) exactly
// like rect reading is stubbable — these tests drive the scoping logic with a
// stub, without a real style engine.
describe("resolveZOrderChange – visibility scoping (injectable stub)", () => {
/** Probe that hides exactly the given elements. */
function hiding(...hidden: HTMLElement[]) {
return { isVisible: (el: HTMLElement) => !hidden.includes(el) };
}
it("bring-forward steps over the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
// Render order: target(1), hidden(2), vis(3). The nearest z-neighbor above
// is invisible at the current frame — forward must land the target above
// `vis` (the next VISIBLE overlapping sibling), leaving `hidden` untouched.
const { target, byId } = makeFamily("1", [
["hidden", "2"],
["vis", "3"],
]);
const patches = resolveZOrderPatches(target, "bring-forward", hiding(byId.hidden!));
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(4);
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
});
it("send-backward steps below the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
// Render order: vis(1), hidden(2), target(3). Backward must drop the target
// below `vis`, not merely below the invisible `hidden`.
const { target, byId } = makeFamily("3", [
["vis", "1"],
["hidden", "2"],
]);
const patches = resolveZOrderPatches(target, "send-backward", hiding(byId.hidden!));
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(0);
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
});
it("forward/backward are no-ops when every overlapping sibling is invisible", () => {
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
expect(resolveZOrderChange(target, "bring-forward", opts)).toBeNull();
expect(resolveZOrderChange(target, "send-backward", opts)).toBeNull();
});
it("bring-to-front / send-to-back keep the FULL painting family (invisible siblings included)", () => {
// Unchanged semantics: front/back operate across all siblings, so an
// invisible sibling still counts and the actions stay meaningful.
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
const patches = resolveZOrderPatches(target, "bring-to-front", opts);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
expect(resolveZOrderChange(target, "send-to-back", opts)).toBeNull(); // already bottom
});
it("the target itself is retained even when the probe reports it invisible", () => {
const { target, byId } = makeFamily("1", [["vis", "2"]]);
const patches = resolveZOrderPatches(target, "bring-forward", hiding(target));
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
});
it("isZOrderActionEnabled matches the resolver under the same visibility scope", () => {
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
// Forward/backward: scoped set collapses to the target alone → disabled.
expect(isZOrderActionEnabled(target, "bring-forward", opts)).toBe(false);
expect(isZOrderActionEnabled(target, "send-backward", opts)).toBe(false);
// Front/back: full family → enabled exactly where the resolver acts.
expect(isZOrderActionEnabled(target, "bring-to-front", opts)).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back", opts)).toBe(false);
});
});
// ── default visibility probe (element-level computed style) ───────────────────
describe("isElementVisibleForZOrder – default probe", () => {
function attachedEl(style: Partial<CSSStyleDeclaration> = {}): HTMLElement {
const el = document.createElement("div");
Object.assign(el.style, style);
document.body.appendChild(el);
return el;
}
it("treats display:none / visibility:hidden / opacity≈0 as invisible", () => {
expect(isElementVisibleForZOrder(attachedEl({ display: "none" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ visibility: "hidden" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.005" }))).toBe(false);
});
it("treats normal, translucent, and unstyled elements as visible", () => {
expect(isElementVisibleForZOrder(attachedEl())).toBe(true);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.5" }))).toBe(true);
expect(isElementVisibleForZOrder(attachedEl({ visibility: "visible" }))).toBe(true);
});
it("exempts a hidden color-grading source (its canvas paints in its place)", () => {
const el = attachedEl({ opacity: "0" });
el.setAttribute("data-hf-color-grading-source-hidden", "");
expect(isElementVisibleForZOrder(el)).toBe(true);
});
it("is the default probe: the runtime's inline visibility:hidden on a time-inactive clip is skipped", () => {
// End-to-end through resolveZOrderChange with NO injected probe: the
// runtime hides inactive clips with inline `visibility:hidden` (see core
// runtime syncTimedElementVisibility) — computed style picks that up.
const parent = document.createElement("div");
const target = makeEl("target", "1");
const hidden = makeEl("hidden", "2");
hidden.style.visibility = "hidden";
const vis = makeEl("vis", "3");
parent.append(target, hidden, vis);
document.body.appendChild(parent);
const patches = resolveZOrderPatches(target, "bring-forward");
expect(patchFor(patches, { target, hidden, vis }, "target")?.zIndex).toBe(4);
expect(patchFor(patches, { target, hidden, vis }, "hidden")).toBeUndefined();
});
});
// ── resolveCrossedNeighbor (the "show your work" flash target) ────────────────
describe("resolveCrossedNeighbor", () => {
it("returns the visible sibling directly above for bring-forward", () => {
const { target, byId } = makeFamily("1", [
["hidden", "2"],
["vis", "3"],
]);
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
expect(resolveCrossedNeighbor(target, "bring-forward", opts)).toBe(byId.vis);
});
it("returns the visible sibling directly below for send-backward", () => {
const { target, byId } = makeFamily("3", [
["vis", "1"],
["hidden", "2"],
]);
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
expect(resolveCrossedNeighbor(target, "send-backward", opts)).toBe(byId.vis);
});
it("returns null for front/back actions and for no-op steps", () => {
const { target } = makeFamily("1", [["a", "2"]]);
expect(resolveCrossedNeighbor(target, "bring-to-front")).toBeNull();
expect(resolveCrossedNeighbor(target, "send-to-back")).toBeNull();
expect(resolveCrossedNeighbor(target, "send-backward")).toBeNull(); // already bottom
const { target: top } = makeFamily("5", [["a", "2"]]);
expect(resolveCrossedNeighbor(top, "bring-forward")).toBeNull(); // already top
});
it("returns null when there are no siblings", () => {
const solo = makeEl("solo", "1");
document.createElement("div").appendChild(solo);
expect(resolveCrossedNeighbor(solo, "bring-forward")).toBeNull();
});
});
// ── isZOrderActionEnabled ─────────────────────────────────────────────────────
describe("isZOrderActionEnabled", () => {
@@ -7,8 +7,23 @@
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
*
* "Overlapping siblings" = siblings whose bounding rects intersect the
* target's bounding rect. Forward/backward operate within that set;
* front/back operate across all siblings.
* target's bounding rect AND are actually visible at the current frame.
* Forward/backward operate within that set; front/back operate across all
* siblings (full painting family, visible or not — unchanged semantics).
*
* ── Visibility ───────────────────────────────────────────────────────────────
* In HyperFrames compositions the nearest z-neighbor is often INVISIBLE at the
* paused frame: the runtime hides time-inactive clips with inline
* `visibility:hidden` / `display:none` (see core runtime
* syncTimedElementVisibility), and GSAP timelines park elements at `opacity:0`.
* Stepping "forward" over such a sibling looks like a silent no-op. The
* forward/backward comparison set therefore keeps only siblings whose
* element-level computed style is visible (display ≠ none, visibility ≠
* hidden, opacity > 0.01) — all runtime hiding signals are inline styles, so
* computed style covers them. Ancestor-chain checks are unnecessary here:
* siblings share the target's ancestors. The probe is injectable
* (ZOrderResolveOptions.isVisible) so the pure-module tests stay meaningful
* without a real style engine, mirroring how tests stub rect reading.
*
* ── Tie-awareness ────────────────────────────────────────────────────────────
* CSS paint order for elements that share a z-index is DOM document order:
@@ -26,6 +41,8 @@
* (project convention clamps z ≥ 0).
*/
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
/** A resolved change: set `element`'s z-index to `zIndex`. */
@@ -34,6 +51,48 @@ export interface ZOrderPatch {
zIndex: number;
}
/** Injectable knobs for the pure resolver (kept mockable like rect reading). */
export interface ZOrderResolveOptions {
/**
* Element-level visibility probe used to scope the forward/backward
* comparison set. Defaults to `isElementVisibleForZOrder` (computed-style
* display/visibility/opacity). Injectable so tests can run without a real
* style engine.
*/
isVisible?: (el: HTMLElement) => boolean;
}
/**
* Default visibility probe: is this element itself visible at the current
* frame? Element-level only (siblings share the target's ancestor chain).
* Covers the runtime's inactive-clip hiding (inline `visibility:hidden` /
* `display:none`) and animation-parked `opacity:0`, all of which surface
* through computed style. A color-grading source (hidden at opacity:0 while
* its canvas paints in its place) still counts as visible, matching
* isElementVisibleThroughAncestors in domEditingDom.
*/
export function isElementVisibleForZOrder(el: HTMLElement): boolean {
try {
const win = el.ownerDocument?.defaultView;
if (!win) return true;
const computed = win.getComputedStyle(el);
if (computed.display === "none") return false;
if (computed.visibility === "hidden" || computed.visibility === "collapse") return false;
const opacity = Number.parseFloat(computed.opacity);
if (
Number.isFinite(opacity) &&
opacity <= 0.01 &&
!el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
) {
return false;
}
return true;
} catch {
/* cross-origin / detached — assume visible (fail open, matches rect fallback) */
return true;
}
}
interface RenderEntry {
element: HTMLElement;
zIndex: number;
@@ -81,8 +140,18 @@ function isElementNode(node: Node): node is HTMLElement {
* z-index onto the qa-clean audio element, and counting it as a sibling skews the
* renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
* non-painting and could otherwise pad the family / eat a z slot.
* `<template>/<noscript>` never paint either — letting them in meant renumber
* fallbacks wrote z-index/position into template source markup.
*/
const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
const NON_PAINTING_TAGS = new Set([
"AUDIO",
"SCRIPT",
"STYLE",
"LINK",
"META",
"TEMPLATE",
"NOSCRIPT",
]);
/** A painting element: an element node whose tag actually renders pixels. */
function isPaintingElement(node: Node): node is HTMLElement {
@@ -112,7 +181,7 @@ function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex:
return { entries, targetIndex };
}
/** True if two DOM bounding rects intersect (even if touching). */
/** True if two DOM bounding rects strictly overlap (rects that merely touch do NOT intersect). */
function rectsIntersect(
a: { left: number; top: number; right: number; bottom: number },
b: { left: number; top: number; right: number; bottom: number },
@@ -121,26 +190,35 @@ function rectsIntersect(
}
/**
* Restrict a family to the target plus siblings whose bounding rect overlaps
* the target's rect. The target is always retained. If the target's rect is
* unavailable or empty (headless / happy-dom returns 0×0), all entries are
* kept — matching the prior behavior.
* Restrict a family to the target plus siblings that are VISIBLE and whose
* bounding rect overlaps the target's rect. The target is always retained
* (even when itself hidden at the current frame — it is the user's explicit
* selection). If the target's rect is unavailable or empty (headless /
* happy-dom returns 0×0), the overlap filter is skipped and all VISIBLE
* entries are kept — matching the prior rect-fallback behavior.
*/
function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
function getOverlappingFamily(
target: HTMLElement,
entries: RenderEntry[],
isVisible: (el: HTMLElement) => boolean,
): RenderEntry[] {
const visibleEntries = entries.filter(
(entry) => entry.element === target || isVisible(entry.element),
);
let targetRect: DOMRect;
try {
targetRect = target.getBoundingClientRect();
} catch {
return entries;
return visibleEntries;
}
if (targetRect.width === 0 && targetRect.height === 0) return entries;
if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
const tr = {
left: targetRect.left,
top: targetRect.top,
right: targetRect.right,
bottom: targetRect.bottom,
};
return entries.filter((entry) => {
return visibleEntries.filter((entry) => {
if (entry.element === target) return true;
try {
const r = entry.element.getBoundingClientRect();
@@ -295,6 +373,33 @@ function buildGlobalOrder(
return rest;
}
/**
* The shared scoping pipeline: full painting family for front/back, visible
* overlapping siblings for forward/backward, sorted into render order with the
* target's position. Null when the family/scope is too small to act on.
*/
function resolveScopedRenderOrder(
target: HTMLElement,
action: ZOrderAction,
options?: ZOrderResolveOptions,
): { entries: RenderEntry[]; order: RenderEntry[]; pos: number } | null {
const { entries } = getFamily(target);
// Family always includes the target; fewer than 2 means no siblings at all.
if (entries.length < 2) return null;
const isVisible = options?.isVisible ?? isElementVisibleForZOrder;
const scoped =
action === "bring-to-front" || action === "send-to-back"
? entries
: getOverlappingFamily(target, entries, isVisible);
if (scoped.length < 2) return null;
const order = toRenderOrder(scoped);
const pos = order.findIndex((e) => e.element === target);
if (pos === -1) return null;
return { entries, order, pos };
}
/**
* Resolve the z-order patches for an action.
*
@@ -304,20 +409,11 @@ function buildGlobalOrder(
export function resolveZOrderChange(
target: HTMLElement,
action: ZOrderAction,
options?: ZOrderResolveOptions,
): ZOrderPatch[] | null {
const { entries } = getFamily(target);
// Family always includes the target; fewer than 2 means no siblings at all.
if (entries.length < 2) return null;
const scoped =
action === "bring-to-front" || action === "send-to-back"
? entries
: getOverlappingFamily(target, entries);
if (scoped.length < 2) return null;
const order = toRenderOrder(scoped);
const pos = order.findIndex((e) => e.element === target);
if (pos === -1) return null;
const resolved = resolveScopedRenderOrder(target, action, options);
if (!resolved) return null;
const { entries, order, pos } = resolved;
const desired = [...order];
const [moved] = desired.splice(pos, 1);
@@ -344,9 +440,35 @@ export function resolveZOrderChange(
}
/**
* Whether a z-order action is available for the target.
* "disabled" = the element is already at that limit.
* The sibling a forward/backward step crosses: the visible overlapping
* neighbor directly above (bring-forward) or below (send-backward) the target
* in render order. Null for front/back, for a no-op step, or when the scope is
* too small. Uses the SAME scoping as resolveZOrderChange, so call it with the
* same options BEFORE any live styles are applied.
*/
export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
return resolveZOrderChange(target, action) !== null;
export function resolveCrossedNeighbor(
target: HTMLElement,
action: ZOrderAction,
options?: ZOrderResolveOptions,
): HTMLElement | null {
if (action !== "bring-forward" && action !== "send-backward") return null;
const resolved = resolveScopedRenderOrder(target, action, options);
if (!resolved) return null;
const { order, pos } = resolved;
const neighbor = action === "bring-forward" ? order[pos + 1] : order[pos - 1];
return neighbor?.element ?? null;
}
/**
* Whether a z-order action is available for the target.
* "disabled" = the element is already at that limit. Shares the resolver (and
* its visibility scoping), so enable/disable always matches what the action
* would actually do.
*/
export function isZOrderActionEnabled(
target: HTMLElement,
action: ZOrderAction,
options?: ZOrderResolveOptions,
): boolean {
return resolveZOrderChange(target, action, options) !== null;
}
@@ -0,0 +1,92 @@
/**
* Canvas right-click context-menu state for DomEditOverlay: where the menu is
* open (viewport x/y) and which selection it targets, plus the right-click
* handler that resolves/selects the element under the pointer before opening.
*/
import { useCallback, useEffect, useState, type RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
export interface CanvasContextMenuState {
x: number;
y: number;
sel: DomEditSelection;
}
interface UseCanvasContextMenuStateParams {
selection: DomEditSelection | null;
selectionRef: RefObject<DomEditSelection | null>;
hoverSelectionRef: RefObject<DomEditSelection | null>;
onCanvasPointerMoveRef: RefObject<
(
event: React.PointerEvent<HTMLDivElement>,
options?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>
>;
onSelectionChangeRef: RefObject<
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
>;
}
export function useCanvasContextMenuState({
selection,
selectionRef,
hoverSelectionRef,
onCanvasPointerMoveRef,
onSelectionChangeRef,
}: UseCanvasContextMenuStateParams): {
contextMenu: CanvasContextMenuState | null;
closeContextMenu: () => void;
handleContextMenu: (event: React.MouseEvent<HTMLDivElement>) => Promise<void>;
} {
// Context menu state: position of the right-click that opened it.
// contextMenu.sel is the element the menu targets — captured at right-click
// time so the menu can open even before the React selection state settles.
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(null);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
// Close the context menu whenever the selection moves off the element the menu
// targets (a click that reselects elsewhere, a deselect, or a preview reload
// that rebuilds the selection). Without this the menu can linger — orphaned —
// over a stale target after the underlying element is gone. A right-click that
// OPENS the menu also selects its target, so the common open path keeps the
// menu (same element) rather than immediately dismissing it.
useEffect(() => {
if (!contextMenu) return;
if (!selection || selection.element !== contextMenu.sel.element) {
setContextMenu(null);
}
}, [selection, contextMenu]);
// Right-click: select element first (if not already selected), then open menu.
const handleContextMenu = useCallback(
async (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
// If no element is selected yet, resolve it from the pointer position first.
const currentSel = selectionRef.current;
let activeSel: DomEditSelection | null = currentSel;
if (!currentSel) {
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
if (!resolved) return; // Nothing under the cursor — skip menu.
onSelectionChangeRef.current(resolved, { revealPanel: true });
// Use `resolved` directly: React state (and therefore selectionRef) won't
// update synchronously after onSelectionChange — we'd be reading stale null.
activeSel = resolved;
} else {
// Check if the user right-clicked on an unselected element (hover target).
const hover = hoverSelectionRef.current;
if (hover && hover.element !== currentSel.element) {
onSelectionChangeRef.current(hover, { revealPanel: true });
activeSel = hover;
}
}
if (!activeSel) return;
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
},
[selectionRef, hoverSelectionRef, onCanvasPointerMoveRef, onSelectionChangeRef],
);
return { contextMenu, closeContextMenu, handleContextMenu };
}
@@ -0,0 +1,68 @@
/**
* Z-order "show your work" flash: after a bring-forward / send-backward, the
* sibling that was stepped over gets a brief (600ms) highlight so the action
* is legible even when the visual change is subtle.
*
* Drawn in the STUDIO's own overlay layer above the preview iframe — nothing
* is written into the iframe DOM or the composition, so a concurrent preview
* reload can never leave a stuck highlight; the timeout merely clears
* studio-local state. The crossed element is resolved by the context menu
* (resolveCrossedNeighbor) from the same pre-mutation render order as the
* z patches; z-index writes don't move layout, so measuring its rect after
* the commit applied live styles is still accurate.
*/
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { toVisibleOverlayRect, type OverlayRect } from "./domEditOverlayGeometry";
const Z_ORDER_CROSSED_FLASH_MS = 600;
interface UseZOrderCrossedFlashParams {
overlayRef: RefObject<HTMLDivElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
}
export function useZOrderCrossedFlash({ overlayRef, iframeRef }: UseZOrderCrossedFlashParams): {
zOrderFlashRect: OverlayRect | null;
handleZOrderCrossed: (crossed: HTMLElement) => void;
} {
const [zOrderFlashRect, setZOrderFlashRect] = useState<OverlayRect | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[],
);
const handleZOrderCrossed = useCallback(
(crossed: HTMLElement) => {
const overlayEl = overlayRef.current;
const iframe = iframeRef.current;
if (!overlayEl || !iframe) return;
const rect = toVisibleOverlayRect(overlayEl, iframe, crossed);
if (!rect || rect.width <= 0 || rect.height <= 0) return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setZOrderFlashRect(rect);
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
setZOrderFlashRect(null);
}, Z_ORDER_CROSSED_FLASH_MS);
},
[overlayRef, iframeRef],
);
return { zOrderFlashRect, handleZOrderCrossed };
}
/** The flash chrome itself — a pulsing accent outline over the crossed sibling. */
export function ZOrderCrossedFlash({ rect }: { rect: OverlayRect | null }) {
if (!rect) return null;
return (
<div
aria-hidden="true"
data-dom-edit-z-flash="true"
className="pointer-events-none absolute rounded-md border-2 border-studio-accent shadow-[0_0_0_2px_rgba(60,230,172,0.35)] animate-pulse"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
/>
);
}
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { usePlayerStore } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
document.body.innerHTML = "";
usePlayerStore.getState().reset();
// reset() does not cover the out-of-loop seek request; clear it explicitly
// so a pending-seek test can't leak into the next one.
usePlayerStore.getState().clearSeekRequest();
useAssetPreviewStore.getState().clearPreviewAsset();
});
function mountOverlay(): void {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(<AssetPreviewOverlay />);
});
}
describe("AssetPreviewOverlay playback dismissal", () => {
it("dismisses immediately when opened while playback is ALREADY running", () => {
// The RAF playback loop bypasses the store, so no post-open store change
// will arrive — the dismiss check must be level-triggered, not edge-triggered.
usePlayerStore.setState({ isPlaying: true });
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("dismisses immediately when opened while a seek is ALREADY in flight", () => {
// A pending out-of-loop seek (requestedSeekTime set, not yet consumed) may
// produce no further store change before currentTime settles — the on-open
// check must evaluate the full shared dismiss predicate, not just isPlaying.
usePlayerStore.setState({ isPlaying: false, requestedSeekTime: 3.2 });
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("stays open when the playhead is idle", () => {
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
it("still dismisses when playback starts AFTER the preview opened", () => {
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
act(() => {
usePlayerStore.setState({ isPlaying: true });
});
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
});
it("still dismisses when the playhead is scrubbed after opening", () => {
usePlayerStore.setState({ currentTime: 2 });
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
act(() => {
usePlayerStore.setState({ currentTime: 4.5 });
});
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
});
});
@@ -2,15 +2,20 @@
* CapCut-style asset preview overlay rendered inside PreviewPane.
*
* Shown when the user clicks an asset card that has NOT yet been added to the
* timeline. Displays the media (image / video / audio) without modifying the
* composition — no undo entry, no file mutation.
* timeline. Displays the media (image / video / audio) as a compact floating
* card over the canvas — the canvas stays visible behind a barely-tinted
* click-catcher — without modifying the composition (no undo entry, no file
* mutation).
*
* Dismiss: X button, Escape key, or click on the scrim.
* Dismiss: X button, Escape key, click outside the card, or any playhead
* activity (starting playback / seeking) — the canvas refocuses.
* Switching to another not-added asset replaces the current preview.
*/
import { useEffect, useCallback } from "react";
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { usePlayerStore } from "../../player/store/playerStore";
import { shouldDismissAssetPreview } from "../../utils/assetPreviewDismiss";
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
function basename(path: string): string {
@@ -37,11 +42,7 @@ function AssetPreviewMedia({
}) {
if (kind === "image") {
return (
<img
src={serveUrl}
alt={name}
className="max-w-full max-h-[70vh] rounded-md object-contain shadow-2xl"
/>
<img src={serveUrl} alt={name} className="max-w-full max-h-[40vh] rounded object-contain" />
);
}
if (kind === "video") {
@@ -52,12 +53,12 @@ function AssetPreviewMedia({
autoPlay
muted
playsInline
className="max-w-full max-h-[70vh] rounded-md shadow-2xl"
className="max-w-full max-h-[40vh] rounded"
/>
);
}
return (
<div className="flex flex-col items-center gap-4 px-6 py-8 rounded-xl bg-neutral-900 border border-neutral-800">
<div className="flex flex-col items-center gap-3 px-6 py-4">
<svg
width="40"
height="40"
@@ -94,6 +95,30 @@ export function AssetPreviewOverlay() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previewAsset, handleKeyDown]);
// The canvas refocuses on any playhead activity: starting playback or a
// seek/scrub away from where the playhead sat when the preview opened
// dismisses it. openedTime is captured per preview open (previewAsset dep),
// so a stale render can never dismiss against the wrong reference time.
useEffect(() => {
if (!previewAsset) return;
const opened = usePlayerStore.getState();
const openedTime = opened.currentTime;
// Level-triggered, not edge-triggered: a preview opened while playback is
// ALREADY running (the RAF loop bypasses the store) or while a seek is
// already in flight gets no store change to react to, so evaluate the
// current state once, through the same shared predicate the subscription
// uses. openedTime is this snapshot's own currentTime, so the
// time-diverged branch can't false-positive at open — only the
// isPlaying / requestedSeekTime branches can fire here.
if (shouldDismissAssetPreview(openedTime, opened)) {
clearPreviewAsset();
return;
}
return usePlayerStore.subscribe((state) => {
if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset();
});
}, [previewAsset, clearPreviewAsset]);
if (!previewAsset || !previewProjectId) return null;
const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId);
@@ -101,40 +126,39 @@ export function AssetPreviewOverlay() {
return (
<div
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80"
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/20"
onClick={clearPreviewAsset}
role="dialog"
aria-label={`Preview: ${name}`}
aria-modal="true"
>
{/* Close button */}
<button
className="absolute top-3 right-3 w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
clearPreviewAsset();
}}
aria-label="Close preview"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Media */}
{/* Floating preview card — compact, canvas stays visible around it */}
<div
className="flex flex-col items-center gap-3 max-w-[90%] max-h-[85%]"
className="relative flex flex-col items-center gap-2 max-w-[58%] rounded-lg border border-neutral-800 bg-neutral-950/95 p-2 pt-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
className="absolute top-2 right-2 w-6 h-6 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
clearPreviewAsset();
}}
aria-label="Close preview"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />
{/* Filename label */}
@@ -18,6 +18,7 @@ import {
import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
import { readHfId, type DomEditSelection } from "../editor/domEditing";
import { buildStableSelector } from "../editor/domEditingDom";
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
import type { BlockPreviewInfo } from "../sidebar/BlocksTab";
import type { GestureRecordingState } from "../editor/GestureRecordControl";
import type { ReactNode } from "react";
@@ -38,6 +39,8 @@ type ZIndexReorderEntry = {
selector?: string;
selectorIndex?: number;
sourceFile: string;
/** Timeline store key — lets the commit update the store zIndex synchronously. */
key?: string;
};
/** Can this element be robustly re-targeted for a persisted z change? */
@@ -58,6 +61,12 @@ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReord
selector: sel.selector,
selectorIndex: sel.selectorIndex,
sourceFile: sel.sourceFile,
key: deriveTimelineStoreKey({
domId: sel.id ?? undefined,
selector: sel.selector,
selectorIndex: sel.selectorIndex,
sourceFile: sel.sourceFile,
}),
};
}
@@ -75,7 +84,15 @@ function siblingZIndexEntry(
const id = element.id || undefined;
const selector = buildStableSelector(element);
if (!canTargetZIndexElement(element, id, selector)) return null;
return { element, zIndex, id, selector, selectorIndex: undefined, sourceFile };
return {
element,
zIndex,
id,
selector,
selectorIndex: undefined,
sourceFile,
key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
};
}
/** Short human-readable label for a dropped sibling, for the console warning below. */
@@ -88,14 +105,16 @@ function describeZIndexElement(element: HTMLElement): string {
}
// Resolve z-index patches into commit entries; a sibling with no stable
// id/selector can't be written to source, so it is collected as a label for the
// revert-on-reload warning instead.
function resolveZIndexEntries(
// id/selector can't be written to source, so it is returned as `dropped` for
// the revert-on-reload warning (and a live-only style write, so the resolved
// stacking order still renders coherently). Exported so tests can drive the
// menu → commit path through the same wiring the app uses.
export function resolveZIndexEntries(
sel: DomEditSelection,
patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
): { entries: ZIndexReorderEntry[]; droppedLabels: string[] } {
): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
const entries: ZIndexReorderEntry[] = [];
const droppedLabels: string[] = [];
const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
for (const patch of patches) {
if (patch.element === sel.element) {
entries.push(selectedZIndexEntry(sel, patch.zIndex));
@@ -103,9 +122,9 @@ function resolveZIndexEntries(
}
const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
if (entry) entries.push(entry);
else droppedLabels.push(describeZIndexElement(patch.element));
else dropped.push(patch);
}
return { entries, droppedLabels };
return { entries, dropped };
}
// fallow-ignore-next-line complexity
@@ -205,19 +224,20 @@ export function PreviewOverlays({
onRotationCommit={handleDomRotationCommit}
onStyleCommit={handleDomStyleCommit}
onDeleteSelection={handleDomEditElementDelete}
onApplyZIndex={(sel, patches) => {
const { entries, droppedLabels } = resolveZIndexEntries(sel, patches);
if (droppedLabels.length > 0) {
// The optimistic z-index has already been applied live at this point —
// these siblings just won't be written to source, so they'll revert to
// their prior stacking order on the next reload with no other signal.
onApplyZIndex={(sel, patches, action) => {
const { entries, dropped } = resolveZIndexEntries(sel, patches);
if (dropped.length > 0) {
// These siblings can't be written to source. Apply their live z
// anyway so the resolved stacking order renders coherently — it
// just reverts to the prior order on the next reload.
for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
console.warn(
"[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
"(will revert on reload):",
droppedLabels.join(", "),
dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
);
}
if (entries.length > 0) handleDomZIndexReorderCommit(entries);
if (entries.length > 0) handleDomZIndexReorderCommit(entries, undefined, action);
}}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { AssetCard } from "./AssetCard";
import { AudioRow } from "./AudioRow";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
document.body.innerHTML = "";
usePlayerStore.getState().reset();
useAssetPreviewStore.getState().clearPreviewAsset();
vi.restoreAllMocks();
});
function mount(node: React.ReactElement): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(node);
});
return host;
}
function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
return { tag: "div", start: 0, duration: 5, track: 0, ...input };
}
/** Simulate a drag-free click: pointerdown + pointerup at the same point. */
function clickCard(host: HTMLElement): void {
const card = host.querySelector('[draggable="true"]');
if (!card) throw new Error("Expected a draggable card root");
const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
act(() => {
card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
});
}
describe("AssetCard click behavior", () => {
const cardProps = {
projectId: "p1",
onCopy: vi.fn(),
isCopied: false,
};
it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
// Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
// or it stays stuck over the canvas while the timeline reveals the clip.
useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(usePlayerStore.getState().selectedElementId).toBe("img1");
});
it("opens the preview overlay for a not-yet-added asset", () => {
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
});
});
describe("AudioRow click behavior", () => {
const rowProps = {
projectId: "p1",
onCopy: vi.fn(),
isCopied: false,
};
it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
usePlayerStore
.getState()
.setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
});
it("opens the preview overlay for a not-yet-added audio asset", () => {
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
});
});
@@ -133,8 +133,10 @@ export function AssetCard({
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -150,14 +152,29 @@ export function AssetCard({
if (used) {
const clip = findClipForAsset(elements, asset);
if (clip) {
setSelectedElementId(clip.key ?? clip.id);
// Dismiss any open preview overlay (from another asset) — the reveal
// must not leave a stale preview card floating over the canvas.
clearPreviewAsset();
const clipKey = clip.key ?? clip.id;
setSelectedElementId(clipKey);
// Scroll the timeline so the selected clip is actually visible.
requestClipReveal(clipKey);
return;
}
}
// Not added (or no matching clip found) → preview overlay
setPreviewAsset(asset, projectId);
},
[used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
[
used,
elements,
asset,
projectId,
setSelectedElementId,
requestClipReveal,
setPreviewAsset,
clearPreviewAsset,
],
);
return (
@@ -43,8 +43,10 @@ export function AudioRow({
// CapCut-style click behavior: drag-threshold gate.
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -59,14 +61,29 @@ export function AudioRow({
if (used) {
const clip = findClipForAsset(elements, asset);
if (clip) {
setSelectedElementId(clip.key ?? clip.id);
// Dismiss any open preview overlay (from another asset) — the reveal
// must not leave a stale preview card floating over the canvas.
clearPreviewAsset();
const clipKey = clip.key ?? clip.id;
setSelectedElementId(clipKey);
// Scroll the timeline so the selected clip is actually visible.
requestClipReveal(clipKey);
return;
}
}
// Not added → preview overlay (audio player)
setPreviewAsset(asset, projectId);
},
[used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
[
used,
elements,
asset,
projectId,
setSelectedElementId,
requestClipReveal,
setPreviewAsset,
clearPreviewAsset,
],
);
useEffect(() => {
@@ -8,7 +8,18 @@ export interface DomEditPatchBatch {
export type CommitDomEditPatchBatches = (
batches: DomEditPatchBatch[],
options: { label: string; coalesceKey: string },
options: {
label: string;
coalesceKey: string;
/**
* Request skipping the preview iframe reload after a successful persist.
* Only honored when the persist is provably in sync with the live DOM:
* every patch operation is inline-style-only AND the server matched every
* patch target. Any unmatched target (or a non-style op) falls back to the
* reload so the preview reconverges with disk. Default: always reload.
*/
skipReload?: boolean;
},
) => Promise<void>;
export type PersistDomEditOperations = (
@@ -2,8 +2,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyTimelineStackingReorder,
buildTimelineMoveTimingPatch,
deleteSelectedKeyframes,
extendRootDurationIfNeeded,
persistTimelineBatchEdit,
type PersistTimelineBatchChange,
} from "./timelineEditingHelpers";
import type { TimelineElement } from "../player/store/playerStore";
import { usePlayerStore } from "../player/store/playerStore";
@@ -108,6 +111,94 @@ describe("extendRootDurationIfNeeded", () => {
});
});
describe("persistTimelineBatchEdit", () => {
const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
function batchInput(changes: PersistTimelineBatchChange[], writes: Array<[string, string]>) {
return {
projectId: "p1",
activeCompPath: "index.html",
label: "Move timeline clips",
changes,
writeProjectFile: async (path: string, content: string) => {
writes.push([path, content]);
},
recordEdit: async () => {},
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set<string>() },
};
}
function stubReadFileContent(content: string) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({ content }),
})),
);
}
function moveMember(
id: string,
start: number,
fromTrack: number,
toTrack: number,
): PersistTimelineBatchChange {
return {
element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
};
}
async function runBatch(changes: PersistTimelineBatchChange[]) {
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await persistTimelineBatchEdit(batchInput(changes, writes));
return writes;
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
// A track-insert renumber can include a member whose attributes already
// hold the target values — its patch is string-identical. The batch must
// skip it and still persist the members that DID change.
const writes = await runBatch([
// no-op: data-start already "1", track already 0
moveMember("a", 1, 0, 0),
// real change: track 1 -> 2
moveMember("b", 2, 1, 2),
]);
expect(writes).toHaveLength(1);
expect(writes[0]![0]).toBe("index.html");
expect(writes[0]![1]).toContain('id="b" class="clip" data-start="2" data-track-index="2"');
});
it("saves nothing when every member is a no-op", async () => {
const writes = await runBatch([moveMember("a", 1, 0, 0)]);
expect(writes).toHaveLength(0);
});
it("throws on a mistargeted member instead of silently dropping it", async () => {
// A member whose target does not resolve in the source (stale id) patches
// to the identical string too — but that is a targeting FAILURE, not an
// already-at-target no-op, and must abort the batch like the single path.
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await expect(
persistTimelineBatchEdit(batchInput([moveMember("ghost", 3, 0, 2)], writes)),
).rejects.toThrow("Unable to patch timeline element ghost in index.html");
expect(writes).toHaveLength(0);
});
});
describe("deleteSelectedKeyframes", () => {
it("coalesces all removals and reloads only after the last one", () => {
usePlayerStore.setState({
@@ -1,17 +1,20 @@
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import {
formatTimelineAttributeNumber,
type TimelineStackingReorderIntent,
} from "../player/components/timelineEditing";
import { getElementZIndex } from "../player/lib/layerOrdering";
import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
import {
furthestClipEndFromSource,
getTimelineElementIdentity,
} from "../player/lib/timelineElementHelpers";
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
import { extendRootDurationInSource } from "../utils/rootDuration";
import { postRuntimeControlMessage } from "../player/lib/runtimeProtocol";
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
import { readFileContent } from "./timelineTimingSync";
export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
export { readFileContent };
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
@@ -149,16 +152,6 @@ export function patchIframeDomTiming(
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
function postRootDurationToPreview(
iframe: HTMLIFrameElement | null,
durationSeconds: number,
): void {
const duration = Number(durationSeconds);
if (!Number.isFinite(duration) || duration <= 0) return;
postRuntimeControlMessage(iframe?.contentWindow, "set-root-duration", {
durationSeconds: duration,
});
}
// fallow-ignore-next-line complexity
function resolveResizePlaybackStart(
original: string,
@@ -211,7 +204,12 @@ export function buildTimelineMoveTimingPatch(
value: formatTimelineAttributeNumber(track),
});
}
return extendRootDurationInSource(patched, start + duration);
// Content-driven duration: sync data-duration to the furthest clip end read
// from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
// past the end and shrinks if the furthest clip moved left. Measured from the
// source, NOT the store — store durations are runtime-truncated to the current
// comp length, which would ratchet the duration down every move.
return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
}
export function buildTimelineResizeTimingPatch(
@@ -238,7 +236,10 @@ export function buildTimelineResizeTimingPatch(
value: formatTimelineAttributeNumber(pbs.value),
});
}
return extendRootDurationInSource(patched, updates.start + updates.duration);
// Content-driven duration from the PATCHED SOURCE (raw data-duration) —
// grows/shrinks to the furthest clip end. Not from the store, whose
// durations are runtime-truncated.
return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
}
export interface PersistTimelineEditInput {
@@ -318,13 +319,24 @@ export async function persistTimelineBatchEdit(
}
const current = patchedByPath.get(targetPath) ?? original;
const patched = change.buildPatches(current, patchTarget);
if (patched === current) {
// Resolve the target FIRST: byte-identical output below is only a legit
// no-op when the member actually resolved in the source. A mistargeted
// member (stale id/selector) must fail loudly like the single-edit path,
// not be silently dropped as "already at target".
if (!findTagByTarget(current, patchTarget)) {
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
}
const patched = change.buildPatches(current, patchTarget);
// The target resolved, so a member whose attributes already hold the target
// values patches to the identical string — e.g. a track-insert renumber
// where one clip's lane is already correct. That is a legitimate no-op:
// skip it instead of aborting (and rolling back) the whole batch.
if (patched === current) continue;
patchedByPath.set(targetPath, patched);
}
if (patchedByPath.size === 0) return;
const files = Object.fromEntries(patchedByPath);
for (const targetPath of Object.keys(files)) {
input.pendingTimelineEditPathRef.current.add(targetPath);
@@ -343,227 +355,6 @@ export async function persistTimelineBatchEdit(
input.domEditSaveTimestampRef.current = Date.now();
}
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
}
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
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();
}
// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
// outlast one GSAP server round-trip, never a real second edit.
const GSAP_HISTORY_COALESCE_MS = 10_000;
/**
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
* folding both writes into one undo step. Returns the mutation status for caller reloads.
*/
export async function foldGsapMutationIntoHistory(input: {
projectId: string;
paths: string[];
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
gsapMutation: () => Promise<GsapMutationStatus>;
}): Promise<GsapMutationStatus> {
const uniquePaths = [...new Set(input.paths)];
const before = new Map<string, string>();
for (const path of uniquePaths) {
before.set(path, await readFileContent(input.projectId, path));
}
const status = await input.gsapMutation();
if (status.mutated) {
const files: Record<string, { before: string; after: string }> = {};
for (const path of uniquePaths) {
const priorContent = before.get(path);
const finalContent = await readFileContent(input.projectId, path);
if (priorContent !== undefined && finalContent !== priorContent) {
files[path] = { before: priorContent, after: finalContent };
}
}
if (Object.keys(files).length > 0) {
await input.recordEdit({
label: input.label,
kind: "timeline",
coalesceKey: input.coalesceKey,
coalesceMs: GSAP_HISTORY_COALESCE_MS,
files,
});
}
}
return status;
}
/**
* 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.
*/
export async function shiftGsapPositions(
projectId: string,
filePath: string,
elementId: string,
delta: number,
): Promise<GsapMutationStatus> {
if (delta === 0 || !elementId) return { mutated: false };
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "shift-positions",
targetSelector: `#${elementId}`,
delta,
}),
},
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(readMutationError(err, "shift-positions failed"));
}
return readMutationStatus(await res.json().catch(() => null));
}
export async function scaleGsapPositions(
projectId: string,
filePath: string,
elementId: string,
oldStart: number,
oldDuration: number,
newStart: number,
newDuration: number,
): 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)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "scale-positions",
targetSelector: `#${elementId}`,
oldStart,
oldDuration,
newStart,
newDuration,
}),
},
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(readMutationError(err, "scale-positions failed"));
}
return readMutationStatus(await res.json().catch(() => null));
}
/** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
export function foldedShiftGsapMutation(input: {
projectId: string;
targetPath: string;
domId: string;
delta: number;
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
}): () => Promise<GsapMutationStatus> {
return () =>
foldGsapMutationIntoHistory({
projectId: input.projectId,
paths: [input.targetPath],
label: input.label,
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: () =>
shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
});
}
/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
export function foldedScaleGsapMutation(input: {
projectId: string;
targetPath: string;
domId: string;
from: { start: number; duration: number };
to: { start: number; duration: number };
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
}): () => Promise<GsapMutationStatus> {
return () =>
foldGsapMutationIntoHistory({
projectId: input.projectId,
paths: [input.targetPath],
label: input.label,
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: () =>
scaleGsapPositions(
input.projectId,
input.targetPath,
input.domId,
input.from.start,
input.from.duration,
input.to.start,
input.to.duration,
),
});
}
export { applyPatchByTarget, formatTimelineAttributeNumber };
export { patchDocumentRootDuration } from "./timelineEditingGsap";
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player";
import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
type MoveArgs = Parameters<typeof persistTimelineMoveEditsAtomically>;
const element = (id: string, track: number): TimelineElement => ({
id,
key: id,
@@ -11,64 +13,66 @@ const element = (id: string, track: number): TimelineElement => ({
track,
});
const twoLaneEdits = (bTrack: number): MoveArgs[0] => [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", bTrack), updates: { start: 3, track: 2 } },
];
const movedPair = (edits: MoveArgs[0]) => [
{ element: edits[0].element, start: 1, track: 1 },
{ element: edits[1].element, start: 3, track: 2 },
];
const runMove = async (edits: MoveArgs[0], coalesceKey: MoveArgs[1], intent: MoveArgs[2]) => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
await persistTimelineMoveEditsAtomically(edits, coalesceKey, intent, {
handleTimelineGroupMove,
});
return handleTimelineGroupMove;
};
describe("persistTimelineMoveEditsAtomically", () => {
it("persists two vertical edits as one group with the gesture coalesce key", async () => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
const edits = [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", 1), updates: { start: 3, track: 2 } },
];
await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
handleTimelineGroupMove,
});
const edits = twoLaneEdits(1);
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "track-insert");
expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
[
{ element: edits[0].element, start: 1, track: 1 },
{ element: edits[1].element, start: 3, track: 2 },
],
{ coalesceKey: "clip-lane-move:7" },
);
});
it("does not persist track attrs for a single z-only lane reorder", async () => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
await persistTimelineMoveEditsAtomically([edit], "clip-lane-move:7", "lane-reorder", {
handleTimelineGroupMove,
});
expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
coalesceKey: "clip-lane-move:7",
});
});
it("does not persist track attrs for a multi-selection lane drag", async () => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
const edits = [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", 2), updates: { start: 3, track: 2 } },
];
await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "lane-reorder", {
handleTimelineGroupMove,
it("omits track attrs for plain timing moves (keeps the SDK fast path eligible)", async () => {
const edit = { element: element("a", 0), updates: { start: 1, track: 0 } };
const handleTimelineGroupMove = await runMove([edit], undefined, "timing");
expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
coalesceKey: undefined,
});
});
it("persists the track attr for a single lane reorder (stable track lanes)", async () => {
// Lane = authored data-track-index; a vertical move that never hits disk
// snaps back on the next normalize, so the lane change MUST persist.
const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
const handleTimelineGroupMove = await runMove([edit], "clip-lane-move:7", "lane-reorder");
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
[
{ element: edits[0].element, start: 1 },
{ element: edits[1].element, start: 3 },
],
[{ element: edit.element, start: 1, track: 1 }],
{ coalesceKey: "clip-lane-move:7" },
);
});
it("persists track attrs for a multi-selection lane drag (stable track lanes)", async () => {
const edits = twoLaneEdits(2);
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "lane-reorder");
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
coalesceKey: "clip-lane-move:7",
});
});
it("rejects without retrying individual members when the atomic batch fails", async () => {
const failure = new Error("batch failed");
const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
const edits = [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", 1), updates: { start: 3, track: 2 } },
];
await expect(
persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
persistTimelineMoveEditsAtomically(twoLaneEdits(1), "clip-lane-move:7", "track-insert", {
handleTimelineGroupMove,
}),
).rejects.toBe(failure);
@@ -28,9 +28,11 @@ export function persistTimelineMoveEditsAtomically(
edits.map(({ element, updates }) => ({
element,
start: updates.start,
// A single vertical edit is the z-only reorder path. Multi-edit gestures
// are track inserts/ripples and must persist every resulting lane index.
track: operation === "track-insert" ? updates.track : undefined,
// Stable track lanes: a lane is the authored data-track-index, so every
// vertical gesture (lane-reorder AND track-insert) must persist the track;
// z is paint order only and is synced separately. Plain horizontal moves
// ("timing") omit it so they stay eligible for the SDK fast path.
track: operation === "timing" ? undefined : updates.track,
})),
{ coalesceKey },
);
@@ -0,0 +1,150 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../player/store/playerStore";
import {
captureDurationRollback,
finishClipTimingFallback,
readFileContent,
shiftGsapPositions,
} from "./timelineTimingSync";
afterEach(() => {
usePlayerStore.getState().reset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
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;
}
/**
* Stub fetch: `/files/` reads return contents from the queue (repeating the
* last entry), the GSAP-mutation endpoint answers with `gsapBody` (a thrown
* Error rejects the call with a non-ok response).
*/
function stubFetch(fileContents: string[], gsapBody: unknown | Error) {
let readIndex = 0;
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/files/")) {
const content = fileContents[Math.min(readIndex, fileContents.length - 1)];
readIndex += 1;
return jsonResponse({ content });
}
if (url.includes("/gsap-mutations/")) {
if (gsapBody instanceof Error) {
return new Response(JSON.stringify({ error: gsapBody.message }), { status: 500 });
}
return jsonResponse(gsapBody);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function clipFallbackInput(overrides: {
reloadPreview: () => void;
recordEdit: (edit: unknown) => Promise<void>;
}) {
return {
iframe: null,
reloadPreview: overrides.reloadPreview,
projectId: "p1",
targetPath: "index.html",
domId: "clip",
label: "Move timeline clip",
recordEdit: overrides.recordEdit as never,
edit: { kind: "shift", delta: 1 } as const,
};
}
describe("finishClipTimingFallback failure domains", () => {
it("still syncs the preview when the history-fold step fails after a successful mutation", async () => {
// Mutation succeeds (server rewrite already on disk), but recordEdit (the
// fold step) throws. The preview MUST still be synced — otherwise stale
// GSAP positions stay on screen. iframe=null makes the sync observable as
// one reloadPreview() call.
stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
const reloadPreview = vi.fn();
const foldError = new Error("history fold failed");
const recordEdit = vi.fn(async () => {
throw foldError;
});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(reloadPreview).toHaveBeenCalledTimes(1);
// The fold error is surfaced, not swallowed silently.
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("GSAP"), foldError);
});
it("skips the preview sync when the MUTATION itself fails", async () => {
stubFetch(["<before>"], new Error("mutation blew up"));
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).not.toHaveBeenCalled();
expect(reloadPreview).not.toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledTimes(1);
});
it("records the fold and syncs on the happy path", async () => {
stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
});
describe("captureDurationRollback", () => {
it("restores the pre-sync duration only when it changed", () => {
usePlayerStore.getState().setDuration(4);
const rollback = captureDurationRollback(null);
// No change → rollback is a no-op (no spurious set).
rollback();
expect(usePlayerStore.getState().duration).toBe(4);
usePlayerStore.getState().setDuration(9);
rollback();
expect(usePlayerStore.getState().duration).toBe(4);
});
});
describe("fetch URL encoding (user-influenced segments)", () => {
it("URI-encodes the projectId in file reads", async () => {
const fetchMock = stubFetch(["<html>"], {});
await readFileContent("p/../evil", "index.html");
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
"/api/projects/p%2F..%2Fevil/files/index.html",
);
});
it("URI-encodes the projectId in GSAP mutation calls", async () => {
const fetchMock = stubFetch([], { mutated: false, scriptText: null });
await shiftGsapPositions("p one", "scenes/intro.html", "clip", 1);
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
"/api/projects/p%20one/gsap-mutations/scenes%2Fintro.html",
);
});
});
@@ -0,0 +1,412 @@
// Soft-reload-first preview sync for timeline timing edits: server GSAP
// position mutations (shift / scale), folding those rewrites into the timing
// edit's undo history, and swapping the rewritten script into the live preview
// without a full iframe reload when possible.
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
import { applySoftReload } from "../utils/gsapSoftReload";
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
import type { RecordEditInput } from "../utils/studioFileHistory";
import { patchDocumentRootDuration } from "./timelineEditingGsap";
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
}
const response = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
return data.content;
}
/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
try {
patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
} catch {
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
/**
* Optimistically push the composition's content-driven length into the player
* store right after the live DOM patch, so the duration readout + seek bar
* update immediately. The readout binds to store.duration (PlayerControls);
* edits only patched store.elements, so the number stayed frozen (esp. on
* shrink) until a manual refresh. Read from the just-patched preview DOM (raw
* data-duration) so it's immune to the runtime's truncated live durations.
*
* Also writes the content end into the live root's `data-duration`. Timing
* edits take the soft-reload path (no full iframe reload), which lets the
* runtime recompute the length from the root's declared duration and post it
* back — reading the STALE root would revert this optimistic set.
*/
export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
if (end > 0) {
usePlayerStore.getState().setDuration(end);
patchIframeRootDuration(iframe, end);
}
}
/**
* Snapshot the store duration BEFORE an optimistic duration update
* (extendRootDurationIfNeeded + syncPreviewContentDuration) and return a
* rollback closure for the persist-failure path. The rollback restores BOTH
* the store duration and the live root's `data-duration` — otherwise a failed
* write leaves the readout/seek bar and the live root advertising a duration
* the saved source never got. No-op when the duration never changed.
*/
export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
const previousDuration = usePlayerStore.getState().duration;
return () => {
if (usePlayerStore.getState().duration === previousDuration) return;
usePlayerStore.getState().setDuration(previousDuration);
patchIframeRootDuration(iframe, previousDuration);
};
}
/**
* The bits of the server GSAP-mutation response the timeline edit path needs.
* `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
* swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
* when the endpoint didn't return one (older server, or a multi-script comp the
* soft path can't scope), in which case the caller full-reloads as before.
*/
export type GsapMutationStatus = { mutated: boolean; scriptText: string | null };
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, scriptText: null };
return {
mutated: value.mutated === true || value.changed === true,
scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
};
}
function readMutationError(value: unknown, fallback: string): string {
if (isRecord(value) && typeof value.error === "string") return value.error;
return fallback;
}
/**
* Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
* soft reload over the full iframe reload that flashes every clip.
*
* Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
* already (a) patched the live DOM timing attributes, (b) updated the store's
* elements optimistically (the drag commit calls `updateElement` before the
* persist), and (c) had the server rewrite the GSAP tween positions — which is the
* `scriptText` we swap in here. `applySoftReload` re-runs that script in the LIVE
* document (no navigation), re-seeks to the current playhead, and rebinds the
* timeline, so the runtime matches the already-correct store. Nothing structural
* changed (no clip added/removed), so `processTimelineMessage` would re-derive the
* identical element set — skipping it just avoids the flash.
*
* Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
* result (no gsap runtime / rebind hook / scopable key / script element, or the
* re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
* already applied the shift; a remount would re-flash for nothing. When the server
* returned no `scriptText` (older server, multi-script comp), we also full-reload.
*/
function syncTimingEditPreview(
iframe: HTMLIFrameElement | null,
outcome: Pick<GsapMutationStatus, "scriptText">,
currentTime: number,
reloadPreview: () => void,
): void {
if (!iframe || !outcome.scriptText) {
reloadPreview();
return;
}
const result = applySoftReload(iframe, outcome.scriptText, {
onAsyncFailure: reloadPreview,
currentTimeOverride: currentTime,
});
if (result === "cannot-soft-reload") reloadPreview();
}
async function finishTimelineTimingFallback(input: {
iframe: HTMLIFrameElement | null;
reloadPreview: () => void;
gsapMutation?: () => Promise<GsapMutationStatus>;
onGsapError: (error: unknown) => void;
}): Promise<void> {
let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
if (input.gsapMutation) {
try {
outcome = await input.gsapMutation();
} catch (error) {
input.onGsapError(error);
return;
}
}
syncTimingEditPreview(
input.iframe,
outcome,
usePlayerStore.getState().currentTime,
input.reloadPreview,
);
}
// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
// outlast one GSAP server round-trip, never a real second edit.
const GSAP_HISTORY_COALESCE_MS = 10_000;
/**
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
* folding both writes into one undo step. Returns the mutation status for caller reloads.
*
* Failure domains are separate: a MUTATION failure propagates (nothing was applied, so
* the caller must skip the preview sync), but a failure in the history-FOLD step
* (re-read / recordEdit) after a successful mutation is surfaced via `onFoldError` and
* the mutation status is still returned — the server rewrite already landed on disk, so
* the caller must still sync the preview or it shows stale GSAP positions.
*/
async function foldGsapMutationIntoHistory(input: {
projectId: string;
paths: string[];
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
gsapMutation: () => Promise<GsapMutationStatus>;
onFoldError: (error: unknown) => void;
}): Promise<GsapMutationStatus> {
const uniquePaths = [...new Set(input.paths)];
const before = new Map<string, string>();
// A `before`-snapshot failure propagates like a mutation failure: the mutation
// has not run yet, so nothing landed on disk and skipping the sync is correct.
for (const path of uniquePaths) {
before.set(path, await readFileContent(input.projectId, path));
}
const status = await input.gsapMutation();
if (status.mutated) {
try {
const files: Record<string, { before: string; after: string }> = {};
for (const path of uniquePaths) {
const priorContent = before.get(path);
const finalContent = await readFileContent(input.projectId, path);
if (priorContent !== undefined && finalContent !== priorContent) {
files[path] = { before: priorContent, after: finalContent };
}
}
if (Object.keys(files).length > 0) {
await input.recordEdit({
label: input.label,
kind: "timeline",
coalesceKey: input.coalesceKey,
coalesceMs: GSAP_HISTORY_COALESCE_MS,
files,
});
}
} catch (error) {
input.onFoldError(error);
}
}
return status;
}
/**
* 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.
* Returns the rewritten script so the caller can soft-reload instead of full-reload.
*/
export async function shiftGsapPositions(
projectId: string,
filePath: string,
elementId: string,
delta: number,
): Promise<GsapMutationStatus> {
if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "shift-positions",
targetSelector: `#${elementId}`,
delta,
}),
},
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(readMutationError(err, "shift-positions failed"));
}
return readMutationStatus(await res.json().catch(() => null));
}
export async function scaleGsapPositions(
projectId: string,
filePath: string,
elementId: string,
oldStart: number,
oldDuration: number,
newStart: number,
newDuration: number,
): Promise<GsapMutationStatus> {
if (!elementId || oldDuration <= 0 || newDuration <= 0)
return { mutated: false, scriptText: null };
if (oldStart === newStart && oldDuration === newDuration)
return { mutated: false, scriptText: null };
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "scale-positions",
targetSelector: `#${elementId}`,
oldStart,
oldDuration,
newStart,
newDuration,
}),
},
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(readMutationError(err, "scale-positions failed"));
}
return readMutationStatus(await res.json().catch(() => null));
}
/** Timing delta a single-clip edit applies to its GSAP tweens. */
export type SingleClipGsapEdit =
| { kind: "shift"; delta: number }
| {
kind: "scale";
from: { start: number; duration: number };
to: { start: number; duration: number };
};
/**
* Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
* server shift/scale mutation, folds the rewrite into the timing edit's history
* entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
* the rewritten script — full reload when the mutation is skipped, failed, or
* returned no script.
*/
export function finishClipTimingFallback(input: {
iframe: HTMLIFrameElement | null;
reloadPreview: () => void;
projectId: string | null;
targetPath: string;
domId: string | undefined;
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
edit: SingleClipGsapEdit;
}): Promise<void> {
const { projectId, targetPath, domId, edit } = input;
const timingChanged =
edit.kind === "shift"
? edit.delta !== 0
: edit.from.start !== edit.to.start || edit.from.duration !== edit.to.duration;
const runMutation = (pid: string, id: string): Promise<GsapMutationStatus> =>
edit.kind === "shift"
? shiftGsapPositions(pid, targetPath, id, edit.delta)
: scaleGsapPositions(
pid,
targetPath,
id,
edit.from.start,
edit.from.duration,
edit.to.start,
edit.to.duration,
);
const onGsapError = (err: unknown) =>
console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err);
return finishTimelineTimingFallback({
iframe: input.iframe,
reloadPreview: input.reloadPreview,
gsapMutation:
timingChanged && domId && projectId
? () =>
foldGsapMutationIntoHistory({
projectId,
paths: [targetPath],
label: input.label,
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: () => runMutation(projectId, domId),
onFoldError: onGsapError,
})
: undefined,
onGsapError,
});
}
/**
* Shared post-persist GSAP sync for GROUP timing edits (move / resize): runs the
* per-change server mutation for every changed clip, folds the rewrites into the
* timing edit's history entry, and soft-reloads the preview when possible.
*
* The preview is a SINGLE shared iframe showing the ACTIVE composition, so only
* the active comp's rewritten script can be soft-reloaded (swapped in place, no
* all-clips flash). If any OTHER file changed too — e.g. a sub-comp group in a
* multi-file move — no scriptText is passed, so the fallback does ONE full
* reload that reflects every changed file.
*/
export async function finishGroupTimingGsapFallback<C extends { element: TimelineElement }>(input: {
projectId: string;
iframe: HTMLIFrameElement | null;
reloadPreview: () => void;
label: string;
errorLabel: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
activeCompPath: string | null;
changes: readonly C[];
resolveChangePath: (element: TimelineElement) => string;
/** Per-change GSAP mutation; return null to skip a change with no timing delta. */
mutateChange: (change: C, changePath: string) => Promise<GsapMutationStatus> | null;
}): Promise<void> {
const activePath = input.activeCompPath || "index.html";
const otherFileChanged = input.changes.some(
(change) => input.resolveChangePath(change.element) !== activePath,
);
const onGsapError = (err: unknown) => console.error(`[Timeline] ${input.errorLabel}`, err);
await finishTimelineTimingFallback({
iframe: input.iframe,
reloadPreview: input.reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
projectId: input.projectId,
paths: input.changes.map((change) => input.resolveChangePath(change.element)),
label: input.label,
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: async () => {
let mutated = false;
let scriptText: GsapMutationStatus["scriptText"] = null;
for (const change of input.changes) {
const changePath = input.resolveChangePath(change.element);
const pending = input.mutateChange(change, changePath);
if (!pending) continue;
const status = await pending;
mutated = mutated || status.mutated;
// The LAST mutation against the active comp carries the cumulative
// rewritten script for that file.
if (changePath === activePath) scriptText = status.scriptText;
}
return { mutated, scriptText: otherFileChanged ? null : scriptText };
},
onFoldError: onGsapError,
}),
onGsapError,
});
}
@@ -265,7 +265,7 @@ describe("useDomEditCommits z-index reorder persistence", () => {
document.body.replaceChildren();
});
it("persists an N-element reorder with one batch POST, one undo entry, and one reload", async () => {
it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
const original =
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
const after =
@@ -338,12 +338,103 @@ describe("useDomEditCommits z-index reorder persistence", () => {
coalesceKey: "z-reorder:test",
files: { "index.html": { before: original, after } },
});
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
// The live DOM + store already hold the final state and the server matched
// every style-only patch, so the reload is provably redundant.
expect(rendered.reloadPreview).not.toHaveBeenCalled();
} finally {
rendered.cleanup();
}
});
it("falls back to reloading when the server response omits matched[]", async () => {
// Without a matched[] confirmation the persist can't be proven in sync with
// the live DOM — the skip-reload path must not engage.
const original = '<div id="a" style="z-index: 1"></div>';
const after = '<div id="a" style="z-index: 2"></div>';
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: original });
if (url.includes("/file-mutations/patch-elements-batch/")) {
return jsonResponse({ ok: true, changed: true, content: after });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const { iframe, element } = createPreviewElement();
element.id = "a";
const rendered = renderDomEditCommits(createSelection(element), iframe);
try {
await act(async () => {
await rendered.hook.handleDomZIndexReorderCommit([
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
]);
});
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
} finally {
rendered.cleanup();
}
});
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
// The server reports per-patch matched[]: #b was not found in the source.
// The matched subset persisted, so the commit must complete (no rollback of
// applied state) while surfacing the partial failure. An unmatched target
// also means the live DOM shows z-order the disk lacks, so the skip-reload
// path must NOT engage — the reload reconverges the preview with disk.
const original = '<div id="a" style="z-index: 1"></div>';
const after = '<div id="a" style="z-index: 2"></div>';
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: original });
if (url.includes("/file-mutations/patch-elements-batch/")) {
return jsonResponse({ ok: true, changed: true, matched: [true, false], content: after });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { iframe, element } = createPreviewElement(
'<div data-hf-id="hf-card"></div><div id="b"></div>',
);
element.id = "a";
const second = iframe.contentDocument!.getElementById("b")!;
const rendered = renderDomEditCommits(createSelection(element), iframe);
try {
await act(async () => {
await rendered.hook.handleDomZIndexReorderCommit([
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
]);
});
// No throw: the applied live state stays, the matched subset is recorded.
expect(element.style.zIndex).toBe("2");
expect(second.style.zIndex).toBe("1");
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("could not match 1 patch target(s) in index.html"),
"b",
);
expect(trackStudioEvent).toHaveBeenCalledWith(
"save_failure",
expect.objectContaining({
mutation_type: "z-reorder-unmatched",
file_path: "index.html",
error_message: expect.stringContaining("b"),
}),
);
} finally {
warnSpy.mockRestore();
rendered.cleanup();
}
});
it("rolls back live state after a failed batch POST without a disk write-back", async () => {
const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
+63 -1
View File
@@ -61,6 +61,34 @@ interface RecordEditInput {
files: Record<string, { before: string; after: string }>;
}
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
}
/**
* Surface server-reported unmatched patches. The matched subset already
* persisted, so this must NOT throw (a throw would roll back applied state) —
* warn and emit save-failure telemetry with a distinct reason instead.
*/
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
const unmatchedIds = batch.patches
.filter((_, index) => matched[index] === false)
.map(describeBatchPatchTarget);
if (unmatchedIds.length === 0) return;
console.warn(
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
`${batch.sourceFile} (their z-order will revert on reload):`,
unmatchedIds.join(", "),
);
trackStudioSaveFailure({
source: "dom_edit",
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
filePath: batch.sourceFile,
mutationType: "z-reorder-unmatched",
});
}
async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
const before = await readProjectFileContent(projectId, batch.sourceFile);
const response = await fetch(
@@ -77,16 +105,39 @@ async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
}
const result = (await response.json()) as {
changed?: boolean;
matched?: boolean[];
content?: string;
};
if (Array.isArray(result.matched)) reportUnmatchedBatchPatches(batch, result.matched);
return {
sourceFile: batch.sourceFile,
changed: result.changed === true,
// Skip-reload safety: the persist is only provably in sync with the live
// DOM when the server confirmed EVERY patch target matched. A missing /
// short matched[] is treated as unknown (false) so the caller falls back
// to reloading rather than silently diverging from disk.
allMatched:
Array.isArray(result.matched) &&
result.matched.length === batch.patches.length &&
result.matched.every(Boolean),
before,
after: typeof result.content === "string" ? result.content : before,
};
}
/**
* A batch is reload-skippable only when it is style-only: every operation is an
* `inline-style` write. The z-reorder commit applies those exact styles to the
* live iframe DOM synchronously, so persisting them adds nothing the preview
* doesn't already show. Any other op type (attribute / text-content / …) can
* have server-side semantics the live DOM hasn't mirrored — reload for those.
*/
function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
return batches.every((batch) =>
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
);
}
export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -360,7 +411,18 @@ export function useDomEditCommits({
files,
});
forceReloadSdkSession?.();
reloadPreview();
// A z-only reorder already applied its inline styles to the live iframe
// DOM (and the store) synchronously, so remounting the iframe here only
// produces a visible blink. Skip the reload when the caller asked for it
// AND the persist is provably in sync: style-only ops, every target
// matched. Any unmatched patch means the live DOM now shows state disk
// doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
// reload is independently suppressed by domEditSaveTimestampRef above.)
const skipSafe =
options.skipReload === true &&
batchesAreInlineStyleOnly(batches) &&
results.every((result) => result.allMatched);
if (!skipSafe) reloadPreview();
}).catch((error) => {
const alreadyToasted =
(error instanceof StudioSaveHttpError ||
@@ -18,6 +18,7 @@ afterEach(() => {
interface BatchOptions {
label: string;
coalesceKey: string;
skipReload?: boolean;
}
interface CapturedBatchCall {
@@ -36,6 +37,7 @@ type ReorderCommit = (
key?: string;
}>,
coalesceKeyOverride?: string,
actionKind?: string,
) => Promise<void>;
function renderReorderHook(
@@ -109,6 +111,25 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
act(() => root.unmount());
});
it("requests skipReload on every z-reorder persist (live DOM already final)", async () => {
// The commit applies the z-index (and any injected position) to the live
// iframe DOM and the store synchronously, so the persisted style-only patch
// adds nothing the preview doesn't already show — the batch commit is asked
// to skip the iframe remount. commitDomEditPatchBatches still falls back to
// reloading when the server can't confirm every patch target matched.
const el = document.createElement("div");
el.id = "clip-z";
const { captured, root } = await runReorderCommit(el, [
{ element: el, zIndex: 4, id: "clip-z", sourceFile: "index.html" },
]);
expect(captured).toHaveLength(1);
expect(captured[0]?.options.skipReload).toBe(true);
act(() => root.unmount());
});
it("preserves a real id when the element has one", async () => {
const el = document.createElement("video");
el.id = "v-hero";
@@ -176,6 +197,150 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
act(() => root.unmount());
});
it("keeps distinct actions in distinct default coalesce keys", async () => {
const el = document.createElement("div");
el.id = "clip-a";
document.body.appendChild(el);
const captured: CapturedBatchCall[] = [];
let commit: ReorderCommit | undefined;
const root = renderReorderHook(captured, (fn) => (commit = fn));
await act(async () => {
await commit!(
[{ element: el, zIndex: 1, id: "clip-a", sourceFile: "index.html" }],
undefined,
"bring-forward",
);
await commit!(
[{ element: el, zIndex: 0, id: "clip-a", sourceFile: "index.html" }],
undefined,
"send-backward",
);
});
// Same element set, different actions — the keys must differ so the two
// edits never coalesce into one undo step within the coalesce window.
expect(captured).toHaveLength(2);
expect(captured[0]?.options.coalesceKey).toBe("z-reorder:bring-forward:clip-a");
expect(captured[1]?.options.coalesceKey).toBe("z-reorder:send-backward:clip-a");
act(() => root.unmount());
});
it("updates the store zIndex synchronously for entries that carry a store key", async () => {
const el = document.createElement("div");
el.id = "clip-a";
document.body.appendChild(el);
usePlayerStore.getState().setElements([
{
id: "clip-a",
key: "index.html#clip-a",
tag: "div",
start: 0,
duration: 1,
track: 0,
zIndex: 0,
hasExplicitZIndex: false,
},
]);
let commit: ReorderCommit | undefined;
let resolveBatch: (() => void) | undefined;
function Harness() {
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
activeCompPath: "index.html",
showToast: vi.fn(),
writeProjectFile: vi.fn(async () => {}),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: vi.fn(async () => {}) },
projectIdRef: { current: null },
reloadPreview: vi.fn(),
clearDomSelection: vi.fn(),
// Persist stays pending so the assertion below can only be satisfied
// by the SYNCHRONOUS store update (the lane-sync path's requirement).
commitDomEditPatchBatches: () => new Promise((resolve) => (resolveBatch = resolve)),
});
commit = handleDomZIndexReorderCommit;
return null;
}
const root = mountReactHarness(<Harness />);
let pending: Promise<void> | undefined;
act(() => {
pending = commit!([
{
element: el,
zIndex: 5,
id: "clip-a",
sourceFile: "index.html",
key: "index.html#clip-a",
},
]);
});
expect(usePlayerStore.getState().elements[0]).toMatchObject({
zIndex: 5,
hasExplicitZIndex: true,
});
resolveBatch?.();
await act(async () => pending);
act(() => root.unmount());
});
// The canvas context-menu path: the menu no longer pre-applies styles, so the
// hook sees the PRISTINE element — prior styles are captured before any
// mutation and a failed persist restores them exactly (previously the menu's
// optimistic write made the "rollback" restore the already-mutated values,
// and the never-persisted position patch silently reverted on reload).
it("rolls back a static, inline-style-free element to pristine styles on failure", async () => {
const el = document.createElement("div");
el.id = "clip-a";
el.style.position = "static"; // happy-dom computes "" for unset position
document.body.appendChild(el);
const failure = new Error("persist failed");
let commit: ReorderCommit | undefined;
function Harness() {
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
activeCompPath: "index.html",
showToast: vi.fn(),
writeProjectFile: vi.fn(async () => {}),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: vi.fn(async () => {}) },
projectIdRef: { current: null },
reloadPreview: vi.fn(),
clearDomSelection: vi.fn(),
commitDomEditPatchBatches: vi.fn(async () => {
// The live styles were applied by the hook before persist ran.
expect(el.style.zIndex).toBe("2");
expect(el.style.position).toBe("relative");
throw failure;
}),
});
commit = handleDomZIndexReorderCommit;
return null;
}
const root = mountReactHarness(<Harness />);
let rejection: unknown;
await act(async () => {
try {
await commit!(
[{ element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html" }],
undefined,
"bring-forward",
);
} catch (error) {
rejection = error;
}
});
expect(rejection).toBe(failure);
expect(el.style.zIndex).toBe("");
expect(el.style.position).toBe("static");
act(() => root.unmount());
});
it("rolls back only live and store state after an atomic reorder failure", async () => {
const writeProjectFile = vi.fn(async () => {});
const recordEdit = vi.fn(async () => {});
@@ -146,6 +146,7 @@ export function useElementLifecycleOps({
key?: string;
}>,
gestureCoalesceKey?: string,
actionKind?: string,
) => {
if (entries.length === 0) return Promise.resolve();
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
@@ -153,9 +154,13 @@ export function useElementLifecycleOps({
onReorderShadow?.(
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
);
// The default key carries the action kind so two DIFFERENT actions on the
// same element set (e.g. "bring-forward" then "send-backward" within the
// coalesce window) never merge into one undo step. Callers that share a
// gesture (lane moves) pass an explicit gestureCoalesceKey instead.
const coalesceKey =
gestureCoalesceKey ??
`z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
`z-reorder:${actionKind ?? "reorder"}:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
const patchesBySourceFile = new Map<string, DomEditPatchBatch["patches"]>();
const rollbacks: Array<() => void> = [];
for (const entry of entries) {
@@ -212,12 +217,20 @@ export function useElementLifecycleOps({
}));
// Resolves once every source-file batch is persisted so a same-file timing write
// can be ordered after it (see applyTimelineStackingReorder callers).
return commitDomEditPatchBatches(batches, { label: "Reorder layers", coalesceKey }).catch(
(error) => {
for (const rollback of rollbacks) rollback();
throw error;
},
);
//
// skipReload: the live iframe DOM and the player store already hold the
// final z state (applied synchronously above), and the persisted patch is
// inline-style-only — a full iframe remount would only blink the preview.
// commitDomEditPatchBatches still falls back to reloading whenever the
// server reports an unmatched patch target (live DOM ≠ disk).
return commitDomEditPatchBatches(batches, {
label: "Reorder layers",
coalesceKey,
skipReload: true,
}).catch((error) => {
for (const rollback of rollbacks) rollback();
throw error;
});
},
[commitDomEditPatchBatches, onReorderShadow],
);
@@ -0,0 +1,175 @@
// Asset-drop handlers for the timeline: drop an existing project asset at a
// placement, or upload dragged-in OS files and place them sequentially.
// Extracted verbatim from useTimelineEditing.ts to keep it under the studio
// 600-line cap.
import { useCallback, type MutableRefObject, type RefObject } from "react";
import type { TimelineElement } from "../player";
import {
buildTimelineAssetId,
buildTimelineAssetInsertHtml,
buildTimelineFileDropPlacements,
fitTimelineAssetGeometry,
getTimelineAssetKind,
insertTimelineAssetIntoSource,
resolveTimelineAssetCompositionSize,
resolveTimelineAssetSrc,
} from "../utils/timelineAssetDrop";
import { generateId } from "../utils/generateId";
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
import { readFileContent } from "./timelineTimingSync";
interface UseTimelineAssetDropOpsOptions {
projectIdRef: MutableRefObject<string | null>;
activeCompPath: string | null;
timelineElements: TimelineElement[];
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRefObject<number>;
reloadPreview: () => void;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: RefObject<boolean>;
forceReloadSdkSession?: () => void;
}
export function useTimelineAssetDropOps({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
uploadProjectFiles,
isRecordingRef,
forceReloadSdkSession,
}: UseTimelineAssetDropOpsOptions) {
// fallow-ignore-next-line complexity
const handleTimelineAssetDrop = useCallback(
// fallow-ignore-next-line complexity
async (
assetPath: string,
placement: Pick<TimelineElement, "start" | "track">,
durationOverride?: number,
) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const kind = getTimelineAssetKind(assetPath);
if (!kind) {
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
return;
}
const targetPath = activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
const duration =
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
? durationOverride
: await resolveDroppedAssetDuration(pid, assetPath, kind);
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
const resolvedTargetPath = targetPath || "index.html";
const relevantElements = timelineElements.filter(
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
);
const newElementZIndex = Math.max(1, relevantElements.length + 1);
const patchedContent = insertTimelineAssetIntoSource(
originalContent,
buildTimelineAssetInsertHtml({
id: newId,
hfId: `hf-${generateId()}`,
assetPath: resolvedAssetSrc,
kind,
start: normalizedStart,
duration: normalizedDuration,
track: placement.track,
zIndex: newElementZIndex,
geometry: fitTimelineAssetGeometry(
null,
resolveTimelineAssetCompositionSize(originalContent),
),
}),
);
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Add timeline asset",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
forceReloadSdkSession?.();
reloadPreview();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
showToast(message);
}
},
[
projectIdRef,
activeCompPath,
recordEdit,
showToast,
timelineElements,
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
],
);
// fallow-ignore-next-line complexity
const handleTimelineFileDrop = useCallback(
// fallow-ignore-next-line complexity
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
const uploaded = await uploadProjectFiles(files);
if (uploaded.length === 0) return;
const durations: number[] = [];
for (const assetPath of uploaded) {
const kind = getTimelineAssetKind(assetPath);
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
durations.push(Number(formatTimelineAttributeNumber(duration)));
}
const placements = buildTimelineFileDropPlacements(
placement ?? { start: 0, track: 0 },
durations,
);
for (const [index, assetPath] of uploaded.entries()) {
await handleTimelineAssetDrop(
assetPath,
placements[index] ?? placements[0],
durations[index],
);
}
},
[handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
);
return { handleTimelineAssetDrop, handleTimelineFileDrop };
}
@@ -77,6 +77,21 @@ function timelineElement(input: {
};
}
/** Mount a harness component under act() and return its unmount hook. */
function mountHarness(node: React.ReactElement): { unmount: () => void } {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(node);
});
return {
unmount: () => {
act(() => root.unmount());
},
};
}
function renderTimelineEditingHook(input: {
timelineElements: TimelineElement[];
iframe: HTMLIFrameElement;
@@ -92,17 +107,20 @@ function renderTimelineEditingHook(input: {
reloadPreview?: () => void;
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
forceReloadSdkSession?: () => void;
showToast?: (message: string, kind?: string) => void;
}): {
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
unmount: () => void;
} {
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
function Harness() {
const commitRef = useRef(input.onZIndexCommit);
@@ -111,7 +129,7 @@ function renderTimelineEditingHook(input: {
projectId: input.projectId ?? null,
activeCompPath: "index.html",
timelineElements: input.timelineElements,
showToast: vi.fn(),
showToast: input.showToast ?? vi.fn(),
writeProjectFile: input.writeProjectFile ?? vi.fn(),
recordEdit: input.recordEdit ?? vi.fn(),
domEditSaveTimestampRef: { current: 0 },
@@ -127,29 +145,17 @@ function renderTimelineEditingHook(input: {
resize = hook.handleTimelineElementResize;
groupMove = hook.handleTimelineGroupMove;
groupResize = hook.handleTimelineGroupResize;
del = hook.handleTimelineElementDelete;
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
const { unmount } = mountHarness(<Harness />);
if (!move) throw new Error("Expected hook to expose move handler");
if (!resize) throw new Error("Expected hook to expose resize handler");
if (!groupMove) throw new Error("Expected hook to expose group move handler");
if (!groupResize) throw new Error("Expected hook to expose group resize handler");
return {
move,
resize,
groupMove,
groupResize,
unmount: () => {
act(() => root.unmount());
},
};
if (!del) throw new Error("Expected hook to expose delete handler");
return { move, resize, groupMove, groupResize, del, unmount };
}
type TimelineRecordEdit = NonNullable<
@@ -198,20 +204,9 @@ function renderTimelineEditingHookWithLifecycle(input: {
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
const { unmount } = mountHarness(<Harness />);
if (!move) throw new Error("Expected hook to expose move handler");
return {
move,
unmount: () => {
act(() => root.unmount());
},
};
return { move, unmount };
}
function jsonResponse(body: unknown): Response {
@@ -233,139 +228,118 @@ async function flushAsyncWork(): Promise<void> {
}
}
/**
* Stub global fetch for project "p1": serves file contents (a single source
* string, or a path → content map) and answers the GSAP-mutation endpoint
* with `gsapBody`. Returns the mock for call inspection.
*/
function stubProjectFetch(
files: string | Record<string, string>,
gsapBody: unknown = { ok: true },
) {
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) {
if (typeof files === "string") return jsonResponse({ content: files });
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
return jsonResponse({ content: files[path] });
}
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse(gsapBody);
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
const ROOT_DURATION_FALLBACK_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");
/** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */
async function setupRootDurationFallback() {
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE);
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(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");
stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, { ok: true, mutated: false });
usePlayerStore.getState().setDuration(4);
const hook = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit,
sdkSession,
forceReloadSdkSession,
reloadPreview,
});
return {
hook,
clip,
setTimingSpy,
writeProjectFile,
forceReloadSdkSession,
reloadPreview,
postMessageSpy,
};
}
/** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */
function expectRootDurationExtendedViaFallback(
ctx: Awaited<ReturnType<typeof setupRootDurationFallback>>,
): void {
expect(ctx.setTimingSpy).not.toHaveBeenCalled();
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain(
'data-composition-id="main" data-duration="5"',
);
expect(usePlayerStore.getState().duration).toBe(5);
expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1);
// The GSAP endpoint returned no rewritten scriptText, so the timing sync
// escalates from the flash-free soft reload to ONE full reload. The root
// duration travels via the persisted content-driven `data-duration` (above),
// not a `set-root-duration` postMessage.
expect(ctx.reloadPreview).toHaveBeenCalledTimes(1);
expect(ctx.postMessageSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ action: "set-root-duration" }),
"*",
);
}
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<TimelineRecordEdit>(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,
});
const ctx = await setupRootDurationFallback();
await act(async () => {
await move(clip, { start: 3, track: clip.track });
await ctx.hook.move(ctx.clip, { start: 3, track: ctx.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(
expect.objectContaining({
source: "hf-parent",
type: "control",
action: "set-root-duration",
durationSeconds: 5,
protocolVersion: 1,
}),
"*",
);
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
expectRootDurationExtendedViaFallback(ctx);
unmount();
ctx.hook.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<TimelineRecordEdit>(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,
});
const ctx = await setupRootDurationFallback();
await act(async () => {
await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
await ctx.hook.resize(ctx.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(
expect.objectContaining({
source: "hf-parent",
type: "control",
action: "set-root-duration",
durationSeconds: 5,
protocolVersion: 1,
}),
"*",
);
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
expectRootDurationExtendedViaFallback(ctx);
unmount();
ctx.hook.unmount();
});
it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
@@ -640,24 +614,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
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 fetchMock = stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
const { move, unmount } = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
@@ -687,6 +644,41 @@ describe("useTimelineEditing timeline z-index reorder", () => {
unmount();
});
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
// pure lane change routed through onMoveElement (no onMoveElements wired)
// wrote NOTHING — the lane snapped back on the next reload.
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 () => {});
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
const { move, unmount } = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: commit,
projectId: "p1",
writeProjectFile,
recordEdit: vi.fn(async () => {}),
});
await act(async () => {
// Vertical-only: same start, new track (already authored-space on this path).
await move(clip, { start: clip.start, track: 2 });
});
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe document");
// Live DOM patched so a pre-reload re-discovery doesn't snap the lane back...
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("2");
// ...and the file write carries the new data-track-index with start intact.
expect(writeProjectFile).toHaveBeenCalled();
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="2"');
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
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" },
@@ -699,17 +691,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
});
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);
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
const { move, unmount } = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
@@ -768,15 +750,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
];
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
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 });
throw new Error(`Unexpected fetch: ${url}`);
}),
);
stubProjectFetch(source);
const { groupMove, unmount } = renderTimelineEditingHook({
timelineElements: clips,
iframe,
@@ -825,18 +799,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
});
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) {
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
return jsonResponse({ content: files[path] });
}
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
throw new Error(`Unexpected fetch: ${url}`);
}),
);
stubProjectFetch(files);
const { groupMove, unmount } = renderTimelineEditingHook({
timelineElements: [a, b],
iframe,
@@ -877,15 +840,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
releaseCommit = resolve;
});
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
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 });
throw new Error(`Unexpected fetch: ${url}`);
}),
);
stubProjectFetch(source);
const { groupMove, unmount } = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
@@ -915,13 +870,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
it("matches the single-clip move output when a group move contains one clip", async () => {
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
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: source });
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
stubProjectFetch(source);
const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const single = renderTimelineEditingHook({
@@ -954,3 +903,218 @@ describe("useTimelineEditing timeline z-index reorder", () => {
group.unmount();
});
});
describe("useTimelineEditing duration rollback on failed persist", () => {
const ROLLBACK_SOURCE = [
`<div data-composition-id="main" data-duration="4">`,
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
`</div>`,
].join("\n");
/** Iframe with a comp root so the optimistic sync (and its rollback) can patch data-duration. */
function createRootedIframe(source: string = ROLLBACK_SOURCE): 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 = source;
return iframe;
}
function rootDurationAttr(iframe: HTMLIFrameElement): string | null | undefined {
return iframe.contentDocument
?.querySelector("[data-composition-id]")
?.getAttribute("data-duration");
}
function setupFailedPersist() {
const iframe = createRootedIframe();
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
const writeError = new Error("write failed");
const writeProjectFile = vi
.fn<(...args: unknown[]) => Promise<void>>()
.mockRejectedValue(writeError);
stubProjectFetch(ROLLBACK_SOURCE);
usePlayerStore.getState().setDuration(4);
vi.spyOn(console, "error").mockImplementation(() => {});
const hook = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit: vi.fn(async () => {}),
reloadPreview: vi.fn(),
});
return { iframe, clip, hook, writeError };
}
it("rolls back the store duration and live root when a move persist fails", async () => {
const { iframe, clip, hook, writeError } = setupFailedPersist();
let rejection: unknown;
await act(async () => {
// Move past the end: the optimistic sync grows the readout to 5s.
await hook.move(clip, { start: 3, track: clip.track }).catch((error) => {
rejection = error;
});
await flushAsyncWork();
});
expect(rejection).toBe(writeError);
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("rolls back the store duration and live root when a resize persist fails", async () => {
const { iframe, clip, hook, writeError } = setupFailedPersist();
let rejection: unknown;
await act(async () => {
await hook
.resize(clip, { start: 0, duration: 6, playbackStart: undefined })
.catch((error) => {
rejection = error;
});
await flushAsyncWork();
});
expect(rejection).toBe(writeError);
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("rolls back the store duration and live root when a group move persist fails", async () => {
const { iframe, clip, hook, writeError } = setupFailedPersist();
let rejection: unknown;
await act(async () => {
await hook.groupMove([{ element: clip, start: 3.5 }]).catch((error) => {
rejection = error;
});
await flushAsyncWork();
});
expect(rejection).toBe(writeError);
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("rolls back the store duration and live root when a group resize persist fails", async () => {
const { iframe, clip, hook, writeError } = setupFailedPersist();
let rejection: unknown;
await act(async () => {
await hook.groupResize([{ element: clip, start: 0, duration: 7 }]).catch((error) => {
rejection = error;
});
await flushAsyncWork();
});
expect(rejection).toBe(writeError);
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("rolls back the store duration and live root when a delete persist fails", async () => {
// Two clips; deleting the furthest one shrinks the content-driven duration
// optimistically (4s -> 2s), so a failed write must roll that shrink back.
const DELETE_SOURCE = [
`<div data-composition-id="main" data-duration="4">`,
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
` <div id="tail" data-start="2" data-duration="2" data-track-index="0"></div>`,
`</div>`,
].join("\n");
const DELETE_REMOVED_SOURCE = [
`<div data-composition-id="main" data-duration="4">`,
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
`</div>`,
].join("\n");
const iframe = createRootedIframe(DELETE_SOURCE);
const tail = timelineElement({ id: "tail", track: 0, zIndex: 0, start: 2, duration: 2 });
const writeError = new Error("write failed");
const writeProjectFile = vi
.fn<(...args: unknown[]) => Promise<void>>()
.mockRejectedValue(writeError);
// The delete path reads the file, then asks the server-side remove-element
// mutation for the post-removal source before persisting it.
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: DELETE_SOURCE });
}
if (url.includes("/api/projects/p1/file-mutations/remove-element/")) {
return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
usePlayerStore.getState().setDuration(4);
const showToast = vi.fn();
const hook = renderTimelineEditingHook({
timelineElements: [tail],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit: vi.fn(async () => {}),
reloadPreview: vi.fn(),
showToast,
});
await act(async () => {
// Unlike move/resize, the delete handler swallows the persist failure
// into a toast, so the promise resolves.
await hook.del(tail);
await flushAsyncWork();
});
// The optimistic shrink reached the persist attempt (root patched to the
// furthest remaining clip end, 2s)...
expect(writeProjectFile).toHaveBeenCalledTimes(1);
expect(String(writeProjectFile.mock.calls[0]![1])).toContain(
'data-composition-id="main" data-duration="2"',
);
expect(showToast).toHaveBeenCalledWith("write failed");
// ...and the failed write rolled the readout AND the live root back.
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("keeps the grown duration when the persist succeeds", async () => {
const { iframe, clip, hook } = setupFailedPersist();
// Same harness, but with a write that succeeds this time.
hook.unmount();
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const succeeding = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit: vi.fn(async () => {}),
reloadPreview: vi.fn(),
});
await act(async () => {
await succeeding.move(clip, { start: 3, track: clip.track });
await flushAsyncWork();
});
expect(usePlayerStore.getState().duration).toBe(5);
expect(rootDurationAttr(iframe)).toBe("5");
succeeding.unmount();
});
});
+181 -265
View File
@@ -3,35 +3,27 @@ import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { useRazorSplit } from "./useRazorSplit";
import {
buildTimelineAssetId,
buildTimelineAssetInsertHtml,
buildTimelineFileDropPlacements,
getTimelineAssetKind,
insertTimelineAssetIntoSource,
resolveTimelineAssetInitialGeometry,
resolveTimelineAssetSrc,
} from "../utils/timelineAssetDrop";
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import {
getTimelineElementLabel,
collectHtmlIds,
resolveDroppedAssetDuration,
} from "../utils/studioHelpers";
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
import { getTimelineElementLabel } from "../utils/studioHelpers";
import {
applyTimelineStackingReorder,
buildPatchTarget,
patchIframeDomTiming,
persistTimelineEdit,
readFileContent,
foldedShiftGsapMutation,
foldedScaleGsapMutation,
formatTimelineAttributeNumber,
finishTimelineTimingFallback,
extendRootDurationIfNeeded,
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
} from "./timelineEditingHelpers";
import {
captureDurationRollback,
finishClipTimingFallback,
readFileContent,
syncPreviewContentDuration,
} from "./timelineTimingSync";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import {
@@ -137,11 +129,22 @@ export function useTimelineEditing({
(element: TimelineElement, updates: TimelineMoveUpdates) => {
const targetPath = element.sourceFile || activeCompPath || "index.html";
const startChanged = updates.start !== element.start;
// A vertical-only lane move arrives with start unchanged but track changed
// (on this single-element path the drag commit has already folded the
// AUTHORED persist track into updates.track). It must persist like any
// other move — early-returning on !startChanged alone silently dropped
// the file write, so the lane snapped back on reload.
const trackChanged = updates.track !== element.track;
if (startChanged) {
patchIframeDomTiming(previewIframeRef.current, element, [
["data-start", formatTimelineAttributeNumber(updates.start)],
]);
if (startChanged || trackChanged) {
const liveAttrs: Array<[string, string]> = [];
if (startChanged) {
liveAttrs.push(["data-start", formatTimelineAttributeNumber(updates.start)]);
}
if (trackChanged) {
liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
}
const reorderDone = applyTimelineStackingReorder({
@@ -153,62 +156,76 @@ export function useTimelineEditing({
commit: handleDomZIndexReorderCommitRef?.current,
});
if (!startChanged) return reorderDone;
if (!startChanged && !trackChanged) return reorderDone;
// Snapshot the duration BEFORE the optimistic updates below so a failed
// persist can roll the readout + live root back (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
// Persist lane changes too — data-start-only writes let reload snap the lane back.
const track = trackChanged ? updates.track : undefined;
return buildTimelineMoveTimingPatch(
original,
target,
updates.start,
element.duration,
track,
);
};
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;
const domId = element.domId;
return finishTimelineTimingFallback({
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
// Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
// DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
finishClipTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: updates.start + element.duration,
reloadPreview,
gsapMutation:
delta !== 0 && domId && pid
? foldedShiftGsapMutation({
projectId: pid,
targetPath,
domId,
delta,
label: "Move timeline clip",
coalesceKey,
recordEdit,
})
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
});
});
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
return reorderDone.then(() => {
if (sdkSession && element.hfId && !needsExtension) {
return sdkTimingPersist(
element.hfId,
projectId: projectIdRef.current,
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();
});
domId: element.domId,
label: "Move timeline clip",
coalesceKey,
recordEdit,
edit: { kind: "shift", delta: updates.start - element.start },
}),
);
return reorderDone
.then(() => {
// The SDK setTiming path writes start only — a lane change must take
// the fallback, whose patch builder writes data-track-index too.
if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
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();
})
.catch((error) => {
// Failed persist: revert the optimistic duration readout + live root.
rollbackDuration();
throw error;
});
},
[
previewIframeRef,
@@ -245,6 +262,13 @@ export function useTimelineEditing({
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
// Snapshot the duration BEFORE the optimistic updates below so a failed
// persist can roll the readout + live root back (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const targetPath = element.sourceFile || activeCompPath || "index.html";
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
return buildTimelineResizeTimingPatch(original, target, element, updates);
@@ -253,59 +277,55 @@ 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. Extending edits can keep the iframe
// live unless a GSAP source rewrite needs a fresh run.
// positions/durations on the server, then soft-reload with the rewritten
// script (timing-only resize) — same no-flash path as move; full reload is
// the fallback.
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;
const domId = element.domId;
return finishTimelineTimingFallback({
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
finishClipTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: updates.start + updates.duration,
reloadPreview,
gsapMutation:
timingChanged && domId && pid
? foldedScaleGsapMutation({
projectId: pid,
targetPath,
domId,
from: { start: element.start, duration: element.duration },
to: { start: updates.start, duration: updates.duration },
label: "Resize timeline clip",
coalesceKey,
recordEdit,
})
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
});
});
if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
return sdkTimingPersist(
element.hfId,
targetPath,
{ start: updates.start, duration: updates.duration },
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
// Capture on-disk bytes as the undo `before` so undoing a timing
// resize restores the file verbatim, not a normalized full-DOM re-emit.
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Resize timeline clip", coalesceKey },
).then((handled) => {
if (!handled) return resizeFallback();
});
}
return resizeFallback();
projectId: projectIdRef.current,
targetPath,
domId: element.domId,
label: "Resize timeline clip",
coalesceKey,
recordEdit,
edit: {
kind: "scale",
from: { start: element.start, duration: element.duration },
to: { start: updates.start, duration: updates.duration },
},
}),
);
const persistDone =
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
? sdkTimingPersist(
element.hfId,
targetPath,
{ start: updates.start, duration: updates.duration },
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
// Capture on-disk bytes as the undo `before` so undoing a timing
// resize restores the file verbatim, not a normalized full-DOM re-emit.
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Resize timeline clip", coalesceKey },
).then((handled) => {
if (!handled) return resizeFallback();
})
: resizeFallback();
return persistDone.catch((error) => {
// Failed persist: revert the optimistic duration readout + live root.
rollbackDuration();
throw error;
});
},
[
previewIframeRef,
@@ -384,19 +404,37 @@ export function useTimelineEditing({
changed?: boolean;
content?: string;
};
const patchedContent =
const removedContent =
typeof removeData.content === "string" ? removeData.content : originalContent;
// Content-driven duration: shrink the composition to the furthest
// remaining clip end, read from the post-removal SOURCE (raw
// data-duration), so deleting the last/longest clip removes trailing
// empty space. Measured from the source, not the store, whose
// durations are runtime-truncated.
const deleteContentEnd = furthestClipEndFromSource(removedContent);
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
// Optimistically reflect the shrunk length in the readout/seek bar,
// rolling it back if the persist below fails (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
usePlayerStore.getState().setDuration(deleteContentEnd);
}
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Delete timeline clip",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
try {
await saveProjectFilesWithHistory({
projectId: pid,
label: "Delete timeline clip",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
} catch (error) {
rollbackDuration();
throw error;
}
usePlayerStore
.getState()
@@ -422,145 +460,23 @@ export function useTimelineEditing({
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
previewIframeRef,
],
);
// fallow-ignore-next-line complexity
const handleTimelineAssetDrop = useCallback(
// fallow-ignore-next-line complexity
async (
assetPath: string,
placement: Pick<TimelineElement, "start" | "track">,
durationOverride?: number,
) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const kind = getTimelineAssetKind(assetPath);
if (!kind) {
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
return;
}
const targetPath = activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
const duration =
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
? durationOverride
: await resolveDroppedAssetDuration(pid, assetPath, kind);
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
const resolvedTargetPath = targetPath || "index.html";
const relevantElements = timelineElements.filter(
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
);
const newElementZIndex = Math.max(1, relevantElements.length + 1);
const patchedContent = insertTimelineAssetIntoSource(
originalContent,
buildTimelineAssetInsertHtml({
id: newId,
assetPath: resolvedAssetSrc,
kind,
start: normalizedStart,
duration: normalizedDuration,
track: placement.track,
zIndex: newElementZIndex,
geometry: resolveTimelineAssetInitialGeometry(originalContent),
}),
);
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Add timeline asset",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
forceReloadSdkSession?.();
reloadPreview();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
showToast(message);
}
},
[
activeCompPath,
recordEdit,
showToast,
timelineElements,
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
],
);
// fallow-ignore-next-line complexity
const handleTimelineFileDrop = useCallback(
// fallow-ignore-next-line complexity
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
const uploaded = await uploadProjectFiles(files);
if (uploaded.length === 0) return;
const durations: number[] = [];
for (const assetPath of uploaded) {
const kind = getTimelineAssetKind(assetPath);
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
durations.push(Number(formatTimelineAttributeNumber(duration)));
}
const placements = buildTimelineFileDropPlacements(
placement ?? { start: 0, track: 0 },
durations,
timelineElements
.filter(
(te) =>
(te.sourceFile || activeCompPath || "index.html") ===
(activeCompPath || "index.html"),
)
.map((te) => ({
start: te.start,
duration: te.duration,
track: te.track,
})),
);
for (const [index, assetPath] of uploaded.entries()) {
await handleTimelineAssetDrop(
assetPath,
placements[index] ?? placements[0],
durations[index],
);
}
},
[
activeCompPath,
handleTimelineAssetDrop,
timelineElements,
uploadProjectFiles,
isRecordingRef,
showToast,
],
);
const { handleTimelineAssetDrop, handleTimelineFileDrop } = useTimelineAssetDropOps({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
uploadProjectFiles,
isRecordingRef,
forceReloadSdkSession,
});
const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
@@ -6,17 +6,20 @@ import {
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
extendRootDurationIfNeeded,
finishTimelineTimingFallback,
foldGsapMutationIntoHistory,
formatTimelineAttributeNumber,
patchIframeDomTiming,
persistTimelineBatchEdit,
readFileContent,
scaleGsapPositions,
shiftGsapPositions,
type PersistTimelineBatchChange,
type RecordEditInput,
} from "./timelineEditingHelpers";
import {
captureDurationRollback,
finishGroupTimingGsapFallback,
readFileContent,
scaleGsapPositions,
shiftGsapPositions,
syncPreviewContentDuration,
} from "./timelineTimingSync";
export interface TimelineGroupMoveChange {
element: TimelineElement;
@@ -75,6 +78,15 @@ function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): strin
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
}
function toSdkTimingChanges<T extends { element: TimelineElement }>(
changes: readonly T[],
timingUpdate: (change: T) => { start: number; duration?: number },
): Array<{ hfId: string; timingUpdate: { start: number; duration?: number } } | null> {
return changes.map((change) =>
change.element.hfId ? { hfId: change.element.hfId, timingUpdate: timingUpdate(change) } : null,
);
}
function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
return (
change.playbackStart != null ||
@@ -149,6 +161,55 @@ export function useTimelineGroupEditing({
],
);
// Shared SDK fast path for group move/resize: eligible when nothing needs the
// server (no root-duration growth, one shared file, every change SDK-addressable
// and `eligible` per the caller's own gate). Returns whether the SDK handled it;
// false → caller falls through to the server batch persist.
const trySdkBatchPersist = useCallback(
async (input: {
changes: readonly { element: TimelineElement }[];
sdkChanges: Array<{
hfId: string;
timingUpdate: { start: number; duration?: number };
} | null>;
eligible: boolean;
needsExtension: boolean;
label: string;
coalesceKey: string;
}): Promise<boolean> => {
const sharedPath = allChangesSharePath(input.changes, activeCompPath);
const canUseSdk =
!input.needsExtension &&
sharedPath !== null &&
input.eligible &&
input.sdkChanges.every((change) => change !== null);
if (!canUseSdk) return false;
return sdkTimingBatchPersist(
input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: input.label, coalesceKey: input.coalesceKey },
);
},
[
activeCompPath,
domEditSaveTimestampRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
],
);
const handleTimelineGroupMove = useCallback(
(changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
if (changes.length === 0) return Promise.resolve();
@@ -163,38 +224,27 @@ export function useTimelineGroupEditing({
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
// Snapshot the duration BEFORE the optimistic updates below so a failed
// persist can roll the readout + live root back (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(maxEnd);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
return enqueueGroupOperation("Move timeline clips", async (projectId) => {
await options?.beforeTiming;
const sharedPath = allChangesSharePath(changes, activeCompPath);
const sdkChanges = changes.map((change) =>
change.element.hfId
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
: null,
);
const canUseSdk =
!needsExtension &&
sharedPath !== null &&
changes.every((change) => change.track == null) &&
sdkChanges.every((change) => change !== null);
if (canUseSdk) {
const handled = await sdkTimingBatchPersist(
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Move timeline clips", coalesceKey },
);
if (handled) return;
}
const handledBySdk = await trySdkBatchPersist({
changes,
sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
eligible: changes.every((change) => change.track == null),
needsExtension,
label: "Move timeline clips",
coalesceKey,
});
if (handledBySdk) return;
await persistServerBatch(
projectId,
@@ -212,50 +262,39 @@ export function useTimelineGroupEditing({
})),
coalesceKey,
);
await finishTimelineTimingFallback({
await finishGroupTimingGsapFallback({
projectId,
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: maxEnd,
reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
projectId,
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
label: "Move timeline clips",
coalesceKey,
recordEdit,
gsapMutation: async () => {
let mutated = false;
for (const change of changes) {
const delta = change.start - change.element.start;
const domId = change.element.domId;
if (delta === 0 || !domId) continue;
const status = await shiftGsapPositions(
projectId,
targetPathFor(change.element, activeCompPath),
domId,
delta,
);
mutated = mutated || status.mutated;
}
return { mutated };
},
}),
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
label: "Move timeline clips",
errorLabel: "Failed to shift GSAP positions",
coalesceKey,
recordEdit,
activeCompPath,
changes,
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
mutateChange: (change, changePath) => {
const delta = change.start - change.element.start;
const domId = change.element.domId;
if (delta === 0 || !domId) return null;
return shiftGsapPositions(projectId, changePath, domId, delta);
},
});
}).catch((error) => {
// Failed persist: revert the optimistic duration readout + live root
// alongside the gesture owner's store rollback.
rollbackDuration();
throw error;
});
},
[
activeCompPath,
domEditSaveTimestampRef,
enqueueGroupOperation,
persistServerBatch,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
trySdkBatchPersist,
],
);
@@ -278,41 +317,30 @@ export function useTimelineGroupEditing({
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
// Snapshot the duration BEFORE the optimistic updates below so a failed
// persist can roll the readout + live root back (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(maxEnd);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
await options?.beforeTiming;
const sharedPath = allChangesSharePath(changes, activeCompPath);
const sdkChanges = changes.map((change) =>
change.element.hfId
? {
hfId: change.element.hfId,
timingUpdate: { start: change.start, duration: change.duration },
}
: null,
);
const canUseSdk =
!needsExtension &&
sharedPath !== null &&
changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
sdkChanges.every((change) => change !== null);
if (canUseSdk) {
const handled = await sdkTimingBatchPersist(
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Resize timeline clips", coalesceKey },
);
if (handled) return;
}
const handledBySdk = await trySdkBatchPersist({
changes,
sdkChanges: toSdkTimingChanges(changes, (change) => ({
start: change.start,
duration: change.duration,
})),
eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
needsExtension,
label: "Resize timeline clips",
coalesceKey,
});
if (handledBySdk) return;
await persistServerBatch(
projectId,
@@ -328,55 +356,48 @@ export function useTimelineGroupEditing({
})),
coalesceKey,
);
await finishTimelineTimingFallback({
await finishGroupTimingGsapFallback({
projectId,
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: maxEnd,
reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
label: "Resize timeline clips",
errorLabel: "Failed to scale GSAP positions",
coalesceKey,
recordEdit,
activeCompPath,
changes,
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
mutateChange: (change, changePath) => {
const domId = change.element.domId;
const timingChanged =
change.start !== change.element.start || change.duration !== change.element.duration;
if (!timingChanged || !domId) return null;
return scaleGsapPositions(
projectId,
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
label: "Resize timeline clips",
coalesceKey,
recordEdit,
gsapMutation: async () => {
let mutated = false;
for (const change of changes) {
const domId = change.element.domId;
const timingChanged =
change.start !== change.element.start ||
change.duration !== change.element.duration;
if (!timingChanged || !domId) continue;
const status = await scaleGsapPositions(
projectId,
targetPathFor(change.element, activeCompPath),
domId,
change.element.start,
change.element.duration,
change.start,
change.duration,
);
mutated = mutated || status.mutated;
}
return { mutated };
},
}),
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
changePath,
domId,
change.element.start,
change.element.duration,
change.start,
change.duration,
);
},
});
}).catch((error) => {
// Failed persist: revert the optimistic duration readout + live root
// alongside the gesture owner's store rollback.
rollbackDuration();
throw error;
});
},
[
activeCompPath,
domEditSaveTimestampRef,
enqueueGroupOperation,
persistServerBatch,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
trySdkBatchPersist,
],
);
@@ -9,10 +9,8 @@ import { Tooltip } from "../../components/ui";
import { ShortcutsPanel } from "./ShortcutsPanel";
import { SpeedMenu } from "./SpeedMenu";
import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
import { useState } from "react";
export { resolveSeekPercent };
type TimeDisplayMode = "time" | "frame";
/* ── Icon sub-components ─────────────────────────────────────────── */
@@ -369,7 +367,8 @@ export const PlayerControls = memo(function PlayerControls({
const outPoint = usePlayerStore((s) => s.outPoint);
const setInPoint = usePlayerStore.getState().setInPoint;
const setOutPoint = usePlayerStore.getState().setOutPoint;
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
const progressFillRef = useRef<HTMLDivElement>(null);
const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -428,10 +427,11 @@ export const PlayerControls = memo(function PlayerControls({
return (
<div
// No own background/border: the transport blends into the preview
// panel's surface — buttons carry their own chrome.
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
aria-disabled={disabled || undefined}
style={{
borderTop: "1px solid rgba(255,255,255,0.04)",
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
}}
>
@@ -456,7 +456,7 @@ export const PlayerControls = memo(function PlayerControls({
>
<button
type="button"
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
disabled={disabled}
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
style={{ color: "#A1A1AA", cursor: "pointer" }}
@@ -9,6 +9,8 @@
* no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
* FILLED while the playhead is actively held/scrubbed (`scrubbing`).
*/
import { PLAYHEAD_HEAD_W } from "./timelineLayout";
interface PlayheadIndicatorProps {
/** CSS color, defaults to the HF accent variable */
color?: string;
@@ -31,8 +33,11 @@ export function PlayheadIndicator({
}: PlayheadIndicatorProps) {
// Head chip dimensions — used to compute the centering offset and the
// point where the vertical line starts (so it begins at the head's bottom
// edge rather than running through the hollow diamond center).
const HEAD_W = 9;
// edge rather than running through the hollow diamond center). The width is
// the shared PLAYHEAD_HEAD_W constant: getTimelinePlayheadLeft shifts the
// wrapper by -PLAYHEAD_HEAD_W/2 so the 1px line (centered at 50% of the
// wrapper) lands exactly on GUTTER + time * pps — the ruler ticks' center x.
const HEAD_W = PLAYHEAD_HEAD_W;
const HEAD_H = 9;
// marginTop(1) + HEAD_H = where the line should start.
const HEAD_TOTAL_H = 1 + HEAD_H;
@@ -19,8 +19,10 @@ import {
shouldAutoScrollTimeline,
} from "./Timeline";
import {
FIT_ZOOM_HEADROOM,
GUTTER,
MIN_TIMELINE_EXTENT_S,
PLAYHEAD_HEAD_W,
RULER_H,
TRACK_H,
getTimelineDisplayContentWidth,
@@ -327,6 +329,60 @@ describe("generateTicks", () => {
const { major } = generateTicks(180, 80);
expect(major[1] - major[0]).toBe(2);
});
it("picks 'nice' NLE steps across zoom levels (no 7s-style intervals)", () => {
// step = first nice interval whose px spacing >= 88 at that pps.
const cases: Array<[number, number]> = [
[2, 60], // 60s * 2pps = 120px
[10, 10], // 10s * 10pps = 100px
[20, 5], // 5s * 20pps = 100px
[50, 2], // 2s * 50pps = 100px
[100, 1], // 1s * 100pps = 100px
];
for (const [pps, expected] of cases) {
const { major } = generateTicks(600, pps);
expect(major[1] - major[0]).toBe(expected);
}
});
it("uses minute/hour steps when zoomed far out instead of colliding 10m labels", () => {
// 0.05 pps → 600s step would be 30px apart (labels collide); 1800s = 90px.
const { major } = generateTicks(7200, 0.05);
expect(major[1] - major[0]).toBe(1800);
expect(major).toContain(3600);
});
it("does not drift on long rulers (ticks are exact multiples of the step)", () => {
const { major } = generateTicks(600, 100); // 1s step, 601 ticks
expect(major[599]).toBe(599);
});
describe("frame display mode (frameRate provided)", () => {
it("snaps sub-frame steps up to one whole frame (no duplicate frame labels)", () => {
// 4400 pps would pick a 0.02s step = 0.6 frames at 30fps → snapped to 1 frame.
const { major } = generateTicks(2, 4400, 30);
const frames = major.map((t) => Math.round(t * 30));
// Frame labels are consecutive integers — no duplicates, no gaps.
frames.forEach((f, i) => expect(f).toBe(i));
});
it("keeps major AND minor ticks on whole frames", () => {
// 200 pps → 0.5s step (15 frames); quarters (3.75f) are rejected in
// frame mode in favour of fifths (3f).
const { major, minor } = generateTicks(20, 200, 30);
expect(major[1]).toBeCloseTo(0.5);
expect(minor).toContain(0.1); // 3 frames
for (const t of [...major, ...minor]) {
const frames = t * 30;
expect(Math.abs(frames - Math.round(frames))).toBeLessThan(1e-3);
}
});
it("leaves whole-second steps unchanged", () => {
const { major } = generateTicks(60, 100, 30);
expect(major[1] - major[0]).toBe(1);
});
});
});
describe("formatTime", () => {
@@ -397,19 +453,33 @@ describe("shouldAutoScrollTimeline", () => {
});
});
describe("getTimelineFitPps (min 60s extent)", () => {
describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
it("computes fit pps against the 60s floor for short compositions", () => {
// A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
// (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
const pps = getTimelineFitPps(viewport, 10);
expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
});
it("keeps filling the viewport with the composition when it is 60s or longer", () => {
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo((viewport - GUTTER - 2) / 60);
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo((viewport - GUTTER - 2) / 120);
it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
(viewport - GUTTER - 2) / (60 * FIT_ZOOM_HEADROOM),
);
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
(viewport - GUTTER - 2) / (120 * FIT_ZOOM_HEADROOM),
);
});
it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
const usable = viewport - GUTTER - 2;
const pps = getTimelineFitPps(viewport, 120);
// Composition content occupies usable/1.2 px; the remaining ~17% is empty
// droppable ruler/lane surface past the end.
expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
expect(120 * pps).toBeLessThan(usable);
});
it("falls back to 100 pps before the viewport is measured", () => {
@@ -529,13 +599,20 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
});
describe("getTimelinePlayheadLeft", () => {
it("converts time to a pixel offset from the gutter", () => {
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
it("offsets the wrapper by half the head width so the line CENTER = GUTTER + t*pps", () => {
// Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
// equal GUTTER + t*pps at any zoom.
expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
});
it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
});
it("guards invalid input", () => {
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
});
});
@@ -26,6 +26,7 @@ import {
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
} from "./timelineLayout";
import { STUDIO_PREVIEW_FPS } from "../lib/time";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
@@ -95,6 +96,7 @@ export const Timeline = memo(function Timeline({
[beatAnalysis, musicElement, beatEdits],
);
const duration = usePlayerStore((s) => s.duration);
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const timelineReady = usePlayerStore((s) => s.timelineReady);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
@@ -156,11 +158,8 @@ export const Timeline = memo(function Timeline({
containerRef.current = el;
}, []);
// Last horizontal scroll offset, tracked so it can be RESTORED across the
// post-edit iframe reload: an edit re-derives elements (and may shrink the
// content width), which the browser clamps into a scroll jump. Paired with the
// pinned zoom (which keeps pps constant so the pixel offset stays meaningful),
// restoring this keeps the user parked at the same spot after any edit.
// Last horizontal scroll offset, RESTORED across the post-edit iframe reload (which clamps into
// a scroll jump); with the pinned zoom this keeps the user parked at the same spot after edits.
const lastScrollLeftRef = useRef(0);
const setScrollRef = useCallback(
(el: HTMLDivElement | null) => {
@@ -395,9 +394,11 @@ export const Timeline = memo(function Timeline({
}
});
// Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
const { major, minor } = useMemo(
() => generateTicks(displayDuration, pps),
[displayDuration, pps],
() => generateTicks(displayDuration, pps, tickFps),
[displayDuration, pps, tickFps],
);
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
@@ -526,8 +527,7 @@ export const Timeline = memo(function Timeline({
const elKey = el.key ?? el.id;
setSelectedElementId(elKey);
onSelectElement?.(el);
// Visually select the clicked diamond (matches shift-click / motion-path
// selection); cleared above so this single-selects it.
// Select the clicked diamond (matches shift-click); cleared above so this single-selects.
toggleSelectedKeyframe(`${elKey}:${pct}`);
const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime);
@@ -10,6 +10,8 @@ import {
CLIP_Y,
TRACKS_TOP_PAD,
TRACKS_BOTTOM_PAD,
PLAYHEAD_HEAD_W,
getTimelinePlayheadLeft,
getTimelineRowTop,
} from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
@@ -20,6 +22,7 @@ import type { Rect } from "../../utils/marqueeGeometry";
import { TimelineClip } from "./TimelineClip";
import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
import { renderClipChildren } from "./timelineClipChildren";
import { useTimelineRevealClip } from "./useTimelineRevealClip";
interface TimelineCanvasProps extends TimelineLaneBaseProps {
major: number[];
@@ -41,6 +44,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
useTimelineEditContextOptional();
const beatDragging = usePlayerStore((s) => s.beatDragging);
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
useTimelineRevealClip(scrollRef);
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
@@ -259,12 +264,16 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
)}
{/* Playhead — hidden while dragging a beat so its guideline doesn't
track the scrub and clutter the beat being moved. */}
track the scrub and clutter the beat being moved. Explicit width +
the half-head offset baked into getTimelinePlayheadLeft keep the
inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler
ticks' center), instead of relying on shrink-wrap sizing. */}
<div
ref={props.playheadRef}
className="absolute top-0 bottom-0 pointer-events-none"
style={{
left: `${GUTTER}px`,
left: `${getTimelinePlayheadLeft(0, 0)}px`,
width: PLAYHEAD_HEAD_W,
zIndex: 100,
display: beatDragging ? "none" : undefined,
}}
@@ -1,6 +1,8 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { secondsToFrame } from "../lib/time";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
interface TimelineRulerProps {
@@ -26,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
theme,
beatAnalysis,
}: TimelineRulerProps) {
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const beatTimes = beatAnalysis?.beatTimes ?? [];
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
@@ -38,27 +41,13 @@ export const TimelineRuler = memo(function TimelineRuler({
return (
<>
{/* Grid lines (major ticks + beat lines) — behind the tracks (background).
Opaque track rows hide them; only the beat dots show on tracks. */}
{/* Background SVG — beat lines only; major-tick gridlines removed so only
the ruler's own small ticks mark intervals (no full-height lines). */}
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
height={totalH}
>
{major.map((t) => {
const x = t * pps;
return (
<line
key={`g-${t}`}
x1={x}
y1={RULER_H}
x2={x}
y2={totalH}
stroke={theme.tickMinor}
strokeWidth="1"
/>
);
})}
{showBeats &&
beatTimes.map((t, i) => {
const x = t * pps;
@@ -79,41 +68,62 @@ export const TimelineRuler = memo(function TimelineRuler({
})}
</svg>
{/* Ruler. The bar fills the full panel width (canvas is min 100% wide);
calc(100% - GUTTER) equals trackContentWidth when zoomed in and extends
past the content when zoomed out. Ticks stay at composition coordinates. */}
{/* Ruler — sticky so the timestamps stay visible while the tracks scroll
vertically. Opaque background (plus the gutter corner block) so clips
scrolling underneath don't bleed through; z-index sits above the track
rows and drag overlays but below the playhead (z 100). */}
<div
className="relative overflow-hidden"
style={{
height: RULER_H,
marginLeft: GUTTER,
width: `calc(100% - ${GUTTER}px)`,
background: theme.gutterBackground,
borderBottom: `1px solid ${theme.rulerBorder}`,
}}
className="sticky top-0 flex"
style={{ height: RULER_H, width: GUTTER + trackContentWidth, zIndex: 70 }}
>
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
</div>
))}
<div
className="sticky left-0 z-[12] flex-shrink-0"
style={{
width: GUTTER,
// Ruler corner uses the panel surface — same as the ruler strip itself.
background: theme.shellBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
/>
<div
className="relative overflow-hidden"
style={{
height: RULER_H,
width: trackContentWidth,
// Ruler background = panel surface (#0A0A0B) — no bottom border,
// no tick lines (CapCut-style clean ruler, labels only).
background: theme.shellBackground,
}}
>
{/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
t * pps — matching the playhead line, which is also centered on
GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
a tick spans [x, x+1) and its center is half a pixel right. */}
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
</div>
))}
{major.map((t) => (
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
<span
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
color: theme.tickText,
left: 5,
top: 5,
fontSize: 10,
}}
>
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
))}
{major.map((t) => (
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
<span
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
color: theme.tickText,
left: 5,
top: 5,
fontSize: 10,
}}
>
{timeDisplayMode === "frame"
? secondsToFrame(t)
: formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
))}
</div>
</div>
</>
);
@@ -219,6 +219,88 @@ describe("commitDraggedClipMove", () => {
expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
});
it("persists a lane change in AUTHORED track space when the file is sparse", () => {
// Discovery normalized authored tracks {1, 2} to display lanes {0, 1}
// (authoredTrack records the file value). Moving 'a' onto b's lane must
// write b's AUTHORED track (2) — writing the display lane (1) would target
// a's own authored row and silently no-op in the file.
const elements = [
{ ...el("a", 0, 0, 3), authoredTrack: 1 },
{ ...el("b", 1, 10, 3), authoredTrack: 2 },
];
const { updateElement, onMoveElements } = runClipMove(
drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
{ elements, trackOrder: [0, 1] },
);
// Store stays in display-lane space, but authoredTrack is refreshed to the
// value just written to the file so a SECOND drag before any reload resolves
// authored tracks from current data, not the stale pre-edit value.
expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1, authoredTrack: 2 });
// ...while the persist is translated to the target lane's authored track.
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.a).toEqual({ start: 20, track: 2 });
});
it("resolves the authored track from occupants of the dragged clip's OWN source file", () => {
// Expanded sub-comp children live on synthetic display lanes next to host
// clips. Their authoredTrack is in THEIR file's coordinate space, so a lane
// occupied by a clip from a DIFFERENT file must never lend its authored
// value. Here lane 1 holds both a host-file clip (authored 12) and a
// same-file sibling (authored 7): the sibling answers.
const child3 = { ...el("c3", 0, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
const child7 = { ...el("c7", 1, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
const hostClip = { ...el("h", 1, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
const elements = [child3, child7, hostClip];
const { onMoveElements } = runClipMove(
drag(child3, { previewStart: 0, previewTrack: 1, desiredTrack: 1 }),
{ elements, trackOrder: [0, 1] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.c3).toEqual({ start: 0, track: 7 });
});
it("never persists the display-lane integer when the target lane has no same-file occupant", () => {
// Reviewer scenario: an expanded child of a SPARSE file (authored tracks 3
// and 7 → display lanes 4 and 5) dragged onto display lane 6, which holds
// only another file's clip. Persisting 6 (the display integer) or 12 (the
// foreign authored value) would corrupt the sparse file; the fallback
// offsets from the NEAREST same-file lane instead: authored 7 at lane 5,
// one lane further down → 8.
const child3 = { ...el("c3", 4, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
const child7 = { ...el("c7", 5, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
const foreign = { ...el("f", 6, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
const elements = [child3, child7, foreign];
const { onMoveElements } = runClipMove(
drag(child3, { previewStart: 0, previewTrack: 6, desiredTrack: 6 }),
{ elements, trackOrder: [4, 5, 6] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.c3.track).not.toBe(6); // not the display-lane integer
expect(map.c3.track).not.toBe(12); // not the foreign file's authored value
expect(map.c3).toEqual({ start: 0, track: 8 });
});
it("dropping onto an overlap spill sub-lane persists the base lane's shared authored track", () => {
// Authored track 2 holds two time-overlapping clips, which packTrackLanes
// spills onto display sub-lanes 1 and 2 (both authoredTrack 2). Dropping
// 'a' onto the spill sub-lane (2) is a legitimate same-track join: the
// persisted value is the shared authored track (2), even though the clip
// may re-pack onto a different sub-lane on the next normalize.
const elements = normalizeToZones([
{ ...el("a", 0, 30, 3), authoredTrack: 1 },
{ ...el("b1", 2, 0, 5), authoredTrack: 2 },
{ ...el("b2", 2, 3, 5), authoredTrack: 2 }, // overlaps b1 → spills
]);
expect(elements.map((e) => e.track)).toEqual([0, 1, 2]); // spill happened
const spillLane = elements.find((e) => e.id === "b2")!.track;
const { onMoveElements } = runClipMove(
drag(elements[0], { previewStart: 30, previewTrack: spillLane, desiredTrack: spillLane }),
{ elements, trackOrder: [0, 1, 2] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.a).toEqual({ start: 30, track: 2 });
});
it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
// Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
@@ -283,8 +365,9 @@ describe("commitDraggedClipMove", () => {
const map = editMap(onMoveElements.mock.calls[0][0]);
// Lanes are contiguous and distinct (no two overlapping clips share a lane).
expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
// The z-aware normalization may reverse the authored lane numbers, but the
// insert must still leave three distinct, contiguous visual lanes.
expect(map.a.track).toBe(0); // above the insert → unchanged
expect(map.c.track).toBe(1); // dragged clip lands on the new lane
expect(map.b.track).toBe(2); // at/below the insert → +1 shift
});
describe("lane ↔ stacking sync", () => {
@@ -440,9 +523,9 @@ describe("commitDraggedClipMove", () => {
drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
[0, 1],
);
// Non-overlapping clips retain their authored/z-derived ordering; a lane
// gesture cannot invent a DOM stacking relationship where none overlaps.
expect(lane.dragged).toBe(2);
expect(lane.dragged).toBe(0); // aimed at the very top
expect(lane.top).toBe(1);
expect(lane.mid).toBe(2);
});
it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
@@ -453,8 +536,8 @@ describe("commitDraggedClipMove", () => {
[0, 1, 2],
);
expect(lane.a).toBe(0);
expect(lane.b).toBe(1);
expect(lane.x).toBe(2);
expect(lane.x).toBe(1); // between a and b, as aimed
expect(lane.b).toBe(2);
});
it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
@@ -466,7 +549,7 @@ describe("commitDraggedClipMove", () => {
drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
[0, 1, 2],
);
expect(lane.X).toBe(1); // X reorders against overlapping M, not disjoint T
expect(lane.X).toBe(0); // aimed top, cleared the non-overlapping T
});
it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
@@ -13,6 +13,13 @@ type StartTrack = Pick<TimelineElement, "start" | "track">;
export interface TimelineMoveEdit {
element: TimelineElement;
updates: StartTrack;
/**
* File-space track override for the persist. The store's `updates.track` is a
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
* 1,2,... or gaps), the file write must target the lane's AUTHORED track or it
* silently re-targets the wrong row. Omitted → persist `updates.track` as-is.
*/
persistTrack?: number;
}
export interface DragCommitDeps {
@@ -114,21 +121,41 @@ function persistMoveEdits(
key: keyOf(e.element),
start: e.element.start,
track: e.element.track,
authoredTrack: e.element.authoredTrack,
}));
const revision = beginTimelineOptimisticGesture(
updateElement,
edits.map((edit) => keyOf(edit.element)),
);
for (const e of edits) updateElement(keyOf(e.element), e.updates);
// The file write below targets `persistTrack` (authored space) when supplied,
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
// that written value into the store's `authoredTrack` so a SECOND drag before
// any reload resolves authored tracks from what the file now says, not stale
// pre-edit data. Pure time-moves leave authoredTrack untouched.
for (const e of edits) {
const writtenTrack =
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
updateElement(
keyOf(e.element),
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
);
}
// The store above gets DISPLAY lanes; the file below gets the authored-space
// track when one was resolved (see TimelineMoveEdit.persistTrack).
const persistEdits = edits.map((e) =>
e.persistTrack == null || e.persistTrack === e.updates.track
? e
: { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
);
const persisted = onMoveElements
? onMoveElements(edits, coalesceKey, operation)
: Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
? onMoveElements(persistEdits, coalesceKey, operation)
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
return Promise.resolve(persisted).then(
() => true,
(error) => {
for (const p of prev) {
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
updateElement(p.key, { start: p.start, track: p.track });
updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
}
}
console.error("[Timeline] Failed to persist clip edits", error);
@@ -143,6 +170,56 @@ function persistMoveEdits(
* then compacts it to a distinct integer lane between its neighbours, and the
* clips at/below the insert shift down by one — the sanctioned index-renumber.
*/
/** Same-source-file predicate: authored track numbers only compare within ONE
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
* file, not the host timeline's). `undefined` means the active composition. */
const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
(a.sourceFile ?? null) === (b.sourceFile ?? null);
/**
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
* file — an occupant from a different file (e.g. an expanded sub-comp child, or
* a host clip next to expanded rows) carries authored values in a different
* coordinate space, and borrowing them would write a foreign file's numbering.
*
* Lane semantics after normalizeToZones: each distinct authored track owns one
* base lane, and time-overlapping same-track clips spill onto adjacent display
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
* trackOrder lists it): its occupants share the base lane's authored track by
* construction, so the same-file occupant lookup returns that authored track and
* the drop persists as a same-track join. The clip may then DISPLAY on a
* different sub-lane than it was dropped on — the spill re-packs
* deterministically by stable id, first-fit — but the persisted track is
* correct.
*
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
* dropped on a lane holding only other files' clips — the display-lane integer
* must NOT be persisted into a sparse file):
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
* preserving "one lane up = one authored track up" in the clip's own file.
* 2. No same-file peers at all → the lane value itself (single-clip files:
* display and authored spaces coincide for want of any other anchor).
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
* never here.
*/
function authoredTrackForLane(
lane: number,
elements: TimelineElement[],
dragged: TimelineElement,
): number {
const dragKey = keyOf(dragged);
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
const occupant = peers.find((e) => e.track === lane);
if (occupant) return occupant.authoredTrack ?? occupant.track;
let nearest: TimelineElement | null = null;
for (const p of peers) {
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
}
if (!nearest) return lane;
return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
}
function insertTrackValue(trackOrder: number[], insertRow: number): number {
if (trackOrder.length === 0) return 0;
if (insertRow <= 0) return trackOrder[0] - 0.5;
@@ -251,6 +328,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
const dragEdit: TimelineMoveEdit = {
element: drag.element,
updates: { start: drag.previewStart, track: drag.previewTrack },
persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
};
const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
@@ -265,9 +343,13 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
// The drop-intent set for the z-sync: the dragged clip at its new lane, others
// as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
// move; computeStackingPatches only compares lanes relatively.
const candidate = elements.map((e) =>
keyOf(e) === dragKey ? { ...e, start: drag.previewStart, track: drag.previewTrack } : e,
);
const candidate = elements.map((e) => {
if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: drag.previewTrack };
// Selection members shift in time with the drag — the z-sync must reason on
// their POST-move overlap sets, same as the insert branch's candidate.
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
return e;
});
const multiKeys = multi ? multi.keys : null;
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
if (moved && isVertical) {
@@ -292,6 +374,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
* The whole affected set is persisted atomically (single undo), and the deliberate
* vertical move syncs the dragged clip's stacking afterwards.
*/
// fallow-ignore-next-line complexity
function commitTrackInsert(
drag: DraggedClipState,
deps: DragCommitDeps,
@@ -314,12 +397,25 @@ function commitTrackInsert(
// shifts the at/below clips down by one — the sanctioned +1 index renumber.
const normalized = normalizeToZones(candidate);
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
// The renumber is only correct as a WHOLE-SET write: skipping an unwritable
// clip whose lane shifts leaves its track colliding with a renumbered
// neighbour, and the next normalize merges the two lanes. If any shifted clip
// can't be written, refuse the insert instead of persisting a broken layout.
for (const norm of normalized) {
const src = bySrc.get(keyOf(norm));
if (src && !canMoveElement(src) && norm.track !== src.track) {
console.warn(
`[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
);
return;
}
}
const edits: TimelineMoveEdit[] = [];
for (const norm of normalized) {
const src = bySrc.get(keyOf(norm));
if (!src) continue;
// Capabilities gate: never write a locked/implicit clip, even one only swept
// along by the renumber (not just a marquee member).
// Capabilities gate (unchanged-lane clips only reach here now): never write
// a locked/implicit clip.
if (!canMoveElement(src)) continue;
const start =
keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
@@ -391,6 +487,7 @@ function syncStackingForEdit(
zIndex: readZIndex(el),
isAudio: classifyZone(el) === "audio",
domIndex,
stackingContextId: el.stackingContextId ?? null,
}));
const editedKeys = [dragKey];
@@ -67,51 +67,105 @@ export const DRAG_EXTEND_MARGIN_PX = 160;
* with 60s of ruler after it.
*/
export const MIN_TIMELINE_EXTENT_S = 60;
/**
* Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
* duration — onto the viewport, so the composition ends at ~83% of the width
* and the trailing ~17% stays empty ruler + droppable lane surface (room to
* drag clips past the current end without first zooming out). Applied ONLY
* inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
* lanes, playhead, marquee, and drag math all inherit it consistently. Manual
* zoom percentages stay defined relative to this fit basis (100% == fit).
*/
export const FIT_ZOOM_HEADROOM = 1.2;
/* ── Tick generation ──────────────────────────────────────────────── */
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
// fallow-ignore-next-line complexity
function getMajorTickInterval(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
): number {
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
const zoomIntervals = [
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
];
let interval: number;
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
const targetMajorPx = 88;
return (
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
);
interval =
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
3600;
} else {
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
}
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
return durationIntervals.find((interval) => interval >= target) ?? 60;
// Frame display mode: labels are frame numbers, so a major step must be a
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
// the label spacing never drops below the readability target.
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
const fps = frameRate ?? 0;
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
}
return interval;
}
// How many equal parts to split each major interval into for minor ticks. Prefer
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
// none (0) as ticks get too dense to read (< ~8px apart).
function getMinorSubdivisions(majorInterval: number, pixelsPerSecond?: number): number {
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
// sub-frame time is not a seekable position), so only divisors of the major
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
// thirds, halves.
// fallow-ignore-next-line complexity
function getMinorSubdivisions(
majorInterval: number,
pixelsPerSecond?: number,
frameRate?: number,
): number {
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
if ((majorInterval / 4) * pps >= 8) return 4;
if ((majorInterval / 2) * pps >= 8) return 2;
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
for (const parts of candidates) {
if (fps > 0 && majorFrames % parts !== 0) continue;
if ((majorInterval / parts) * pps >= 8) return parts;
}
return 0;
}
// Ticks are exact multiples of the interval (multiplied per index, never
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
function roundTickValue(t: number): number {
return Math.round(t * 1e6) / 1e6;
}
export function generateTicks(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
return { major: [], minor: [] };
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond);
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
const major: number[] = [];
const minor: number[] = [];
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
const rounded = Math.round(t * 100) / 100;
major.push(rounded);
for (let i = 0; major.length < maxTicks; i++) {
const t = i * majorInterval;
if (t > duration + 0.001) break;
major.push(roundTickValue(t));
// Emit the (subdivisions - 1) minor ticks between this major and the next.
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
const m = Math.round((t + k * minorInterval) * 100) / 100;
if (m <= duration + 0.001) minor.push(m);
const m = t + k * minorInterval;
if (m <= duration + 0.001) minor.push(roundTickValue(m));
}
}
return { major, minor };
@@ -144,15 +198,17 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
/* ── Width / duration derivation ──────────────────────────────────── */
/**
* Fit-mode pixels-per-second: fill the viewport with the composition, but
* never map fewer than MIN_TIMELINE_EXTENT_S seconds onto it — a short comp
* takes a fraction of the width and the remaining ruler runs to 1:00.
* Fit-mode pixels-per-second: fill the viewport with the composition plus
* FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
* into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
* seconds onto it — a short comp takes a fraction of the width and the
* remaining ruler runs to 1:00.
* Manual zoom multiplies this base, so the floor only anchors the default.
*/
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
const safeDuration =
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
return (viewportWidth - GUTTER - 2) / span;
}
@@ -227,9 +283,26 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
}
/* ── Playhead / canvas ────────────────────────────────────────────── */
/**
* Width of the playhead wrapper element (== the diamond head chip's layout
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
* shifted LEFT by half this width for the line's center to land exactly on
* `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
*/
export const PLAYHEAD_HEAD_W = 9;
/**
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
* on) at every zoom level. Without the half-head offset the line sat
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
*/
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
return GUTTER - PLAYHEAD_HEAD_W / 2;
}
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
}
export function getTimelineCanvasHeight(trackCount: number): number {
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import {
computeRevealScroll,
REVEAL_SCROLL_PADDING_PX,
type RevealScrollInput,
} from "./timelineRevealScroll";
/** A 1000×400 viewport with a 32px sticky gutter and 24px sticky ruler. */
function makeInput(overrides: Partial<RevealScrollInput> = {}): RevealScrollInput {
return {
scrollLeft: 0,
scrollTop: 0,
viewportWidth: 1000,
viewportHeight: 400,
clipLeft: 100,
clipRight: 200,
clipTop: 100,
clipBottom: 148,
stickyLeft: 32,
stickyTop: 24,
allowHorizontal: true,
...overrides,
};
}
describe("computeRevealScroll", () => {
it("returns null on both axes when the clip is fully visible", () => {
expect(computeRevealScroll(makeInput())).toEqual({ left: null, top: null });
});
it("scrolls right minimally when the clip end is past the right edge", () => {
const result = computeRevealScroll(makeInput({ clipLeft: 1500, clipRight: 1600 }));
// Clip end lands padding px inside the right edge.
expect(result.left).toBe(1600 - 1000 + REVEAL_SCROLL_PADDING_PX);
expect(result.top).toBeNull();
});
it("scrolls left when the clip start is hidden (including under the sticky gutter)", () => {
const result = computeRevealScroll(
makeInput({ scrollLeft: 500, clipLeft: 510, clipRight: 610 }),
);
// clipLeft 510 sits under the 32px sticky gutter (window starts at 500+32+pad).
expect(result.left).toBe(510 - 32 - REVEAL_SCROLL_PADDING_PX);
});
it("aligns the start edge when the clip is wider than the viewport", () => {
const result = computeRevealScroll(makeInput({ clipLeft: 2000, clipRight: 4000 }));
expect(result.left).toBe(2000 - 32 - REVEAL_SCROLL_PADDING_PX);
});
it("never returns a negative scroll target", () => {
const result = computeRevealScroll(
makeInput({
scrollLeft: 300,
clipLeft: 10,
clipRight: 60,
scrollTop: 200,
clipTop: 4,
clipBottom: 20,
}),
);
expect(result.left).toBe(0);
expect(result.top).toBe(0);
});
it("suppresses horizontal scroll when allowHorizontal is false (fit zoom)", () => {
const result = computeRevealScroll(
makeInput({
allowHorizontal: false,
clipLeft: 1500,
clipRight: 1600,
clipTop: 700,
clipBottom: 748,
}),
);
expect(result.left).toBeNull();
// Vertical reveal still happens.
expect(result.top).toBe(748 - 400 + REVEAL_SCROLL_PADDING_PX);
});
it("scrolls up when the clip lane is hidden under the sticky ruler", () => {
const result = computeRevealScroll(
makeInput({ scrollTop: 200, clipTop: 210, clipBottom: 258 }),
);
// clipTop 210 sits under the 24px sticky ruler (window starts at 200+24+pad).
expect(result.top).toBe(210 - 24 - REVEAL_SCROLL_PADDING_PX);
});
it("scrolls down minimally when the clip lane is below the viewport", () => {
const result = computeRevealScroll(makeInput({ clipTop: 500, clipBottom: 548 }));
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
expect(result.left).toBeNull();
});
it("never scrolls on an axis whose visible window is degenerate", () => {
// Horizontal window collapses: 40px viewport minus the 32px gutter and
// 2x12px padding is negative; vertical is intact and still reveals.
const result = computeRevealScroll(
makeInput({
viewportWidth: 40,
clipLeft: 1500,
clipRight: 1600,
clipTop: 500,
clipBottom: 548,
}),
);
expect(result.left).toBeNull();
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
// Exactly-zero window (viewport == sticky + 2x padding) is degenerate too.
const zero = computeRevealScroll(
makeInput({
viewportWidth: 32 + 2 * REVEAL_SCROLL_PADDING_PX,
clipLeft: 1500,
clipRight: 1600,
}),
);
expect(zero.left).toBeNull();
// Both axes degenerate: no scroll at all.
const both = computeRevealScroll(
makeInput({
viewportWidth: 10,
viewportHeight: 10,
clipLeft: 1500,
clipRight: 1600,
clipTop: 500,
clipBottom: 548,
}),
);
expect(both).toEqual({ left: null, top: null });
});
});
@@ -0,0 +1,91 @@
/**
* Pure scroll-target math for revealing a timeline clip inside the timeline's
* scroll container (the overflow div in Timeline.tsx).
*
* Coordinates are content-space: a clip edge measured from the scroll
* container's content origin (rect delta + current scroll offset). The visible
* window on each axis is reduced by the sticky chrome that occludes it — the
* track gutter on the left (GUTTER) and the ruler on top (RULER_H) — so a clip
* "hidden" under the sticky gutter still counts as off-screen.
*
* Scrolls minimally: an axis already fully visible returns null for that axis;
* otherwise the nearest edge is brought just inside the window (plus padding).
* A clip larger than the window aligns its start edge. Pure — unit-tested.
*/
export interface RevealScrollInput {
scrollLeft: number;
scrollTop: number;
/** Scroll container clientWidth / clientHeight. */
viewportWidth: number;
viewportHeight: number;
/** Clip bounds in content-space (relative to the scroll content origin). */
clipLeft: number;
clipRight: number;
clipTop: number;
clipBottom: number;
/** Width of the sticky left gutter occluding the viewport's left edge. */
stickyLeft: number;
/** Height of the sticky ruler occluding the viewport's top edge. */
stickyTop: number;
/** False in "fit" zoom mode, where horizontal scrolling is disabled. */
allowHorizontal: boolean;
}
export interface RevealScrollTarget {
/** Target scrollLeft, or null when the horizontal axis needs no scroll. */
left: number | null;
/** Target scrollTop, or null when the vertical axis needs no scroll. */
top: number | null;
}
/** Breathing room between the revealed clip edge and the window edge. */
export const REVEAL_SCROLL_PADDING_PX = 12;
/**
* Minimal scroll on one axis to bring [start, end] inside the visible window
* [scroll + stickyStart, scroll + viewport], with padding. Returns null when
* the range is already fully visible.
*/
function revealAxis(
scroll: number,
viewport: number,
stickyStart: number,
start: number,
end: number,
): number | null {
const windowStart = scroll + stickyStart + REVEAL_SCROLL_PADDING_PX;
const windowEnd = scroll + viewport - REVEAL_SCROLL_PADDING_PX;
const windowSize = windowEnd - windowStart;
// Degenerate viewport (container smaller than the sticky chrome + padding):
// there is no visible window to reveal into, so never scroll on this axis.
if (windowSize <= 0) return null;
if (start >= windowStart && end <= windowEnd) return null;
// Oversized range (or start hidden): align the start edge to the window start.
if (end - start > windowSize || start < windowStart) {
return Math.max(0, start - stickyStart - REVEAL_SCROLL_PADDING_PX);
}
// Only the end is clipped: pull it just inside the window's far edge.
return Math.max(0, end - viewport + REVEAL_SCROLL_PADDING_PX);
}
export function computeRevealScroll(input: RevealScrollInput): RevealScrollTarget {
return {
left: input.allowHorizontal
? revealAxis(
input.scrollLeft,
input.viewportWidth,
input.stickyLeft,
input.clipLeft,
input.clipRight,
)
: null,
top: revealAxis(
input.scrollTop,
input.viewportHeight,
input.stickyTop,
input.clipTop,
input.clipBottom,
),
};
}
@@ -19,6 +19,66 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
return out;
}
describe("stacking-context partitioning", () => {
it("never compares or patches across stacking contexts", () => {
// X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
// with a lower leaf z, overlapping in time. Their leaf z values are NOT
// comparable (the ancestors' z decides paint order), so moving X's lane above
// Y must not reason on Y or patch either based on the 10-vs-5 comparison.
const x: StackingElement = {
key: "x",
track: 0,
start: 0,
duration: 5,
zIndex: 10,
isAudio: false,
stackingContextId: "scene-1",
};
const y: StackingElement = {
key: "y",
track: 1,
start: 0,
duration: 5,
zIndex: 5,
isAudio: false,
stackingContextId: null,
};
// X edited: only same-context neighbours participate — none here, so X keeps
// its z (nothing to fix WITHIN its context) and Y is never touched.
expect(patchMap([x, y], ["x"])).toEqual({});
});
it("still resolves within the edited clip's own context", () => {
const a: StackingElement = {
key: "a",
track: 1,
start: 0,
duration: 5,
zIndex: 1,
isAudio: false,
stackingContextId: "scene-1",
};
const b: StackingElement = {
key: "b",
track: 0,
start: 0,
duration: 5,
zIndex: 5,
isAudio: false,
stackingContextId: "scene-1",
};
// 'a' moved BELOW b's lane... a (track 1) is under b (track 0): a's z (1)
// is already below b's (5) — consistent, no patch. Move a ABOVE (track -1
// relative ordering) is covered by existing suites; here we just prove the
// same-context pair still participates (no patch ≠ no participation: verify
// by flipping z so a MUST be lifted).
const aWrong = { ...a, track: 0, zIndex: 1 };
const bLow = { ...b, track: 1, zIndex: 5 };
const patches = patchMap([aWrong, bLow], ["a"]);
expect(patches.a).toBeGreaterThan(5);
});
});
describe("laneIsAbove", () => {
it("lower track renders above (top of timeline wins)", () => {
expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
@@ -43,6 +43,13 @@ export interface StackingElement {
zIndex: number;
/** Audio clips have no visual stacking and are excluded from the computation. */
isAudio: boolean;
/**
* CSS stacking context the clip's node lives in (TimelineElement.stackingContextId).
* Leaf z-indexes are only comparable WITHIN one context — across contexts the
* ancestors' z decides paint order — so the sync partitions by this key and
* never patches across contexts. Null/undefined ⇒ the root context.
*/
stackingContextId?: string | null;
/**
* Discovery / DOM document position (optional). Two clips with EQUAL z paint by
* DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
@@ -62,6 +69,14 @@ export interface StackingPatch {
const EPS = 1e-6;
/**
* Canonical stacking-context key: null/undefined both mean the root context.
* The ONLY place the normalization lives — context partitioning, membership
* checks, and pairwise equality must all go through it.
*/
const contextKey = (el: { stackingContextId?: string | null }): string | null =>
el.stackingContextId ?? null;
/**
* Two clips overlap in time when their half-open [start, end) intervals intersect.
*
@@ -284,7 +299,14 @@ export function computeStackingPatches(
// z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
// unresolved clip is neither a neighbour nor resolvable as an edit, so it is
// excluded outright (item 13).
const resolved = elements.filter((e) => Number.isFinite(e.zIndex));
const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
// Leaf z is only meaningful within ONE stacking context: across contexts the
// ancestor contexts' z decides paint order, so comparing (or patching) leaf
// values across contexts is nonsense. Restrict the computation to the edited
// clips' own context(s); cross-context lane relations are out of scope.
const editedContexts = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(contextKey));
const resolved = allResolved.filter((e) => editedContexts.has(contextKey(e)));
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
@@ -304,8 +326,11 @@ export function computeStackingPatches(
// The full live set, so the transitive cascade can reach clips that overlap a
// LIFTED neighbour without overlapping the edited clip itself (#2198).
const all = [...byKey.values()];
const sameContext = (a: MutZ, b: MutZ) => contextKey(a) === contextKey(b);
const overlappersOf = (clip: MutZ): MutZ[] =>
all.filter((o) => o.key !== clip.key && !o.isAudio && overlapsInTime(clip, o));
all.filter(
(o) => o.key !== clip.key && !o.isAudio && sameContext(clip, o) && overlapsInTime(clip, o),
);
for (const clip of edited) {
resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "../lib/playbackTypes";
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
import { buildExpandedElements } from "../hooks/useExpandedTimelineElements";
import { normalizeToZones } from "./timelineZones";
import { commitDraggedClipMove, type TimelineMoveEdit } from "./timelineClipDragCommit";
import type { DraggedClipState } from "./useTimelineClipDrag";
/**
* Pipeline test across the REAL manifest→element boundary: no hand-injected
* `authoredTrack` / `stackingContextId`. A runtime-manifest-shaped payload with
* SPARSE authored tracks flows through createTimelineElementFromManifestClip →
* (normalizeToZones | buildChildElements) → commitDraggedClipMove, and the
* persisted data-track-index must be the AUTHORED target, never the display lane.
*/
const manifestClip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
id: "x",
label: "x",
start: 0,
duration: 2,
track: 0,
stackingContextId: "root",
kind: "element",
tagName: "div",
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
...over,
});
function fromManifest(clips: ClipManifestClip[]): TimelineElement[] {
return clips.map((clip, index) =>
createTimelineElementFromManifestClip({ clip, fallbackIndex: index }),
);
}
function drag(
element: TimelineElement,
opts: { previewStart: number; previewTrack: number },
): DraggedClipState {
return {
element,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: opts.previewStart,
previewTrack: opts.previewTrack,
desiredTrack: opts.previewTrack,
insertRow: null,
snapTime: null,
snapType: null,
started: true,
};
}
/** Commit a lane-change drag and return the single persisted edit batch. */
function commitLaneChange(
element: TimelineElement,
previewTrack: number,
elements: TimelineElement[],
trackOrder: number[],
): TimelineMoveEdit[] {
const onMoveElements = vi.fn();
commitDraggedClipMove(drag(element, { previewStart: element.start, previewTrack }), {
elements,
trackOrder,
updateElement: vi.fn(),
onMoveElements,
});
expect(onMoveElements).toHaveBeenCalledTimes(1);
return onMoveElements.mock.calls[0][0] as TimelineMoveEdit[];
}
describe("track persist pipeline (manifest → factory → lanes → drag commit)", () => {
// Sparse authored tracks 3 and 7 (mixed kinds), plus audio on 5, exactly as a
// runtime manifest would ship them (clip.track is the verbatim data-track-index).
const sparseManifest = [
manifestClip({ id: "v", kind: "video", tagName: "video", track: 3, start: 0 }),
manifestClip({ id: "g", kind: "element", tagName: "div", track: 7, start: 10 }),
manifestClip({ id: "m", kind: "audio", tagName: "audio", track: 5, start: 0 }),
];
it("factory records the authored track and stacking context from the manifest clip", () => {
const [v, g, m] = fromManifest(sparseManifest);
expect([v.authoredTrack, g.authoredTrack, m.authoredTrack]).toEqual([3, 7, 5]);
expect(v.stackingContextId).toBe("root");
});
it("a lane change on a sparse file persists the AUTHORED target track, not the display lane", () => {
// normalizeToZones packs visual tracks {3, 7} onto display lanes {0, 1} and
// the audio track 5 onto lane 2, preserving the factory-set authoredTrack.
const elements = normalizeToZones(fromManifest(sparseManifest));
const byId = new Map(elements.map((e) => [e.id, e]));
expect(byId.get("v")).toMatchObject({ track: 0, authoredTrack: 3 });
expect(byId.get("g")).toMatchObject({ track: 1, authoredTrack: 7 });
expect(byId.get("m")).toMatchObject({ track: 2, authoredTrack: 5 });
// Drag the video (lane 0) onto the div's lane (display 1, authored 7).
const down = commitLaneChange(byId.get("v")!, 1, elements, [0, 1, 2]);
expect(down).toHaveLength(1);
expect(down[0].updates.track).toBe(7); // authored, NOT display lane 1
expect(down[0].updates.track).not.toBe(1);
// And the reverse: the div (lane 1) onto the video's lane (display 0, authored 3).
const up = commitLaneChange(byId.get("g")!, 0, elements, [0, 1, 2]);
expect(up[0].updates.track).toBe(3); // authored, NOT display lane 0
});
it("an expanded sub-comp child's lane change persists the sibling's authored track from ITS file", () => {
// Host timeline: the sub-comp host plus a root clip, discovered through the
// factory and lane-normalized like the store does.
const hostManifest = [
manifestClip({
id: "scene",
kind: "composition",
tagName: "div",
track: 0,
start: 0,
duration: 10,
compositionId: "scene",
compositionSrc: "scene.html",
}),
manifestClip({ id: "root-clip", kind: "video", tagName: "video", track: 1, start: 0 }),
];
// scene.html has SPARSE authored tracks 3 and 7.
const childClips = [
manifestClip({ id: "c3", track: 3, start: 1, duration: 2, parentCompositionId: "scene" }),
manifestClip({ id: "c7", track: 7, start: 4, duration: 2, parentCompositionId: "scene" }),
];
const storeElements = normalizeToZones(fromManifest(hostManifest));
const parentMap = new Map([
["c3", "scene"],
["c7", "scene"],
]);
const expanded = buildExpandedElements(
storeElements,
[...hostManifest, ...childClips],
parentMap,
"scene",
"scene",
);
// The children replaced the host row: synthetic display lanes, but the
// authored track (in scene.html's coordinate space) survived the expansion.
const c3 = expanded.find((e) => e.domId === "c3")!;
const c7 = expanded.find((e) => e.domId === "c7")!;
expect(c3).toMatchObject({ authoredTrack: 3, sourceFile: "scene.html" });
expect(c7).toMatchObject({ authoredTrack: 7, sourceFile: "scene.html" });
expect(c3.stackingContextId).toBe("root");
expect(c3.track).not.toBe(3); // display row is synthetic
// Drag c3 onto c7's display lane: the persist target is c3's OWN file, so
// the written track must be c7's authored 7 — not the display-lane integer.
const trackOrder = [...new Set(expanded.map((e) => e.track))].sort((a, b) => a - b);
const edits = commitLaneChange(c3, c7.track, expanded, trackOrder);
expect(edits).toHaveLength(1);
expect(edits[0].updates.track).toBe(7);
expect(edits[0].updates.track).not.toBe(c7.track);
});
});
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { classifyZone, normalizeToZones } from "./timelineZones";
import { computeStackingPatches, type StackingElement } from "./timelineStackingSync";
function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
return { id, tag, start: 0, duration, track };
@@ -29,16 +28,6 @@ function expectZoningIdempotent(input: TimelineElement[]): void {
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
}
/** The exact qa-clean live repro (array order = DOM order); fresh objects per call. */
function qaCleanRepro(): TimelineElement[] {
return [
zClip("blue-logo", 6.37, 3, 0, 3, "img"),
zClip("ralu", 6.37, 3, 0, 0, "img"),
zClip("black-logo", 11.92, 3, 1, 1, "img"),
zClip("video", 0.84, 20, 3, 2, "video"),
];
}
describe("classifyZone", () => {
it("audio → audio; video / image / everything else → visual", () => {
expect(classifyZone(el("m", "audio", 3))).toBe("audio");
@@ -66,52 +55,77 @@ describe("classifyZone", () => {
});
});
describe("normalizeToZones", () => {
it("orders visual (top) → audio (bottom); equal-z overlap stacks by DOM order", () => {
// img and vid are both z=0, start=0 → they OVERLAP in time. CSS paints
// equal-z siblings by DOM order (later paints on top), so the later-in-array
// `vid` must own the upper lane. (Was: pinned img=0/vid=1 to authored order,
// which contradicts the canvas — updated for per-clip DOM-order tie-break.)
describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
it("orders visual lanes by authored track-index (ascending), audio at the bottom", () => {
// img (track 0), vid (track 2), mus (audio, track 5). Lanes follow the track
// index: the LOWER visual track owns the upper lane. z is irrelevant (absent).
const out = normalizeToZones([
el("img", "img", 0),
el("vid", "video", 2),
el("mus", "audio", 5),
]);
expect(trackOf(out, "vid")).toBe(0); // later in DOM → paints on top → upper lane
expect(trackOf(out, "img")).toBe(1); // earlier in DOM → below
expect(trackOf(out, "mus")).toBe(2); // audio (bottom)
expect(trackOf(out, "img")).toBe(0); // track 0 → top lane
expect(trackOf(out, "vid")).toBe(1); // track 2 → below it
expect(trackOf(out, "mus")).toBe(2); // audio → bottom
});
it("keeps all visual lanes together on top; equal-z overlap stacks by DOM order", () => {
// All three z=0, start=0 → mutually overlapping. DOM order (later on top)
// decides the stack: v3 (last) top, then i1, then v0. (Was: pinned to authored
// order v0=0/i1=1/v3=2, which the canvas contradicts for overlapping equal-z.)
const out = normalizeToZones([el("v0", "video", 0), el("i1", "img", 1), el("v3", "video", 3)]);
expect(trackOf(out, "v3")).toBe(0);
expect(trackOf(out, "i1")).toBe(1);
expect(trackOf(out, "v0")).toBe(2);
it("compacts sparse visual track-indexes to contiguous lanes, preserving ascending order", () => {
// Distinct authored tracks 0, 3, 7 → three adjacent lanes in the same order.
const out = normalizeToZones([el("a", "video", 7), el("b", "img", 0), el("c", "video", 3)]);
expect(trackOf(out, "b")).toBe(0); // track 0
expect(trackOf(out, "c")).toBe(1); // track 3
expect(trackOf(out, "a")).toBe(2); // track 7
});
it("drops audio below the visual lanes even when it holds a LOWER authored index", () => {
// Audio authored at track 0, video at track 5 — audio must still sink below.
const out = normalizeToZones([zClip("a", 0, 10, 0, 0, "audio"), zClip("v", 0, 10, 5, 0)]);
expect(trackOf(out, "v")).toBe(0); // visual on top
expect(trackOf(out, "a")).toBe(1); // audio below, despite its lower track index
});
it("drops audio below the visual lanes even when sharing a track index", () => {
const out = normalizeToZones([el("v", "video", 0), el("a", "audio", 0)]);
expect(trackOf(out, "v")).toBe(0); // visual
expect(trackOf(out, "a")).toBe(1); // audio, below
expect(trackOf(out, "v")).toBe(0);
expect(trackOf(out, "a")).toBe(1);
});
it("groups multiple audio tracks at the bottom preserving relative order", () => {
const out = normalizeToZones([el("v", "video", 0), el("a1", "audio", 1), el("a2", "audio", 4)]);
it("groups multiple audio tracks at the bottom, ordered by their track index", () => {
const out = normalizeToZones([el("v", "video", 0), el("a2", "audio", 4), el("a1", "audio", 1)]);
expect(trackOf(out, "v")).toBe(0);
expect(trackOf(out, "a1")).toBe(1);
expect(trackOf(out, "a1")).toBe(1); // audio track 1 above audio track 4
expect(trackOf(out, "a2")).toBe(2);
});
it("ignores z-index entirely — a high-z clip does NOT jump above a lower-track clip", () => {
// lo on track 0 with z=1; hi on track 1 with z=99. They fully overlap in time.
// The old z-rank pack lifted hi above lo; the CapCut rule keeps lane = track.
const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 1, 99)]);
expect(trackOf(out, "lo")).toBe(0); // track 0 stays on top
expect(trackOf(out, "hi")).toBe(1); // higher z does NOT lift it
});
it("ignores z-index across authored tracks (scattered z, lanes still by track)", () => {
const out = normalizeToZones([
zClip("t0", 0, 10, 0, 3),
zClip("t1", 0, 10, 1, 26),
zClip("t2", 0, 10, 2, 0),
]);
expect(trackOf(out, "t0")).toBe(0);
expect(trackOf(out, "t1")).toBe(1);
expect(trackOf(out, "t2")).toBe(2);
});
it("sequential (non-overlapping) same-track clips share a lane", () => {
const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]);
expect(trackOf(out, "a")).toBe(0);
expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of z
});
it("returns the same array (identity) when already zoned", () => {
// A fixed-point layout: the two overlapping equal-z visual clips are already
// in DOM-order-consistent lanes (later-in-array `v` on the upper lane 0), so
// re-zoning is a no-op and the SAME array reference comes back. (Was: i=0/v=1,
// which the new per-clip DOM-order pack would flip — so it wasn't a fixed
// point under the corrected canvas semantics; swapped to the stable order.)
const input = [el("v", "video", 1), el("i", "img", 0), el("a", "audio", 2)];
// i on track 0, v on track 1, a (audio) on track 2 — already contiguous, visual
// above audio, so re-zoning is a no-op and the SAME reference comes back.
const input = [el("i", "img", 0), el("v", "video", 1), el("a", "audio", 2)];
expect(normalizeToZones(input)).toBe(input);
});
@@ -125,112 +139,11 @@ describe("normalizeToZones", () => {
expectZoningIdempotent(input);
});
it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => {
const clip = (id: string, start: number, duration: number): TimelineElement => ({
id,
tag: "video",
start,
duration,
track: 1, // all authored on the SAME track, some overlapping in time
});
// a [0,5), b [2,7) overlaps a, c [6,9) fits after a. All equal-z (absent).
// Per-clip pack (DOM-order tie-break): c is last in DOM so it places on the
// top lane 0; b overlaps c and lands on lane 1; a overlaps b (and is earlier
// in DOM than b, so it must paint BELOW b) → lane 2. a can NOT drop onto c's
// lane 0 even though it doesn't overlap c, because that would place a ABOVE b
// in lane order while a paints below b — the canvas-correct constraint the old
// whole-track packer ignored (it shared a/c on lane 0, contradicting paint).
const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]);
expect(trackOf(out, "c")).toBe(0); // last in DOM → top lane
expect(trackOf(out, "b")).toBe(1); // overlaps c → below it
expect(trackOf(out, "a")).toBe(2); // paints below b (earlier DOM, equal z) → lane below b
// No two time-overlapping clips share a lane (the real NLE invariant).
expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
expect(trackOf(out, "b")).not.toBe(trackOf(out, "c"));
// Idempotent: re-laying the split result changes nothing.
const twice = normalizeToZones(out);
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
});
});
describe("normalizeToZones — reverse z→lane mapping", () => {
it("orders overlapping same-zone clips by z: higher z → higher (upper) lane", () => {
// lo (z=1) and hi (z=9) fully overlap in time on the same authored track.
const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 0, 9)]);
expect(trackOf(out, "hi")).toBe(0); // higher z → upper lane (top)
expect(trackOf(out, "lo")).toBe(1); // lower z → below
});
it("orders three overlapping clips strictly by descending z", () => {
const out = normalizeToZones([
zClip("mid", 0, 10, 0, 5),
zClip("top", 0, 10, 0, 8),
zClip("bot", 0, 10, 0, 2),
]);
expect(trackOf(out, "top")).toBe(0);
expect(trackOf(out, "mid")).toBe(1);
expect(trackOf(out, "bot")).toBe(2);
});
it("does NOT reorder non-overlapping (sequential) clips by z — they share a lane", () => {
// a [0,5) z=1 then c [6,9) z=9 — no time overlap, so z is irrelevant.
const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]);
expect(trackOf(out, "a")).toBe(0);
expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of higher z
});
it("leaves the audio zone unaffected by z", () => {
const out = normalizeToZones([
zClip("v", 0, 10, 0, 1),
zClip("m1", 0, 10, 1, 99, "audio"),
zClip("m2", 0, 10, 1, 0, "audio"),
]);
// Two overlapping audio clips split onto lanes below the visual clip; their
// relative z does not lift one above a visual clip.
expect(trackOf(out, "v")).toBe(0);
expect(trackOf(out, "m1")).toBeGreaterThan(trackOf(out, "v"));
expect(trackOf(out, "m2")).toBeGreaterThan(trackOf(out, "v"));
});
it("treats missing / auto z as 0 (undefined z clip sinks below a positive-z overlap)", () => {
const out = normalizeToZones([
{ id: "noz", tag: "video", start: 0, duration: 10, track: 0 }, // no zIndex
zClip("pos", 0, 10, 0, 3),
]);
expect(trackOf(out, "pos")).toBe(0); // z=3 → upper
expect(trackOf(out, "noz")).toBe(1); // absent z ⇒ 0 → below
});
it("tie-breaks equal-z overlapping clips on the STABLE id, not the mutated lane", () => {
// Equal z + full overlap: order must be deterministic (id asc) and survive
// re-normalization — the historical oscillation bug tie-broke on the track.
const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]);
expect(trackOf(out, "a")).toBe(0); // "a" < "b"
expect(trackOf(out, "b")).toBe(1);
const twice = normalizeToZones(out);
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
});
it("FIXED POINT: normalizeToZones(normalizeToZones(x)) === normalizeToZones(x) with z present", () => {
const input = [
zClip("hi", 0, 10, 0, 9),
zClip("lo", 0, 10, 0, 1),
zClip("mid", 2, 6, 0, 5),
zClip("seq", 12, 4, 0, 7),
zClip("music", 0, 16, 1, 3, "audio"),
];
expectZoningIdempotent(input);
});
it("reload simulation: re-deriving lanes from the SAME z values yields identical lanes", () => {
// Simulate two independent discovery passes producing fresh element objects
// carrying the same z — lane assignment must be stable across reloads.
it("re-derives identical lanes from fresh objects carrying the same tracks (reload-stable)", () => {
const build = (): TimelineElement[] => [
zClip("hi", 0, 10, 0, 9),
zClip("lo", 0, 10, 0, 1),
zClip("mid", 3, 5, 0, 5),
zClip("lo", 0, 10, 1, 1),
zClip("mid", 3, 5, 2, 5),
];
const first = normalizeToZones(build());
const second = normalizeToZones(build());
@@ -238,188 +151,87 @@ describe("normalizeToZones — reverse z→lane mapping", () => {
});
});
describe("normalizeToZones — cross-track z→lane (real qa-clean shape)", () => {
// Derived from /tmp/hf-dnd-qa/qa-clean: a full-length video on authored track 0
// (z=0), two logo SVGs on track 1 (z=26 and z=0), an icon on track 3 (z=5), and
// background music on track 2. In the canvas the z=26 / z=5 icons paint ON TOP of
// the z=0 video; the timeline must agree — the higher-z tracks sit on upper lanes.
const realProject = (): TimelineElement[] => [
zClip("ralu", 6.14, 3, 3, 5, "img"),
zClip("video", 1, 20, 0, 0, "video"),
zClip("blueLogo", 5.93, 3, 1, 26, "img"),
zClip("blackLogo", 1, 3, 1, 0, "img"),
zClip("music", 8.93, 8, 2, 0, "audio"),
describe("normalizeToZones — legacy overlap spill (display-only, deterministic)", () => {
it("splits time-overlapping SAME-track clips onto adjacent sub-lanes (no visible overlap)", () => {
// a [0,5), b [2,7) overlaps a, c [6,9) sequential — all authored on track 1.
// The editor forbids per-track overlap, but a legacy file can carry it; the
// spill orders by stable id (a, b, c) and first-fits: a→lane0, b overlaps a→
// lane1, c fits back on lane0 (no overlap with a).
const clip = (id: string, start: number, duration: number): TimelineElement => ({
id,
tag: "video",
start,
duration,
track: 1,
});
const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]);
expect(trackOf(out, "a")).toBe(0);
expect(trackOf(out, "b")).toBe(1); // overlaps a → adjacent sub-lane
expect(trackOf(out, "c")).toBe(0); // sequential to a → shares lane 0
// No two time-overlapping clips share a lane.
expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
// Idempotent: re-laying the split result changes nothing.
const twice = normalizeToZones(out);
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
});
it("spills two fully-overlapping same-track clips by stable id (a above b)", () => {
const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]);
expect(trackOf(out, "a")).toBe(0); // "a" < "b"
expect(trackOf(out, "b")).toBe(1);
// Survives re-normalization (stable id tie-break, never the mutated lane).
const twice = normalizeToZones(out);
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
});
});
describe("normalizeToZones — legacy file with scattered z (requirement 6)", () => {
// Mirrors /tmp/hf-fixwave/userproj/index.html: many visual clips on contiguous
// authored tracks (0..17) each carrying an unrelated, scattered inline z-index,
// and audio on the highest tracks (18..). The display must follow the
// track-index, NOT the z, and a well-formed (contiguous, audio-last) legacy file
// must not be re-laned at all — normalize is the identity.
const legacy = (): TimelineElement[] => [
zClip("sub-0", 3, 1.15, 0, 0, "div"), // no explicit z (0)
zClip("cap-hit", 4.51, 1.73, 3, 26, "div"), // scattered z
zClip("cap-send", 5.85, 1.27, 4, 25, "div"),
zClip("avatar", 6.4, 1.148, 1, 26),
zClip("v-opener", 0, 3, 15, 12),
zClip("v-letters", 30.08, 4.39, 5, 25),
zClip("music", 3, 42.95, 18, 10, "audio"),
zClip("vo", 3.2, 10.3, 19, 4, "audio"),
];
it("stacks a higher-z track ABOVE a lower-z track on a different authored track", () => {
const out = normalizeToZones(realProject());
// Track 1 (max z 26) tops the visual zone, then track 3 (z 5), then track 0 (z 0).
expect(trackOf(out, "blueLogo")).toBe(0);
expect(trackOf(out, "blackLogo")).toBe(0); // sequential to blueLogo → shares lane
expect(trackOf(out, "ralu")).toBe(1);
expect(trackOf(out, "video")).toBe(2);
// Audio stays at the very bottom regardless of its authored track index.
expect(trackOf(out, "music")).toBe(3);
it("lanes follow the track-index, not the scattered z", () => {
const out = normalizeToZones(legacy());
// Visual tracks 0,1,3,4,5,15 compact to lanes 0..5 in ascending track order —
// z (0,26,25,26,12,25) is ignored.
expect(trackOf(out, "sub-0")).toBe(0); // track 0
expect(trackOf(out, "avatar")).toBe(1); // track 1
expect(trackOf(out, "cap-hit")).toBe(2); // track 3
expect(trackOf(out, "cap-send")).toBe(3); // track 4
expect(trackOf(out, "v-letters")).toBe(4); // track 5
expect(trackOf(out, "v-opener")).toBe(5); // track 15
// Audio stays below every visual lane.
expect(trackOf(out, "music")).toBe(6);
expect(trackOf(out, "vo")).toBe(7);
// The z=26 caption does NOT ride above the z=0 subtitle on track 0.
expect(trackOf(out, "cap-hit")).toBeGreaterThan(trackOf(out, "sub-0"));
});
it("the video (z=0) no longer sits above the z=26 / z=5 icons — canvas & timeline agree", () => {
const out = normalizeToZones(realProject());
expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "blueLogo"));
expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "ralu"));
it("does not rewrite a well-formed (contiguous, audio-last) legacy set — identity", () => {
// Same shape but authored tracks already contiguous 0..7 with audio last.
const input = [
zClip("a", 0, 3, 0, 12),
zClip("b", 0, 3, 1, 26),
zClip("c", 0, 3, 2, 3),
zClip("m", 0, 3, 3, 9, "audio"),
];
// Every clip already sits on its track-index lane, so normalize is a no-op.
expect(normalizeToZones(input)).toBe(input);
});
it("is idempotent on the real-project shape (no lane drift on re-discovery)", () => {
const once = normalizeToZones(realProject());
const twice = normalizeToZones(once);
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
});
it("re-derives identical lanes from fresh objects carrying the same z (reload-stable)", () => {
const first = normalizeToZones(realProject());
const second = normalizeToZones(realProject());
for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
});
it("all-equal-z overlapping clips stack by DOM order (later on top), lanes contiguous", () => {
// Was: "keeps ascending authored track order when all tracks share z" — that
// pinned t0=0/t1=1/t3=2 to the authored track index. But these three all
// start at 0 and OVERLAP, all z=0, so CSS paints them by DOM order: t3 (last)
// on top. The per-clip pack now reflects that (t3 lane 0, then t1, then t0),
// which is what the canvas actually shows. Lanes stay contiguous 0..2.
const out = normalizeToZones([
zClip("t0", 0, 2, 0, 0),
zClip("t1", 0, 2, 1, 0),
zClip("t3", 0, 2, 3, 0),
]);
expect(trackOf(out, "t3")).toBe(0); // last in DOM → paints on top
expect(trackOf(out, "t1")).toBe(1);
expect(trackOf(out, "t0")).toBe(2);
});
});
describe("normalizeToZones — EXACT qa-clean repro (per-clip constrained pack)", () => {
// The live repro from /tmp/hf-dnd-qa/qa-clean/index.html:
// blue-logo authored track 0, z=3, 6.37–9.37
// ralu image authored track 0, z=0, 6.37–9.37 (shares track 0 with blue-logo)
// black-logo authored track 1, z=1, 11.92–14.92
// video authored track 3, z=2, 0.84–20.84
// Canvas truth: video (z=2) covers ralu (z=0). The OLD whole-track packer
// ordered track 0 by its MAX z (3, from blue-logo), so ralu rode above the
// z=2 video — the timeline↔canvas contradiction. Array order below = DOM order.
it("ACCEPTANCE: lane order top→bottom is blue-logo, video, black-logo, ralu", () => {
const out = normalizeToZones(qaCleanRepro());
expect(trackOf(out, "blue-logo")).toBe(0); // z=3 → top
expect(trackOf(out, "video")).toBe(1); // z=2, overlaps blue-logo → below it
expect(trackOf(out, "black-logo")).toBe(2); // z=1, overlaps video (11.92–14.92 ∩ 0.84–20.84)
expect(trackOf(out, "ralu")).toBe(3); // z=0, overlaps blue-logo AND video → bottom
});
it("REGRESSION: a low-z clip must not ride its authored trackmate's high z above a clip that covers it", () => {
const out = normalizeToZones(qaCleanRepro());
// ralu (z=0) shares authored track 0 with blue-logo (z=3) but must sink BELOW
// the video (z=2) that overlaps and paints over it — the whole-track bug.
expect(trackOf(out, "ralu")).toBeGreaterThan(trackOf(out, "video"));
// black-logo (z=1) below video (z=2) because they overlap in time.
expect(trackOf(out, "black-logo")).toBeGreaterThan(trackOf(out, "video"));
});
it("FIXED POINT: running the NEW pack on its own output changes nothing", () => {
const once = normalizeToZones(qaCleanRepro());
const twice = normalizeToZones(once);
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
// And a third pass, to be sure convergence is genuine.
const thrice = normalizeToZones(twice);
for (const e of twice) expect(trackOf(thrice, e.id)).toBe(e.track);
});
});
describe("z ↔ lane round-trip convergence (both directions agree)", () => {
// Project a normalized TimelineElement onto the StackingElement view the
// forward (lane→z) mapping reasons over.
const toStacking = (els: TimelineElement[]): StackingElement[] =>
els.map((e, domIndex) => ({
key: e.key ?? e.id,
start: e.start,
duration: e.duration,
track: e.track,
zIndex: Number.isFinite(e.zIndex) ? (e.zIndex as number) : 0,
isAudio: classifyZone(e) === "audio",
domIndex,
}));
it("lane-move → z patch → re-discovery orders lanes by that same z → identical lanes (no oscillation)", () => {
// Two fully-overlapping visual clips. Authored: a below (z=1), b above (z=5).
const authored: TimelineElement[] = [zClip("a", 0, 10, 0, 1), zClip("b", 0, 10, 0, 5)];
const normalized = normalizeToZones(authored);
// z→lane placed b (z=5) on the upper lane 0, a on lane 1.
expect(trackOf(normalized, "b")).toBe(0);
expect(trackOf(normalized, "a")).toBe(1);
// USER lane-move: drag a to the TOP (lane 0) and push b down (lane 1).
const afterMove = normalized.map((e) =>
e.id === "a" ? { ...e, track: 0 } : e.id === "b" ? { ...e, track: 1 } : e,
);
// FORWARD: a lane-move writes the minimal z patch for the edited clip.
const patches = computeStackingPatches(toStacking(afterMove), ["a"]);
expect(patches).toEqual([{ key: "a", zIndex: 6 }]); // lifted above b (5)
// Apply the z patch back onto the elements (what handleDomZIndexReorderCommit
// persists; next discovery re-reads it as TimelineElement.zIndex).
const rediscovered = afterMove.map((e) => {
const p = patches.find((pp) => pp.key === (e.key ?? e.id));
return p ? { ...e, zIndex: p.zIndex } : e;
});
// REVERSE: re-normalize from the new z. a (z=6) must now own the upper lane —
// the same lane the user moved it to. Directions converge, they do not fight.
const renormalized = normalizeToZones(rediscovered);
expect(trackOf(renormalized, "a")).toBe(0);
expect(trackOf(renormalized, "b")).toBe(1);
// FIXED POINT: forward on the converged state produces NO further patch, and
// reverse is idempotent — the round-trip is stable.
expect(computeStackingPatches(toStacking(renormalized), ["a"])).toEqual([]);
const twice = normalizeToZones(renormalized);
for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
});
it("qa-clean: drag video BELOW ralu → z patch → re-pack keeps it below, no oscillation", () => {
// EXACT repro fixture (array order = DOM order).
const normalized = normalizeToZones(qaCleanRepro());
// Baseline lanes: blue-logo 0, video 1, black-logo 2, ralu 3.
expect(trackOf(normalized, "video")).toBe(1);
expect(trackOf(normalized, "ralu")).toBe(3);
// USER lane-move: drag video to the lane BELOW ralu (bottom). ralu is at
// lane 3, so video goes to a lane strictly greater — model it as lane 4.
const afterMove = normalized.map((e) => (e.id === "video" ? { ...e, track: 4 } : e));
// FORWARD: video (z=2) must drop below ralu (z=0). No integer z ≥ 0 fits
// strictly below 0, so the tie-aware sync cascades: video→0 and the clips that
// must stay above it (ralu z=0, black-logo z=1, blue-logo z=3) are bumped as
// needed so video paints below ralu with all z ≥ 0.
const patches = computeStackingPatches(toStacking(afterMove), ["video"]);
const patchByKey = new Map(patches.map((p) => [p.key, p.zIndex]));
// Video was moved; it must now be strictly below ralu in paint order.
const zAfter = (id: string): number =>
patchByKey.get(id) ?? (afterMove.find((e) => e.id === id)!.zIndex as number);
expect(zAfter("video")).toBeLessThan(zAfter("ralu"));
expect(zAfter("video")).toBeGreaterThanOrEqual(0);
expect(patchByKey.size).toBeGreaterThan(0);
// Apply patches and re-pack: video's lane must now be BELOW ralu's.
const rediscovered = afterMove.map((e) => {
const z = patchByKey.get(e.id);
return z != null ? { ...e, zIndex: z } : e;
});
const renormalized = normalizeToZones(rediscovered);
expect(trackOf(renormalized, "video")).toBeGreaterThan(trackOf(renormalized, "ralu"));
// FIXED POINT: re-running BOTH directions on the converged state is a no-op.
expect(computeStackingPatches(toStacking(renormalized), ["video"])).toEqual([]);
const twice = normalizeToZones(renormalized);
for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
it("is idempotent on the scattered-z legacy shape (no drift on re-discovery)", () => {
expectZoningIdempotent(legacy());
});
});
@@ -3,8 +3,8 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
/**
* Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
* — layering is CSS z-index (the renderer ignores track index), so the timeline's
* only job is to keep visual clips grouped above audio clips.
* — canvas layering is CSS z-index (the renderer ignores track index), so the
* timeline's only job is to keep visual clips grouped above audio clips.
*/
export type TrackZone = "visual" | "audio";
@@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone {
const keyOf = (el: TimelineElement) => el.key ?? el.id;
/** Stacking order for a clip: missing / "auto" z is treated as 0. */
const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
const EPS = 1e-6;
/** Two clips overlap when their half-open [start, end) intervals intersect. */
@@ -26,173 +23,123 @@ function overlaps(a: TimelineElement, b: TimelineElement): boolean {
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
}
/** A clip paired with its position in the discovery/document (input) order. */
interface IndexedClip {
el: TimelineElement;
/** Index in the input `elements` array = discovery/DOM order. */
domIndex: number;
}
/** One display lane: the clips packed onto it, in placement order. */
interface Lane {
occupants: IndexedClip[];
/** The single authored track all occupants share, or null once mixed (never
* happens — we only ever add same-track clips to an existing lane). */
track: number;
/** Deterministic order on the stable clip id (never the mutated lane/track). */
function byStableId(a: TimelineElement, b: TimelineElement): number {
const ka = keyOf(a);
const kb = keyOf(b);
return ka < kb ? -1 : ka > kb ? 1 : 0;
}
/**
* Lowest lane index a clip may occupy: strictly above every already-placed lane
* holding a clip it overlaps in time (all of which out-stack it by the z-desc
* placement order).
*/
function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
let minLane = 0;
for (let i = 0; i < lanes.length; i++) {
if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
}
return minLane;
}
/**
* First lane at index ≥ minLane that holds solely this clip's authored track and
* nothing overlapping (so sequential same-track clips share a lane); -1 when none
* qualifies and a fresh lane must open.
*/
function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
for (let i = minLane; i < lanes.length; i++) {
const lane = lanes[i];
if (lane.track !== item.el.track) continue;
if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
return i;
}
return -1;
}
/**
* Pack a WHOLE zone's clips onto display lanes with a single constrained pass so
* that, for EVERY pair of time-overlapping clips, lane order (upper = lower index)
* equals canvas stacking order. This replaces the old two-stage
* `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored
* tracks by their MAX z and so lifted a low-z clip above a clip that covers it
* whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a
* low-z image rode its z=3 trackmate above the z=2 video that paints over it). No
* whole-track mapping can fix that; the mapping must be per-clip.
* Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
* share a lane. Clips are ordered by their STABLE id (a function of the input, not
* of the lane being computed — the historical oscillation bug tie-broke on the
* mutated track) and placed first-fit, so sequential (non-overlapping) clips
* collapse onto a single lane and only genuine time overlaps spill onto adjacent
* sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
* number of lanes used (≥ 1 when non-empty).
*
* Algorithm:
* 1. Order clips by z DESC; z-tie → INPUT-ARRAY-INDEX (DOM order) DESC (CSS
* paints equal-z siblings by DOM order — LATER in DOM paints on top, so it
* must place first / upper); final tie → stable key. NEVER tie-break on the
* mutated lane/track index (historic oscillation bug — the tie-break must be
* a stable function of the input, not of the output being computed).
* 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
* OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
* clip out-stacks this one (higher z, or equal z but later in DOM), so this
* guarantees lane order == stacking order for every overlapping pair.
* 3. To preserve the "distinct authored tracks stay distinct / sequential
* same-track clips share a lane" feel, reuse an existing lane at index ≥ that
* minimum ONLY when the lane's occupants are all from the SAME authored track
* AND none overlaps this clip in time; otherwise open a fresh lane.
* The editor enforces no per-track time overlap, so the spill only fires on legacy
* files. It is DISPLAY-ONLY — a drag commit persists just the dragged clip, never
* this re-lane — so it never rewrites the source.
*
* Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
* returns the number of lanes used (≥ 1 when non-empty).
* Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
* display lane). Because every occupant of a sub-lane shares the base lane's
* authored track by construction, dropping a clip onto one persists that shared
* authored track — a legitimate same-track join. On the next normalize the
* joined track re-packs (stable-id first-fit), so the clip may display on a
* DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
* the persisted source value is correct either way.
*/
function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map<string, number>): number {
const ordered = [...clips].sort(
(a, b) =>
zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
);
const lanes: Lane[] = [];
for (const item of ordered) {
const minLane = lowestAllowedLane(lanes, item);
let placed = findReusableLane(lanes, minLane, item);
if (placed === -1) {
placed = lanes.length;
lanes.push({ occupants: [], track: item.el.track });
}
lanes[placed].occupants.push(item);
laneOf.set(keyOf(item.el), base + placed);
}
return lanes.length;
}
/**
* Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one
* authored track's clips onto sub-lanes so no two overlap in time — sequential
* clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by
* start (then stable key) so the layout is deterministic and idempotent. Returns
* the number of lanes used (≥ 1 when non-empty).
*/
function packAudioTrackLanes(
clips: IndexedClip[],
function packTrackLanes(
clips: TimelineElement[],
base: number,
laneOf: Map<string, number>,
): number {
const ordered = [...clips].sort(
(a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
);
const lanes: IndexedClip[][] = [];
for (const item of ordered) {
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
const ordered = [...clips].sort(byStableId);
const lanes: TimelineElement[][] = [];
for (const el of ordered) {
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
if (sub === -1) {
sub = lanes.length;
lanes.push([]);
}
lanes[sub].push(item);
laneOf.set(keyOf(item.el), base + sub);
lanes[sub].push(el);
laneOf.set(keyOf(el), base + sub);
}
return Math.max(1, lanes.length);
}
/**
* Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
* follow the authored `data-track-index` (ASCENDING; ties by stable id) — NEVER a
* z-rank. Each distinct authored track owns its own lane (in ascending order);
* sequential same-track clips share it; time-overlapping same-track clips spill to
* adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
*
* This REPLACES the old global-z-rank interval pack. That pack ordered visual
* lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
* whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
* product decision is the opposite: a clip's lane is its track, period — z is
* canvas paint order only, and lane assignment must ignore it.
*/
function packZoneLanes(
clips: TimelineElement[],
base: number,
laneOf: Map<string, number>,
): number {
const byTrack = new Map<number, TimelineElement[]>();
for (const el of clips) {
const list = byTrack.get(el.track);
if (list) list.push(el);
else byTrack.set(el.track, [el]);
}
let used = 0;
for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
}
return used;
}
/**
* Assign display lanes for the timeline: visual lanes on top, audio lanes below.
*
* The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical
* order matches the canvas's CSS stacking for EVERY time-overlapping pair — a
* low-z clip sinks below a clip that covers it even if it shares an authored track
* with a higher-z clip. Time-overlapping clips still split onto separate lanes
* (standard NLE), sequential same-track clips still share a lane, and distinct
* authored tracks stay distinct.
*
* The AUDIO zone keeps the original behavior — authored-track order, per-track
* interval packing — because audio has no z / stacking semantics.
* Both zones are packed the SAME way — by authored track-index, ascending (see
* packZoneLanes) — so the timeline's vertical order follows each clip's track and
* nothing else. z-index does not participate in lane assignment (it is canvas
* paint order only; the lane ↔ z stacking sync in timelineStackingSync runs the
* other direction, only on a deliberate vertical edit). Time-overlapping same-track
* clips still split onto separate sub-lanes (legacy files only — the editor forbids
* per-track overlap), and that split is display-only, never persisted.
*
* Pure — returns a new array; unchanged clips keep their identity. Display-only
* (runs on discovery); it does not rewrite the source. Idempotent (running it on
* its own output is a fixed point).
* its own output is a fixed point): the lanes it emits are contiguous integers in
* ascending track order, and re-running groups by those same integers unchanged.
*/
export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
if (elements.length === 0) return elements;
const laneOf = new Map<string, number>();
const visual: TimelineElement[] = [];
const audio: TimelineElement[] = [];
for (const el of elements) {
(classifyZone(el) === "audio" ? audio : visual).push(el);
}
let nextLane = 0;
const visual: IndexedClip[] = [];
const audio: IndexedClip[] = [];
elements.forEach((el, domIndex) => {
(classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
});
nextLane += packZoneLanes(visual, nextLane, laneOf);
// Audio: preserve legacy behavior — group by authored track (ascending), pack
// each track's overlapping clips onto sub-lanes.
const audioByTrack = new Map<number, IndexedClip[]>();
for (const item of audio) {
const list = audioByTrack.get(item.el.track);
if (list) list.push(item);
else audioByTrack.set(item.el.track, [item]);
}
for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
}
packZoneLanes(audio, nextLane, laneOf);
let changed = false;
const remapped = elements.map((el) => {
const lane = laneOf.get(keyOf(el));
if (lane == null || lane === el.track) return el;
changed = true;
return { ...el, track: lane };
// Record the source-file track the first time a clip is remapped so lane
// edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
// Re-normalizing already-remapped elements must keep the original value.
return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
});
return changed ? remapped : elements;
}
@@ -0,0 +1,56 @@
/**
* Consumes playerStore.clipRevealRequest: when another surface (the sidebar
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
* timeline's scroll container so that clip is visible — horizontally to its
* time and vertically to its lane.
*
* The request is consumed (cleared) whether or not the clip node is found, so
* a stale request can never replay a scroll later. Respects zoom mode: in
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
* only the vertical axis is scrolled there.
*/
import { useEffect } from "react";
import { usePlayerStore } from "../store/playerStore";
import { GUTTER, RULER_H } from "./timelineLayout";
import { computeRevealScroll } from "./timelineRevealScroll";
export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
useEffect(() => {
if (!revealRequest) return;
// Consume the request first — reveal is one-shot, even when the clip node
// isn't currently rendered (e.g. drilled into a different composition).
usePlayerStore.getState().clearClipRevealRequest();
const container = scrollRef.current;
if (!container) return;
const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
if (!(clip instanceof HTMLElement)) return;
const containerRect = container.getBoundingClientRect();
const clipRect = clip.getBoundingClientRect();
const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
const clipTop = clipRect.top - containerRect.top + container.scrollTop;
const target = computeRevealScroll({
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
viewportWidth: container.clientWidth,
viewportHeight: container.clientHeight,
clipLeft,
clipRight: clipLeft + clipRect.width,
clipTop,
clipBottom: clipTop + clipRect.height,
stickyLeft: GUTTER,
stickyTop: RULER_H,
allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
});
if (target.left === null && target.top === null) return;
container.scrollTo({
left: target.left ?? container.scrollLeft,
top: target.top ?? container.scrollTop,
behavior: "smooth",
});
}, [revealRequest, scrollRef]);
}
@@ -57,7 +57,17 @@ describe("useTimelineStackingSync", () => {
});
expect(commit).toHaveBeenCalledWith(
[expect.objectContaining({ element: node, zIndex: 8, sourceFile: "nested.html" })],
[
expect.objectContaining({
element: node,
zIndex: 8,
sourceFile: "nested.html",
// The patch's store key rides along so the commit updates the store
// zIndex synchronously on the lane-sync path (and rolls it back on
// failure) instead of waiting for a preview reload.
key: "a",
}),
],
"clip-lane-move:7",
);
act(() => root.unmount());
@@ -45,10 +45,16 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
[zSyncPreviewIframeRef, zSyncActiveCompPath],
);
// NaN (NOT 0) when the element can't be resolved in the preview iframe — a
// nested / unmounted sub-comp node, or one outside the active file. Fabricating
// z=0 would enter computeStackingPatches as a real overlapping neighbour at the
// z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this
// clip instead. NaN (rather than null) keeps the return assignable to the
// `(el) => number` reader contract the drag hook / commit deps declare.
const readClipZIndex = useCallback(
(el: TimelineElement): number => {
const node = resolveIframeElement(el);
return node ? readEffectiveZIndex(node) : 0;
return node ? readEffectiveZIndex(node) : Number.NaN;
},
[resolveIframeElement],
);
@@ -68,10 +74,15 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
selector: el.selector,
selectorIndex: el.selectorIndex,
sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
// The store key: lets the commit update the store's zIndex
// synchronously (and roll it back on failure).
key: p.key,
},
];
});
if (entries.length) return handleDomZIndexReorderCommit(entries, coalesceKey);
// Forward the drag-commit's shared coalesce key so the z-reorder history
// entry merges with the lane change's move entry into one undo step.
if (entries.length) handleDomZIndexReorderCommit(entries, coalesceKey);
},
[handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
);
@@ -163,7 +163,14 @@ function buildChildElements(
key,
start: clamped.start,
duration: clamped.duration,
// `track` becomes a synthetic display row under the expanded host, but the
// factory-set `authoredTrack` (the child's data-track-index in ITS OWN
// file's coordinate space) and the runtime-computed `stackingContextId`
// must survive verbatim — lane persists and z-sync read them, they are
// never reconstructed from display lanes.
track: display.track + result.length,
authoredTrack: base.authoredTrack,
stackingContextId: base.stackingContextId,
expandedParentStart: editBasis.start,
domId,
selector,
@@ -113,6 +113,15 @@ export function createTimelineElementFromManifestClip(params: {
start: clip.start,
duration: clip.duration,
track: clip.track,
// clip.track IS the authored data-track-index verbatim (the runtime honors
// it; see parseAuthoredTrack in core/runtime/timeline.ts). Record it at this
// translation boundary so later display-lane remaps (normalizeToZones,
// expanded-child rows) can persist in AUTHORED space instead of
// reconstructing it from lane occupants.
authoredTrack: clip.track,
// Runtime-computed stacking context — authoritative; helpers read it, never
// re-derive it.
stackingContextId: clip.stackingContextId ?? null,
domId,
hfId,
selector,
@@ -298,6 +298,25 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
return element.key ?? element.id;
}
/**
* Timeline store key for a z-reorder entry built OUTSIDE the timeline
* expansion (canvas context menu / LayersPanel), so the reorder commit can
* update the store's zIndex synchronously. Matches buildTimelineElementKey's
* stable branches (`sourceFile#domId`, else the selector-based key). Undefined
* when the element has neither a DOM id nor a selector — the timeline's
* fallback branch needs its own fallbackIndex, which these callers don't have,
* so such an entry simply skips the synchronous store update.
*/
export function deriveTimelineStoreKey(params: {
domId?: string;
selector?: string;
selectorIndex?: number;
sourceFile?: string;
}): string | undefined {
if (!params.domId && !params.selector) return undefined;
return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
}
// ---------------------------------------------------------------------------
// DOM node querying
// ---------------------------------------------------------------------------
@@ -1,6 +1,16 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
/** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
expect(state.timelineReady).toBe(false);
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
}
describe("usePlayerStore", () => {
beforeEach(() => {
usePlayerStore.getState().reset();
@@ -9,12 +19,7 @@ describe("usePlayerStore", () => {
describe("initial state", () => {
it("has correct defaults", () => {
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
expect(state.timelineReady).toBe(false);
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
expectResettableDefaults(state);
expect(state.playbackRate).toBe(1);
expect(state.audioMuted).toBe(false);
expect(state.loopEnabled).toBe(false);
@@ -384,6 +389,32 @@ describe("usePlayerStore", () => {
});
});
describe("clipRevealRequest", () => {
it("starts null and carries the requested element id", () => {
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
usePlayerStore.getState().requestClipReveal("el-1");
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
});
it("bumps the nonce on repeat requests for the same clip", () => {
usePlayerStore.getState().requestClipReveal("el-1");
const first = usePlayerStore.getState().clipRevealRequest;
usePlayerStore.getState().requestClipReveal("el-1");
const second = usePlayerStore.getState().clipRevealRequest;
expect(second?.nonce).not.toBe(first?.nonce);
});
it("clears via clearClipRevealRequest and on reset", () => {
usePlayerStore.getState().requestClipReveal("el-1");
usePlayerStore.getState().clearClipRevealRequest();
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
usePlayerStore.getState().requestClipReveal("el-2");
usePlayerStore.getState().reset();
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
});
});
describe("reset", () => {
it("resets all state to defaults", () => {
// Mutate everything
@@ -398,13 +429,7 @@ describe("usePlayerStore", () => {
// Reset
usePlayerStore.getState().reset();
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
expect(state.timelineReady).toBe(false);
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
expectResettableDefaults(usePlayerStore.getState());
});
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
@@ -29,6 +29,17 @@ export interface TimelineElement {
start: number;
duration: number;
track: number;
/**
* The data-track-index as written in the source file. Set at the manifest
* translation boundary (createTimelineElementFromManifestClip) from the
* runtime clip's verbatim track, and preserved through display-lane remaps
* (normalizeToZones packs sparse authored tracks onto contiguous display
* lanes; expanded sub-comp children get synthetic display rows). Lane edits
* must persist THIS space — writing a display-lane number into a sparse file
* re-targets the wrong track. For an expanded child the value is in its OWN
* source file's coordinate space, not the host timeline's.
*/
authoredTrack?: number;
/** Resolved z-index for stacking-aware timeline ordering. */
zIndex?: number;
/** True when the effective z-index was authored inline or through CSS, not auto. */
@@ -153,6 +164,9 @@ interface PlayerState {
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
timelineSnapEnabled: boolean;
setTimelineSnapEnabled: (enabled: boolean) => void;
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
timeDisplayMode: "time" | "frame";
setTimeDisplayMode: (mode: "time" | "frame") => void;
/**
* Pin the timeline zoom to its current visual scale before a duration-changing
* edit, so a subsequent duration change (which recomputes fit-pps) stops
@@ -210,6 +224,16 @@ interface PlayerState {
requestSeek: (time: number) => void;
clearSeekRequest: () => void;
/**
* Request the timeline to scroll a clip into view (e.g. clicking an
* already-added asset card in the sidebar). Consumed and cleared by
* useTimelineRevealClip. The nonce makes repeat requests for the same
* clip observable so a second click re-reveals after the user scrolls away.
*/
clipRevealRequest: { elementId: string; nonce: number } | null;
requestClipReveal: (elementId: string) => void;
clearClipRevealRequest: () => void;
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
@@ -341,6 +365,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
requestSeek: (time) => set({ requestedSeekTime: time }),
clearSeekRequest: () => set({ requestedSeekTime: null }),
clipRevealRequest: null,
requestClipReveal: (elementId) =>
set((s) => ({
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
})),
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
lintFindingsByElement: new Map(),
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
@@ -416,6 +447,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
set({ timelineSnapEnabled: enabled });
},
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
setTimeDisplayMode: (mode) => {
writeStudioUiPreferences({ timeDisplayMode: mode });
set({ timeDisplayMode: mode });
},
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
set((s) => {
// Already pinned (or the user manually zoomed) — never clobber that.
@@ -522,6 +558,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
activeTool: "select",
selectedKeyframes: new Set(),
selectedElementIds: new Set(),
clipRevealRequest: null,
keyframeCache: new Map(),
beatAnalysis: null,
beatEdits: null,
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { shouldDismissAssetPreview } from "./assetPreviewDismiss";
describe("shouldDismissAssetPreview", () => {
const idle = { isPlaying: false, currentTime: 3.5, requestedSeekTime: null };
it("keeps the preview open while nothing moves", () => {
expect(shouldDismissAssetPreview(3.5, idle)).toBe(false);
});
it("tolerates sub-epsilon float noise in currentTime echoes", () => {
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 3.5 + 1e-9 })).toBe(false);
});
it("dismisses when playback starts", () => {
expect(shouldDismissAssetPreview(3.5, { ...idle, isPlaying: true })).toBe(true);
});
it("dismisses when the playhead is scrubbed/seeked to a new time", () => {
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 4.2 })).toBe(true);
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 0 })).toBe(true);
});
it("dismisses on a pending out-of-loop seek request", () => {
expect(shouldDismissAssetPreview(3.5, { ...idle, requestedSeekTime: 3.5 })).toBe(true);
});
});
@@ -0,0 +1,29 @@
/**
* Dismissal rule for the sidebar asset preview overlay (AssetPreviewOverlay):
* the preview is a transient "look at this asset" state, so any playhead
* activity — starting playback, or seeking/scrubbing away from where the
* playhead sat when the preview opened — hands focus back to the canvas and
* closes it.
*
* Pure — unit-tested. The overlay captures `openedTime` when the preview
* opens and feeds every subsequent player-store snapshot through this.
*/
export interface AssetPreviewDismissSnapshot {
isPlaying: boolean;
currentTime: number;
/** Pending out-of-loop seek request (playerStore.requestedSeekTime). */
requestedSeekTime: number | null;
}
/** Tolerance for float noise in currentTime echoes (well under one frame). */
const TIME_EPSILON_S = 1e-6;
export function shouldDismissAssetPreview(
openedTime: number,
snapshot: AssetPreviewDismissSnapshot,
): boolean {
if (snapshot.isPlaying) return true;
if (snapshot.requestedSeekTime !== null) return true;
return Math.abs(snapshot.currentTime - openedTime) > TIME_EPSILON_S;
}
@@ -1,13 +1,80 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
buildTimelineFileDropPlacements,
buildTimelineAssetInsertHtml,
extendCompositionDurationIfNeeded,
fitTimelineAssetGeometry,
getTimelineAssetKind,
insertTimelineAssetIntoSource,
resolveTimelineAssetInitialGeometry,
resolveTimelineAssetCompositionSize,
resolveTimelineAssetSrc,
setCompositionDurationToContent,
} from "./timelineAssetDrop";
describe("setCompositionDurationToContent", () => {
const src = (dur: number) =>
`<div id="root" data-composition-id="c" data-duration="${dur}">x</div>`;
it("shrinks the root duration to the content end", () => {
expect(setCompositionDurationToContent(src(20), 8)).toContain('data-duration="8"');
});
it("grows the root duration to the content end", () => {
expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"');
});
it("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => {
expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12));
});
it("is a no-op when already equal", () => {
expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9));
});
// Reviewer round-2 finding #3: attribute-order and single-quote variants that
// the old order-dependent, double-quotes-only regex silently ignored.
it("patches when data-duration precedes data-composition-id", () => {
const source = `<div data-duration="20" data-composition-id="c">x</div>`;
expect(setCompositionDurationToContent(source, 8)).toBe(
`<div data-duration="8" data-composition-id="c">x</div>`,
);
});
it("patches single-quoted attributes and keeps the quote style", () => {
const source = `<div data-composition-id='c' data-duration='20'>x</div>`;
expect(setCompositionDurationToContent(source, 8)).toBe(
`<div data-composition-id='c' data-duration='8'>x</div>`,
);
});
});
describe("extendCompositionDurationIfNeeded", () => {
it("grows the root duration when a clip lands past the end", () => {
const source = `<div data-composition-id="c" data-duration="5">x</div>`;
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
`<div data-composition-id="c" data-duration="8">x</div>`,
);
});
it("is a no-op when the required end fits within the current duration", () => {
const source = `<div data-composition-id="c" data-duration="10">x</div>`;
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
});
it("grows even when the attribute order is swapped and quotes are single", () => {
const source = `<div data-duration='5' data-composition-id='c'>x</div>`;
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
`<div data-duration='8' data-composition-id='c'>x</div>`,
);
});
it("is a no-op when there is no composition root", () => {
const source = `<div data-duration="5">x</div>`;
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
});
});
describe("getTimelineAssetKind", () => {
it("detects image, video, and audio assets", () => {
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
@@ -16,12 +83,28 @@ describe("getTimelineAssetKind", () => {
expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
});
it("classifies svg as image", () => {
expect(getTimelineAssetKind("assets/logo.svg")).toBe("image");
expect(getTimelineAssetKind("assets/ICON.SVG")).toBe("image");
});
it("classifies avif and webp as image", () => {
expect(getTimelineAssetKind("assets/photo.avif")).toBe("image");
expect(getTimelineAssetKind("assets/photo.webp")).toBe("image");
});
it("returns null for unknown extensions", () => {
expect(getTimelineAssetKind("assets/data.json")).toBeNull();
expect(getTimelineAssetKind("assets/font.woff2")).toBeNull();
});
});
describe("buildTimelineAssetInsertHtml", () => {
it("builds an image clip with explicit timing and track", () => {
const html = buildTimelineAssetInsertHtml({
id: "photo_asset",
hfId: "hf-abc123",
assetPath: "assets/photo.png",
kind: "image",
start: 1.25,
@@ -40,6 +123,7 @@ describe("buildTimelineAssetInsertHtml", () => {
it("builds an audio clip without visual layout styles", () => {
const html = buildTimelineAssetInsertHtml({
id: "music_asset",
hfId: "hf-xyz789",
assetPath: "assets/music.wav",
kind: "audio",
start: 0.5,
@@ -52,15 +136,13 @@ describe("buildTimelineAssetInsertHtml", () => {
});
});
describe("resolveTimelineAssetInitialGeometry", () => {
describe("resolveTimelineAssetCompositionSize", () => {
it("uses the target composition dimensions for visual media", () => {
expect(
resolveTimelineAssetInitialGeometry(
resolveTimelineAssetCompositionSize(
`<div data-composition-id="main" data-width="330" data-height="228"></div>`,
),
).toEqual({
left: 0,
top: 0,
width: 330,
height: 228,
});
@@ -84,7 +166,9 @@ describe("buildTimelineFileDropPlacements", () => {
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
});
it("uses the dropped start and spaces multiple files by duration on the same track", () => {
it("spaces multiple files by duration and keeps every one on the dropped track", () => {
// A clip placed onto an occupied track stays there (overlap is allowed); it is
// NOT bumped to a new track — that produced surprise empty tracks for users.
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
{ start: 1.5, track: 2 },
{ start: 2.7, track: 2 },
@@ -99,52 +183,6 @@ describe("buildTimelineFileDropPlacements", () => {
{ start: 7.7, track: 2 },
]);
});
it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
expect(
buildTimelineFileDropPlacements(
{ start: 1.5, track: 2 },
[1.2, 1.6, 1.1],
[
{ start: 0, duration: 8, track: 2 },
{ start: 0, duration: 4, track: 5 },
],
),
).toEqual([
{ start: 1.5, track: 6 },
{ start: 2.7, track: 6 },
{ start: 4.3, track: 6 },
]);
});
it("keeps a requested track above occupied rows when that track is clear", () => {
expect(
buildTimelineFileDropPlacements(
{ start: 1.5, track: 8 },
[1.2, 1.6],
[
{ start: 0, duration: 8, track: 2 },
{ start: 0, duration: 4, track: 5 },
],
),
).toEqual([
{ start: 1.5, track: 8 },
{ start: 2.7, track: 8 },
]);
});
it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
expect(
buildTimelineFileDropPlacements(
{ start: 0, track: 0 },
[1.2, 1.6],
[{ start: 0, duration: 8, track: 0 }],
),
).toEqual([
{ start: 0, track: 1 },
{ start: 1.2, track: 1 },
]);
});
});
describe("insertTimelineAssetIntoSource", () => {
@@ -159,3 +197,57 @@ describe("insertTimelineAssetIntoSource", () => {
expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
});
});
describe("buildTimelineAssetInsertHtml markup quality", () => {
const base = {
id: "clip_1",
hfId: "hf-test-1",
assetPath: "assets/a.mp4",
start: 1,
duration: 4,
track: 2,
zIndex: 3,
};
it("stamps data-hf-id on all kinds", () => {
for (const kind of ["image", "video", "audio"] as const) {
expect(buildTimelineAssetInsertHtml({ ...base, kind })).toContain('data-hf-id="hf-test-1"');
}
});
it("audio gets an explicit data-volume", () => {
expect(buildTimelineAssetInsertHtml({ ...base, kind: "audio" })).toContain('data-volume="1"');
});
});
describe("fitTimelineAssetGeometry", () => {
const comp = { width: 1920, height: 1080 };
it("centers a smaller-than-comp asset at natural size", () => {
expect(fitTimelineAssetGeometry({ width: 640, height: 360 }, comp)).toEqual({
left: 640,
top: 360,
width: 640,
height: 360,
});
});
it("scales an oversized asset down to fit, preserving aspect, centered", () => {
// 4000x1000 → capped to 1920 wide → 1920x480, centered vertically
expect(fitTimelineAssetGeometry({ width: 4000, height: 1000 }, comp)).toEqual({
left: 0,
top: 300,
width: 1920,
height: 480,
});
});
it("falls back to full-frame when natural size is unknown", () => {
expect(fitTimelineAssetGeometry(null, comp)).toEqual({
left: 0,
top: 0,
width: 1920,
height: 1080,
});
});
});
+84 -108
View File
@@ -1,7 +1,7 @@
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
import { roundToCenti } from "./rounding";
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block";
@@ -49,125 +49,44 @@ export function resolveTimelineAssetSrc(targetPath: string, assetPath: string):
return relative || assetPath.split("/").pop() || assetPath;
}
/**
* Sequence one or more dropped files end-to-end starting at the drop point, all on
* the track the user dropped onto. The clip lands where the ghost showed it — we do
* NOT bump to a different track on overlap (that produced surprise "new tracks" and,
* because it jumped past high indices like a grain-overlay track, wild numbers).
* HyperFrames allows time-overlap on a track; the user can nudge if they want a gap.
*/
export function buildTimelineFileDropPlacements(
placement: { start: number; track: number },
durations: number[],
occupiedClips: Array<{ start: number; duration: number; track: number }> = [],
): Array<{ start: number; track: number }> {
let nextStart = roundToCenti(Math.max(0, placement.start));
const sequenceStart = nextStart;
const resolvedDurations = durations.map((duration) =>
Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION,
);
const sequenceEnd = resolvedDurations.reduce(
(end, duration) => roundToCenti(end + duration),
sequenceStart,
);
const overlapsDropTrack = occupiedClips.some((clip) => {
if (clip.track !== placement.track) return false;
const clipStart = Math.max(0, clip.start);
const clipEnd = clipStart + Math.max(0, clip.duration);
return sequenceStart < clipEnd && sequenceEnd > clipStart;
});
const track = overlapsDropTrack
? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1
: placement.track;
return resolvedDurations.map((duration) => {
return durations.map((rawDuration) => {
const duration =
Number.isFinite(rawDuration) && rawDuration > 0
? rawDuration
: FALLBACK_TIMELINE_FILE_DROP_DURATION;
const start = nextStart;
nextStart = roundToCenti(nextStart + duration);
return { start, track };
return { start, track: placement.track };
});
}
export function resolveTimelineAssetInitialGeometry(source: string): {
left: number;
top: number;
export function resolveTimelineAssetCompositionSize(source: string): {
width: number;
height: number;
} {
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
return {
left: 0,
top: 0,
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
};
}
export function buildTimelineAssetInsertHtml(input: {
id: string;
/** Stable hf-id stamped as data-hf-id by the NLE drop path (optional in the legacy path). */
hfId?: string;
assetPath: string;
kind: TimelineAssetKind;
start: number;
duration: number;
track: number;
zIndex: number;
geometry?: { left: number; top: number; width: number; height: number };
}): string {
const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
if (input.kind === "image") {
return `<img ${sharedAttrs} style="${visualStyles}" />`;
}
if (input.kind === "video") {
return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
}
return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;
}
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
if (!match || match.index == null) {
throw new Error("No composition root found in target source");
}
const insertAt = match.index + match[0].length;
const lineStart = source.lastIndexOf("\n", match.index);
const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
const childIndent = leadingWhitespace + " ";
const indented = assetHtml
.split("\n")
.map((line, i) => (i === 0 ? line : childIndent + line))
.join("\n");
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
}
/**
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
* timeline length tracks content — the content-driven counterpart to
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
* an empty timeline keeps its declared duration instead of collapsing to 0.
*/
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
const rootDur = readRootCompositionDuration(source);
if (rootDur == null) return source;
const next = roundToCenti(contentEnd);
if (rootDur === next) return source;
return patchRootCompositionDuration(source, String(next));
}
export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
const rootDur = readRootCompositionDuration(source);
if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
}
/**
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
* timeline length tracks content — the content-driven counterpart to
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
* an empty timeline keeps its declared duration instead of collapsing to 0.
* CapCut-style placement: natural size when it fits, scaled-to-fit when
* oversized, always centered. Unknown natural size → full-frame.
*/
export function fitTimelineAssetGeometry(
natural: { width: number; height: number } | null,
@@ -187,14 +106,71 @@ export function fitTimelineAssetGeometry(
};
}
export function resolveTimelineAssetCompositionSize(source: string): {
width: number;
height: number;
} {
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
return {
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
};
export function buildTimelineAssetInsertHtml(input: {
id: string;
hfId: string;
assetPath: string;
kind: TimelineAssetKind;
start: number;
duration: number;
track: number;
zIndex: number;
geometry?: { left: number; top: number; width: number; height: number };
}): string {
const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
if (input.kind === "image") {
return `<img ${sharedAttrs} style="${visualStyles}" />`;
}
if (input.kind === "video") {
return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
}
return `<audio ${sharedAttrs} data-volume="1" style="z-index: ${input.zIndex}"></audio>`;
}
/**
* A clip inserted past the composition end would exist in the HTML but never
* appear on the timeline or in playback. Extend the root's data-duration to
* cover it (mirrors blockInstaller's behavior for installed blocks).
*/
export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
const rootDur = readRootCompositionDuration(source);
if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
}
/**
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
* timeline length tracks content — the content-driven counterpart to
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
* an empty timeline keeps its declared duration instead of collapsing to 0.
*/
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
const rootDur = readRootCompositionDuration(source);
if (rootDur == null) return source;
const next = roundToCenti(contentEnd);
if (rootDur === next) return source;
return patchRootCompositionDuration(source, String(next));
}
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
if (!match || match.index == null) {
throw new Error("No composition root found in target source");
}
const insertAt = match.index + match[0].length;
const lineStart = source.lastIndexOf("\n", match.index);
const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
const childIndent = leadingWhitespace + " ";
const indented = assetHtml
.split("\n")
.map((line, i) => (i === 0 ? line : childIndent + line))
.join("\n");
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
}