diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4cb09db319..52048298fb 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -189,10 +189,10 @@ export function StudioApp() { }>, coalesceKey?: string, operation: TimelineMoveOperation = "timing", + coalesceMs?: number, ) => { - await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, { - handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove, - }); + const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove }; + await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs); }, [timelineEditing.handleTimelineGroupMove], ); diff --git a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx index 57a093e81b..888e0e34a1 100644 --- a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx +++ b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx @@ -5,6 +5,7 @@ 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 { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils"; import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes"; import { CanvasContextMenu } from "./CanvasContextMenu"; import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder"; @@ -102,9 +103,26 @@ describe("CanvasContextMenu — handler gating", () => { renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() }); - expect(zOrderButtons()).toHaveLength(4); + const buttons = zOrderButtons(); + expect(buttons).toHaveLength(4); expect(hasDeleteItem()).toBe(false); expect(document.body.querySelector(".border-t")).toBeNull(); + + // Labels stay the exact industry-standard names (the icons add no text)... + expect(buttons.map((b) => b.textContent)).toEqual([ + "Bring to front", + "Bring forward", + "Send backward", + "Send to back", + ]); + // ...and each item leads with a stroke icon that inherits the item's text + // color (currentColor), so the disabled muted tone applies to it too. + for (const button of buttons) { + const svg = button.firstElementChild; + expect(svg?.tagName.toLowerCase()).toBe("svg"); + expect(svg?.getAttribute("stroke")).toBe("currentColor"); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + } }); it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => { @@ -158,19 +176,14 @@ function renderCommitHook(captured: CapturedBatchCall[]) { type Commit = ReturnType["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 }); - }, - })); + ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps( + makeLifecycleOpsParams({ + commitDomEditPatchBatches: async (batches, options) => { + captured.push({ batches, options }); + return { allMatched: true, changed: true }; + }, + }), + )); return null; } const hookHost = document.createElement("div"); diff --git a/packages/studio/src/components/editor/CanvasContextMenu.tsx b/packages/studio/src/components/editor/CanvasContextMenu.tsx index cf06d95487..0fae550544 100644 --- a/packages/studio/src/components/editor/CanvasContextMenu.tsx +++ b/packages/studio/src/components/editor/CanvasContextMenu.tsx @@ -50,8 +50,17 @@ interface CanvasContextMenuProps { * affected siblings). The menu does NOT touch the live DOM — wire to * handleDomZIndexReorderCommit, which applies the live styles itself * (see module-level wiring comment). + * + * `crossed` is the sibling a forward/backward step moved past, resolved from + * the SAME pre-mutation render order as the patches (null for front/back or + * when there is no neighbor). The host uses it to mirror the z action into a + * timeline lane move (resolveZMirrorLaneMove's crossedKey). */ - onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void; + onApplyZIndex?: ( + patches: ZOrderPatch[], + action: ZOrderAction, + crossed: HTMLElement | null, + ) => 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 @@ -70,10 +79,61 @@ interface CanvasContextMenuProps { type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back"; +// Stacked-layer + arrow glyphs, one per z action (16px, stroke, currentColor — +// matches the studio's inline-SVG conventions: fill="none", 1.2 stroke, round +// caps/joins). Single actions show ONE layer diamond with the arrow stepping +// one way; front/back show a TWO-diamond stack with the arrow piercing through +// and beyond it. `paths` are the d attributes, drawn in order. +const Z_ACTION_ICONS: Record = { + "bring-forward": [ + "M3 11 L8 8.5 L13 11 L8 13.5 Z", // layer diamond (bottom) + "M8 8.5 L8 2", // arrow shaft up + "M5.5 4.5 L8 2 L10.5 4.5", // arrow head + ], + "send-backward": [ + "M3 5 L8 2.5 L13 5 L8 7.5 Z", // layer diamond (top) + "M8 7.5 L8 14", // arrow shaft down + "M5.5 11.5 L8 14 L10.5 11.5", // arrow head + ], + "bring-to-front": [ + "M3 9.5 L8 7 L13 9.5 L8 12 Z", // upper layer of the stack + "M3 12.5 L8 10 L13 12.5 L8 15 Z", // lower layer of the stack + "M8 12.5 L8 2", // arrow piercing up through/above the stack + "M5.5 4.5 L8 2 L10.5 4.5", // arrow head + ], + "send-to-back": [ + "M3 4 L8 1.5 L13 4 L8 6.5 Z", // upper layer of the stack + "M3 7 L8 4.5 L13 7 L8 9.5 Z", // lower layer of the stack + "M8 3.5 L8 14", // arrow piercing down through/below the stack + "M5.5 11.5 L8 14 L10.5 11.5", // arrow head + ], +}; + +function ZActionIcon({ action }: { action: ZAction }) { + return ( + + ); +} + const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [ + { action: "bring-to-front", label: "Bring to front" }, { action: "bring-forward", label: "Bring forward" }, { action: "send-backward", label: "Send backward" }, - { action: "bring-to-front", label: "Bring to front" }, { action: "send-to-back", label: "Send to back" }, ]; @@ -113,14 +173,16 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({ const patches = resolveZOrderChange(el, action); if (patches === null) return; // 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; + // both resolvers must read the same pre-change render order. Always resolved + // (not only for the flash): onApplyZIndex forwards it so the host can mirror + // the z step into a timeline lane move. + const crossed = resolveCrossedNeighbor(el, action); // 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); + onApplyZIndex(patches, action, crossed); if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action); onClose(); } @@ -185,7 +247,10 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({ if (enabled) handleZAction(action); }} > - {label} + {/* Icon inherits the item's text color via currentColor, so the + disabled muted tone applies to both icon and label. */} + + {label} ); })} diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 18bfe32c17..2c6d6add3c 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -103,6 +103,9 @@ interface DomEditOverlayProps { selection: DomEditSelection, patches: ZOrderPatch[], action: ZOrderAction, + /** Sibling a forward/backward step moved past (pre-mutation render order); + * null for front/back. Feeds the timeline z-mirror's crossedKey. */ + crossed: HTMLElement | null, ) => void; } @@ -529,8 +532,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ } onApplyZIndex={ onApplyZIndex - ? (patches, action) => { - onApplyZIndex(contextMenu.sel, patches, action); + ? (patches, action, crossed) => { + onApplyZIndex(contextMenu.sel, patches, action, crossed); } : undefined } diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index 2c2ae25cd3..7103f8cd34 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -14,8 +14,14 @@ import { } from "../../utils/studioHelpers"; import { Layers } from "../../icons/SystemIcons"; import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag"; -import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering"; +import { getVisibleLayers, sortLayersByZIndex } from "./layersPanelSort"; import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers"; +import { resolveZOrderReposition } from "./canvasContextMenuZOrder"; +import { buildStableSelector } from "./domEditingDom"; +import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps"; +import { useLayerReorderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror"; +import { runZLaneGesture } from "../nle/zLaneGesture"; +import { useLayerRevealOverride } from "./useLayerRevealOverride"; const TAG_ICONS: Record = { video: "Vi", @@ -85,8 +91,13 @@ interface CollapsedState { // fallow-ignore-next-line complexity export const LayersPanel = memo(function LayersPanel() { const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext(); - const { refreshKey, compositionLoading, timelineElements } = useStudioPlaybackContext(); + const { refreshKey, compositionLoading, timelineElements, isPlaying } = + useStudioPlaybackContext(); const currentTime = usePlayerStore((s) => s.currentTime); + // Flashless z commits (canvas menu, timeline lane-drag z-sync) mutate iframe + // z-indexes with no reload and no refreshKey bump — while paused, nothing + // else re-collects, so the panel's z-sorted order would go stale. + const zEditVersion = usePlayerStore((s) => s.zEditVersion); const { domEditSelection, activeGroupElement, @@ -100,6 +111,15 @@ export const LayersPanel = memo(function LayersPanel() { const [collapsed, setCollapsed] = useState({}); const prevDocVersionRef = useRef(0); const scrollContainerRef = useRef(null); + const mirrorLayerReorderToTimeline = useLayerReorderTimelineMirror(); + const { reveal, restoreReveal, revealedBase } = useLayerRevealOverride({ isPlaying }); + + // Drop the temporary reveal when the selection leaves the revealed layer + // (deselect, or a selection made anywhere else in the studio). + useEffect(() => { + const base = revealedBase(); + if (base && domEditSelection?.element !== base) restoreReveal(); + }, [domEditSelection, revealedBase, restoreReveal]); const isMasterView = !activeCompPath || activeCompPath === "index.html"; @@ -131,7 +151,7 @@ export const LayersPanel = memo(function LayersPanel() { useEffect(() => { collectLayers(); - }, [collectLayers, refreshKey]); + }, [collectLayers, refreshKey, zEditVersion]); useEffect(() => { const iframe = previewIframeRef.current; @@ -224,15 +244,25 @@ export const LayersPanel = memo(function LayersPanel() { [currentTime, resolveSelection, timelineElements], ); + const revealTimerRef = useRef(0); const handleSelectLayer = useCallback( async (layer: DomEditLayerItem) => { const selection = await resolveSelection(layer); if (!selection) return; applyDomSelection(selection); await seekToLayer(layer); + // Force-reveal AFTER the seek's runtime visibility sync has had a beat: + // a clip made active by the seek shows naturally and needs no override, + // so the reveal only touches nodes that REMAIN hidden (animation-parked + // opacity, non-clip display/visibility hides, hidden ancestors). + window.clearTimeout(revealTimerRef.current); + revealTimerRef.current = window.setTimeout(() => { + if (selection.element.isConnected) reveal(selection.element); + }, 150); }, - [resolveSelection, applyDomSelection, seekToLayer], + [resolveSelection, applyDomSelection, seekToLayer, reveal], ); + useEffect(() => () => window.clearTimeout(revealTimerRef.current), []); // Double-click a group row → drill into it; any other row → select it. const handleLayerDoubleClick = useCallback( @@ -264,6 +294,7 @@ export const LayersPanel = memo(function LayersPanel() { setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })); }, []); + // fallow-ignore-next-line complexity const handleReorder = useCallback( (event: LayerReorderEvent) => { const { siblingLayers, fromIndex, toIndex } = event; @@ -271,29 +302,88 @@ export const LayersPanel = memo(function LayersPanel() { const [moved] = reordered.splice(fromIndex, 1); reordered.splice(toIndex, 0, moved); - const existingValues = siblingLayers.map((l) => getElementZIndex(l.element)); - const zValues = computeReorderZValues(existingValues, fromIndex, toIndex); - - const entries = reordered.map((layer, i) => ({ - element: layer.element, - zIndex: zValues[i], - id: layer.id, - selector: layer.selector, - selectorIndex: layer.selectorIndex, - sourceFile: layer.sourceFile, - key: deriveTimelineStoreKey({ - domId: layer.id, - selector: layer.selector, - selectorIndex: layer.selectorIndex, - sourceFile: layer.sourceFile, - }), - })); - - // "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"); + // Panel order is top-first (sortLayersByZIndex: z desc, later-DOM-first), + // so the desired RENDER order (bottom→top) is the reverse. The minimal + // resolver (shared with the canvas z-menu) then writes one between-z + // value when a strict gap exists, band-safe renumber otherwise — instead + // of the old computeReorderZValues stamp of every sibling. + const desiredBottomToTop = [...reordered].reverse(); + const patches = resolveZOrderReposition( + moved.element, + desiredBottomToTop.map((l) => l.element), + ); + if (!patches || patches.length === 0) return; // paint order unchanged + + const layerByElement = new Map(siblingLayers.map((l) => [l.element, l])); + const entries: Array<{ + element: HTMLElement; + zIndex: number; + id?: string; + selector?: string; + selectorIndex?: number; + sourceFile: string; + key?: string; + }> = []; + for (const patch of patches) { + // The renumber fallback can patch a painting sibling the panel didn't + // list (non-collected family member): derive its identity from the DOM + // node, exactly like the canvas menu's siblingZIndexEntry. Un-targetable + // nodes get a live-only style write (reverts on reload). + const layer = layerByElement.get(patch.element); + const id = layer?.id ?? (patch.element.id || undefined); + const selector = layer?.selector ?? buildStableSelector(patch.element); + if (!id && !selector) { + patch.element.style.zIndex = String(patch.zIndex); + continue; + } + const sourceFile = layer?.sourceFile ?? moved.sourceFile; + entries.push({ + element: patch.element, + zIndex: patch.zIndex, + id, + selector, + selectorIndex: layer?.selectorIndex, + sourceFile, + key: deriveTimelineStoreKey({ + domId: id, + selector, + selectorIndex: layer?.selectorIndex, + sourceFile, + }), + }); + } + if (entries.length === 0) return; + + // ONE undo entry for the whole gesture: the z persist and the timeline + // lane mirror below share this per-gesture-unique key (same contract as + // the canvas menu's wiring in PreviewOverlays). + const coalesceKey = zReorderCoalesceKey(entries, "layer-drag"); + const desiredOrderKeys = desiredBottomToTop.map( + (l) => + deriveTimelineStoreKey({ + domId: l.id, + selector: l.selector, + selectorIndex: l.selectorIndex, + sourceFile: l.sourceFile, + }) ?? null, + ); + const movedKey = deriveTimelineStoreKey({ + domId: moved.id, + selector: moved.selector, + selectorIndex: moved.selectorIndex, + sourceFile: moved.sourceFile, + }); + // One serialized z→lane transaction (shared queue with the canvas + // z-order menu): the mirror runs only after a DURABLE z persist, and + // rapid successive gestures cannot interleave phases — see + // runZLaneGesture. + runZLaneGesture({ + commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, "layer-drag"), + mirror: () => + mirrorLayerReorderToTimeline({ selectionKey: movedKey, desiredOrderKeys, coalesceKey }), + }).catch(() => undefined); }, - [handleDomZIndexReorderCommit], + [handleDomZIndexReorderCommit, mirrorLayerReorderToTimeline], ); const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null; @@ -436,76 +526,6 @@ export const LayersPanel = memo(function LayersPanel() { ); }); -// ── Pure helpers ────────────────────────────────────────────────────── - -// fallow-ignore-next-line complexity -export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] { - if (layers.length <= 1) return layers; - - const minDepth = layers[0].depth; - for (let i = 1; i < layers.length; i++) { - if (layers[i].depth < minDepth) return layers; - } - - const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> = - []; - - for (let i = 0; i < layers.length; i++) { - if (layers[i].depth === minDepth) { - const children: DomEditLayerItem[] = []; - let j = i + 1; - while (j < layers.length && layers[j].depth > minDepth) { - children.push(layers[j]); - j++; - } - chunks.push({ root: layers[i], children, domIndex: chunks.length }); - } - } - - if (chunks.length <= 1) { - if (chunks.length === 1 && chunks[0].children.length > 0) { - const sorted = sortLayersByZIndex(chunks[0].children); - return [chunks[0].root, ...sorted]; - } - return layers; - } - - chunks.sort((a, b) => { - const zA = getElementZIndex(a.root.element); - const zB = getElementZIndex(b.root.element); - if (zA !== zB) return zB - zA; - return b.domIndex - a.domIndex; - }); - - const result: DomEditLayerItem[] = []; - for (const chunk of chunks) { - result.push(chunk.root); - if (chunk.children.length > 0) { - result.push(...sortLayersByZIndex(chunk.children)); - } - } - return result; -} - -function getVisibleLayers( - layers: DomEditLayerItem[], - collapsed: CollapsedState, -): DomEditLayerItem[] { - if (Object.keys(collapsed).length === 0) return layers; - - const result: DomEditLayerItem[] = []; - let skipDepth = -1; - - for (const layer of layers) { - if (skipDepth >= 0 && layer.depth > skipDepth) continue; - skipDepth = -1; - - result.push(layer); - - if (collapsed[layer.key] && layer.childCount > 0) { - skipDepth = layer.depth; - } - } - - return result; -} +// The sort helper lives in layersPanelSort.ts (600-line studio cap); +// re-exported so existing imports from "./LayersPanel" still resolve. +export { sortLayersByZIndex } from "./layersPanelSort"; diff --git a/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts b/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts index b663192cdd..a01a803269 100644 --- a/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts +++ b/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts @@ -6,6 +6,7 @@ import { parseZIndex, resolveCrossedNeighbor, resolveZOrderChange, + resolveZOrderReposition, type ZOrderAction, type ZOrderPatch, } from "./canvasContextMenuZOrder"; @@ -617,3 +618,59 @@ describe("isZOrderActionEnabled", () => { } }); }); + +describe("resolveZOrderReposition (Layers-panel arbitrary drop)", () => { + it("multi-position jump with distinct z resolves to ONE between-z write", () => { + // Render order bottom→top today: target(1), a(3), b(5). Drop target between + // a and b → single write: z strictly between 3 and 5. + const { target, byId } = makeFamily("1", [ + ["a", "3"], + ["b", "5"], + ]); + const patches = resolveZOrderReposition(target, [byId.a, target, byId.b]); + expect(patches).toEqual([{ element: target, zIndex: 4 }]); + }); + + it("jump to the very top writes one z above the previous top", () => { + const { target, byId } = makeFamily("1", [ + ["a", "3"], + ["b", "5"], + ]); + const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]); + expect(patches).toEqual([{ element: target, zIndex: 6 }]); + }); + + it("no-op drop (unchanged order) returns null", () => { + const { target, byId } = makeFamily("1", [ + ["a", "3"], + ["b", "5"], + ]); + expect(resolveZOrderReposition(target, [target, byId.a, byId.b])).toBeNull(); + }); + + it("tied z values renumber the scoped set minimally (band-safe)", () => { + const { target, byId } = makeFamily("2", [ + ["a", "2"], + ["b", "2"], + ]); + // All tied at 2; DOM order target,a,b → render bottom→top target,a,b. + // Move target to the top: scoped renumber within the band. + const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]); + expect(patches).not.toBeNull(); + const z = new Map(patches!.map((p) => [(p.element as HTMLElement).id, p.zIndex])); + const zOf = (id: string) => z.get(id) ?? 2; + expect(zOf("a")).toBeLessThan(zOf("b")); + expect(zOf("b")).toBeLessThan(zOf("target")); + }); + + it("rejects elements that are not painting siblings of the target", () => { + const { target, byId } = makeFamily("1", [["a", "3"]]); + const stranger = makeEl("stranger", "2"); + expect(resolveZOrderReposition(target, [stranger, target, byId.a])).toBeNull(); + }); + + it("returns null for sets too small to reorder", () => { + const { target } = makeFamily("1", []); + expect(resolveZOrderReposition(target, [target])).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/canvasContextMenuZOrder.ts b/packages/studio/src/components/editor/canvasContextMenuZOrder.ts index 52b2005628..6e5be92c2f 100644 --- a/packages/studio/src/components/editor/canvasContextMenuZOrder.ts +++ b/packages/studio/src/components/editor/canvasContextMenuZOrder.ts @@ -42,6 +42,7 @@ */ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading"; +import { readLayerRevealPriorZ } from "../../player/lib/timelineElementHelpers"; export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back"; @@ -107,8 +108,11 @@ export function parseZIndex(value: string | null | undefined): number { return Number.isFinite(n) ? n : 0; } -/** Read the effective z-index for an element (inline style preferred). */ +/** Read the effective z-index for an element (inline style preferred). + * Reveal-lift transparent: an active Layers-panel lift reports the TRUE z. */ export function readEffectiveZIndex(el: HTMLElement): number { + const prior = readLayerRevealPriorZ(el); + if (prior != null) return prior; const inline = el.style.zIndex; if (inline && inline !== "auto") return parseZIndex(inline); try { @@ -439,6 +443,41 @@ export function resolveZOrderChange( return realizeOrder(order, desired, target, entries); } +/** + * Realize an ARBITRARY repositioning of `target` within a scoped sibling set — + * the Layers-panel drag, which can jump several siblings in one drop, unlike + * the menu's four fixed actions. `desiredOrderBottomToTop` is the scoped set + * (target included at its new slot) in the intended render order. Reuses the + * menu's minimal-write realization (realizeOrder): one between-z write when a + * strict gap exists, band-safe scoped renumber otherwise — replacing the old + * LayersPanel computeReorderZValues path that stamped EVERY sibling. + * + * Null when nothing changes, the set is too small, or an element in the + * desired order is not actually a painting sibling of `target`. + */ +export function resolveZOrderReposition( + target: HTMLElement, + desiredOrderBottomToTop: readonly HTMLElement[], +): ZOrderPatch[] | null { + const { entries } = getFamily(target); + if (entries.length < 2) return null; + const byElement = new Map(entries.map((entry) => [entry.element, entry])); + const desired: RenderEntry[] = []; + for (const el of desiredOrderBottomToTop) { + const entry = byElement.get(el); + if (!entry) return null; + desired.push(entry); + } + if (desired.length < 2 || !desired.some((entry) => entry.element === target)) return null; + const currentOrder = toRenderOrder(desired); + // A drop back into the same slot is a no-op. The menu actions guard this via + // their position checks before realizeOrder; an arbitrary reposition must + // compare the orders itself — realizeOrder would otherwise "normalize" an + // end-of-set target to a fresh z value it doesn't need. + if (currentOrder.every((entry, i) => entry.element === desired[i].element)) return null; + return realizeOrder(currentOrder, desired, target, entries); +} + /** * The sibling a forward/backward step crosses: the visible overlapping * neighbor directly above (bring-forward) or below (send-backward) the target diff --git a/packages/studio/src/components/editor/domEditingDom.ts b/packages/studio/src/components/editor/domEditingDom.ts index e01eb476bf..b498f3cae8 100644 --- a/packages/studio/src/components/editor/domEditingDom.ts +++ b/packages/studio/src/components/editor/domEditingDom.ts @@ -4,6 +4,7 @@ * No imports from other domEditing* modules — safe to import from anywhere. */ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading"; +import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex"; import { CURATED_STYLE_PROPERTIES } from "./domEditingTypes"; // ─── Type guard ─────────────────────────────────────────────────────────────── @@ -296,11 +297,9 @@ export function getSelectorIndex( ): number | undefined { if (!selector?.startsWith(".")) return undefined; - const candidates = querySelectorAllSafely(doc, selector).filter( - (candidate): candidate is HTMLElement => - isHtmlElement(candidate) && - getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile, + return getSourceScopedSelectorIndex(doc, el, selector, sourceFile, (candidate) => + isHtmlElement(candidate) + ? getSourceFileForElement(candidate, activeCompositionPath).sourceFile + : undefined, ); - const index = candidates.indexOf(el); - return index >= 0 ? index : undefined; } diff --git a/packages/studio/src/components/editor/layersPanelSort.ts b/packages/studio/src/components/editor/layersPanelSort.ts new file mode 100644 index 0000000000..29105f4f30 --- /dev/null +++ b/packages/studio/src/components/editor/layersPanelSort.ts @@ -0,0 +1,80 @@ +import type { DomEditLayerItem } from "./domEditingTypes"; +import { getElementZIndex } from "../../player/lib/layerOrdering"; + +interface CollapsedState { + [key: string]: boolean; +} + +// ── Pure helpers ────────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] { + if (layers.length <= 1) return layers; + + const minDepth = layers[0].depth; + for (let i = 1; i < layers.length; i++) { + if (layers[i].depth < minDepth) return layers; + } + + const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> = + []; + + for (let i = 0; i < layers.length; i++) { + if (layers[i].depth === minDepth) { + const children: DomEditLayerItem[] = []; + let j = i + 1; + while (j < layers.length && layers[j].depth > minDepth) { + children.push(layers[j]); + j++; + } + chunks.push({ root: layers[i], children, domIndex: chunks.length }); + } + } + + if (chunks.length <= 1) { + if (chunks.length === 1 && chunks[0].children.length > 0) { + const sorted = sortLayersByZIndex(chunks[0].children); + return [chunks[0].root, ...sorted]; + } + return layers; + } + + chunks.sort((a, b) => { + const zA = getElementZIndex(a.root.element); + const zB = getElementZIndex(b.root.element); + if (zA !== zB) return zB - zA; + return b.domIndex - a.domIndex; + }); + + const result: DomEditLayerItem[] = []; + for (const chunk of chunks) { + result.push(chunk.root); + if (chunk.children.length > 0) { + result.push(...sortLayersByZIndex(chunk.children)); + } + } + return result; +} + +export function getVisibleLayers( + layers: DomEditLayerItem[], + collapsed: CollapsedState, +): DomEditLayerItem[] { + if (Object.keys(collapsed).length === 0) return layers; + + const result: DomEditLayerItem[] = []; + let skipDepth = -1; + + for (const layer of layers) { + if (skipDepth >= 0 && layer.depth > skipDepth) continue; + skipDepth = -1; + + result.push(layer); + + if (collapsed[layer.key] && layer.childCount > 0) { + skipDepth = layer.depth; + } + } + + return result; +} diff --git a/packages/studio/src/components/editor/useLayerRevealOverride.test.ts b/packages/studio/src/components/editor/useLayerRevealOverride.test.ts new file mode 100644 index 0000000000..e06b2f77c1 --- /dev/null +++ b/packages/studio/src/components/editor/useLayerRevealOverride.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import { + LAYER_REVEAL_LIFT_Z, + liftElementToTop, + restoreLiftedElement, +} from "./useLayerRevealOverride"; +import { readEffectiveZIndex } from "./canvasContextMenuZOrder"; +import { getElementZIndex } from "../../player/lib/layerOrdering"; +import { + LAYER_REVEAL_PRIOR_Z_ATTR, + readTimelineElementZIndex, +} from "../../player/lib/timelineElementHelpers"; + +function makeEl(zIndex?: string, position?: string): HTMLElement { + const el = document.createElement("div"); + if (zIndex != null) el.style.zIndex = zIndex; + if (position != null) el.style.position = position; + document.body.appendChild(el); + return el; +} + +describe("liftElementToTop / restoreLiftedElement", () => { + it("paints on top but every z reader keeps reporting the TRUE z", () => { + const el = makeEl("6", "absolute"); + const lift = liftElementToTop(el); + expect(lift).not.toBeNull(); + // The renderer sees the lifted value… + expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z); + // …every studio reader sees the true z. + expect(readEffectiveZIndex(el)).toBe(6); + expect(getElementZIndex(el)).toBe(6); + expect(readTimelineElementZIndex(el)).toBe(6); + + restoreLiftedElement(el, lift!); + expect(el.style.zIndex).toBe("6"); + expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false); + }); + + it("gives a static element a temporary position:relative and restores it", () => { + const el = makeEl(); + const lift = liftElementToTop(el)!; + expect(el.style.position).toBe("relative"); + expect(lift.positionLifted).toBe(true); + restoreLiftedElement(el, lift); + expect(el.style.position).toBe(""); + expect(el.style.zIndex).toBe(""); + }); + + it("a z-reorder commit consumes the lift: restore becomes a no-op", () => { + const el = makeEl("3", "absolute"); + const lift = liftElementToTop(el)!; + // Simulate handleDomZIndexReorderCommit: real z written, attrs removed. + el.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR); + el.style.zIndex = "8"; + restoreLiftedElement(el, lift); + expect(el.style.zIndex).toBe("8"); // the commit's value survives + expect(readEffectiveZIndex(el)).toBe(8); + }); + + it("does not clobber a z someone else wrote while lifted", () => { + const el = makeEl("3", "absolute"); + const lift = liftElementToTop(el)!; + el.style.zIndex = "42"; // e.g. a GSAP seek or manual edit + restoreLiftedElement(el, lift); + expect(el.style.zIndex).toBe("42"); + }); +}); diff --git a/packages/studio/src/components/editor/useLayerRevealOverride.ts b/packages/studio/src/components/editor/useLayerRevealOverride.ts new file mode 100644 index 0000000000..50748c9cf1 --- /dev/null +++ b/packages/studio/src/components/editor/useLayerRevealOverride.ts @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useRef } from "react"; +import { + LAYER_REVEAL_PRIOR_POSITION_ATTR, + LAYER_REVEAL_PRIOR_Z_ATTR, +} from "../../player/lib/timelineElementHelpers"; +import { readEffectiveZIndex } from "./canvasContextMenuZOrder"; + +/** The lifted paint order — far above any authored z. Only the RENDERER sees + * it: every studio z reader is reveal-transparent (readLayerRevealPriorZ). */ +export const LAYER_REVEAL_LIFT_Z = "2147483000"; + +interface RevealedNode { + element: HTMLElement; + priors: { display: string; visibility: string; opacity: string }; + /** Values THIS override wrote — restore only while they are still in place. */ + applied: { display?: string; visibility?: string; opacity?: string }; +} + +interface RevealLift { + priors: { zIndex: string; position: string }; + positionLifted: boolean; +} + +interface RevealState { + /** The layer element the reveal was applied for (deselect detection). */ + base: HTMLElement; + nodes: RevealedNode[]; + lift: RevealLift | null; +} + +function restoreInline(el: HTMLElement, property: string, prior: string): void { + if (prior) el.style.setProperty(property, prior); + else el.style.removeProperty(property); +} + +/** Restore a property ONLY when its current inline value is still the one this + * override wrote — a later real edit (commit, animation seek) is the new + * truth and must not be clobbered. */ +function restoreIfOurs( + el: HTMLElement, + property: "display" | "visibility" | "opacity", + applied: string | undefined, + prior: string, +): void { + if (applied == null) return; + if (el.style.getPropertyValue(property) !== applied) return; + restoreInline(el, property, prior); +} + +/** What hides this node at the current frame, per computed style. */ +function readHideSignals(el: HTMLElement, win: Window) { + const computed = win.getComputedStyle(el); + const opacity = Number.parseFloat(computed.opacity); + return { + display: computed.display === "none", + visibility: computed.visibility === "hidden" || computed.visibility === "collapse", + opacity: Number.isFinite(opacity) && opacity <= 0.01, + }; +} + +/** Force one hidden node visible with inline styles; returns priors + applied. */ +function revealNode( + el: HTMLElement, + win: Window, + needs: ReturnType, +): RevealedNode { + const priors = { + display: el.style.display, + visibility: el.style.visibility, + opacity: el.style.opacity, + }; + const applied: RevealedNode["applied"] = {}; + if (needs.display) { + // Prefer whatever the stylesheet says once the inline hide is lifted; + // only force block when the sheet itself hides it. + el.style.removeProperty("display"); + if (win.getComputedStyle(el).display === "none") el.style.display = "block"; + applied.display = el.style.display; + } + if (needs.visibility) { + el.style.visibility = "visible"; + applied.visibility = "visible"; + } + if (needs.opacity) { + el.style.opacity = "1"; + applied.opacity = "1"; + } + return { element: el, priors, applied }; +} + +/** Walk `element` → body, force-revealing every hiding node; returns the touched nodes. */ +function revealHiddenChain(element: HTMLElement): RevealedNode[] { + const doc = element.ownerDocument; + const win = doc.defaultView; + if (!win) return []; + const nodes: RevealedNode[] = []; + let el: HTMLElement | null = element; + while (el && el !== doc.body && el !== doc.documentElement) { + let needs: ReturnType; + try { + needs = readHideSignals(el, win); + } catch { + break; // detached / cross-realm — leave the rest alone + } + if (needs.display || needs.visibility || needs.opacity) nodes.push(revealNode(el, win, needs)); + el = el.parentElement; + } + return nodes; +} + +/** + * Lift the selected element to the TOP of the paint order while selected — + * regardless of its authored z or panel position. The true z is parked in + * LAYER_REVEAL_PRIOR_Z_ATTR so every studio z reader keeps reporting it (the + * lift is invisible to menus, badges, the lane mirror, and the panel sort); + * only the renderer sees the lifted inline value. A static element gets a + * temporary position:relative (layout-preserving) so the z applies, with the + * prior position parked in LAYER_REVEAL_PRIOR_POSITION_ATTR for the z-commit's + * static check. Exported for direct unit testing. + */ +export function liftElementToTop(element: HTMLElement): RevealLift | null { + const win = element.ownerDocument.defaultView; + if (!win) return null; + const priors = { zIndex: element.style.zIndex, position: element.style.position }; + let positionLifted = false; + try { + element.setAttribute(LAYER_REVEAL_PRIOR_Z_ATTR, String(readEffectiveZIndex(element))); + if (win.getComputedStyle(element).position === "static") { + element.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, "static"); + element.style.position = "relative"; + positionLifted = true; + } + } catch { + element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR); + return null; // detached / cross-realm — no lift + } + element.style.zIndex = LAYER_REVEAL_LIFT_Z; + return { priors, positionLifted }; +} + +/** + * Undo an active lift. Skipped entirely when the prior-z attribute is gone — + * a z-reorder commit consumed the lift (handleDomZIndexReorderCommit removes + * the attributes and writes the new real z), and that commit is the truth. + * Exported for direct unit testing. + */ +export function restoreLiftedElement(element: HTMLElement, lift: RevealLift): void { + if (!element.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)) return; + element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR); + element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR); + if (element.style.zIndex === LAYER_REVEAL_LIFT_Z) { + restoreInline(element, "z-index", lift.priors.zIndex); + } + if (lift.positionLifted && element.style.position === "relative") { + restoreInline(element, "position", lift.priors.position); + } +} + +/** + * Temporary "show me this element" override for the Layers panel + * (Webflow-navigator style): clicking a layer forces it (and every hiding + * ancestor up to the body) visible with LIVE inline styles, and paints it on + * TOP of the stack while selected (see liftElementToTop). + * + * Strictly ephemeral by construction: + * - Exact prior inline values are recorded per touched node and restored on + * every exit path — reveal of a different layer, deselect, playback start, + * unmount. Nothing is ever sent to a persist path, and each property is + * restored only while it still holds the value this override wrote. + * - A post-edit iframe reload replaces the DOM; detached nodes are skipped on + * restore (the fresh document never had the override). + * - Scrubbing/playing lets the runtime and GSAP rewrite these same inline + * styles — that is the animation showing reality, and the override is + * dropped on play for exactly that reason. + */ +export function useLayerRevealOverride({ isPlaying }: { isPlaying: boolean }): { + reveal: (element: HTMLElement) => void; + restoreReveal: () => void; + /** The element the active reveal targets, or null. */ + revealedBase: () => HTMLElement | null; +} { + const stateRef = useRef(null); + + const restoreReveal = useCallback(() => { + const state = stateRef.current; + stateRef.current = null; + if (!state) return; + for (const { element, priors, applied } of state.nodes) { + if (!element.isConnected) continue; + restoreIfOurs(element, "display", applied.display, priors.display); + restoreIfOurs(element, "visibility", applied.visibility, priors.visibility); + restoreIfOurs(element, "opacity", applied.opacity, priors.opacity); + } + if (state.lift && state.base.isConnected) restoreLiftedElement(state.base, state.lift); + }, []); + + const reveal = useCallback( + (element: HTMLElement) => { + restoreReveal(); + const nodes = revealHiddenChain(element); + const lift = liftElementToTop(element); + if (nodes.length > 0 || lift) stateRef.current = { base: element, nodes, lift }; + }, + [restoreReveal], + ); + + // Playback start: the animation owns visibility again. + useEffect(() => { + if (isPlaying) restoreReveal(); + }, [isPlaying, restoreReveal]); + + // Unmount: never leave overrides behind. + useEffect(() => restoreReveal, [restoreReveal]); + + const revealedBase = useCallback(() => stateRef.current?.base ?? null, []); + + return { reveal, restoreReveal, revealedBase }; +} diff --git a/packages/studio/src/components/nle/PreviewOverlays.tsx b/packages/studio/src/components/nle/PreviewOverlays.tsx index 19c9a234f7..2eeb4f6294 100644 --- a/packages/studio/src/components/nle/PreviewOverlays.tsx +++ b/packages/studio/src/components/nle/PreviewOverlays.tsx @@ -19,6 +19,9 @@ import { readStudioUiPreferences } from "../../utils/studioUiPreferences"; import { readHfId, type DomEditSelection } from "../editor/domEditing"; import { buildStableSelector } from "../editor/domEditingDom"; import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers"; +import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps"; +import { useCanvasZOrderTimelineMirror } from "./useCanvasZOrderTimelineMirror"; +import { runZLaneGesture } from "./zLaneGesture"; import type { BlockPreviewInfo } from "../sidebar/BlocksTab"; import type { GestureRecordingState } from "../editor/GestureRecordControl"; import type { ReactNode } from "react"; @@ -158,6 +161,7 @@ export function PreviewOverlays({ handleDomEditElementDelete, handleDomZIndexReorderCommit, } = useDomEditActionsContext(); + const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror(); // fallow-ignore-next-line complexity const [snapPrefs, setSnapPrefs] = useState(() => { @@ -224,7 +228,7 @@ export function PreviewOverlays({ onRotationCommit={handleDomRotationCommit} onStyleCommit={handleDomStyleCommit} onDeleteSelection={handleDomEditElementDelete} - onApplyZIndex={(sel, patches, action) => { + onApplyZIndex={(sel, patches, action, crossed) => { const { entries, dropped } = resolveZIndexEntries(sel, patches); if (dropped.length > 0) { // These siblings can't be written to source. Apply their live z @@ -237,7 +241,28 @@ export function PreviewOverlays({ dropped.map((patch) => describeZIndexElement(patch.element)).join(", "), ); } - if (entries.length > 0) handleDomZIndexReorderCommit(entries, undefined, action); + if (entries.length === 0) return; + // Shared undo coalesce key: passed to BOTH the z persist and the + // timeline lane mirror below so editHistory folds the two records + // into one undo entry (same value handleDomZIndexReorderCommit would + // default to — passed explicitly so the mirror shares it by + // construction, not by formula duplication). + const coalesceKey = zReorderCoalesceKey(entries, action); + // One serialized z→lane transaction: the mirror runs only AFTER the + // z commit resolved AND reported durable targets, and a second rapid + // gesture cannot interleave between the two phases — see + // runZLaneGesture. A failed z commit already toasted + rolled back. + runZLaneGesture({ + commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action), + mirror: () => + mirrorZOrderToTimeline({ + selectionKey: entries.find((e) => e.element === sel.element)?.key, + action, + crossed, + sourceFile: sel.sourceFile, + coalesceKey, + }), + }).catch(() => undefined); }} gridVisible={snapPrefs.gridVisible} gridSpacing={snapPrefs.gridSpacing} diff --git a/packages/studio/src/components/nle/TimelinePane.test.ts b/packages/studio/src/components/nle/TimelinePane.test.ts index e01813bc43..1e4c0c082f 100644 --- a/packages/studio/src/components/nle/TimelinePane.test.ts +++ b/packages/studio/src/components/nle/TimelinePane.test.ts @@ -22,6 +22,7 @@ describe("TimelinePane move wrapper", () => { "clip-lane-move:7", "track-insert", onMoveElements, + Number.POSITIVE_INFINITY, ); expect(onMoveElements).toHaveBeenCalledWith( [ @@ -32,6 +33,8 @@ describe("TimelinePane move wrapper", () => { ], "clip-lane-move:7", "track-insert", + // The per-gesture coalesce window rides along with the shared key. + Number.POSITIVE_INFINITY, ); }); diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 1b0b402eab..3c9587f756 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -21,7 +21,9 @@ export function forwardRebasedTimelineMoveElements( edits: TimelineMoveEdit[], coalesceKey?: string, operation?: TimelineMoveOperation, + coalesceMs?: number, ) => Promise | void, + coalesceMs?: number, ) { return onMoveElements( edits.map(({ element, updates }) => { @@ -34,6 +36,7 @@ export function forwardRebasedTimelineMoveElements( }), coalesceKey, operation, + coalesceMs, ); } @@ -160,6 +163,7 @@ export function TimelinePane({ edits: Array<{ element: TimelineElement; updates: Pick }>, coalesceKey?: string, operation?: TimelineMoveOperation, + coalesceMs?: number, ) => { // Match the sibling handlers: report the telemetry when the batch touches at // least one expanded sub-comp child (the clips being rebased to local coords). @@ -167,7 +171,13 @@ export function TimelinePane({ trackStudioExpandedClipEdit({ action: "move" }); } if (!onMoveElements) return; - return forwardRebasedTimelineMoveElements(edits, coalesceKey, operation, onMoveElements); + return forwardRebasedTimelineMoveElements( + edits, + coalesceKey, + operation, + onMoveElements, + coalesceMs, + ); }, [onMoveElements], ); diff --git a/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx b/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx new file mode 100644 index 0000000000..522d1acd48 --- /dev/null +++ b/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx @@ -0,0 +1,440 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { usePlayerStore, type TimelineElement } from "../../player"; +import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; +import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks"; +import { useElementLifecycleOps, zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps"; +import { + useCanvasZOrderTimelineMirror, + type MirrorZOrderInput, +} from "./useCanvasZOrderTimelineMirror"; +import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils"; +import { mountReactHarness } from "../../hooks/domSelectionTestHarness"; +import { + buildEditHistoryEntry, + createEmptyEditHistory, + pushEditHistoryEntry, + type EditHistoryState, +} from "../../utils/editHistory"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + // Wrapped in act: mounted harnesses subscribe to the store via + // useExpandedTimelineElements, so the reset re-renders them. + act(() => usePlayerStore.getState().setElements([])); +}); + +/** Set the store elements inside act (mounted harnesses re-render on it). */ +function setStoreElements(elements: TimelineElement[]): void { + act(() => usePlayerStore.getState().setElements(elements)); +} + +// Store keys follow buildTimelineElementKey's `#` branch — +// the same shape deriveTimelineStoreKey produces for reorder entries. +function storeEl(domId: string, track: number, start: number, duration: number): TimelineElement { + return { + id: domId, + key: `index.html#${domId}`, + tag: "video", + start, + duration, + track, + domId, + }; +} + +type ReorderEntries = Array<{ + element: HTMLElement; + zIndex: number; + id?: string; + selector?: string; + sourceFile: string; + key?: string; +}>; + +interface HarnessApi { + commitZ: (entries: ReorderEntries, coalesceKey: string, action: string) => Promise; + mirror: (input: MirrorZOrderInput) => Promise; +} + +/** + * Mount the REAL wiring pair PreviewOverlays composes — handleDomZIndexReorderCommit + * (z persist) + useCanvasZOrderTimelineMirror (lane mirror) — over a shared, + * real editHistory reducer so the undo-fold assertion exercises the actual + * pushEditHistoryEntry coalescing: + * + * - the z sink mimics commitDomEditPatchBatches' recordEdit call verbatim + * (kind "manual", options.coalesceKey — see useDomEditCommits.ts), and + * - the move sink mimics persistTimelineBatchEdit → saveProjectFilesWithHistory + * (kind "timeline", the coalesceKey forwarded through onMoveElements — see + * timelineEditingHelpers.ts / studioFileHistory.ts), + * + * with a deterministic clock inside the reducer's 300ms coalesce window. + */ +function mountMirrorHarness(history: { + state: EditHistoryState; + now: () => number; + fileContent: { current: string }; + moveCoalesceKeys: string[]; +}) { + const record = ( + label: string, + kind: "manual" | "timeline", + coalesceKey: string, + coalesceMs: number | undefined, + after: string, + ) => { + const entry = buildEditHistoryEntry({ + id: `e-${history.now()}`, + projectId: "p", + label, + kind, + coalesceKey, + coalesceMs, + now: history.now(), + files: { "index.html": { before: history.fileContent.current, after } }, + }); + history.fileContent.current = after; + history.state = pushEditHistoryEntry(history.state, entry); + }; + + const onMoveElements: TimelineEditCallbacks["onMoveElements"] = ( + _edits, + coalesceKey, + _operation, + coalesceMs, + ) => { + history.moveCoalesceKeys.push(coalesceKey ?? ""); + record("Move timeline clips", "timeline", coalesceKey ?? "", coalesceMs, "C-move"); + }; + + const api: Partial = {}; + function Harness() { + const { handleDomZIndexReorderCommit } = useElementLifecycleOps( + makeLifecycleOpsParams({ + commitDomEditPatchBatches: async (_batches, options) => { + record(options.label, "manual", options.coalesceKey, options.coalesceMs, "B-z"); + return { allMatched: true, changed: true }; + }, + }), + ); + api.commitZ = handleDomZIndexReorderCommit; + api.mirror = useCanvasZOrderTimelineMirror(); + return null; + } + mountReactHarness( + + + , + ); + return api as HarnessApi; +} + +function makeHistory() { + let tick = 1000; + return { + state: createEmptyEditHistory(), + // Deterministic clock: consecutive records land 400ms apart — PAST the + // reducer's default 300ms coalesce window, as in the live flow where the + // mirror is dispatched only after the z persist's server round-trip + // resolves (real network latency exceeds 300ms). The fold must therefore + // ride the gesture's explicit coalesceMs window, not the default. + now: () => (tick += 400), + fileContent: { current: "A-original" }, + moveCoalesceKeys: [] as string[], + }; +} + +function mountMirrorOnlyHarness( + onMoveElements: TimelineEditCallbacks["onMoveElements"], +): Pick { + const api: Partial = {}; + function Harness() { + api.mirror = useCanvasZOrderTimelineMirror(); + return null; + } + mountReactHarness( + + + , + ); + const mirror = api.mirror; + if (!mirror) throw new Error("mirror harness failed to mount"); + return { mirror }; +} + +function domTarget(id: string): HTMLElement { + const el = document.createElement("div"); + el.id = id; + document.body.appendChild(el); + return el; +} + +describe("useCanvasZOrderTimelineMirror", () => { + it("folds the z write and the mirrored lane write into ONE undo entry (shared coalesce key)", async () => { + // Timeline: t on lane 2, b on lane 1 (the crossed neighbor), a on lane 0 + // free over t's span → bring-forward mirrors to a kind:"move" onto lane 0. + setStoreElements([storeEl("a", 0, 20, 5), storeEl("b", 1, 0, 10), storeEl("t", 2, 0, 10)]); + const history = makeHistory(); + const api = mountMirrorHarness(history); + + const target = domTarget("t"); + const entries: ReorderEntries = [ + { element: target, zIndex: 7, id: "t", sourceFile: "index.html", key: "index.html#t" }, + ]; + const coalesceKey = zReorderCoalesceKey(entries, "bring-forward"); + // Gesture-unique key: action + ids + a per-call gesture sequence, so two + // SEPARATE actions on the same selection never share a key (see the + // two-gestures test below) while this gesture's two records do. + expect(coalesceKey).toMatch(/^z-reorder:bring-forward:t:g\d+$/); + + await act(async () => { + // The PreviewOverlays wiring: z commit first, mirror after it resolves, + // BOTH with the same key. + await api.commitZ(entries, coalesceKey, "bring-forward"); + const mirrored = await api.mirror({ + selectionKey: "index.html#t", + action: "bring-forward", + crossed: domTarget("b"), + sourceFile: "index.html", + coalesceKey, + }); + expect(mirrored).toBe(true); + }); + + // The move persist received the EXACT z coalesce key… + expect(history.moveCoalesceKeys).toEqual([coalesceKey]); + // …and the real reducer folded the two records into one undo entry spanning + // pre-z "before" → post-move "after". One Cmd+Z reverts both writes. + expect(history.state.undo).toHaveLength(1); + expect(history.state.undo[0].files["index.html"]).toMatchObject({ + before: "A-original", + after: "C-move", + }); + // Timeline UI reflects the lane change without a reload: optimistic store update. + const t = usePlayerStore.getState().elements.find((e) => e.key === "index.html#t"); + expect(t?.track).toBe(0); + }); + + it("two SEPARATE gestures on the same selection produce TWO undo entries (distinct keys)", async () => { + // Two consecutive gestures on the same element: each mints its own coalesce + // key (gesture sequence), so even an unbounded per-gesture window must never + // merge distinct user actions into one undo step. + setStoreElements([storeEl("t", 0, 0, 10), storeEl("b", 1, 0, 10)]); + const history = makeHistory(); + const api = mountMirrorHarness(history); + + const target = domTarget("t"); + const entries: ReorderEntries = [ + { element: target, zIndex: 7, id: "t", sourceFile: "index.html", key: "index.html#t" }, + ]; + + // Identical action + identical selection still mints a fresh key per call. + expect(zReorderCoalesceKey(entries, "send-backward")).not.toBe( + zReorderCoalesceKey(entries, "send-backward"), + ); + + const keys: string[] = []; + // Gesture 1: t (top) sent backward past b → back-to-back, no free lane in + // the bounded interval → insert immediately below b (t and b swap lanes). + // Gesture 2: t brought forward past b → insert immediately above b (swap + // back). Both gestures mirror (lane move persists), so each records a + // z+move pair. + const gestures = [ + { action: "send-backward" as const, crossedId: "b" }, + { action: "bring-forward" as const, crossedId: "b" }, + ]; + for (const { action, crossedId } of gestures) { + const coalesceKey = zReorderCoalesceKey(entries, action); + keys.push(coalesceKey); + await act(async () => { + await api.commitZ(entries, coalesceKey, action); + const mirrored = await api.mirror({ + selectionKey: "index.html#t", + action, + crossed: domTarget(crossedId), + sourceFile: "index.html", + coalesceKey, + }); + expect(mirrored).toBe(true); + }); + } + + expect(keys[0]).not.toBe(keys[1]); // fresh key per gesture + // Each gesture folded its own z+move pair, but the two gestures stayed apart. + expect(history.state.undo).toHaveLength(2); + expect(history.moveCoalesceKeys).toEqual(keys); + }); + + it("z-only actions leave the timeline untouched (resolver null → single z undo entry)", async () => { + // t has NO overlapping neighbor above → bring-forward has no lane mirror. + setStoreElements([storeEl("a", 0, 20, 5), storeEl("t", 1, 0, 10)]); + const history = makeHistory(); + const api = mountMirrorHarness(history); + + const entries: ReorderEntries = [ + { + element: domTarget("t"), + zIndex: 3, + id: "t", + sourceFile: "index.html", + key: "index.html#t", + }, + ]; + const coalesceKey = zReorderCoalesceKey(entries, "bring-forward"); + await act(async () => { + await api.commitZ(entries, coalesceKey, "bring-forward"); + const mirrored = await api.mirror({ + selectionKey: "index.html#t", + action: "bring-forward", + crossed: null, + sourceFile: "index.html", + coalesceKey, + }); + expect(mirrored).toBe(false); + }); + + expect(history.moveCoalesceKeys).toEqual([]); // no lane persist dispatched + expect(history.state.undo).toHaveLength(1); // just the z entry + const t = usePlayerStore.getState().elements.find((e) => e.key === "index.html#t"); + expect(t?.track).toBe(1); // lane unchanged + }); + + it("elements that are not timeline clips resolve false without touching the persist path", async () => { + setStoreElements([storeEl("a", 0, 0, 10)]); + const history = makeHistory(); + const api = mountMirrorHarness(history); + const mirrored = await act(async () => + api.mirror({ + selectionKey: undefined, // canvas-only decoration: no timeline key + action: "send-to-back", + crossed: null, + sourceFile: "index.html", + coalesceKey: "z-reorder:send-to-back:x", + }), + ); + expect(mirrored).toBe(false); + expect(history.moveCoalesceKeys).toEqual([]); + }); + + it("maps the crossed neighbor to its timeline key and rebases expanded sub-comp children", async () => { + // t is an expanded sub-comp child (expandedParentStart 5, absolute start 5): + // the mirror must forward its persist in LOCAL time (start 0), the same + // rebase a timeline lane drag applies (forwardRebasedTimelineMoveElements). + setStoreElements([ + { + ...storeEl("a", 0, 25, 5), + sourceFile: "sub.html", + key: "sub.html#a", + expandedParentStart: 5, + }, + { + ...storeEl("b", 1, 5, 10), + sourceFile: "sub.html", + key: "sub.html#b", + expandedParentStart: 5, + }, + { + ...storeEl("t", 2, 5, 10), + sourceFile: "sub.html", + key: "sub.html#t", + expandedParentStart: 5, + }, + ]); + const edits: Array<{ element: TimelineElement; updates: { start: number; track: number } }> = + []; + const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (batch) => { + edits.push(...batch); + }; + const { mirror } = mountMirrorOnlyHarness(onMoveElements); + + const mirrored = await act(async () => + mirror({ + selectionKey: "sub.html#t", + action: "bring-forward", + // The crossed sibling maps to sub.html#b via its DOM id + sourceFile — + // the same derivation reorder entries use (deriveTimelineStoreKey). + crossed: domTarget("b"), + sourceFile: "sub.html", + coalesceKey: "z-reorder:bring-forward:t", + }), + ); + expect(mirrored).toBe(true); + expect(edits).toHaveLength(1); + // Rebased to sub-comp local coords: absolute 5 − parent start 5 = 0. + expect(edits[0].element.start).toBe(0); + expect(edits[0].updates).toMatchObject({ start: 0, track: 0 }); + }); + + it("maps a cross-file duplicate selector to the source-scoped crossed occurrence", async () => { + setStoreElements([ + { + id: "scene.html:.sub:0", + key: "scene.html:.sub:0", + tag: "div", + start: 20, + duration: 5, + track: 0, + selector: ".sub", + selectorIndex: 0, + sourceFile: "scene.html", + }, + { + id: "scene.html:.sub:1", + key: "scene.html:.sub:1", + tag: "div", + start: 0, + duration: 10, + track: 1, + selector: ".sub", + selectorIndex: 1, + sourceFile: "scene.html", + }, + { + ...storeEl("t", 2, 0, 10), + key: "scene.html#t", + sourceFile: "scene.html", + }, + ]); + const edits: Array<{ element: TimelineElement; updates: { start: number; track: number } }> = + []; + const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (batch) => { + edits.push(...batch); + }; + const { mirror } = mountMirrorOnlyHarness(onMoveElements); + + // This root-file duplicate precedes the scene nodes in the flattened preview + // DOM. It must not offset scene.html's selector indices. + const rootDuplicate = document.createElement("div"); + rootDuplicate.className = "sub"; + document.body.appendChild(rootDuplicate); + const scene = document.createElement("div"); + scene.setAttribute("data-composition-id", "scene"); + scene.setAttribute("data-composition-file", "scene.html"); + document.body.appendChild(scene); + const first = document.createElement("div"); + first.className = "sub"; + scene.appendChild(first); + const crossed = document.createElement("div"); + crossed.className = "sub"; + scene.appendChild(crossed); + + const mirrored = await act(async () => + mirror({ + selectionKey: "scene.html#t", + action: "bring-forward", + crossed, + sourceFile: "scene.html", + coalesceKey: "z-reorder:bring-forward:t", + }), + ); + + expect(mirrored).toBe(true); + expect(edits).toHaveLength(1); + expect(edits[0].updates.track).toBe(0); + }); +}); diff --git a/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.ts b/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.ts new file mode 100644 index 0000000000..994e58da87 --- /dev/null +++ b/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.ts @@ -0,0 +1,191 @@ +import { useCallback, useRef } from "react"; +import { usePlayerStore } from "../../player"; +import { useExpandedTimelineElements } from "../../player/hooks/useExpandedTimelineElements"; +import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; +import { + displayTrackOrder, + resolveRepositionLaneMove, + resolveZMirrorLaneMove, + type ZMirrorAction, + type ZMirrorLaneMove, +} from "../../player/components/timelineZMirror"; +import type { TimelineElement } from "../../player/store/playerStore"; +import { commitZMirrorLaneMove } from "../../player/components/timelineClipDragCommit"; +import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers"; +import { buildStableSelector, getSelectorIndex } from "../editor/domEditingDom"; +import { useStudioShellContextOptional } from "../../contexts/StudioContext"; +import { forwardRebasedTimelineMoveElements } from "./TimelinePane"; + +export interface MirrorZOrderInput { + /** Timeline store key of the element the menu acted on (entry.key), if any. */ + selectionKey: string | undefined; + action: ZMirrorAction; + /** Sibling a forward/backward step moved past (from resolveCrossedNeighbor). */ + crossed: HTMLElement | null; + /** Source file of the selection — siblings share it (same document). */ + sourceFile: string; + /** The z persist's coalesce key (zReorderCoalesceKey) — REQUIRED so the lane + * write folds into the same undo entry as the z write. */ + coalesceKey: string; +} + +/** + * Mirror a successful canvas z-order menu action into a timeline LANE move. + * + * The caller (PreviewOverlays) invokes the returned callback AFTER the z commit + * resolved — serializing the two same-file writes, exactly like the lane-drag's + * move→z ordering (see persistMoveEdits' doc) — and with the SAME coalesce key + * the z persist recorded, so editHistory folds both records into one undo entry. + * Because the z round-trip puts a real-network gap between the two records, the + * lane persist carries an unbounded per-gesture coalesce window (see + * commitZMirrorLaneMove) — the shared key is unique per gesture + * (zReorderCoalesceKey's gesture seq), so the fold stays gesture-scoped. + * + * Element source: `useExpandedTimelineElements()` — the same expanded display + * set the Timeline renders and the resolver expects (post-normalizeToZones + * lanes, expanded sub-comp children on their synthetic rows). No new expansion + * is built here. + * + * The mirror persists through the SAME machinery as a timeline lane drag + * (commitZMirrorLaneMove → persistMoveEdits → onMoveElements, with expanded + * children rebased to local coords via forwardRebasedTimelineMoveElements) — + * optimistic store update + rollback included, so the timeline reflects the + * lane change without a reload. The deps below deliberately OMIT + * `readZIndex`/`onStackingPatches`: the lane→z stacking sync + * (syncStackingForEdit) must not fire and recompute the z values the user just + * set — commitZMirrorLaneMove never calls it, and without these deps it would + * no-op even if called. + * + * Resolves `true` when a lane move persisted, `false` for z-only actions (no + * timeline mirror applies) or a rolled-back persist. + */ +export function useCanvasZOrderTimelineMirror(): (input: MirrorZOrderInput) => Promise { + const commitMirrorMove = useMirrorLaneMoveCommit(); + const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null; + + return useCallback( + (input: MirrorZOrderInput) => + commitMirrorMove(input.selectionKey, input.coalesceKey, (element, els) => { + // Map the crossed neighbor to its timeline key the same way z-reorder + // entries get theirs (siblingZIndexEntry): DOM id, else stable selector + // WITH its selector index, scoped to the selection's source file. The + // index matters: class selectors are duplicated across clips (.sub), + // and a key derived without it resolves to occurrence 0 — a DIFFERENT + // clip — silently mirroring against the wrong neighbor. + const crossedSelector = input.crossed ? buildStableSelector(input.crossed) : undefined; + const crossedKey = input.crossed + ? deriveTimelineStoreKey({ + domId: input.crossed.id || undefined, + selector: crossedSelector, + selectorIndex: getSelectorIndex( + input.crossed.ownerDocument, + input.crossed, + crossedSelector, + input.sourceFile, + activeCompPath, + ), + sourceFile: input.sourceFile, + }) + : null; + return resolveZMirrorLaneMove({ action: input.action, element, elements: els, crossedKey }); + }), + [commitMirrorMove, activeCompPath], + ); +} + +export interface LayerReorderMirrorInput { + /** Timeline store key of the dragged layer (entry.key), if any. */ + selectionKey: string | undefined; + /** Reordered sibling keys in DESIRED render order, bottom→top (null = no + * timeline presence) — see resolveRepositionLaneMove. */ + desiredOrderKeys: ReadonlyArray; + /** The z persist's coalesce key — REQUIRED so the lane write folds into the + * same undo entry as the z write. */ + coalesceKey: string; +} + +/** + * Mirror a Layers-panel drag (arbitrary reposition, possibly jumping several + * siblings) into a timeline lane move — the "equal jump". Identical plumbing, + * serialization, and undo-fold contract as {@link useCanvasZOrderTimelineMirror}; + * only the resolver differs (resolveRepositionLaneMove's between-new-neighbors + * rule instead of the menu's four fixed actions). + */ +export function useLayerReorderTimelineMirror(): ( + input: LayerReorderMirrorInput, +) => Promise { + const commitMirrorMove = useMirrorLaneMoveCommit(); + + return useCallback( + (input: LayerReorderMirrorInput) => + commitMirrorMove(input.selectionKey, input.coalesceKey, (element, els) => + resolveRepositionLaneMove({ + element, + elements: els, + desiredOrderKeys: input.desiredOrderKeys, + }), + ), + [commitMirrorMove], + ); +} + +/** + * Shared commit plumbing for both mirrors: resolve the selection key against + * the expanded display set, run the caller's resolver, and persist the lane + * move through commitZMirrorLaneMove with the same deps + undo-fold contract. + * Resolves `true` when a lane move persisted, `false` for z-only actions (no + * timeline mirror applies) or a rolled-back persist. + */ +function useMirrorLaneMoveCommit(): ( + selectionKey: string | undefined, + coalesceKey: string, + resolveMove: (element: TimelineElement, elements: TimelineElement[]) => ZMirrorLaneMove, +) => Promise { + const elements = useExpandedTimelineElements(); + const elementsRef = useRef(elements); + elementsRef.current = elements; + const { onMoveElements } = useTimelineEditContextOptional(); + + return useCallback( + (selectionKey, coalesceKey, resolveMove) => { + const els = elementsRef.current; + const element = selectionKey ? els.find((e) => (e.key ?? e.id) === selectionKey) : undefined; + // Not a timeline clip (canvas-only decoration) → z-only action, unchanged. + if (!element) return Promise.resolve(false); + + const move = resolveMove(element, els); + if (!move) return Promise.resolve(false); + + return commitZMirrorLaneMove( + element, + move, + { + elements: els, + trackOrder: displayTrackOrder(els), + updateElement: (key, updates) => usePlayerStore.getState().updateElement(key, updates), + onMoveElements: onMoveElements + ? (edits, coalesceKey2, operation, coalesceMs) => + forwardRebasedTimelineMoveElements( + edits, + coalesceKey2, + operation, + onMoveElements, + coalesceMs, + ) + : undefined, + // NO readZIndex / onStackingPatches: see the hook doc — the lane→z + // stacking sync must not re-trigger and fight the just-set z values. + }, + coalesceKey, + // Unbounded fold window: this record lands only AFTER the z persist's + // server round-trip resolved, so the gap between the gesture's two + // records exceeds editHistory's 300ms default under real latency and + // the fold would silently split into two undo entries. The shared key + // is unique per gesture (zReorderCoalesceKey's gesture seq), so the + // unbounded window can never merge two distinct user actions. + Number.POSITIVE_INFINITY, + ); + }, + [onMoveElements], + ); +} diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index ec6f9ff725..7413aa18a4 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -22,6 +22,7 @@ export interface TimelineEditCallbackDeps { edits: Array<{ element: TimelineElement; updates: Pick }>, coalesceKey?: string, operation?: TimelineMoveOperation, + coalesceMs?: number, ) => Promise | void; handleTimelineElementResize: ( element: TimelineElement, diff --git a/packages/studio/src/components/nle/zLaneGesture.test.ts b/packages/studio/src/components/nle/zLaneGesture.test.ts new file mode 100644 index 0000000000..1377198f5e --- /dev/null +++ b/packages/studio/src/components/nle/zLaneGesture.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { runZLaneGesture } from "./zLaneGesture"; + +const durable = { allMatched: true, changed: true }; + +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("runZLaneGesture", () => { + it("runs the mirror after a durable z commit and resolves its result", async () => { + const order: string[] = []; + const result = await runZLaneGesture({ + commitZ: async () => { + order.push("z"); + return durable; + }, + mirror: async () => { + order.push("mirror"); + return true; + }, + }); + expect(result).toBe(true); + expect(order).toEqual(["z", "mirror"]); + }); + + it("skips the mirror when the z commit reports unmatched targets", async () => { + const mirror = vi.fn(async () => true); + const result = await runZLaneGesture({ + commitZ: async () => ({ allMatched: false, changed: true }), + mirror, + }); + expect(result).toBe(false); + expect(mirror).not.toHaveBeenCalled(); + }); + + it("still mirrors on a void resolution (empty-entries commit path)", async () => { + const mirror = vi.fn(async () => true); + await runZLaneGesture({ commitZ: async () => undefined, mirror }); + expect(mirror).toHaveBeenCalledTimes(1); + }); + + it("serializes gestures: B's z phase waits for A's mirror phase", async () => { + const order: string[] = []; + const aMirrorGate = deferred(); + + const a = runZLaneGesture({ + commitZ: async () => { + order.push("A:z"); + return durable; + }, + mirror: async () => { + await aMirrorGate.promise; + order.push("A:mirror"); + return true; + }, + }); + const b = runZLaneGesture({ + commitZ: async () => { + order.push("B:z"); + return durable; + }, + mirror: async () => { + order.push("B:mirror"); + return true; + }, + }); + + // Give B every chance to start early — it must not. + await new Promise((r) => setTimeout(r, 10)); + expect(order).toEqual(["A:z"]); + + aMirrorGate.resolve(); + await Promise.all([a, b]); + expect(order).toEqual(["A:z", "A:mirror", "B:z", "B:mirror"]); + }); + + it("a failed gesture rejects its caller but never wedges the queue", async () => { + const boom = new Error("z failed"); + const failed = runZLaneGesture({ + commitZ: async () => { + throw boom; + }, + mirror: async () => true, + }); + await expect(failed).rejects.toBe(boom); + + const next = await runZLaneGesture({ + commitZ: async () => durable, + mirror: async () => true, + }); + expect(next).toBe(true); + }); +}); diff --git a/packages/studio/src/components/nle/zLaneGesture.ts b/packages/studio/src/components/nle/zLaneGesture.ts new file mode 100644 index 0000000000..0d3da2f608 --- /dev/null +++ b/packages/studio/src/components/nle/zLaneGesture.ts @@ -0,0 +1,45 @@ +import type { DomEditPatchBatchesResult } from "../../hooks/domEditCommitTypes"; + +/** + * Run one COMPLETE z→lane gesture — the z-index persist followed by its + * timeline lane mirror — as a single serialized transaction. + * + * Why a queue: the two phases persist through DIFFERENT pipelines (the z patch + * rides the DOM-edit save queue, the lane move rides the timeline/SDK move + * path). Each gesture orders its own phases by awaiting the z persist, but + * without cross-gesture serialization a second rapid gesture's z write can + * land BETWEEN the first gesture's z and lane phases and the interleaved + * file writes can clobber each other. All z→lane gestures (canvas z-order + * menu AND Layers-panel drag) chain through this single module-level tail, so + * gesture B's z phase cannot start until gesture A's lane phase settled. + * + * Why the durability gate: a resolved z commit is not necessarily durable — + * when the server cannot match a patch target, commitDomEditPatchBatches + * resolves with `allMatched: false` (after scheduling a reload to + * reconverge). Mirroring a lane move onto a z state that disk never held + * would desync track order from what actually paints, so the mirror phase is + * skipped and the gesture resolves `false`. + * + * Failures never wedge the queue: a rejected gesture propagates to ITS caller + * while the tail continues for the next gesture. + */ +let gestureTail: Promise = Promise.resolve(); + +export function runZLaneGesture(input: { + /** Phase 1: persist the z patch (handleDomZIndexReorderCommit). */ + commitZ: () => Promise; + /** Phase 2: mirror into a timeline lane move; only runs on a durable phase 1. */ + mirror: () => Promise; +}): Promise { + const run = async (): Promise => { + const result = await input.commitZ(); + if (result && result.allMatched === false) return false; + return input.mirror(); + }; + const gesture = gestureTail.then(run, run); + gestureTail = gesture.then( + () => undefined, + () => undefined, + ); + return gesture; +} diff --git a/packages/studio/src/hooks/domEditCommitTypes.ts b/packages/studio/src/hooks/domEditCommitTypes.ts index d8e762f1a1..6995187db0 100644 --- a/packages/studio/src/hooks/domEditCommitTypes.ts +++ b/packages/studio/src/hooks/domEditCommitTypes.ts @@ -11,6 +11,8 @@ export type CommitDomEditPatchBatches = ( options: { label: string; coalesceKey: string; + /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */ + coalesceMs?: number; /** * Request skipping the preview iframe reload after a successful persist. * Only honored when the persist is provably in sync with the live DOM: @@ -20,7 +22,19 @@ export type CommitDomEditPatchBatches = ( */ skipReload?: boolean; }, -) => Promise; +) => Promise; + +/** + * Durability report for a patch-batches commit. `allMatched === false` means + * the server could not locate at least one patch target on disk — the write is + * NOT durable for that target (the preview was reloaded to reconverge), and + * dependent follow-up writes (the z→lane timeline mirror) must be skipped. + * `changed === false` means every batch was a byte-identical no-op. + */ +export interface DomEditPatchBatchesResult { + allMatched: boolean; + changed: boolean; +} export type PersistDomEditOperations = ( selection: DomEditSelection, diff --git a/packages/studio/src/hooks/elementLifecycleOpsTestUtils.ts b/packages/studio/src/hooks/elementLifecycleOpsTestUtils.ts new file mode 100644 index 0000000000..a0b86cc8f9 --- /dev/null +++ b/packages/studio/src/hooks/elementLifecycleOpsTestUtils.ts @@ -0,0 +1,25 @@ +import { vi } from "vitest"; +import type { useElementLifecycleOps } from "./useElementLifecycleOps"; + +type LifecycleOpsParams = Parameters[0]; + +/** + * Baseline `useElementLifecycleOps` params for tests: inert stubs for every + * dependency, overridden per test (typically just `commitDomEditPatchBatches`). + * Shared by the z-reorder commit tests and the timeline-mirror harness. + */ +export function makeLifecycleOpsParams( + overrides: Partial & Pick, +): LifecycleOpsParams { + return { + 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(), + ...overrides, + }; +} diff --git a/packages/studio/src/hooks/fetchStubTestUtils.ts b/packages/studio/src/hooks/fetchStubTestUtils.ts new file mode 100644 index 0000000000..1cd61d13e3 --- /dev/null +++ b/packages/studio/src/hooks/fetchStubTestUtils.ts @@ -0,0 +1,16 @@ +// Shared helpers for test-side `fetch` stubs (timelineTimingSync.test.ts, +// useTimelineEditing.test.tsx): a JSON Response factory and a Request → URL +// normalizer. Test-only module — imported exclusively from *.test.* files. + +export function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +export function requestUrl(input: Parameters[0]): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 6304e259da..78ebe83e2f 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -87,7 +87,10 @@ export function applyTimelineStackingReorder(input: { }); } if (commitEntries.length === 0) return Promise.resolve(); - return input.commit?.(commitEntries, input.coalesceKey) ?? Promise.resolve(); + // The durability report is for gesture-level callers (z→lane mirror); this + // lane-drag z-sync path has no dependent follow-up write — swallow it. + // Promise.resolve-wrapped: a commit implementation may return void. + return Promise.resolve(input.commit?.(commitEntries, input.coalesceKey)).then(() => undefined); } export function extendRootDurationIfNeeded(newEnd: number): boolean { const store = usePlayerStore.getState(); @@ -299,6 +302,8 @@ export interface PersistTimelineBatchEditInput { domEditSaveTimestampRef: React.MutableRefObject; pendingTimelineEditPathRef: React.MutableRefObject>; coalesceKey?: string; + /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */ + coalesceMs?: number; } export async function persistTimelineBatchEdit( @@ -347,6 +352,7 @@ export async function persistTimelineBatchEdit( label: input.label, kind: "timeline", coalesceKey: input.coalesceKey, + coalesceMs: input.coalesceMs, files, readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path), writeFile: input.writeProjectFile, diff --git a/packages/studio/src/hooks/timelineMoveAdapter.ts b/packages/studio/src/hooks/timelineMoveAdapter.ts index 7672888e61..f5b9b2fb65 100644 --- a/packages/studio/src/hooks/timelineMoveAdapter.ts +++ b/packages/studio/src/hooks/timelineMoveAdapter.ts @@ -23,6 +23,7 @@ export function persistTimelineMoveEditsAtomically( coalesceKey: string | undefined, operation: TimelineMoveOperation, deps: AtomicMoveDeps, + coalesceMs?: number, ): Promise { return deps.handleTimelineGroupMove( edits.map(({ element, updates }) => ({ @@ -34,6 +35,6 @@ export function persistTimelineMoveEditsAtomically( // ("timing") omit it so they stay eligible for the SDK fast path. track: operation === "timing" ? undefined : updates.track, })), - { coalesceKey }, + { coalesceKey, coalesceMs }, ); } diff --git a/packages/studio/src/hooks/timelineTimingSync.test.ts b/packages/studio/src/hooks/timelineTimingSync.test.ts index f0e45e3ab3..ce461f5f1e 100644 --- a/packages/studio/src/hooks/timelineTimingSync.test.ts +++ b/packages/studio/src/hooks/timelineTimingSync.test.ts @@ -1,9 +1,12 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore } from "../player/store/playerStore"; +import { jsonResponse, requestUrl } from "./fetchStubTestUtils"; +import type { TimelineElement } from "../player/store/playerStore"; import { captureDurationRollback, finishClipTimingFallback, + finishGroupTimingGsapFallback, readFileContent, shiftGsapPositions, } from "./timelineTimingSync"; @@ -14,19 +17,6 @@ afterEach(() => { vi.unstubAllGlobals(); }); -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function requestUrl(input: Parameters[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 @@ -148,3 +138,296 @@ describe("fetch URL encoding (user-influenced segments)", () => { ); }); }); + +/** + * Live-preview iframe stub: inline GSAP script elements plus the runtime hooks + * the timing rebind needs. `appendedScripts` records any script a sync path + * executes — the rebind-only path must record NONE — and `scriptEls` lets tests + * assert the original script elements were left untouched in the document. + */ +const LIVE_SCRIPT = + 'window.__timelines = window.__timelines || {}; window.__timelines["root"] = tl;'; +const LIVE_CAPTION_SCRIPT = + 'window.__timelines = window.__timelines || {}; window.__timelines["captions"] = capTl;'; + +function buildLivePreviewIframe(liveScripts: string[] = [LIVE_SCRIPT]) { + const scriptEls = liveScripts.map((text) => { + const el = document.createElement("script"); + el.textContent = text; + return el; + }); + const container = document.createElement("div"); + for (const el of scriptEls) container.appendChild(el); + + const contentWindow = { + gsap: { timeline: vi.fn(), set: vi.fn() }, + __hfForceTimelineRebind: vi.fn() as unknown, + __timelines: { root: { kill: vi.fn() } } as Record, + __player: { getTime: () => 0, seek: vi.fn() }, + __hfStudioManualEditsApply: vi.fn(), + }; + + const appendedScripts: string[] = []; + const realAppendChild = container.appendChild.bind(container); + container.appendChild = (node: T): T => { + const result = realAppendChild(node); + if (node instanceof HTMLScriptElement) appendedScripts.push(node.textContent ?? ""); + return result; + }; + + const contentDocument = { + querySelectorAll: (sel: string) => (sel === "script:not([src])" ? scriptEls : []), + createElement: (tag: string) => document.createElement(tag), + body: container, + head: document.createElement("div"), + }; + + return { + iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement, + contentWindow, + container, + scriptEls, + appendedScripts, + }; +} + +describe("nothing-to-rewrite timing edits rebind in place (no script re-execution)", () => { + it("single clip without a domId: rebinds + seeks, executes NO script, no full reload", async () => { + // A selector-addressed clip (e.g. a .sub caption) has no domId, so there is + // no GSAP mutation to run — the timing attributes are already live-patched + // and __timelines is still valid, so the runtime only needs to re-derive + // the clip windows (rebind) and re-seek. Re-running init-style scripts + // (three.js setups etc.) is exactly the unsafe case this must avoid. + const { iframe, contentWindow, container, scriptEls, appendedScripts } = + buildLivePreviewIframe(); + const reloadPreview = vi.fn(); + + await finishClipTimingFallback({ + ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }), + iframe, + domId: undefined, + }); + + expect(reloadPreview).not.toHaveBeenCalled(); + expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1); + expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1); + expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalledTimes(1); + // NO script executed, the original script element untouched in place. + expect(appendedScripts).toHaveLength(0); + expect(container.contains(scriptEls[0]!)).toBe(true); + expect(scriptEls[0]!.textContent).toBe(LIVE_SCRIPT); + }); + + it("multi-script document (e.g. three.js + captions): rebind-only, both scripts untouched", async () => { + // Real compositions commonly hold heavy inline scripts (main timeline, + // three.js setup, captions). None of them may run twice — the rebind path + // must not create, remove, or re-execute any of them. + const { iframe, contentWindow, container, scriptEls, appendedScripts } = buildLivePreviewIframe( + [LIVE_SCRIPT, LIVE_CAPTION_SCRIPT], + ); + const rootKill = vi.fn(); + const captionsKill = vi.fn(); + contentWindow.__timelines = { root: { kill: rootKill }, captions: { kill: captionsKill } }; + const reloadPreview = vi.fn(); + + await finishClipTimingFallback({ + ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }), + iframe, + domId: undefined, + }); + + expect(reloadPreview).not.toHaveBeenCalled(); + expect(appendedScripts).toHaveLength(0); + expect(container.contains(scriptEls[0]!)).toBe(true); + expect(container.contains(scriptEls[1]!)).toBe(true); + // The still-valid timelines are NOT killed — nothing re-registers them. + expect(rootKill).not.toHaveBeenCalled(); + expect(captionsKill).not.toHaveBeenCalled(); + expect(contentWindow.__timelines.root).toBeDefined(); + expect(contentWindow.__timelines.captions).toBeDefined(); + // One finalization: one seek, one rebind. + expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1); + expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1); + }); + + it("comp with ZERO GSAP scripts also rebinds in place (previously full-reloaded)", async () => { + // The rebind hook is installed by the runtime unconditionally — it does not + // depend on GSAP — so a script-less comp gets the flashless path too. + const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe([]); + const reloadPreview = vi.fn(); + + await finishClipTimingFallback({ + ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }), + iframe, + domId: undefined, + }); + + expect(reloadPreview).not.toHaveBeenCalled(); + expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1); + expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1); + expect(appendedScripts).toHaveLength(0); + }); + + it("full-reloads when the runtime rebind hook is unavailable", async () => { + const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe(); + contentWindow.__hfForceTimelineRebind = undefined; + const reloadPreview = vi.fn(); + + await finishClipTimingFallback({ + ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }), + iframe, + domId: undefined, + }); + + expect(reloadPreview).toHaveBeenCalledTimes(1); + expect(appendedScripts).toHaveLength(0); + }); + + it("still full-reloads when the server MUTATED the file but returned no script", async () => { + // mutated:true with scriptText:null (older server) means the live script is + // now STALE relative to disk — a rebind against it would show wrong + // positions. + stubFetch(["", ""], { mutated: true, scriptText: null }); + const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe(); + const reloadPreview = vi.fn(); + + await finishClipTimingFallback({ + ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }), + iframe, + }); + + expect(reloadPreview).toHaveBeenCalledTimes(1); + expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled(); + expect(appendedScripts).toHaveLength(0); + }); + + it("mutated WITH a rewritten script keeps the script-swap soft path (not rebind-only)", async () => { + // A genuine rewrite must re-run the REWRITTEN script — the rebind-only + // shortcut is reserved for the no-op case where every script is unchanged. + stubFetch(["", ""], { + mutated: true, + scriptText: 'window.__timelines["root"] = tl2;', + }); + const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe(); + const reloadPreview = vi.fn(); + + await finishClipTimingFallback({ + ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }), + iframe, + }); + + expect(reloadPreview).not.toHaveBeenCalled(); + expect(appendedScripts).toHaveLength(1); + expect(appendedScripts[0]).toContain('__timelines["root"] = tl2;'); + expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1); + }); + + it("group batch where every change had nothing to rewrite (gap close over no-domId clips) rebinds in place", async () => { + stubFetch([""], { mutated: false, scriptText: null }); + const { iframe, contentWindow, container, scriptEls, appendedScripts } = + buildLivePreviewIframe(); + const reloadPreview = vi.fn(); + const element = { sourceFile: "index.html" } as TimelineElement; + + await finishGroupTimingGsapFallback({ + projectId: "p1", + iframe, + reloadPreview, + label: "Move timeline clips", + errorLabel: "Failed to shift GSAP positions", + recordEdit: vi.fn(async () => {}) as never, + activeCompPath: "index.html", + changes: [{ element }, { element }], + resolveChangePath: () => "index.html", + // No domId → nothing to rewrite for ANY change (the gap-close blink path). + mutateChange: () => null, + }); + + expect(reloadPreview).not.toHaveBeenCalled(); + expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1); + expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1); + // No script executed, the live script element untouched. + expect(appendedScripts).toHaveLength(0); + expect(container.contains(scriptEls[0]!)).toBe(true); + }); + + it("group batch touching ANOTHER file still full-reloads even when nothing was rewritten", async () => { + stubFetch([""], { mutated: false, scriptText: null }); + const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe(); + const reloadPreview = vi.fn(); + + await finishGroupTimingGsapFallback({ + projectId: "p1", + iframe, + reloadPreview, + label: "Move timeline clips", + errorLabel: "Failed to shift GSAP positions", + recordEdit: vi.fn(async () => {}) as never, + activeCompPath: "index.html", + changes: [ + { element: { sourceFile: "index.html" } as TimelineElement }, + { element: { sourceFile: "scenes/intro.html" } as TimelineElement }, + ], + resolveChangePath: (el) => el.sourceFile ?? "index.html", + mutateChange: () => null, + }); + + expect(reloadPreview).toHaveBeenCalledTimes(1); + expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled(); + expect(appendedScripts).toHaveLength(0); + }); +}); + +describe("foldGsapMutationIntoHistory — batch rollback (via finishGroupTimingGsapFallback)", () => { + it("a late per-clip failure restores every touched file to its snapshot", async () => { + // Custom fetch: reads return per-path CURRENT content (mutated after clip 1 + // "succeeds"), and the rollback PUT is captured. + const contents = new Map([["index.html", "ORIGINAL"]]); + const puts: Array<{ path: string; body: string }> = []; + const fetchMock = vi.fn(async (input: Parameters[0], init?: RequestInit) => { + const url = requestUrl(input); + const path = decodeURIComponent(url.split("/files/")[1] ?? ""); + if (url.includes("/files/") && init?.method === "PUT") { + puts.push({ path, body: String(init.body) }); + contents.set(path, String(init.body)); + return new Response(null, { status: 200 }); + } + if (url.includes("/files/")) { + return jsonResponse({ content: contents.get(path) ?? "" }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const { iframe } = buildLivePreviewIframe(); + const reloadPreview = vi.fn(); + const element = { sourceFile: "index.html" } as TimelineElement; + const boom = new Error("clip 2 rewrite failed"); + + let clipIndex = 0; + await finishGroupTimingGsapFallback({ + projectId: "p1", + iframe, + reloadPreview, + label: "Move timeline clips", + errorLabel: "Failed to shift GSAP positions", + recordEdit: vi.fn(async () => {}) as never, + activeCompPath: "index.html", + changes: [{ element }, { element }], + resolveChangePath: () => "index.html", + mutateChange: async () => { + clipIndex += 1; + if (clipIndex === 1) { + // Clip 1 "succeeded": its rewrite landed on disk. + contents.set("index.html", "MUTATED-BY-CLIP-1"); + return { mutated: true, scriptText: null }; + } + throw boom; // clip 2 fails AFTER clip 1's write + }, + }); + + // The batch rolled the file back to the pre-batch snapshot. + expect(puts).toEqual([{ path: "index.html", body: "ORIGINAL" }]); + expect(contents.get("index.html")).toBe("ORIGINAL"); + }); +}); diff --git a/packages/studio/src/hooks/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts index 4db660b338..4f6079b590 100644 --- a/packages/studio/src/hooks/timelineTimingSync.ts +++ b/packages/studio/src/hooks/timelineTimingSync.ts @@ -3,7 +3,7 @@ // 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 { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftReload"; import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers"; import type { RecordEditInput } from "../utils/studioFileHistory"; import { patchDocumentRootDuration } from "./timelineEditingGsap"; @@ -25,6 +25,25 @@ export async function readFileContent(projectId: string, targetPath: string): Pr return data.content; } +/** Raw file write (same endpoint shape as useFileManager.writeProjectFile) — + * used only by the batch-mutation rollback below. */ +async function writeFileContent( + projectId: string, + targetPath: string, + content: string, +): Promise { + if (targetPath.includes("\0") || targetPath.includes("..")) { + throw new Error(`Unsafe path: ${targetPath}`); + } + const response = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`, + { method: "PUT", headers: { "Content-Type": "text/plain" }, body: content }, + ); + if (!response.ok) { + throw new Error(`Failed to restore ${targetPath}`); + } +} + /** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */ function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void { try { @@ -77,7 +96,9 @@ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => * `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. + * soft path can't scope) — the caller then full-reloads when `mutated`, or + * rebinds the runtime timing in place when nothing was rewritten (see + * syncTimingEditPreview). */ export type GsapMutationStatus = { mutated: boolean; scriptText: string | null }; @@ -98,6 +119,26 @@ function readMutationError(value: unknown, fallback: string): string { return fallback; } +/** + * Flashless preview sync for a timing edit that rewrote NO script: the live + * DOM timing attributes were already patched (patchIframeDomTiming), the GSAP + * timelines in `window.__timelines` are still valid, so the preview only needs + * the runtime to re-derive clip visibility windows from the live + * `data-start`/`data-duration` attributes and re-seek to the current time. + * That is exactly applySoftReload's finalization (seek → + * `__hfForceTimelineRebind` → manual-edits reapply) — run WITHOUT touching or + * re-executing any script. Re-running init-style scripts is the unsafe case + * this replaces: a comp loading three.js / heavy inline scripts must not have + * them executed twice. + * + * Works for zero-GSAP compositions too — the rebind hook is installed by the + * runtime unconditionally. Returns false when the iframe/runtime hook is + * unavailable (caller full-reloads). + */ +function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: number): boolean { + return applySoftReloadFinalization(iframe, currentTime); +} + /** * Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a * soft reload over the full iframe reload that flashes every clip. @@ -115,15 +156,34 @@ function readMutationError(value: unknown, fallback: string): string { * 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. + * already applied the shift; a remount would re-flash for nothing. + * + * When there is no rewritten `scriptText`, `mutated` disambiguates two very + * different situations: + * - `mutated: false` — nothing was rewritten because there was NOTHING TO + * REWRITE (the clip has no domId so no id-addressed tweens, the delta was + * zero, or the server confirmed a no-op). Every script is unchanged and the + * timing attributes are already live-patched, so (when `rebindWhenUnmutated` + * allows it) `rebindPreviewTiming` re-seeks + rebinds and the runtime + * re-derives the clip windows from the live DOM — no script re-execution, + * no full-reload blink. This covers comps with zero GSAP scripts too; only + * a missing iframe/runtime hook falls back to the full reload. + * - `mutated: true` with no script — the file on disk WAS rewritten but the + * server returned no script (older server, multi-script comp): the live + * script is now stale, so a rebind against it would show wrong positions → + * full-reload. */ function syncTimingEditPreview( iframe: HTMLIFrameElement | null, - outcome: Pick, + outcome: GsapMutationStatus, currentTime: number, reloadPreview: () => void, + rebindWhenUnmutated: boolean, ): void { + if (!outcome.scriptText && !outcome.mutated && rebindWhenUnmutated) { + if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview(); + return; + } if (!iframe || !outcome.scriptText) { reloadPreview(); return; @@ -140,6 +200,14 @@ async function finishTimelineTimingFallback(input: { reloadPreview: () => void; gsapMutation?: () => Promise; onGsapError: (error: unknown) => void; + /** + * When the mutation produced no rewrite (mutated:false, no scriptText), + * rebind the runtime timing in place (no script re-execution) instead of + * full-reloading (see syncTimingEditPreview). Callers pass false when a full + * reload is the only sync that reflects everything (e.g. a multi-file group + * edit). + */ + rebindWhenUnmutated: boolean; }): Promise { let outcome: GsapMutationStatus = { mutated: false, scriptText: null }; if (input.gsapMutation) { @@ -155,6 +223,7 @@ async function finishTimelineTimingFallback(input: { outcome, usePlayerStore.getState().currentTime, input.reloadPreview, + input.rebindWhenUnmutated, ); } @@ -175,6 +244,30 @@ const GSAP_HISTORY_COALESCE_MS = 10_000; * 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. */ +/** + * Restore every snapshotted path whose disk content changed. A multi-clip + * batch mutates files SEQUENTIALLY; a late per-clip failure would otherwise + * leave the earlier rewrites on disk with no aggregate history entry + * (unreachable by undo) — the batch must be all-or-nothing. Restore errors are + * reported via onError but never mask the original failure. + */ +async function rollbackMutatedFiles( + projectId: string, + before: ReadonlyMap, + onError: (error: unknown) => void, +): Promise { + for (const [path, priorContent] of before) { + try { + const current = await readFileContent(projectId, path); + if (current !== priorContent) { + await writeFileContent(projectId, path, priorContent); + } + } catch (rollbackError) { + onError(rollbackError); + } + } +} + async function foldGsapMutationIntoHistory(input: { projectId: string; paths: string[]; @@ -191,7 +284,13 @@ async function foldGsapMutationIntoHistory(input: { for (const path of uniquePaths) { before.set(path, await readFileContent(input.projectId, path)); } - const status = await input.gsapMutation(); + let status: GsapMutationStatus; + try { + status = await input.gsapMutation(); + } catch (error) { + await rollbackMutatedFiles(input.projectId, before, input.onFoldError); + throw error; + } if (status.mutated) { try { const files: Record = {}; @@ -297,8 +396,11 @@ export type SingleClipGsapEdit = * 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. + * the rewritten script. When there was nothing to rewrite (no domId — e.g. a + * selector-addressed caption clip — zero delta, or a server no-op) it rebinds + * the runtime timing in place instead of full-reloading; full reload remains + * for genuine rewrites without a returned script and for comps the soft path + * can't handle (see syncTimingEditPreview). */ export function finishClipTimingFallback(input: { iframe: HTMLIFrameElement | null; @@ -347,6 +449,7 @@ export function finishClipTimingFallback(input: { }) : undefined, onGsapError, + rebindWhenUnmutated: true, }); } @@ -408,5 +511,10 @@ export async function finishGroupTimingGsapFallback { label: "Reorder layers", kind: "manual", coalesceKey: "z-reorder:test", + // Unbounded per-gesture fold window (keys are unique per gesture): + // the z entry and its mirror/lane counterpart fold across the server + // round-trip that separates them. + coalesceMs: Number.POSITIVE_INFINITY, files: { "index.html": { before: original, after } }, }); // FIX: a z-only reorder must NOT remount the preview iframe ("the blink"). diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 3b88442071..dae73f1d66 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -142,7 +142,7 @@ export interface UseDomEditCommitsParams { activeCompPath: string | null; previewIframeRef: React.MutableRefObject; showToast: (message: string, tone?: "error" | "info") => void; - queueDomEditSave: (save: () => Promise) => Promise; + queueDomEditSave: (save: () => Promise) => Promise; writeProjectFile: (path: string, content: string) => Promise; domEditSaveTimestampRef: React.MutableRefObject; editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; @@ -398,16 +398,18 @@ export function useDomEditCommits({ domEditSaveTimestampRef.current = Date.now(); const results = await Promise.all(batches.map((batch) => patchElementBatch(pid, batch))); + const allMatched = results.every((result) => result.allMatched); const files = Object.fromEntries( results .filter((result) => result.changed) .map((result) => [result.sourceFile, { before: result.before, after: result.after }]), ); - if (Object.keys(files).length === 0) return; + if (Object.keys(files).length === 0) return { allMatched, changed: false }; await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, + coalesceMs: options.coalesceMs, files, }); forceReloadSdkSession?.(); @@ -419,10 +421,9 @@ export function useDomEditCommits({ // 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); + options.skipReload === true && batchesAreInlineStyleOnly(batches) && allMatched; if (!skipSafe) reloadPreview(); + return { allMatched, changed: true }; }).catch((error) => { const alreadyToasted = (error instanceof StudioSaveHttpError || diff --git a/packages/studio/src/hooks/useDomEditSession.test.tsx b/packages/studio/src/hooks/useDomEditSession.test.tsx index 4a70a3f0d0..57b4751f42 100644 --- a/packages/studio/src/hooks/useDomEditSession.test.tsx +++ b/packages/studio/src/hooks/useDomEditSession.test.tsx @@ -231,7 +231,9 @@ describe("onReorderShadow source filter", () => { setRightPanelTab: vi.fn(), showToast: vi.fn(), refreshPreviewDocumentVersion: vi.fn(), - queueDomEditSave: vi.fn(async (save: () => Promise) => save()), + queueDomEditSave: vi.fn(async (save: () => Promise) => save()) as ( + save: () => Promise, + ) => Promise, readProjectFile, writeProjectFile: vi.fn(async () => {}), updateEditingFileContent: vi.fn(), diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index f8cba7f987..4d94ddd355 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -43,7 +43,7 @@ export interface UseDomEditSessionParams { setRightPanelTab: (tab: RightPanelTab) => void; showToast: (message: string, tone?: "error" | "info") => void; refreshPreviewDocumentVersion: () => void; - queueDomEditSave: (save: () => Promise) => Promise; + queueDomEditSave: (save: () => Promise) => Promise; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; updateEditingFileContent: (path: string, content: string) => void; diff --git a/packages/studio/src/hooks/useElementLifecycleOps.test.tsx b/packages/studio/src/hooks/useElementLifecycleOps.test.tsx index 979cdea6c9..a736461a95 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.test.tsx +++ b/packages/studio/src/hooks/useElementLifecycleOps.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore } from "../player"; import type { DomEditPatchBatch } from "./domEditCommitTypes"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; +import { makeLifecycleOpsParams } from "./elementLifecycleOpsTestUtils"; import { mountReactHarness } from "./domSelectionTestHarness"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -18,6 +19,7 @@ afterEach(() => { interface BatchOptions { label: string; coalesceKey: string; + coalesceMs?: number; skipReload?: boolean; } @@ -38,26 +40,21 @@ type ReorderCommit = ( }>, coalesceKeyOverride?: string, actionKind?: string, -) => Promise; +) => Promise; function renderReorderHook( capturedCalls: CapturedBatchCall[], onReady: (commit: ReorderCommit) => void, ) { 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: async (batches, options) => { - capturedCalls.push({ batches, options }); - }, - }); + const { handleDomZIndexReorderCommit } = useElementLifecycleOps( + makeLifecycleOpsParams({ + commitDomEditPatchBatches: async (batches, options) => { + capturedCalls.push({ batches, options }); + return { allMatched: true, changed: true }; + }, + }), + ); onReady(handleDomZIndexReorderCommit); return null; } @@ -219,10 +216,15 @@ describe("useElementLifecycleOps — z-index reorder payload", () => { }); // Same element set, different actions — the keys must differ so the two - // edits never coalesce into one undo step within the coalesce window. + // edits never coalesce into one undo step. Each key also carries a fresh + // gesture sequence, which is what makes the commit's unbounded per-gesture + // coalesce window safe (see zReorderCoalesceKey). 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"); + expect(captured[0]?.options.coalesceKey).toMatch(/^z-reorder:bring-forward:clip-a:g\d+$/); + expect(captured[1]?.options.coalesceKey).toMatch(/^z-reorder:send-backward:clip-a:g\d+$/); + expect(captured[0]?.options.coalesceKey).not.toBe(captured[1]?.options.coalesceKey); + // The two-phase gesture fold rides an unbounded window on both records. + expect(captured[0]?.options.coalesceMs).toBe(Number.POSITIVE_INFINITY); act(() => root.unmount()); }); @@ -245,26 +247,22 @@ describe("useElementLifecycleOps — z-index reorder payload", () => { let commit: ReorderCommit | undefined; let resolveBatch: (() => void) | undefined; + const batchResult = { allMatched: true, changed: true }; 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)), - }); + const { handleDomZIndexReorderCommit } = useElementLifecycleOps( + makeLifecycleOpsParams({ + // 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(batchResult))), + }), + ); commit = handleDomZIndexReorderCommit; return null; } const root = mountReactHarness(); - let pending: Promise | undefined; + let pending: Promise | undefined; act(() => { pending = commit!([ { @@ -301,22 +299,16 @@ describe("useElementLifecycleOps — z-index reorder payload", () => { 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; + const { handleDomZIndexReorderCommit } = useElementLifecycleOps( + makeLifecycleOpsParams({ + 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; } @@ -367,20 +359,17 @@ describe("useElementLifecycleOps — z-index reorder payload", () => { let commit: ReorderCommit | undefined; function Harness() { - const { handleDomZIndexReorderCommit } = useElementLifecycleOps({ - activeCompPath: "index.html", - showToast: vi.fn(), - writeProjectFile, - domEditSaveTimestampRef: { current: 0 }, - editHistory: { recordEdit }, - projectIdRef: { current: "demo" }, - reloadPreview: vi.fn(), - clearDomSelection: vi.fn(), - forceReloadSdkSession, - commitDomEditPatchBatches: vi.fn(async () => { - throw originalError; + const { handleDomZIndexReorderCommit } = useElementLifecycleOps( + makeLifecycleOpsParams({ + writeProjectFile, + editHistory: { recordEdit }, + projectIdRef: { current: "demo" }, + forceReloadSdkSession, + commitDomEditPatchBatches: vi.fn(async () => { + throw originalError; + }), }), - }); + ); commit = handleDomZIndexReorderCommit; return null; } diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index f8ac938d73..73f8f96edf 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -11,6 +11,10 @@ import { readHfId, type DomEditSelection, } from "../components/editor/domEditing"; +import { + LAYER_REVEAL_PRIOR_POSITION_ATTR, + LAYER_REVEAL_PRIOR_Z_ATTR, +} from "../player/lib/timelineElementHelpers"; import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes"; interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams { @@ -25,6 +29,36 @@ interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams { onElementDeleted?: (selection: DomEditSelection) => void; } +// One coalesce key per z-reorder gesture. A monotonic counter — NOT Date.now() +// / Math.random(), which the determinism rules forbid — matches the +// laneChangeGestureSeq precedent in timelineClipDragCommit.ts: the key only has +// to be unique per gesture and identical across the gesture's records. +let zReorderGestureSeq = 0; + +/** + * Undo coalesce key for ONE z-reorder gesture — unique per call. The key + * carries the action kind + element ids for debuggability, plus a gesture + * sequence so two SEPARATE user actions (even the same action on the same + * selection) never share a key. That uniqueness is what makes the unbounded + * per-gesture coalesce window (see handleDomZIndexReorderCommit) safe: the + * fold can only ever merge records of the SAME gesture. + * + * Exported as THE single implementation of the key: the canvas z-order wiring + * (PreviewOverlays) mints it once per gesture and passes the same instance to + * both the z persist and the timeline lane mirror (useCanvasZOrderTimelineMirror) + * so editHistory folds the z write and the track write into one undo entry — + * recomputing the key per record would silently split the undo. + */ +export function zReorderCoalesceKey( + entries: ReadonlyArray<{ element: HTMLElement; id?: string; selector?: string }>, + actionKind?: string, +): string { + const ids = entries + .map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el") + .join(":"); + return `z-reorder:${actionKind ?? "reorder"}:${ids}:g${zReorderGestureSeq++}`; +} + export function useElementLifecycleOps({ activeCompPath, showToast, @@ -158,9 +192,7 @@ export function useElementLifecycleOps({ // 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:${actionKind ?? "reorder"}:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`; + const coalesceKey = gestureCoalesceKey ?? zReorderCoalesceKey(entries, actionKind); const patchesBySourceFile = new Map(); const rollbacks: Array<() => void> = []; for (const entry of entries) { @@ -170,13 +202,24 @@ export function useElementLifecycleOps({ ? usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === entry.key) : undefined; let positionChanged = false; + // An active Layers-panel reveal lift on this element is consumed by + // this commit: the new z is the truth. Read the parked TRUE position + // for the static check below (the lift set a temporary + // position:relative that would otherwise mask the need to persist + // one), then drop the lift attributes so z readers stop reporting the + // stale prior (see useLayerRevealOverride / readLayerRevealPriorZ). + const liftPriorPosition = entry.element.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR); + entry.element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR); + entry.element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR); entry.element.style.zIndex = String(entry.zIndex); const patches: Array<{ type: "inline-style"; property: string; value: string }> = [ { type: "inline-style", property: "z-index", value: String(entry.zIndex) }, ]; try { const win = entry.element.ownerDocument?.defaultView; - if (win && win.getComputedStyle(entry.element).position === "static") { + const effectivePosition = + liftPriorPosition ?? (win ? win.getComputedStyle(entry.element).position : undefined); + if (effectivePosition === "static") { entry.element.style.position = "relative"; positionChanged = true; patches.push({ type: "inline-style", property: "position", value: "relative" }); @@ -215,6 +258,9 @@ export function useElementLifecycleOps({ sourceFile, patches, })); + // Live z state changed with NO reload coming (skipReload below) — nudge + // DOM-derived views (Layers panel z-sort) to re-read the iframe. + usePlayerStore.getState().bumpZEditVersion(); // Resolves once every source-file batch is persisted so a same-file timing write // can be ordered after it (see applyTimelineStackingReorder callers). // @@ -226,9 +272,17 @@ export function useElementLifecycleOps({ return commitDomEditPatchBatches(batches, { label: "Reorder layers", coalesceKey, + // Unbounded window: every key this commit records under is unique per + // gesture (zReorderCoalesceKey's gesture seq, or the lane drag's + // clip-lane-move:), so the fold can only merge records of the SAME + // gesture — and those records are separated by a server round-trip + // (move→z on a lane drag, z→lane-mirror on a canvas action), which + // under real network latency exceeds the 300ms default window. + coalesceMs: Number.POSITIVE_INFINITY, skipReload: true, }).catch((error) => { for (const rollback of rollbacks) rollback(); + usePlayerStore.getState().bumpZEditVersion(); // rolled-back z is a live change too throw error; }); }, diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index b6759485b9..0fbb50a5ec 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -10,7 +10,7 @@ import type { EditHistoryKind } from "../utils/editHistory"; import { createDomEditSaveQueue } from "../utils/domEditSaveQueue"; import { flushStudioPendingEdits } from "../utils/studioPendingEdits"; import { trackStudioEvent } from "../utils/studioTelemetry"; -import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapSoftReload"; +import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore"; import { usePlayerStore } from "../player"; /** The restore payload the undo/redo preview-sync consumes (from the history store). */ @@ -156,7 +156,7 @@ export function usePreviewPersistence({ // ── Queue / drain helpers ── - const queueDomEditSave = useCallback((save: () => Promise) => { + const queueDomEditSave = useCallback((save: () => Promise): Promise => { return domEditSaveQueueRef.current?.enqueue(save) ?? save(); }, []); diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index 449d143bc3..54397eeb87 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { openComposition } from "@hyperframes/sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore, type TimelineElement } from "../player"; +import { jsonResponse, requestUrl } from "./fetchStubTestUtils"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { useTimelineEditing } from "./useTimelineEditing"; @@ -165,7 +166,9 @@ type TimelineRecordEdit = NonNullable< function renderTimelineEditingHookWithLifecycle(input: { timelineElements: TimelineElement[]; iframe: HTMLIFrameElement; - commitDomEditPatchBatches: ReturnType Promise>>; + commitDomEditPatchBatches: ReturnType< + typeof vi.fn<(...args: unknown[]) => Promise<{ allMatched: boolean; changed: boolean }>> + >; }): { move: ReturnType["handleTimelineElementMove"]; unmount: () => void; @@ -209,19 +212,6 @@ function renderTimelineEditingHookWithLifecycle(input: { return { move, unmount }; } -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function requestUrl(input: Parameters[0]): string { - if (typeof input === "string") return input; - if (input instanceof URL) return input.toString(); - return input.url; -} - async function flushAsyncWork(): Promise { for (let i = 0; i < 8; i += 1) { await Promise.resolve(); @@ -251,6 +241,72 @@ function stubProjectFetch( return fetchMock; } +/** + * One clip on track 0 + the editing hook wired to project "p1" — shared setup + * for the single-clip move-path tests (horizontal, vertical-only, track-only, + * diagonal) so their arrange blocks aren't clones of each other. + */ +function setupSingleClipHarness(options?: { + source?: string; + clipStyle?: string; + onZIndexCommit?: (entries: ZIndexEntry[]) => Promise; +}) { + const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); + const commit = + options?.onZIndexCommit ?? + vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const reloadPreview = vi.fn(); + const fetchMock = stubProjectFetch( + options?.source ?? '
', + ); + const hook = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: commit, + projectId: "p1", + writeProjectFile, + recordEdit: vi.fn(async () => {}), + reloadPreview, + }); + return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook }; +} + +/** Assert a lane write landed in both the live iframe DOM and the persisted file. */ +function expectLanePersisted( + iframe: HTMLIFrameElement, + writeProjectFile: { mock: { calls: unknown[][] } }, + track: string, +): void { + const doc = iframe.contentDocument; + if (!doc) throw new Error("Expected iframe document"); + expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe(track); + expect(writeProjectFile.mock.calls[0]![1]).toContain(`data-track-index="${track}"`); +} + +/** + * Two 1s clips — a@0s on track 0, b@1s on track 1 — plus the matching iframe. + * Shared by the group-move tests; `bSourceFile` puts b in its own file for the + * cross-file partition test. + */ +function makeTwoClipPair(bSourceFile?: string) { + const iframe = createPreviewIframe([ + { id: "a", track: 0 }, + { id: "b", track: 1 }, + ]); + const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }); + const b = timelineElement({ + id: "b", + track: 1, + zIndex: 0, + start: 1, + duration: 1, + sourceFile: bSourceFile, + }); + return { iframe, a, b }; +} + const ROOT_DURATION_FALLBACK_SOURCE = [ `
`, `
`, @@ -457,7 +513,9 @@ describe("useTimelineEditing timeline z-index reorder", () => { ]); const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); const back = timelineElement({ id: "back", track: 1, zIndex: 0 }); - const commitDomEditPatchBatches = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const commitDomEditPatchBatches = vi.fn< + (...args: unknown[]) => Promise<{ allMatched: boolean; changed: boolean }> + >(async () => ({ allMatched: true, changed: true })); const { move, unmount } = renderTimelineEditingHookWithLifecycle({ timelineElements: [front, back], iframe, @@ -502,7 +560,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { ]); const saveError = new Error("save failed"); const commitDomEditPatchBatches = vi - .fn<(...args: unknown[]) => Promise>() + .fn<(...args: unknown[]) => Promise<{ allMatched: boolean; changed: boolean }>>() .mockRejectedValueOnce(saveError); const { move, unmount } = renderTimelineEditingHookWithLifecycle({ timelineElements: [front, back], @@ -566,8 +624,8 @@ describe("useTimelineEditing timeline z-index reorder", () => { releaseBatch = resolve; }); const commitDomEditPatchBatches = vi - .fn<(...args: unknown[]) => Promise>() - .mockReturnValueOnce(batchSave); + .fn<(...args: unknown[]) => Promise<{ allMatched: boolean; changed: boolean }>>() + .mockReturnValueOnce(batchSave.then(() => ({ allMatched: true, changed: true }))); const { move, unmount } = renderTimelineEditingHookWithLifecycle({ timelineElements: [front, back], iframe, @@ -608,98 +666,59 @@ describe("useTimelineEditing timeline z-index reorder", () => { }); it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => { - const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); - const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const recordEdit = vi.fn(async (_entry) => {}); - const reloadPreview = vi.fn(); - const fetchMock = stubProjectFetch('
'); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: commit, - projectId: "p1", - writeProjectFile, - recordEdit, - reloadPreview, - }); + const h = setupSingleClipHarness(); await act(async () => { - await move(clip, { start: 1.25, track: clip.track }); + await h.move(h.clip, { start: 1.25, track: h.clip.track }); }); - const doc = iframe.contentDocument; + const doc = h.iframe.contentDocument; if (!doc) throw new Error("Expected iframe document"); expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25"); expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0"); - expect(commit).not.toHaveBeenCalled(); - expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"'); - expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"'); - expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index"); + expect(h.commit).not.toHaveBeenCalled(); + expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"'); + expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"'); + expect(h.writeProjectFile.mock.calls[0]![1]).not.toContain("z-index"); expect( - fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), + h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), ).toBe(true); - unmount(); + h.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>().mockResolvedValue(undefined); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - stubProjectFetch('
'); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: commit, - projectId: "p1", - writeProjectFile, - recordEdit: vi.fn(async () => {}), - }); + const h = setupSingleClipHarness(); await act(async () => { // Vertical-only: same start, new track (already authored-space on this path). - await move(clip, { start: clip.start, track: 2 }); + await h.move(h.clip, { start: h.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"'); + // Live DOM patched (no pre-reload lane snap-back) and the file write + // carries the new data-track-index with start intact. + expectLanePersisted(h.iframe, h.writeProjectFile, "2"); + expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"'); - unmount(); + h.unmount(); }); it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => { - const iframe = createPreviewIframe([ - { id: "clip", track: 0, style: "position: relative; z-index: 0" }, - ]); - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); // Gate the z-index commit so we can observe whether the timing write waits. let releaseCommit!: () => void; const commitGate = new Promise((resolve) => { releaseCommit = resolve; }); - const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockReturnValue(commitGate); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - stubProjectFetch('
'); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: commit, - projectId: "p1", - writeProjectFile, - recordEdit: vi.fn(async () => {}), + const h = setupSingleClipHarness({ + clipStyle: "position: relative; z-index: 0", + onZIndexCommit: vi + .fn<(entries: ZIndexEntry[]) => Promise>() + .mockReturnValue(commitGate), }); + const { clip, commit, writeProjectFile, move, unmount } = h; // Diagonal drag: both a time move (start change) and a restack (z-index change). let movePromise!: Promise; @@ -784,19 +803,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { "index.html": '
', "scene.html": '
', }; - const iframe = createPreviewIframe([ - { id: "a", track: 0 }, - { id: "b", track: 1 }, - ]); - const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }); - const b = timelineElement({ - id: "b", - track: 1, - zIndex: 0, - start: 1, - duration: 1, - sourceFile: "scene.html", - }); + const { iframe, a, b } = makeTwoClipPair("scene.html"); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const recordEdit = vi.fn(async (_entry) => {}); stubProjectFetch(files); @@ -867,6 +874,143 @@ describe("useTimelineEditing timeline z-index reorder", () => { unmount(); }); + it("skips the GSAP fallback and reload for a TRACK-ONLY group move (z-mirror lane move)", async () => { + // The mirrored z-order lane move persists {start: unchanged, track: new}. + // Nothing timing-related changed — data-track-index is never read by the + // renderer — so the persist must NOT be followed by the GSAP round-trip or + // the full preview reload (the reload is what blinked the canvas). + const h = setupSingleClipHarness({ + source: '
', + }); + + await act(async () => { + await h.groupMove([{ element: h.clip, start: h.clip.start, track: 2 }]); + await flushAsyncWork(); + }); + + // The lane write still persisted (live DOM + file)... + expectLanePersisted(h.iframe, h.writeProjectFile, "2"); + expect(h.writeProjectFile).toHaveBeenCalledTimes(1); + // ...but no GSAP mutation ran and the preview was NOT reloaded. + expect( + h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), + ).toBe(false); + expect(h.reloadPreview).not.toHaveBeenCalled(); + + h.unmount(); + }); + + it("rebinds the preview in place (no blink) for a time-move of a no-domId clip", async () => { + // The gap-close blink path: "Close gap" issues pure TIME moves through + // handleTimelineGroupMove. A selector-addressed clip (no domId — e.g. a + // .sub caption) has no id-addressed GSAP tweens, so the mutation step has + // nothing to rewrite and used to fall through to a full reloadPreview(). + // The sync must instead rebind the runtime timing in place — seek + rebind + // re-derive the clip windows from the already-patched DOM — WITHOUT + // re-executing any script (a comp's init-style scripts, e.g. a three.js + // setup, must never run twice) and without the full-reload blink. + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument; + if (!doc) throw new Error("Expected iframe document"); + doc.body.innerHTML = + '
'; + const liveScript = doc.createElement("script"); + liveScript.textContent = + 'window.__timelines = window.__timelines || {}; window.__timelines["root"] = { kill: function () {} };'; + doc.body.appendChild(liveScript); + const win = iframe.contentWindow as unknown as Record; + win.gsap = { timeline: vi.fn(), set: vi.fn() }; + win.__timelines = { root: { kill: vi.fn() } }; + win.__hfForceTimelineRebind = vi.fn(); + win.__player = { getTime: () => 0, seek: vi.fn() }; + + const cap: TimelineElement = { + ...timelineElement({ id: "cap", track: 0, zIndex: 0, start: 2, duration: 1 }), + domId: undefined, + selector: ".cap", + selectorIndex: 0, + }; + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const reloadPreview = vi.fn(); + const fetchMock = stubProjectFetch( + '
', + ); + const { groupMove, unmount } = renderTimelineEditingHook({ + timelineElements: [cap], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit: vi.fn(async () => {}), + reloadPreview, + }); + + await act(async () => { + // Mirror timelineGapCommit: a pure time move keeping the current lane. + await groupMove([{ element: cap, start: 1, track: cap.track }]); + await flushAsyncWork(); + }); + + // The move persisted (live DOM + file)... + expect(doc.querySelector(".cap")?.getAttribute("data-start")).toBe("1"); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1"'); + // ...no GSAP mutation ran (nothing id-addressed to rewrite)... + expect( + fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), + ).toBe(false); + // ...and the preview was rebound in place, NOT full-reloaded (blink), + // with the live script element left untouched (no re-execution). + expect(reloadPreview).not.toHaveBeenCalled(); + expect(win.__hfForceTimelineRebind).toHaveBeenCalledTimes(1); + expect(doc.body.contains(liveScript)).toBe(true); + expect(doc.querySelectorAll("script")).toHaveLength(1); + + unmount(); + }); + + it("keeps the GSAP fallback + reload for a MIXED batch (any start change)", async () => { + // One clip changes lane only, the other shifts in time — the batch is not + // track-only, so the existing behavior (GSAP shift + full reload when no + // rewritten scriptText comes back — this stub iframe has no runtime rebind + // hook, so the in-place rebind can't apply) must be preserved. + const source = [ + '
', + '
', + ].join("\n"); + const { iframe, a, b } = makeTwoClipPair(); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const reloadPreview = vi.fn(); + const fetchMock = stubProjectFetch(source, { ok: true, mutated: false }); + const { groupMove, unmount } = renderTimelineEditingHook({ + timelineElements: [a, b], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit: vi.fn(async () => {}), + reloadPreview, + }); + + await act(async () => { + await groupMove([ + { element: a, start: a.start, track: 2 }, + { element: b, start: 1.5, track: b.track }, + ]); + await flushAsyncWork(); + }); + + expect(writeProjectFile).toHaveBeenCalledTimes(1); + // The time-shifted clip still goes through the GSAP shift endpoint... + expect( + fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), + ).toBe(true); + // ...and with no rewritten scriptText the sync escalates to one full reload. + expect(reloadPreview).toHaveBeenCalledTimes(1); + + unmount(); + }); + it("matches the single-clip move output when a group move contains one clip", async () => { const source = '
'; const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 }); @@ -949,79 +1093,56 @@ describe("useTimelineEditing duration rollback on failed persist", () => { 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(); - + /** + * Run a persist that must reject with the harness's writeError, then assert + * the store duration AND the live root rolled back to the pre-edit 4s — + * the shared act/assert core of the four failed-persist tests below. + */ + async function expectPersistRollback( + ctx: ReturnType, + run: () => Promise, + ): Promise { 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) => { + await run().catch((error) => { rejection = error; }); await flushAsyncWork(); }); - - expect(rejection).toBe(writeError); + expect(rejection).toBe(ctx.writeError); expect(usePlayerStore.getState().duration).toBe(4); - expect(rootDurationAttr(iframe)).toBe("4"); + expect(rootDurationAttr(ctx.iframe)).toBe("4"); + } - hook.unmount(); + it("rolls back the store duration and live root when a move persist fails", async () => { + const ctx = setupFailedPersist(); + // Move past the end: the optimistic sync grows the readout to 5s. + await expectPersistRollback(ctx, () => + ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track }), + ); + ctx.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(); + const ctx = setupFailedPersist(); + await expectPersistRollback(ctx, () => + ctx.hook.resize(ctx.clip, { start: 0, duration: 6, playbackStart: undefined }), + ); + ctx.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(); + const ctx = setupFailedPersist(); + await expectPersistRollback(ctx, () => ctx.hook.groupMove([{ element: ctx.clip, start: 3.5 }])); + ctx.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(); + const ctx = setupFailedPersist(); + await expectPersistRollback(ctx, () => + ctx.hook.groupResize([{ element: ctx.clip, start: 0, duration: 7 }]), + ); + ctx.hook.unmount(); }); it("rolls back the store duration and live root when a delete persist fails", async () => { diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index 6689169a8d..a8660cc707 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -23,7 +23,7 @@ export type TimelineZIndexReorderCommit = ( key?: string; }>, coalesceKey?: string, -) => Promise; +) => Promise; export interface UseTimelineEditingOptions { projectId: string | null; diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts index 3dace0f6f6..29bcb29f37 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -37,6 +37,8 @@ export interface TimelineGroupResizeChange { export interface TimelineGroupCommitOptions { beforeTiming?: Promise; coalesceKey?: string; + /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */ + coalesceMs?: number; } interface UseTimelineGroupEditingOptions { @@ -137,6 +139,7 @@ export function useTimelineGroupEditing({ label: string, batchChanges: PersistTimelineBatchChange[], coalesceKey: string, + coalesceMs?: number, ) => { await persistTimelineBatchEdit({ projectId, @@ -148,6 +151,7 @@ export function useTimelineGroupEditing({ domEditSaveTimestampRef, pendingTimelineEditPathRef, coalesceKey, + coalesceMs, }); forceReloadSdkSession?.(); }, @@ -176,6 +180,7 @@ export function useTimelineGroupEditing({ needsExtension: boolean; label: string; coalesceKey: string; + coalesceMs?: number; }): Promise => { const sharedPath = allChangesSharePath(input.changes, activeCompPath); const canUseSdk = @@ -196,7 +201,7 @@ export function useTimelineGroupEditing({ compositionPath: activeCompPath, readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), }, - { label: input.label, coalesceKey: input.coalesceKey }, + { label: input.label, coalesceKey: input.coalesceKey, coalesceMs: input.coalesceMs }, ); }, [ @@ -223,17 +228,35 @@ export function useTimelineGroupEditing({ patchIframeDomTiming(previewIframeRef.current, change.element, attrs); } + // TRACK-ONLY batch: every change keeps its start (moves never carry a + // duration change), so nothing timing-related changed — the batch only + // rewrites data-track-index, which the renderer never reads (documented + // in core runtime/timeline.ts; track is a studio lane concept). The live + // DOM patch above + the gesture owner's optimistic store update fully + // cover the UI, so after the persist there is nothing to GSAP-shift and + // nothing for the preview to recompute: skip the fallback below entirely. + // Running it anyway is what made the mirrored z-order lane move blink — + // a zero-delta batch yields no scriptText, and finishGroupTimingGsapFallback + // used to full-reload the iframe when there was no script to soft-swap + // (it now rebinds the runtime timing in place, but a track-only batch + // needs NO preview sync at all, so the skip stays). + const trackOnly = changes.every((change) => change.start === change.element.start); + 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. + // Track-only batches leave every clip end unchanged, so both this and the + // readout sync below are provable no-ops there — kept unconditional so the + // duration machinery stays on one code path. 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); + const coalesceMs = options?.coalesceMs; return enqueueGroupOperation("Move timeline clips", async (projectId) => { await options?.beforeTiming; const handledBySdk = await trySdkBatchPersist({ @@ -243,6 +266,7 @@ export function useTimelineGroupEditing({ needsExtension, label: "Move timeline clips", coalesceKey, + coalesceMs, }); if (handledBySdk) return; @@ -261,7 +285,12 @@ export function useTimelineGroupEditing({ ), })), coalesceKey, + coalesceMs, ); + // Track-only: no timing delta → no GSAP positions to shift and no + // reload (see the trackOnly doc above). Mixed batches (any start + // change) keep the full fallback below. + if (trackOnly) return; await finishGroupTimingGsapFallback({ projectId, iframe: previewIframeRef.current, @@ -327,6 +356,7 @@ export function useTimelineGroupEditing({ // the just-patched live DOM. See syncPreviewContentDuration. syncPreviewContentDuration(previewIframeRef.current); const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes); + const coalesceMs = options?.coalesceMs; return enqueueGroupOperation("Resize timeline clips", async (projectId) => { await options?.beforeTiming; const handledBySdk = await trySdkBatchPersist({ @@ -339,6 +369,7 @@ export function useTimelineGroupEditing({ needsExtension, label: "Resize timeline clips", coalesceKey, + coalesceMs, }); if (handledBySdk) return; @@ -355,6 +386,7 @@ export function useTimelineGroupEditing({ }), })), coalesceKey, + coalesceMs, ); await finishGroupTimingGsapFallback({ projectId, diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 8926060c74..e3db29f4a3 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -25,6 +25,7 @@ import { PLAYHEAD_HEAD_W, RULER_H, TRACK_H, + TRACKS_LEFT_PAD, getTimelineDisplayContentWidth, getTimelineFitPps, } from "./timelineLayout"; @@ -152,7 +153,8 @@ describe("Timeline provider boundary", () => { }); const row = button.parentElement?.parentElement; - const trackContent = row?.children.item(1); + // Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content]. + const trackContent = row?.children.item(2); expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false); expect(trackContent).toBeInstanceOf(HTMLElement); if (!(trackContent instanceof HTMLElement)) { @@ -454,27 +456,27 @@ describe("shouldAutoScrollTimeline", () => { }); describe("getTimelineFitPps (min 60s extent + fit headroom)", () => { - const viewport = 632; // usable width = 632 - GUTTER - 2 = 598 + const viewport = 632; // usable width = 632 - GUTTER - TRACKS_LEFT_PAD - 2 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); + expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S); + expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6); }); 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), + (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM), ); expect(getTimelineFitPps(viewport, 120)).toBeCloseTo( - (viewport - GUTTER - 2) / (120 * FIT_ZOOM_HEADROOM), + (viewport - GUTTER - TRACKS_LEFT_PAD - 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 usable = viewport - GUTTER - TRACKS_LEFT_PAD - 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. @@ -484,16 +486,16 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => { it("falls back to 100 pps before the viewport is measured", () => { expect(getTimelineFitPps(0, 10)).toBe(100); - expect(getTimelineFitPps(GUTTER, 10)).toBe(100); + expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100); expect(getTimelineFitPps(Number.NaN, 10)).toBe(100); }); it("uses the floor for zero/invalid durations", () => { expect(getTimelineFitPps(viewport, 0)).toBeCloseTo( - (viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S, + (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S, ); expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo( - (viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S, + (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S, ); }); }); @@ -509,7 +511,7 @@ describe("getTimelineDisplayContentWidth", () => { it("still fills the viewport when that is larger than the 60s floor", () => { expect( getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }), - ).toBe(2000 - GUTTER - 2); + ).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2); }); it("tracks a drag ghost past every other bound (drag-to-extend)", () => { @@ -599,20 +601,28 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { }); describe("getTimelinePlayheadLeft", () => { - it("offsets the wrapper by half the head width so the line CENTER = GUTTER + t*pps", () => { + it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + 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); + // equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom. + expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe( + GUTTER + TRACKS_LEFT_PAD + 4 * 20, + ); + expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe( + GUTTER + TRACKS_LEFT_PAD + 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("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => { + expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD); }); it("guards invalid input", () => { - expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2); - expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2); + expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe( + GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2, + ); + expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe( + GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2, + ); }); }); @@ -681,7 +691,7 @@ describe("resolveTimelineAssetDrop", () => { trackHeight: 72, trackOrder: [0, 3, 7], }, - 432, + 480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps // clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) + // TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3. 368, @@ -702,7 +712,7 @@ describe("resolveTimelineAssetDrop", () => { trackHeight: 72, trackOrder: [0, 3, 7], }, - 250, + 250 + TRACKS_LEFT_PAD, 600, ), ).toEqual({ start: 1.18, track: 8 }); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 8bd838e3ce..2e215381f6 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -9,7 +9,7 @@ import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; -import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons"; +import { getTrackStyle } from "./timelineIcons"; import { useTimelineZoom } from "./useTimelineZoom"; import { useTimelineAssetDrop } from "./timelineDragDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; @@ -20,15 +20,14 @@ import { TimelineOverlays } from "./TimelineOverlays"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; -import { - GUTTER, - generateTicks, - getTimelineCanvasHeight, - shouldShowTimelineShortcutHint, -} from "./timelineLayout"; +import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; +import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout"; +import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; +import { useTrackGapMenu } from "./useTrackGapMenu"; +import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -114,106 +113,41 @@ export const Timeline = memo(function Timeline({ const [razorGuideX, setRazorGuideX] = useState(null); useMountEffect(() => { - const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true); - const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false); + const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown"); const blur = () => setShiftHeld(false); - window.addEventListener("keydown", down); - window.addEventListener("keyup", up); + window.addEventListener("keydown", key); + window.addEventListener("keyup", key); window.addEventListener("blur", blur); return () => { - window.removeEventListener("keydown", down); - window.removeEventListener("keyup", up); + window.removeEventListener("keydown", key); + window.removeEventListener("keyup", key); window.removeEventListener("blur", blur); }; }); const [showPopover, setShowPopover] = useState(false); - const [showShortcutHint, setShowShortcutHint] = useState(true); const [kfContextMenu, setKfContextMenu] = useState(null); const [clipContextMenu, setClipContextMenu] = useState<{ x: number; y: number; element: TimelineElement; } | null>(null); - const [viewportWidth, setViewportWidth] = useState(0); - const roRef = useRef(null); - const shortcutHintRafRef = useRef(0); - - const syncShortcutHintVisibility = useCallback(() => { - const scroll = scrollRef.current; - setShowShortcutHint( - scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true, - ); - }, []); - - const scheduleShortcutHintVisibilitySync = useCallback(() => { - if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current); - shortcutHintRafRef.current = requestAnimationFrame(() => { - shortcutHintRafRef.current = 0; - syncShortcutHintVisibility(); - }); - }, [syncShortcutHintVisibility]); const setContainerRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el; }, []); - // 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. + // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom). const lastScrollLeftRef = useRef(0); - const setScrollRef = useCallback( - (el: HTMLDivElement | null) => { - if (roRef.current) { - roRef.current.disconnect(); - roRef.current = null; - } - scrollRef.current = el; - if (!el) return; - - const syncScrollViewport = () => { - setViewportWidth(el.clientWidth); - scheduleShortcutHintVisibilitySync(); - }; - - syncScrollViewport(); - roRef.current = new ResizeObserver(syncScrollViewport); - roRef.current.observe(el); - }, - [scheduleShortcutHintVisibilitySync], - ); - - useMountEffect(() => () => { - roRef.current?.disconnect(); - if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current); - }); const effectiveDuration = useMemo(() => { const safeDur = Number.isFinite(duration) ? duration : 0; if (rawElements.length === 0) return safeDur; - const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration)); - const result = Math.max(safeDur, maxEnd); + const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration)); return Number.isFinite(result) ? result : safeDur; }, [rawElements, duration]); - const tracks = useMemo(() => { - const map = new Map(); - for (const el of expandedElements) { - const list = map.get(el.track) ?? []; - list.push(el); - map.set(el.track, list); - } - return Array.from(map.entries()).sort(([a], [b]) => a - b); - }, [expandedElements]); - - const trackStyles = useMemo(() => { - const map = new Map(); - for (const [trackNum, els] of tracks) { - map.set(trackNum, getTrackStyle(els[0]?.tag ?? "")); - } - return map; - }, [tracks]); - - const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]); + const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements); const trackOrderRef = useRef(trackOrder); trackOrderRef.current = trackOrder; const expandedElementsRef = useRef(expandedElements); @@ -222,8 +156,7 @@ export const Timeline = memo(function Timeline({ const ppsRef = useRef(100); const durationRef = useRef(effectiveDuration); durationRef.current = effectiveDuration; - // Declared here (used before the fitPps derivation below) so the edit-pin - // wrappers can close over it; `fitPpsRef.current` is refreshed each render. + // Declared before the fitPps derivation so the edit-pin wrappers can close over it. const fitPpsRef = useRef(100); const { @@ -252,6 +185,22 @@ export const Timeline = memo(function Timeline({ expandedElementsRef, }); + const { + gapMenuModel, + gapHighlight, + setHoveredGapAction, + openGapMenu, + dismissGapMenu, + closeTrackGap, + closeAllTrackGaps, + } = useTrackGapMenu({ + tracks, + expandedElementsRef, + trackOrderRef, + onMoveElement: pinnedOnMoveElement, + onMoveElements: pinnedOnMoveElements, + }); + const { draggedClip, setDraggedClip, @@ -293,6 +242,11 @@ export const Timeline = memo(function Timeline({ }, [draggedClip, trackOrder]); const totalH = getTimelineCanvasHeight(displayTrackOrder.length); + const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [ + timelineReady, + expandedElements.length, + totalH, + ]); const keyframeCache = usePlayerStore((s) => s.keyframeCache); const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); @@ -328,6 +282,16 @@ export const Timeline = memo(function Timeline({ lastScrollLeftRef, }); + const laneGapStrips = useTimelineGapHighlights({ + gapHighlight, + tracks, + selectedElementId, + selectedElementIds, + expandedElements, + dragActive: draggedClip?.started === true || resizingClip != null, + displayDuration, + }); + const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({ playheadRef, scrollRef, @@ -379,8 +343,7 @@ export const Timeline = memo(function Timeline({ trackOrderRef, onSelectElement, }); - // Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag - setRangeSelectionRef.current = setRangeSelection; + setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag const prevSelectedRef = useRef(selectedElementRef.current); // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps @@ -402,10 +365,6 @@ export const Timeline = memo(function Timeline({ ); const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration; - useEffect(() => { - syncShortcutHintVisibility(); - }, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]); - const getPreviewElement = useCallback( (element: TimelineElement): TimelineElement => { if ( @@ -459,8 +418,7 @@ export const Timeline = memo(function Timeline({ tabIndex={-1} className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`} onScroll={(e) => { - // Remember the live offset so it can be restored across a post-edit reload. - lastScrollLeftRef.current = e.currentTarget.scrollLeft; + lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload }} onDragOver={handleAssetDragOver} onDragLeave={() => clearDropPreview()} @@ -468,7 +426,8 @@ export const Timeline = memo(function Timeline({ onPointerDown={(e) => { if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) { const rect = scrollRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER; + const x = + e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD; const splitTime = Math.max(0, x / pps); onRazorSplitAll?.(splitTime); return; @@ -489,6 +448,7 @@ export const Timeline = memo(function Timeline({ majorTickInterval={majorTickInterval} rangeSelection={rangeSelection} marqueeRect={marqueeRect} + laneGapStrips={laneGapStrips} theme={theme} displayTrackOrder={displayTrackOrder} trackOrder={trackOrder} @@ -560,8 +520,14 @@ export const Timeline = memo(function Timeline({ e.preventDefault(); setSelectedElementId(el.key ?? el.id); onSelectElement?.(el); + dismissGapMenu(); setClipContextMenu({ x: e.clientX, y: e.clientY, element: el }); }} + onContextMenuLane={(e, track, time) => { + if (draggedClip?.started || resizingClip) return; + setClipContextMenu(null); + openGapMenu({ x: e.clientX, y: e.clientY, track, time }); + }} /> {activeTool === "razor" && razorGuideX !== null && (
); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index f706088da9..462acc2885 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -10,6 +10,7 @@ import { CLIP_Y, TRACKS_TOP_PAD, TRACKS_BOTTOM_PAD, + TRACKS_LEFT_PAD, PLAYHEAD_HEAD_W, getTimelinePlayheadLeft, getTimelineRowTop, @@ -23,6 +24,7 @@ import { TimelineClip } from "./TimelineClip"; import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes"; import { renderClipChildren } from "./timelineClipChildren"; import { useTimelineRevealClip } from "./useTimelineRevealClip"; +import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights"; interface TimelineCanvasProps extends TimelineLaneBaseProps { major: number[]; @@ -37,6 +39,8 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps { /** Playhead is being actively scrubbed — fills the grab-handle head. */ isScrubbing: boolean; playheadRef: React.RefObject; + /** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */ + laneGapStrips: TimelineLaneGapStrips[]; } export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) { @@ -97,7 +101,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas return (