mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
Merge pull request #2347 from heygen-com/fix/studio-restore-golden-behaviors
fix(studio): restore golden-branch timeline behaviors dropped by the stack rebuild
This commit is contained in:
@@ -39,6 +39,44 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
expect(result.clips[0].id).toBe("hf-headline");
|
||||
});
|
||||
|
||||
// Regression: the authored data-track-index must round-trip verbatim, even
|
||||
// when clips of DIFFERENT kinds (video vs element) share a track. The old
|
||||
// mixed-kind renumber split them onto separate tracks, which made the
|
||||
// written track drift from the displayed one on every editor move.
|
||||
it("honors authored track indices verbatim for mixed-kind tracks", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-duration", "20");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.id = "clip-video";
|
||||
video.setAttribute("data-start", "1");
|
||||
video.setAttribute("data-duration", "3");
|
||||
video.setAttribute("data-track-index", "1");
|
||||
root.appendChild(video);
|
||||
|
||||
const caption = document.createElement("div");
|
||||
caption.id = "clip-caption";
|
||||
caption.setAttribute("data-start", "8");
|
||||
caption.setAttribute("data-duration", "3");
|
||||
caption.setAttribute("data-track-index", "1");
|
||||
root.appendChild(caption);
|
||||
|
||||
const other = document.createElement("div");
|
||||
other.id = "clip-other";
|
||||
other.setAttribute("data-start", "0");
|
||||
other.setAttribute("data-duration", "3");
|
||||
other.setAttribute("data-track-index", "2");
|
||||
root.appendChild(other);
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
const trackOf = (id: string) => result.clips.find((c) => c.id === id)?.track;
|
||||
expect(trackOf("clip-video")).toBe(1);
|
||||
expect(trackOf("clip-caption")).toBe(1);
|
||||
expect(trackOf("clip-other")).toBe(2);
|
||||
});
|
||||
|
||||
it("collects clips from elements with data-start and data-duration", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -52,57 +52,15 @@ function maxDefinedNumber(...values: Array<number | null>): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* When multiple content kinds share the same track number, split them
|
||||
* onto separate tracks so the timeline UI shows distinct rows.
|
||||
*
|
||||
* Preferred kind order (top → bottom): composition, video, image, element, audio.
|
||||
* Tracks that contain only one kind are left untouched.
|
||||
* Parse an authored track attribute, honoring 0 (a valid top-lane index).
|
||||
* `parseInt(...) || fallback` silently replaced authored track 0 with the
|
||||
* synthetic fallback, so track-0 clips drifted to the bottom of the timeline.
|
||||
*/
|
||||
const KIND_ORDER: Record<string, number> = {
|
||||
composition: 0,
|
||||
video: 1,
|
||||
image: 2,
|
||||
element: 3,
|
||||
audio: 4,
|
||||
};
|
||||
|
||||
function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
|
||||
if (clips.length === 0) return;
|
||||
|
||||
// Group clips by their raw track number and detect which tracks have mixed kinds
|
||||
const trackKinds = new Map<number, Set<string>>();
|
||||
for (const clip of clips) {
|
||||
const kinds = trackKinds.get(clip.track) ?? new Set();
|
||||
kinds.add(clip.kind);
|
||||
trackKinds.set(clip.track, kinds);
|
||||
}
|
||||
|
||||
const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
|
||||
if (!hasMixedTracks) return;
|
||||
|
||||
// Build new contiguous track numbers, splitting mixed tracks by kind
|
||||
let nextTrack = 0;
|
||||
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack
|
||||
|
||||
const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
|
||||
for (const track of sortedTracks) {
|
||||
const kinds = trackKinds.get(track)!;
|
||||
if (kinds.size === 1) {
|
||||
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
|
||||
} else {
|
||||
// Split by kind in preferred order
|
||||
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
|
||||
for (const kind of sorted) {
|
||||
newTrackMap.set(`${track}:${kind}`, nextTrack++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const clip of clips) {
|
||||
const key = `${clip.track}:${clip.kind}`;
|
||||
const newTrack = newTrackMap.get(key);
|
||||
if (newTrack != null) clip.track = newTrack;
|
||||
}
|
||||
function parseAuthoredTrack(el: Element, fallback: number): number {
|
||||
const raw = el.getAttribute("data-track-index") ?? el.getAttribute("data-track");
|
||||
if (raw == null) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
|
||||
@@ -441,11 +399,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
label: buildTimelineClipLabel(node, kind, clips.length),
|
||||
start,
|
||||
duration,
|
||||
track:
|
||||
Number.parseInt(
|
||||
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
|
||||
10,
|
||||
) || 0,
|
||||
track: parseAuthoredTrack(node, i),
|
||||
zIndex: readInlineZIndex(node),
|
||||
stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId,
|
||||
kind,
|
||||
@@ -554,11 +508,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
el.id,
|
||||
start: range.start,
|
||||
duration: clampedDuration,
|
||||
track:
|
||||
Number.parseInt(
|
||||
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
|
||||
10,
|
||||
) || gsapTrack,
|
||||
track: parseAuthoredTrack(el, gsapTrack),
|
||||
zIndex: readInlineZIndex(el),
|
||||
stackingContextId: rootCompositionIdForGsap,
|
||||
kind: "element",
|
||||
@@ -613,11 +563,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
el.id,
|
||||
start: 0,
|
||||
duration: clampedDuration,
|
||||
track:
|
||||
Number.parseInt(
|
||||
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
|
||||
10,
|
||||
) || overlayTrack,
|
||||
track: parseAuthoredTrack(el, overlayTrack),
|
||||
zIndex: readInlineZIndex(el),
|
||||
stackingContextId: rootCompositionIdForGsap,
|
||||
kind: "element",
|
||||
@@ -637,11 +583,12 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Track normalization ────────────────────────────────────────────────
|
||||
// When multiple content kinds (composition, audio, video, …) share the same
|
||||
// data-track-index value, split them onto separate tracks so the timeline UI
|
||||
// shows distinct rows for each kind.
|
||||
normalizeTrackAssignments(clips);
|
||||
// Track assignment honors the authored data-track-index verbatim: a clip stays
|
||||
// on the track it was placed on, regardless of kind. (Previously mixed-kind
|
||||
// tracks were split onto separate rows, but that renumbered tracks — breaking
|
||||
// "drop a clip onto an existing track" and causing the written track to drift
|
||||
// from the displayed one on every move. Track index is display-only; render
|
||||
// never reads it, so honoring it verbatim is the correct NLE behavior.)
|
||||
|
||||
for (const compositionNode of compositionNodes) {
|
||||
if (compositionNode === root) continue;
|
||||
|
||||
@@ -3,7 +3,11 @@ import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
|
||||
import { resolveZIndexEntries } from "../nle/PreviewOverlays";
|
||||
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
|
||||
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
|
||||
import { CanvasContextMenu } from "./CanvasContextMenu";
|
||||
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
|
||||
installReactActEnvironment();
|
||||
@@ -24,7 +28,8 @@ afterEach(() => {
|
||||
|
||||
function renderMenu(props: {
|
||||
selection: DomEditSelection;
|
||||
onApplyZIndex?: () => void;
|
||||
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
|
||||
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
|
||||
onDelete?: (selection: DomEditSelection) => void;
|
||||
}) {
|
||||
root = createRoot(host);
|
||||
@@ -36,6 +41,7 @@ function renderMenu(props: {
|
||||
selection: props.selection,
|
||||
onClose: () => {},
|
||||
onApplyZIndex: props.onApplyZIndex,
|
||||
onZOrderCrossed: props.onZOrderCrossed,
|
||||
onDelete: props.onDelete,
|
||||
}),
|
||||
);
|
||||
@@ -113,3 +119,148 @@ describe("CanvasContextMenu — handler gating", () => {
|
||||
expect(document.body.querySelector(".border-t")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──
|
||||
|
||||
function pressMenuItem(label: string) {
|
||||
const button = zOrderButtons().find((b) => b.textContent === label);
|
||||
expect(button).toBeDefined();
|
||||
act(() => {
|
||||
button!.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */
|
||||
function makeStaticFamily() {
|
||||
const parent = document.createElement("div");
|
||||
const target = document.createElement("div");
|
||||
target.id = "target";
|
||||
// In happy-dom an unset computed position is "" (not "static"), which would
|
||||
// skip the commit hook's static-position injection; declare it explicitly so
|
||||
// the test exercises the browser default.
|
||||
target.style.position = "static";
|
||||
const other = document.createElement("div");
|
||||
other.id = "other";
|
||||
parent.append(target, other);
|
||||
document.body.append(parent);
|
||||
return { parent, target, other };
|
||||
}
|
||||
|
||||
interface CapturedBatchCall {
|
||||
batches: DomEditPatchBatch[];
|
||||
options: { label: string; coalesceKey: string };
|
||||
}
|
||||
|
||||
/** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */
|
||||
function renderCommitHook(captured: CapturedBatchCall[]) {
|
||||
type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
|
||||
let commit: Commit | undefined;
|
||||
function Harness() {
|
||||
({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps({
|
||||
activeCompPath: "index.html",
|
||||
showToast: vi.fn(),
|
||||
writeProjectFile: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
editHistory: { recordEdit: vi.fn(async () => {}) },
|
||||
projectIdRef: { current: null },
|
||||
reloadPreview: vi.fn(),
|
||||
clearDomSelection: vi.fn(),
|
||||
commitDomEditPatchBatches: async (batches, options) => {
|
||||
captured.push({ batches, options });
|
||||
},
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
const hookHost = document.createElement("div");
|
||||
document.body.append(hookHost);
|
||||
const hookRoot = createRoot(hookHost);
|
||||
act(() => hookRoot.render(<Harness />));
|
||||
return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
|
||||
}
|
||||
|
||||
describe("CanvasContextMenu — z-action commit path", () => {
|
||||
it("never mutates live styles itself and persists the position patch for a static element", async () => {
|
||||
const { target } = makeStaticFamily();
|
||||
const selection = makeSelection("Target", target);
|
||||
const captured: CapturedBatchCall[] = [];
|
||||
const { commit, cleanup } = renderCommitHook(captured);
|
||||
|
||||
// Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
|
||||
// hook), asserting the menu has NOT touched the DOM when it fires — the
|
||||
// hook must capture true pre-change styles for its rollback.
|
||||
const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
|
||||
renderMenu({
|
||||
selection,
|
||||
onApplyZIndex: (patches, action) => {
|
||||
stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
|
||||
const { entries } = resolveZIndexEntries(selection, patches);
|
||||
void commit(entries, undefined, action);
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => pressMenuItem("Bring forward"));
|
||||
|
||||
// The menu left the element pristine; only the commit hook wrote styles.
|
||||
expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]);
|
||||
expect(target.style.zIndex).toBe("1");
|
||||
expect(target.style.position).toBe("relative");
|
||||
|
||||
// The persisted payload carries BOTH the z-index and the injected position,
|
||||
// so the reorder survives the post-commit reloadPreview().
|
||||
expect(captured).toHaveLength(1);
|
||||
const targetPatch = captured[0]?.batches
|
||||
.flatMap((batch) => batch.patches)
|
||||
.find((patch) => patch.target.id === "target");
|
||||
expect(targetPatch?.operations).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: "inline-style", property: "z-index", value: "1" },
|
||||
{ type: "inline-style", property: "position", value: "relative" },
|
||||
]),
|
||||
);
|
||||
// F7: the action kind is part of the default undo coalesce key, so two
|
||||
// different menu actions never merge into one undo step.
|
||||
expect(captured[0]?.options.coalesceKey).toContain("bring-forward");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => {
|
||||
// target (earlier in DOM) and other are tied — bring-forward steps over
|
||||
// `other`, and the flash callback must receive exactly that element, after
|
||||
// onApplyZIndex ran (call order lets the host measure post-commit rects).
|
||||
const { target, other } = makeStaticFamily();
|
||||
const selection = makeSelection("Target", target);
|
||||
const calls: Array<{ kind: string; crossed?: HTMLElement }> = [];
|
||||
|
||||
renderMenu({
|
||||
selection,
|
||||
onApplyZIndex: () => calls.push({ kind: "apply" }),
|
||||
onZOrderCrossed: (crossed, action) => {
|
||||
expect(action).toBe("bring-forward");
|
||||
calls.push({ kind: "crossed", crossed });
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => pressMenuItem("Bring forward"));
|
||||
|
||||
expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]);
|
||||
expect(calls[1]?.crossed).toBe(other);
|
||||
});
|
||||
|
||||
it("does not call onZOrderCrossed for bring-to-front", async () => {
|
||||
const { target } = makeStaticFamily();
|
||||
const onZOrderCrossed = vi.fn();
|
||||
|
||||
renderMenu({
|
||||
selection: makeSelection("Target", target),
|
||||
onApplyZIndex: vi.fn(),
|
||||
onZOrderCrossed,
|
||||
});
|
||||
|
||||
await act(async () => pressMenuItem("Bring to front"));
|
||||
|
||||
expect(onZOrderCrossed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
* useContextMenuDismiss.
|
||||
*
|
||||
* ── Wiring (z-order persistence) ─────────────────────────────────────────────
|
||||
* Z-index changes are applied optimistically to the live iframe element(s) via
|
||||
* Z-index changes are resolved against the live iframe DOM via
|
||||
* `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
|
||||
* moving a target past an equal-z sibling can require renumbering the affected
|
||||
* set). The patches are surfaced through the `onApplyZIndex` prop.
|
||||
* set). The patches are surfaced through the `onApplyZIndex` prop; the menu
|
||||
* itself never mutates element styles — handleDomZIndexReorderCommit applies
|
||||
* the live z-index (and injects position when needed) in the same synchronous
|
||||
* flow, and captures the TRUE prior styles for its failure rollback.
|
||||
*
|
||||
* The prop MUST be wired at the call site to route through the full persist
|
||||
* path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
|
||||
@@ -26,7 +29,9 @@ import type { DomEditSelection } from "./domEditing";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
import {
|
||||
isZOrderActionEnabled,
|
||||
resolveCrossedNeighbor,
|
||||
resolveZOrderChange,
|
||||
type ZOrderAction,
|
||||
type ZOrderPatch,
|
||||
} from "./canvasContextMenuZOrder";
|
||||
|
||||
@@ -38,12 +43,22 @@ interface CanvasContextMenuProps {
|
||||
selection: DomEditSelection;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Called with the resolved z-order patch list after an optimistic DOM update.
|
||||
* Each patch is an { element, zIndex } pair (the target and, when a renumber
|
||||
* is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
|
||||
* module-level wiring comment).
|
||||
* Called with the resolved z-order patch list and the menu action that
|
||||
* produced it (the action feeds the undo coalesce key, so two DIFFERENT
|
||||
* actions never merge into one undo step). Each patch is an
|
||||
* { element, zIndex } pair (the target and, when a renumber is needed,
|
||||
* affected siblings). The menu does NOT touch the live DOM — wire to
|
||||
* handleDomZIndexReorderCommit, which applies the live styles itself
|
||||
* (see module-level wiring comment).
|
||||
*/
|
||||
onApplyZIndex?: (patches: ZOrderPatch[]) => void;
|
||||
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
|
||||
/**
|
||||
* Called after a successful bring-forward / send-backward with the sibling
|
||||
* the target stepped over (resolved from the SAME pre-mutation state as the
|
||||
* patches), so the host can flash a highlight on it in the studio overlay.
|
||||
* Never called for front/back or no-op actions.
|
||||
*/
|
||||
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
|
||||
/**
|
||||
* Delete the selected element. Wire to handleDomEditElementDelete from
|
||||
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
|
||||
@@ -68,6 +83,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
||||
selection,
|
||||
onClose,
|
||||
onApplyZIndex,
|
||||
onZOrderCrossed,
|
||||
onDelete,
|
||||
}: CanvasContextMenuProps) {
|
||||
const menuRef = useContextMenuDismiss(onClose);
|
||||
@@ -93,20 +109,19 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
||||
const el = selection.element;
|
||||
|
||||
function handleZAction(action: ZAction) {
|
||||
// No persist handler → do NOT touch the live iframe DOM. An optimistic
|
||||
// write with nothing to persist just reverts on the next reload.
|
||||
if (!onApplyZIndex) return;
|
||||
const patches = resolveZOrderChange(el, action);
|
||||
if (patches === null) return;
|
||||
// Optimistic update — visible immediately even before persist completes.
|
||||
for (const patch of patches) {
|
||||
patch.element.style.zIndex = String(patch.zIndex);
|
||||
const view = patch.element.ownerDocument?.defaultView;
|
||||
if (view && view.getComputedStyle(patch.element).position === "static") {
|
||||
patch.element.style.position = "relative";
|
||||
}
|
||||
}
|
||||
onApplyZIndex(patches);
|
||||
// Resolve the crossed neighbor BEFORE the commit path mutates live styles —
|
||||
// both resolvers must read the same pre-change render order.
|
||||
const crossed = onZOrderCrossed ? resolveCrossedNeighbor(el, action) : null;
|
||||
// Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
|
||||
// live z-index (and injects position:relative for static elements) in the
|
||||
// same synchronous flow, so feedback is still instant — and it must read
|
||||
// the PRE-change styles itself, both to capture true rollback values and
|
||||
// to detect a static position that needs persisting.
|
||||
onApplyZIndex(patches, action);
|
||||
if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
|
||||
onClose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
||||
import { useMarqueeGestures } from "./marqueeCommit";
|
||||
import { MarqueeOverlay } from "./MarqueeOverlay";
|
||||
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
|
||||
import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFlash";
|
||||
import { useCanvasContextMenuState } from "./useCanvasContextMenuState";
|
||||
import {
|
||||
type BlockedMoveState,
|
||||
type DomEditGroupPathOffsetCommit,
|
||||
@@ -27,7 +29,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
||||
import { CanvasContextMenu } from "./CanvasContextMenu";
|
||||
import type { ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
||||
|
||||
// Re-exports for external consumers — preserving existing import paths.
|
||||
@@ -91,12 +93,17 @@ interface DomEditOverlayProps {
|
||||
*/
|
||||
onDeleteSelection?: (selection: DomEditSelection) => void;
|
||||
/**
|
||||
* Called with the resolved z-order patch list after an optimistic DOM update.
|
||||
* The patch list is tie-aware and may include sibling elements (see
|
||||
* canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from
|
||||
* Called with the resolved z-order patch list and the menu action that
|
||||
* produced it (feeds the undo coalesce key). The patch list is tie-aware and
|
||||
* may include sibling elements (see canvasContextMenuZOrder); the live DOM is
|
||||
* NOT yet mutated. Wire to handleDomZIndexReorderCommit from
|
||||
* useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
|
||||
*/
|
||||
onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void;
|
||||
onApplyZIndex?: (
|
||||
selection: DomEditSelection,
|
||||
patches: ZOrderPatch[],
|
||||
action: ZOrderAction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -139,30 +146,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
const snapGuidesRef = useRef<SnapGuidesState | null>(null);
|
||||
const rafPausedRef = useRef(false);
|
||||
|
||||
// Context menu state: position of the right-click that opened it.
|
||||
// contextMenuSelection is the element the menu targets — captured at right-click
|
||||
// time so the menu can open even before the React selection state settles.
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
sel: DomEditSelection;
|
||||
} | null>(null);
|
||||
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
|
||||
// Close the context menu whenever the selection moves off the element the menu
|
||||
// targets (a click that reselects elsewhere, a deselect, or a preview reload
|
||||
// that rebuilds the selection). Without this the menu can linger — orphaned —
|
||||
// over a stale target after the underlying element is gone. A right-click that
|
||||
// OPENS the menu also selects its target, so the common open path keeps the
|
||||
// menu (same element) rather than immediately dismissing it.
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
if (!selection || selection.element !== contextMenu.sel.element) {
|
||||
setContextMenu(null);
|
||||
}
|
||||
}, [selection, contextMenu]);
|
||||
// Brief highlight on the sibling a forward/backward z step crossed — drawn
|
||||
// in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash).
|
||||
const { zOrderFlashRect, handleZOrderCrossed } = useZOrderCrossedFlash({ overlayRef, iframeRef });
|
||||
|
||||
const activeCompositionPathRef = useRef(activeCompositionPath);
|
||||
activeCompositionPathRef.current = activeCompositionPath;
|
||||
@@ -428,37 +417,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Right-click: select element first (if not already selected), then open menu.
|
||||
const handleContextMenu = useCallback(
|
||||
async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
// If no element is selected yet, resolve it from the pointer position first.
|
||||
const currentSel = selectionRef.current;
|
||||
let activeSel: DomEditSelection | null = currentSel;
|
||||
if (!currentSel) {
|
||||
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
|
||||
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
|
||||
if (!resolved) return; // Nothing under the cursor — skip menu.
|
||||
onSelectionChangeRef.current(resolved, { revealPanel: true });
|
||||
// Use `resolved` directly: React state (and therefore selectionRef) won't
|
||||
// update synchronously after onSelectionChange — we'd be reading stale null.
|
||||
activeSel = resolved;
|
||||
} else {
|
||||
// Check if the user right-clicked on an unselected element (hover target).
|
||||
const hover = hoverSelectionRef.current;
|
||||
if (hover && hover.element !== currentSel.element) {
|
||||
onSelectionChangeRef.current(hover, { revealPanel: true });
|
||||
activeSel = hover;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSel) return;
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
// Right-click state + handler: select the element under the pointer (if
|
||||
// needed), then open the menu; closes when the selection moves off-target.
|
||||
const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
|
||||
selection,
|
||||
selectionRef,
|
||||
hoverSelectionRef,
|
||||
onCanvasPointerMoveRef,
|
||||
onSelectionChangeRef,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -551,24 +518,26 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
selection={contextMenu.sel}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onClose={closeContextMenu}
|
||||
onDelete={
|
||||
onDeleteSelection
|
||||
? (sel) => {
|
||||
setContextMenu(null);
|
||||
closeContextMenu();
|
||||
onDeleteSelection(sel);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onApplyZIndex={
|
||||
onApplyZIndex
|
||||
? (patches) => {
|
||||
onApplyZIndex(contextMenu.sel, patches);
|
||||
? (patches, action) => {
|
||||
onApplyZIndex(contextMenu.sel, patches, action);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onZOrderCrossed={handleZOrderCrossed}
|
||||
/>
|
||||
)}
|
||||
<ZOrderCrossedFlash rect={zOrderFlashRect} />
|
||||
<GridOverlay
|
||||
visible={gridVisible}
|
||||
spacing={gridSpacing}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Layers } from "../../icons/SystemIcons";
|
||||
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
|
||||
import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
|
||||
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
||||
|
||||
const TAG_ICONS: Record<string, string> = {
|
||||
video: "Vi",
|
||||
@@ -280,9 +281,17 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
selector: layer.selector,
|
||||
selectorIndex: layer.selectorIndex,
|
||||
sourceFile: layer.sourceFile,
|
||||
key: deriveTimelineStoreKey({
|
||||
domId: layer.id,
|
||||
selector: layer.selector,
|
||||
selectorIndex: layer.selectorIndex,
|
||||
sourceFile: layer.sourceFile,
|
||||
}),
|
||||
}));
|
||||
|
||||
handleDomZIndexReorderCommit(entries);
|
||||
// "layer-drag" keeps consecutive drops of the same sibling set coalescing
|
||||
// into one undo step, without merging with a context-menu z action.
|
||||
handleDomZIndexReorderCommit(entries, undefined, "layer-drag");
|
||||
},
|
||||
[handleDomZIndexReorderCommit],
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isElementVisibleForZOrder,
|
||||
isZOrderActionEnabled,
|
||||
parseZIndex,
|
||||
resolveCrossedNeighbor,
|
||||
resolveZOrderChange,
|
||||
type ZOrderAction,
|
||||
type ZOrderPatch,
|
||||
@@ -48,8 +50,12 @@ function makeFamily(
|
||||
}
|
||||
|
||||
/** Resolve a z-order change and assert it produced patches (fails otherwise). */
|
||||
function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] {
|
||||
const patches = resolveZOrderChange(target, action);
|
||||
function resolveZOrderPatches(
|
||||
target: HTMLElement,
|
||||
action: ZOrderAction,
|
||||
options?: { isVisible?: (el: HTMLElement) => boolean },
|
||||
): ZOrderPatch[] {
|
||||
const patches = resolveZOrderChange(target, action, options);
|
||||
expect(patches).not.toBeNull();
|
||||
if (!patches) throw new Error("expected z-order patches");
|
||||
return patches;
|
||||
@@ -375,6 +381,27 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
|
||||
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
|
||||
});
|
||||
|
||||
it("ignores <template>/<noscript> siblings in the family", () => {
|
||||
// A renumber fallback once wrote z-index/position into <template> source
|
||||
// markup because templates entered the sibling family. They never paint —
|
||||
// exclude them like audio/script/style.
|
||||
const parent = document.createElement("div");
|
||||
const a = makeEl("a", "0");
|
||||
const template = document.createElement("template");
|
||||
template.style.zIndex = "2";
|
||||
const noscript = document.createElement("noscript");
|
||||
const target = makeEl("target", "0");
|
||||
parent.append(a, template, noscript, target);
|
||||
|
||||
const patches = resolveZOrderPatches(target, "send-to-back");
|
||||
for (const p of patches) {
|
||||
expect(p.element).not.toBe(template);
|
||||
expect(p.element).not.toBe(noscript);
|
||||
}
|
||||
const order = renderOrderIds(parent, { a, target }, patches);
|
||||
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
|
||||
});
|
||||
|
||||
it("a lone painting element beside only non-painting siblings has no family → null", () => {
|
||||
const parent = document.createElement("div");
|
||||
const target = makeEl("target", "1");
|
||||
@@ -392,6 +419,163 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── visibility scoping (forward/backward step over VISIBLE siblings only) ──────
|
||||
//
|
||||
// The visibility probe is injectable (ZOrderResolveOptions.isVisible) exactly
|
||||
// like rect reading is stubbable — these tests drive the scoping logic with a
|
||||
// stub, without a real style engine.
|
||||
|
||||
describe("resolveZOrderChange – visibility scoping (injectable stub)", () => {
|
||||
/** Probe that hides exactly the given elements. */
|
||||
function hiding(...hidden: HTMLElement[]) {
|
||||
return { isVisible: (el: HTMLElement) => !hidden.includes(el) };
|
||||
}
|
||||
|
||||
it("bring-forward steps over the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
|
||||
// Render order: target(1), hidden(2), vis(3). The nearest z-neighbor above
|
||||
// is invisible at the current frame — forward must land the target above
|
||||
// `vis` (the next VISIBLE overlapping sibling), leaving `hidden` untouched.
|
||||
const { target, byId } = makeFamily("1", [
|
||||
["hidden", "2"],
|
||||
["vis", "3"],
|
||||
]);
|
||||
const patches = resolveZOrderPatches(target, "bring-forward", hiding(byId.hidden!));
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patchFor(patches, byId, "target")?.zIndex).toBe(4);
|
||||
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("send-backward steps below the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
|
||||
// Render order: vis(1), hidden(2), target(3). Backward must drop the target
|
||||
// below `vis`, not merely below the invisible `hidden`.
|
||||
const { target, byId } = makeFamily("3", [
|
||||
["vis", "1"],
|
||||
["hidden", "2"],
|
||||
]);
|
||||
const patches = resolveZOrderPatches(target, "send-backward", hiding(byId.hidden!));
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patchFor(patches, byId, "target")?.zIndex).toBe(0);
|
||||
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forward/backward are no-ops when every overlapping sibling is invisible", () => {
|
||||
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
|
||||
const opts = hiding(byId.hidden!);
|
||||
expect(resolveZOrderChange(target, "bring-forward", opts)).toBeNull();
|
||||
expect(resolveZOrderChange(target, "send-backward", opts)).toBeNull();
|
||||
});
|
||||
|
||||
it("bring-to-front / send-to-back keep the FULL painting family (invisible siblings included)", () => {
|
||||
// Unchanged semantics: front/back operate across all siblings, so an
|
||||
// invisible sibling still counts and the actions stay meaningful.
|
||||
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
|
||||
const opts = hiding(byId.hidden!);
|
||||
const patches = resolveZOrderPatches(target, "bring-to-front", opts);
|
||||
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
|
||||
expect(resolveZOrderChange(target, "send-to-back", opts)).toBeNull(); // already bottom
|
||||
});
|
||||
|
||||
it("the target itself is retained even when the probe reports it invisible", () => {
|
||||
const { target, byId } = makeFamily("1", [["vis", "2"]]);
|
||||
const patches = resolveZOrderPatches(target, "bring-forward", hiding(target));
|
||||
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
|
||||
});
|
||||
|
||||
it("isZOrderActionEnabled matches the resolver under the same visibility scope", () => {
|
||||
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
|
||||
const opts = hiding(byId.hidden!);
|
||||
// Forward/backward: scoped set collapses to the target alone → disabled.
|
||||
expect(isZOrderActionEnabled(target, "bring-forward", opts)).toBe(false);
|
||||
expect(isZOrderActionEnabled(target, "send-backward", opts)).toBe(false);
|
||||
// Front/back: full family → enabled exactly where the resolver acts.
|
||||
expect(isZOrderActionEnabled(target, "bring-to-front", opts)).toBe(true);
|
||||
expect(isZOrderActionEnabled(target, "send-to-back", opts)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── default visibility probe (element-level computed style) ───────────────────
|
||||
|
||||
describe("isElementVisibleForZOrder – default probe", () => {
|
||||
function attachedEl(style: Partial<CSSStyleDeclaration> = {}): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
Object.assign(el.style, style);
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
it("treats display:none / visibility:hidden / opacity≈0 as invisible", () => {
|
||||
expect(isElementVisibleForZOrder(attachedEl({ display: "none" }))).toBe(false);
|
||||
expect(isElementVisibleForZOrder(attachedEl({ visibility: "hidden" }))).toBe(false);
|
||||
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0" }))).toBe(false);
|
||||
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.005" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats normal, translucent, and unstyled elements as visible", () => {
|
||||
expect(isElementVisibleForZOrder(attachedEl())).toBe(true);
|
||||
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.5" }))).toBe(true);
|
||||
expect(isElementVisibleForZOrder(attachedEl({ visibility: "visible" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("exempts a hidden color-grading source (its canvas paints in its place)", () => {
|
||||
const el = attachedEl({ opacity: "0" });
|
||||
el.setAttribute("data-hf-color-grading-source-hidden", "");
|
||||
expect(isElementVisibleForZOrder(el)).toBe(true);
|
||||
});
|
||||
|
||||
it("is the default probe: the runtime's inline visibility:hidden on a time-inactive clip is skipped", () => {
|
||||
// End-to-end through resolveZOrderChange with NO injected probe: the
|
||||
// runtime hides inactive clips with inline `visibility:hidden` (see core
|
||||
// runtime syncTimedElementVisibility) — computed style picks that up.
|
||||
const parent = document.createElement("div");
|
||||
const target = makeEl("target", "1");
|
||||
const hidden = makeEl("hidden", "2");
|
||||
hidden.style.visibility = "hidden";
|
||||
const vis = makeEl("vis", "3");
|
||||
parent.append(target, hidden, vis);
|
||||
document.body.appendChild(parent);
|
||||
const patches = resolveZOrderPatches(target, "bring-forward");
|
||||
expect(patchFor(patches, { target, hidden, vis }, "target")?.zIndex).toBe(4);
|
||||
expect(patchFor(patches, { target, hidden, vis }, "hidden")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveCrossedNeighbor (the "show your work" flash target) ────────────────
|
||||
|
||||
describe("resolveCrossedNeighbor", () => {
|
||||
it("returns the visible sibling directly above for bring-forward", () => {
|
||||
const { target, byId } = makeFamily("1", [
|
||||
["hidden", "2"],
|
||||
["vis", "3"],
|
||||
]);
|
||||
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
|
||||
expect(resolveCrossedNeighbor(target, "bring-forward", opts)).toBe(byId.vis);
|
||||
});
|
||||
|
||||
it("returns the visible sibling directly below for send-backward", () => {
|
||||
const { target, byId } = makeFamily("3", [
|
||||
["vis", "1"],
|
||||
["hidden", "2"],
|
||||
]);
|
||||
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
|
||||
expect(resolveCrossedNeighbor(target, "send-backward", opts)).toBe(byId.vis);
|
||||
});
|
||||
|
||||
it("returns null for front/back actions and for no-op steps", () => {
|
||||
const { target } = makeFamily("1", [["a", "2"]]);
|
||||
expect(resolveCrossedNeighbor(target, "bring-to-front")).toBeNull();
|
||||
expect(resolveCrossedNeighbor(target, "send-to-back")).toBeNull();
|
||||
expect(resolveCrossedNeighbor(target, "send-backward")).toBeNull(); // already bottom
|
||||
const { target: top } = makeFamily("5", [["a", "2"]]);
|
||||
expect(resolveCrossedNeighbor(top, "bring-forward")).toBeNull(); // already top
|
||||
});
|
||||
|
||||
it("returns null when there are no siblings", () => {
|
||||
const solo = makeEl("solo", "1");
|
||||
document.createElement("div").appendChild(solo);
|
||||
expect(resolveCrossedNeighbor(solo, "bring-forward")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── isZOrderActionEnabled ─────────────────────────────────────────────────────
|
||||
|
||||
describe("isZOrderActionEnabled", () => {
|
||||
|
||||
@@ -7,8 +7,23 @@
|
||||
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
|
||||
*
|
||||
* "Overlapping siblings" = siblings whose bounding rects intersect the
|
||||
* target's bounding rect. Forward/backward operate within that set;
|
||||
* front/back operate across all siblings.
|
||||
* target's bounding rect AND are actually visible at the current frame.
|
||||
* Forward/backward operate within that set; front/back operate across all
|
||||
* siblings (full painting family, visible or not — unchanged semantics).
|
||||
*
|
||||
* ── Visibility ───────────────────────────────────────────────────────────────
|
||||
* In HyperFrames compositions the nearest z-neighbor is often INVISIBLE at the
|
||||
* paused frame: the runtime hides time-inactive clips with inline
|
||||
* `visibility:hidden` / `display:none` (see core runtime
|
||||
* syncTimedElementVisibility), and GSAP timelines park elements at `opacity:0`.
|
||||
* Stepping "forward" over such a sibling looks like a silent no-op. The
|
||||
* forward/backward comparison set therefore keeps only siblings whose
|
||||
* element-level computed style is visible (display ≠ none, visibility ≠
|
||||
* hidden, opacity > 0.01) — all runtime hiding signals are inline styles, so
|
||||
* computed style covers them. Ancestor-chain checks are unnecessary here:
|
||||
* siblings share the target's ancestors. The probe is injectable
|
||||
* (ZOrderResolveOptions.isVisible) so the pure-module tests stay meaningful
|
||||
* without a real style engine, mirroring how tests stub rect reading.
|
||||
*
|
||||
* ── Tie-awareness ────────────────────────────────────────────────────────────
|
||||
* CSS paint order for elements that share a z-index is DOM document order:
|
||||
@@ -26,6 +41,8 @@
|
||||
* (project convention clamps z ≥ 0).
|
||||
*/
|
||||
|
||||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
||||
|
||||
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
||||
|
||||
/** A resolved change: set `element`'s z-index to `zIndex`. */
|
||||
@@ -34,6 +51,48 @@ export interface ZOrderPatch {
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
/** Injectable knobs for the pure resolver (kept mockable like rect reading). */
|
||||
export interface ZOrderResolveOptions {
|
||||
/**
|
||||
* Element-level visibility probe used to scope the forward/backward
|
||||
* comparison set. Defaults to `isElementVisibleForZOrder` (computed-style
|
||||
* display/visibility/opacity). Injectable so tests can run without a real
|
||||
* style engine.
|
||||
*/
|
||||
isVisible?: (el: HTMLElement) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default visibility probe: is this element itself visible at the current
|
||||
* frame? Element-level only (siblings share the target's ancestor chain).
|
||||
* Covers the runtime's inactive-clip hiding (inline `visibility:hidden` /
|
||||
* `display:none`) and animation-parked `opacity:0`, all of which surface
|
||||
* through computed style. A color-grading source (hidden at opacity:0 while
|
||||
* its canvas paints in its place) still counts as visible, matching
|
||||
* isElementVisibleThroughAncestors in domEditingDom.
|
||||
*/
|
||||
export function isElementVisibleForZOrder(el: HTMLElement): boolean {
|
||||
try {
|
||||
const win = el.ownerDocument?.defaultView;
|
||||
if (!win) return true;
|
||||
const computed = win.getComputedStyle(el);
|
||||
if (computed.display === "none") return false;
|
||||
if (computed.visibility === "hidden" || computed.visibility === "collapse") return false;
|
||||
const opacity = Number.parseFloat(computed.opacity);
|
||||
if (
|
||||
Number.isFinite(opacity) &&
|
||||
opacity <= 0.01 &&
|
||||
!el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
/* cross-origin / detached — assume visible (fail open, matches rect fallback) */
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
interface RenderEntry {
|
||||
element: HTMLElement;
|
||||
zIndex: number;
|
||||
@@ -81,8 +140,18 @@ function isElementNode(node: Node): node is HTMLElement {
|
||||
* z-index onto the qa-clean audio element, and counting it as a sibling skews the
|
||||
* renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
|
||||
* non-painting and could otherwise pad the family / eat a z slot.
|
||||
* `<template>/<noscript>` never paint either — letting them in meant renumber
|
||||
* fallbacks wrote z-index/position into template source markup.
|
||||
*/
|
||||
const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
|
||||
const NON_PAINTING_TAGS = new Set([
|
||||
"AUDIO",
|
||||
"SCRIPT",
|
||||
"STYLE",
|
||||
"LINK",
|
||||
"META",
|
||||
"TEMPLATE",
|
||||
"NOSCRIPT",
|
||||
]);
|
||||
|
||||
/** A painting element: an element node whose tag actually renders pixels. */
|
||||
function isPaintingElement(node: Node): node is HTMLElement {
|
||||
@@ -112,7 +181,7 @@ function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex:
|
||||
return { entries, targetIndex };
|
||||
}
|
||||
|
||||
/** True if two DOM bounding rects intersect (even if touching). */
|
||||
/** True if two DOM bounding rects strictly overlap (rects that merely touch do NOT intersect). */
|
||||
function rectsIntersect(
|
||||
a: { left: number; top: number; right: number; bottom: number },
|
||||
b: { left: number; top: number; right: number; bottom: number },
|
||||
@@ -121,26 +190,35 @@ function rectsIntersect(
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a family to the target plus siblings whose bounding rect overlaps
|
||||
* the target's rect. The target is always retained. If the target's rect is
|
||||
* unavailable or empty (headless / happy-dom returns 0×0), all entries are
|
||||
* kept — matching the prior behavior.
|
||||
* Restrict a family to the target plus siblings that are VISIBLE and whose
|
||||
* bounding rect overlaps the target's rect. The target is always retained
|
||||
* (even when itself hidden at the current frame — it is the user's explicit
|
||||
* selection). If the target's rect is unavailable or empty (headless /
|
||||
* happy-dom returns 0×0), the overlap filter is skipped and all VISIBLE
|
||||
* entries are kept — matching the prior rect-fallback behavior.
|
||||
*/
|
||||
function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
|
||||
function getOverlappingFamily(
|
||||
target: HTMLElement,
|
||||
entries: RenderEntry[],
|
||||
isVisible: (el: HTMLElement) => boolean,
|
||||
): RenderEntry[] {
|
||||
const visibleEntries = entries.filter(
|
||||
(entry) => entry.element === target || isVisible(entry.element),
|
||||
);
|
||||
let targetRect: DOMRect;
|
||||
try {
|
||||
targetRect = target.getBoundingClientRect();
|
||||
} catch {
|
||||
return entries;
|
||||
return visibleEntries;
|
||||
}
|
||||
if (targetRect.width === 0 && targetRect.height === 0) return entries;
|
||||
if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
|
||||
const tr = {
|
||||
left: targetRect.left,
|
||||
top: targetRect.top,
|
||||
right: targetRect.right,
|
||||
bottom: targetRect.bottom,
|
||||
};
|
||||
return entries.filter((entry) => {
|
||||
return visibleEntries.filter((entry) => {
|
||||
if (entry.element === target) return true;
|
||||
try {
|
||||
const r = entry.element.getBoundingClientRect();
|
||||
@@ -295,6 +373,33 @@ function buildGlobalOrder(
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared scoping pipeline: full painting family for front/back, visible
|
||||
* overlapping siblings for forward/backward, sorted into render order with the
|
||||
* target's position. Null when the family/scope is too small to act on.
|
||||
*/
|
||||
function resolveScopedRenderOrder(
|
||||
target: HTMLElement,
|
||||
action: ZOrderAction,
|
||||
options?: ZOrderResolveOptions,
|
||||
): { entries: RenderEntry[]; order: RenderEntry[]; pos: number } | null {
|
||||
const { entries } = getFamily(target);
|
||||
// Family always includes the target; fewer than 2 means no siblings at all.
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const isVisible = options?.isVisible ?? isElementVisibleForZOrder;
|
||||
const scoped =
|
||||
action === "bring-to-front" || action === "send-to-back"
|
||||
? entries
|
||||
: getOverlappingFamily(target, entries, isVisible);
|
||||
if (scoped.length < 2) return null;
|
||||
|
||||
const order = toRenderOrder(scoped);
|
||||
const pos = order.findIndex((e) => e.element === target);
|
||||
if (pos === -1) return null;
|
||||
return { entries, order, pos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the z-order patches for an action.
|
||||
*
|
||||
@@ -304,20 +409,11 @@ function buildGlobalOrder(
|
||||
export function resolveZOrderChange(
|
||||
target: HTMLElement,
|
||||
action: ZOrderAction,
|
||||
options?: ZOrderResolveOptions,
|
||||
): ZOrderPatch[] | null {
|
||||
const { entries } = getFamily(target);
|
||||
// Family always includes the target; fewer than 2 means no siblings at all.
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const scoped =
|
||||
action === "bring-to-front" || action === "send-to-back"
|
||||
? entries
|
||||
: getOverlappingFamily(target, entries);
|
||||
if (scoped.length < 2) return null;
|
||||
|
||||
const order = toRenderOrder(scoped);
|
||||
const pos = order.findIndex((e) => e.element === target);
|
||||
if (pos === -1) return null;
|
||||
const resolved = resolveScopedRenderOrder(target, action, options);
|
||||
if (!resolved) return null;
|
||||
const { entries, order, pos } = resolved;
|
||||
|
||||
const desired = [...order];
|
||||
const [moved] = desired.splice(pos, 1);
|
||||
@@ -344,9 +440,35 @@ export function resolveZOrderChange(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a z-order action is available for the target.
|
||||
* "disabled" = the element is already at that limit.
|
||||
* The sibling a forward/backward step crosses: the visible overlapping
|
||||
* neighbor directly above (bring-forward) or below (send-backward) the target
|
||||
* in render order. Null for front/back, for a no-op step, or when the scope is
|
||||
* too small. Uses the SAME scoping as resolveZOrderChange, so call it with the
|
||||
* same options BEFORE any live styles are applied.
|
||||
*/
|
||||
export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
|
||||
return resolveZOrderChange(target, action) !== null;
|
||||
export function resolveCrossedNeighbor(
|
||||
target: HTMLElement,
|
||||
action: ZOrderAction,
|
||||
options?: ZOrderResolveOptions,
|
||||
): HTMLElement | null {
|
||||
if (action !== "bring-forward" && action !== "send-backward") return null;
|
||||
const resolved = resolveScopedRenderOrder(target, action, options);
|
||||
if (!resolved) return null;
|
||||
const { order, pos } = resolved;
|
||||
const neighbor = action === "bring-forward" ? order[pos + 1] : order[pos - 1];
|
||||
return neighbor?.element ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a z-order action is available for the target.
|
||||
* "disabled" = the element is already at that limit. Shares the resolver (and
|
||||
* its visibility scoping), so enable/disable always matches what the action
|
||||
* would actually do.
|
||||
*/
|
||||
export function isZOrderActionEnabled(
|
||||
target: HTMLElement,
|
||||
action: ZOrderAction,
|
||||
options?: ZOrderResolveOptions,
|
||||
): boolean {
|
||||
return resolveZOrderChange(target, action, options) !== null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Canvas right-click context-menu state for DomEditOverlay: where the menu is
|
||||
* open (viewport x/y) and which selection it targets, plus the right-click
|
||||
* handler that resolves/selects the element under the pointer before opening.
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type RefObject } from "react";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
|
||||
export interface CanvasContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
sel: DomEditSelection;
|
||||
}
|
||||
|
||||
interface UseCanvasContextMenuStateParams {
|
||||
selection: DomEditSelection | null;
|
||||
selectionRef: RefObject<DomEditSelection | null>;
|
||||
hoverSelectionRef: RefObject<DomEditSelection | null>;
|
||||
onCanvasPointerMoveRef: RefObject<
|
||||
(
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
) => Promise<DomEditSelection | null>
|
||||
>;
|
||||
onSelectionChangeRef: RefObject<
|
||||
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
|
||||
>;
|
||||
}
|
||||
|
||||
export function useCanvasContextMenuState({
|
||||
selection,
|
||||
selectionRef,
|
||||
hoverSelectionRef,
|
||||
onCanvasPointerMoveRef,
|
||||
onSelectionChangeRef,
|
||||
}: UseCanvasContextMenuStateParams): {
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
closeContextMenu: () => void;
|
||||
handleContextMenu: (event: React.MouseEvent<HTMLDivElement>) => Promise<void>;
|
||||
} {
|
||||
// Context menu state: position of the right-click that opened it.
|
||||
// contextMenu.sel is the element the menu targets — captured at right-click
|
||||
// time so the menu can open even before the React selection state settles.
|
||||
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(null);
|
||||
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
||||
|
||||
// Close the context menu whenever the selection moves off the element the menu
|
||||
// targets (a click that reselects elsewhere, a deselect, or a preview reload
|
||||
// that rebuilds the selection). Without this the menu can linger — orphaned —
|
||||
// over a stale target after the underlying element is gone. A right-click that
|
||||
// OPENS the menu also selects its target, so the common open path keeps the
|
||||
// menu (same element) rather than immediately dismissing it.
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
if (!selection || selection.element !== contextMenu.sel.element) {
|
||||
setContextMenu(null);
|
||||
}
|
||||
}, [selection, contextMenu]);
|
||||
|
||||
// Right-click: select element first (if not already selected), then open menu.
|
||||
const handleContextMenu = useCallback(
|
||||
async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
// If no element is selected yet, resolve it from the pointer position first.
|
||||
const currentSel = selectionRef.current;
|
||||
let activeSel: DomEditSelection | null = currentSel;
|
||||
if (!currentSel) {
|
||||
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
|
||||
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
|
||||
if (!resolved) return; // Nothing under the cursor — skip menu.
|
||||
onSelectionChangeRef.current(resolved, { revealPanel: true });
|
||||
// Use `resolved` directly: React state (and therefore selectionRef) won't
|
||||
// update synchronously after onSelectionChange — we'd be reading stale null.
|
||||
activeSel = resolved;
|
||||
} else {
|
||||
// Check if the user right-clicked on an unselected element (hover target).
|
||||
const hover = hoverSelectionRef.current;
|
||||
if (hover && hover.element !== currentSel.element) {
|
||||
onSelectionChangeRef.current(hover, { revealPanel: true });
|
||||
activeSel = hover;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSel) return;
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
|
||||
},
|
||||
[selectionRef, hoverSelectionRef, onCanvasPointerMoveRef, onSelectionChangeRef],
|
||||
);
|
||||
|
||||
return { contextMenu, closeContextMenu, handleContextMenu };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Z-order "show your work" flash: after a bring-forward / send-backward, the
|
||||
* sibling that was stepped over gets a brief (600ms) highlight so the action
|
||||
* is legible even when the visual change is subtle.
|
||||
*
|
||||
* Drawn in the STUDIO's own overlay layer above the preview iframe — nothing
|
||||
* is written into the iframe DOM or the composition, so a concurrent preview
|
||||
* reload can never leave a stuck highlight; the timeout merely clears
|
||||
* studio-local state. The crossed element is resolved by the context menu
|
||||
* (resolveCrossedNeighbor) from the same pre-mutation render order as the
|
||||
* z patches; z-index writes don't move layout, so measuring its rect after
|
||||
* the commit applied live styles is still accurate.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { toVisibleOverlayRect, type OverlayRect } from "./domEditOverlayGeometry";
|
||||
|
||||
const Z_ORDER_CROSSED_FLASH_MS = 600;
|
||||
|
||||
interface UseZOrderCrossedFlashParams {
|
||||
overlayRef: RefObject<HTMLDivElement | null>;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
export function useZOrderCrossedFlash({ overlayRef, iframeRef }: UseZOrderCrossedFlashParams): {
|
||||
zOrderFlashRect: OverlayRect | null;
|
||||
handleZOrderCrossed: (crossed: HTMLElement) => void;
|
||||
} {
|
||||
const [zOrderFlashRect, setZOrderFlashRect] = useState<OverlayRect | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleZOrderCrossed = useCallback(
|
||||
(crossed: HTMLElement) => {
|
||||
const overlayEl = overlayRef.current;
|
||||
const iframe = iframeRef.current;
|
||||
if (!overlayEl || !iframe) return;
|
||||
const rect = toVisibleOverlayRect(overlayEl, iframe, crossed);
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setZOrderFlashRect(rect);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
setZOrderFlashRect(null);
|
||||
}, Z_ORDER_CROSSED_FLASH_MS);
|
||||
},
|
||||
[overlayRef, iframeRef],
|
||||
);
|
||||
|
||||
return { zOrderFlashRect, handleZOrderCrossed };
|
||||
}
|
||||
|
||||
/** The flash chrome itself — a pulsing accent outline over the crossed sibling. */
|
||||
export function ZOrderCrossedFlash({ rect }: { rect: OverlayRect | null }) {
|
||||
if (!rect) return null;
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-dom-edit-z-flash="true"
|
||||
className="pointer-events-none absolute rounded-md border-2 border-studio-accent shadow-[0_0_0_2px_rgba(60,230,172,0.35)] animate-pulse"
|
||||
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
||||
import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.getState().reset();
|
||||
// reset() does not cover the out-of-loop seek request; clear it explicitly
|
||||
// so a pending-seek test can't leak into the next one.
|
||||
usePlayerStore.getState().clearSeekRequest();
|
||||
useAssetPreviewStore.getState().clearPreviewAsset();
|
||||
});
|
||||
|
||||
function mountOverlay(): void {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
act(() => {
|
||||
root?.render(<AssetPreviewOverlay />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("AssetPreviewOverlay playback dismissal", () => {
|
||||
it("dismisses immediately when opened while playback is ALREADY running", () => {
|
||||
// The RAF playback loop bypasses the store, so no post-open store change
|
||||
// will arrive — the dismiss check must be level-triggered, not edge-triggered.
|
||||
usePlayerStore.setState({ isPlaying: true });
|
||||
mountOverlay();
|
||||
|
||||
act(() => {
|
||||
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
||||
});
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("dismisses immediately when opened while a seek is ALREADY in flight", () => {
|
||||
// A pending out-of-loop seek (requestedSeekTime set, not yet consumed) may
|
||||
// produce no further store change before currentTime settles — the on-open
|
||||
// check must evaluate the full shared dismiss predicate, not just isPlaying.
|
||||
usePlayerStore.setState({ isPlaying: false, requestedSeekTime: 3.2 });
|
||||
mountOverlay();
|
||||
|
||||
act(() => {
|
||||
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
||||
});
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("stays open when the playhead is idle", () => {
|
||||
mountOverlay();
|
||||
|
||||
act(() => {
|
||||
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
||||
});
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("still dismisses when playback starts AFTER the preview opened", () => {
|
||||
mountOverlay();
|
||||
|
||||
act(() => {
|
||||
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
||||
});
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
|
||||
|
||||
act(() => {
|
||||
usePlayerStore.setState({ isPlaying: true });
|
||||
});
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
||||
});
|
||||
|
||||
it("still dismisses when the playhead is scrubbed after opening", () => {
|
||||
usePlayerStore.setState({ currentTime: 2 });
|
||||
mountOverlay();
|
||||
|
||||
act(() => {
|
||||
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
usePlayerStore.setState({ currentTime: 4.5 });
|
||||
});
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,20 @@
|
||||
* CapCut-style asset preview overlay rendered inside PreviewPane.
|
||||
*
|
||||
* Shown when the user clicks an asset card that has NOT yet been added to the
|
||||
* timeline. Displays the media (image / video / audio) without modifying the
|
||||
* composition — no undo entry, no file mutation.
|
||||
* timeline. Displays the media (image / video / audio) as a compact floating
|
||||
* card over the canvas — the canvas stays visible behind a barely-tinted
|
||||
* click-catcher — without modifying the composition (no undo entry, no file
|
||||
* mutation).
|
||||
*
|
||||
* Dismiss: X button, Escape key, or click on the scrim.
|
||||
* Dismiss: X button, Escape key, click outside the card, or any playhead
|
||||
* activity (starting playback / seeking) — the canvas refocuses.
|
||||
* Switching to another not-added asset replaces the current preview.
|
||||
*/
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
|
||||
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { shouldDismissAssetPreview } from "../../utils/assetPreviewDismiss";
|
||||
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
||||
|
||||
function basename(path: string): string {
|
||||
@@ -37,11 +42,7 @@ function AssetPreviewMedia({
|
||||
}) {
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
className="max-w-full max-h-[70vh] rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
<img src={serveUrl} alt={name} className="max-w-full max-h-[40vh] rounded object-contain" />
|
||||
);
|
||||
}
|
||||
if (kind === "video") {
|
||||
@@ -52,12 +53,12 @@ function AssetPreviewMedia({
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="max-w-full max-h-[70vh] rounded-md shadow-2xl"
|
||||
className="max-w-full max-h-[40vh] rounded"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 px-6 py-8 rounded-xl bg-neutral-900 border border-neutral-800">
|
||||
<div className="flex flex-col items-center gap-3 px-6 py-4">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
@@ -94,6 +95,30 @@ export function AssetPreviewOverlay() {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [previewAsset, handleKeyDown]);
|
||||
|
||||
// The canvas refocuses on any playhead activity: starting playback or a
|
||||
// seek/scrub away from where the playhead sat when the preview opened
|
||||
// dismisses it. openedTime is captured per preview open (previewAsset dep),
|
||||
// so a stale render can never dismiss against the wrong reference time.
|
||||
useEffect(() => {
|
||||
if (!previewAsset) return;
|
||||
const opened = usePlayerStore.getState();
|
||||
const openedTime = opened.currentTime;
|
||||
// Level-triggered, not edge-triggered: a preview opened while playback is
|
||||
// ALREADY running (the RAF loop bypasses the store) or while a seek is
|
||||
// already in flight gets no store change to react to, so evaluate the
|
||||
// current state once, through the same shared predicate the subscription
|
||||
// uses. openedTime is this snapshot's own currentTime, so the
|
||||
// time-diverged branch can't false-positive at open — only the
|
||||
// isPlaying / requestedSeekTime branches can fire here.
|
||||
if (shouldDismissAssetPreview(openedTime, opened)) {
|
||||
clearPreviewAsset();
|
||||
return;
|
||||
}
|
||||
return usePlayerStore.subscribe((state) => {
|
||||
if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset();
|
||||
});
|
||||
}, [previewAsset, clearPreviewAsset]);
|
||||
|
||||
if (!previewAsset || !previewProjectId) return null;
|
||||
|
||||
const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId);
|
||||
@@ -101,40 +126,39 @@ export function AssetPreviewOverlay() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80"
|
||||
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/20"
|
||||
onClick={clearPreviewAsset}
|
||||
role="dialog"
|
||||
aria-label={`Preview: ${name}`}
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="absolute top-3 right-3 w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearPreviewAsset();
|
||||
}}
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Media */}
|
||||
{/* Floating preview card — compact, canvas stays visible around it */}
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 max-w-[90%] max-h-[85%]"
|
||||
className="relative flex flex-col items-center gap-2 max-w-[58%] rounded-lg border border-neutral-800 bg-neutral-950/95 p-2 pt-8 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="absolute top-2 right-2 w-6 h-6 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearPreviewAsset();
|
||||
}}
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />
|
||||
|
||||
{/* Filename label */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
import { readHfId, type DomEditSelection } from "../editor/domEditing";
|
||||
import { buildStableSelector } from "../editor/domEditingDom";
|
||||
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
||||
import type { BlockPreviewInfo } from "../sidebar/BlocksTab";
|
||||
import type { GestureRecordingState } from "../editor/GestureRecordControl";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -38,6 +39,8 @@ type ZIndexReorderEntry = {
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile: string;
|
||||
/** Timeline store key — lets the commit update the store zIndex synchronously. */
|
||||
key?: string;
|
||||
};
|
||||
|
||||
/** Can this element be robustly re-targeted for a persisted z change? */
|
||||
@@ -58,6 +61,12 @@ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReord
|
||||
selector: sel.selector,
|
||||
selectorIndex: sel.selectorIndex,
|
||||
sourceFile: sel.sourceFile,
|
||||
key: deriveTimelineStoreKey({
|
||||
domId: sel.id ?? undefined,
|
||||
selector: sel.selector,
|
||||
selectorIndex: sel.selectorIndex,
|
||||
sourceFile: sel.sourceFile,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +84,15 @@ function siblingZIndexEntry(
|
||||
const id = element.id || undefined;
|
||||
const selector = buildStableSelector(element);
|
||||
if (!canTargetZIndexElement(element, id, selector)) return null;
|
||||
return { element, zIndex, id, selector, selectorIndex: undefined, sourceFile };
|
||||
return {
|
||||
element,
|
||||
zIndex,
|
||||
id,
|
||||
selector,
|
||||
selectorIndex: undefined,
|
||||
sourceFile,
|
||||
key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Short human-readable label for a dropped sibling, for the console warning below. */
|
||||
@@ -88,14 +105,16 @@ function describeZIndexElement(element: HTMLElement): string {
|
||||
}
|
||||
|
||||
// Resolve z-index patches into commit entries; a sibling with no stable
|
||||
// id/selector can't be written to source, so it is collected as a label for the
|
||||
// revert-on-reload warning instead.
|
||||
function resolveZIndexEntries(
|
||||
// id/selector can't be written to source, so it is returned as `dropped` for
|
||||
// the revert-on-reload warning (and a live-only style write, so the resolved
|
||||
// stacking order still renders coherently). Exported so tests can drive the
|
||||
// menu → commit path through the same wiring the app uses.
|
||||
export function resolveZIndexEntries(
|
||||
sel: DomEditSelection,
|
||||
patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
|
||||
): { entries: ZIndexReorderEntry[]; droppedLabels: string[] } {
|
||||
): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
|
||||
const entries: ZIndexReorderEntry[] = [];
|
||||
const droppedLabels: string[] = [];
|
||||
const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
|
||||
for (const patch of patches) {
|
||||
if (patch.element === sel.element) {
|
||||
entries.push(selectedZIndexEntry(sel, patch.zIndex));
|
||||
@@ -103,9 +122,9 @@ function resolveZIndexEntries(
|
||||
}
|
||||
const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
|
||||
if (entry) entries.push(entry);
|
||||
else droppedLabels.push(describeZIndexElement(patch.element));
|
||||
else dropped.push(patch);
|
||||
}
|
||||
return { entries, droppedLabels };
|
||||
return { entries, dropped };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -205,19 +224,20 @@ export function PreviewOverlays({
|
||||
onRotationCommit={handleDomRotationCommit}
|
||||
onStyleCommit={handleDomStyleCommit}
|
||||
onDeleteSelection={handleDomEditElementDelete}
|
||||
onApplyZIndex={(sel, patches) => {
|
||||
const { entries, droppedLabels } = resolveZIndexEntries(sel, patches);
|
||||
if (droppedLabels.length > 0) {
|
||||
// The optimistic z-index has already been applied live at this point —
|
||||
// these siblings just won't be written to source, so they'll revert to
|
||||
// their prior stacking order on the next reload with no other signal.
|
||||
onApplyZIndex={(sel, patches, action) => {
|
||||
const { entries, dropped } = resolveZIndexEntries(sel, patches);
|
||||
if (dropped.length > 0) {
|
||||
// These siblings can't be written to source. Apply their live z
|
||||
// anyway so the resolved stacking order renders coherently — it
|
||||
// just reverts to the prior order on the next reload.
|
||||
for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
|
||||
console.warn(
|
||||
"[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
|
||||
"(will revert on reload):",
|
||||
droppedLabels.join(", "),
|
||||
dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
|
||||
);
|
||||
}
|
||||
if (entries.length > 0) handleDomZIndexReorderCommit(entries);
|
||||
if (entries.length > 0) handleDomZIndexReorderCommit(entries, undefined, action);
|
||||
}}
|
||||
gridVisible={snapPrefs.gridVisible}
|
||||
gridSpacing={snapPrefs.gridSpacing}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
||||
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
||||
import { AssetCard } from "./AssetCard";
|
||||
import { AudioRow } from "./AudioRow";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.getState().reset();
|
||||
useAssetPreviewStore.getState().clearPreviewAsset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mount(node: React.ReactElement): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
act(() => {
|
||||
root?.render(node);
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
|
||||
return { tag: "div", start: 0, duration: 5, track: 0, ...input };
|
||||
}
|
||||
|
||||
/** Simulate a drag-free click: pointerdown + pointerup at the same point. */
|
||||
function clickCard(host: HTMLElement): void {
|
||||
const card = host.querySelector('[draggable="true"]');
|
||||
if (!card) throw new Error("Expected a draggable card root");
|
||||
const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
|
||||
act(() => {
|
||||
card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
|
||||
card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("AssetCard click behavior", () => {
|
||||
const cardProps = {
|
||||
projectId: "p1",
|
||||
onCopy: vi.fn(),
|
||||
isCopied: false,
|
||||
};
|
||||
|
||||
it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
|
||||
usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
|
||||
// Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
|
||||
// or it stays stuck over the canvas while the timeline reveals the clip.
|
||||
useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
|
||||
|
||||
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
|
||||
clickCard(host);
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
||||
expect(usePlayerStore.getState().selectedElementId).toBe("img1");
|
||||
});
|
||||
|
||||
it("opens the preview overlay for a not-yet-added asset", () => {
|
||||
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
|
||||
clickCard(host);
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
|
||||
expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioRow click behavior", () => {
|
||||
const rowProps = {
|
||||
projectId: "p1",
|
||||
onCopy: vi.fn(),
|
||||
isCopied: false,
|
||||
};
|
||||
|
||||
it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
|
||||
useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
|
||||
|
||||
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
|
||||
clickCard(host);
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
||||
expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
|
||||
});
|
||||
|
||||
it("opens the preview overlay for a not-yet-added audio asset", () => {
|
||||
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
|
||||
clickCard(host);
|
||||
|
||||
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
|
||||
});
|
||||
});
|
||||
@@ -133,8 +133,10 @@ export function AssetCard({
|
||||
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
||||
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
pointerDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
@@ -150,14 +152,29 @@ export function AssetCard({
|
||||
if (used) {
|
||||
const clip = findClipForAsset(elements, asset);
|
||||
if (clip) {
|
||||
setSelectedElementId(clip.key ?? clip.id);
|
||||
// Dismiss any open preview overlay (from another asset) — the reveal
|
||||
// must not leave a stale preview card floating over the canvas.
|
||||
clearPreviewAsset();
|
||||
const clipKey = clip.key ?? clip.id;
|
||||
setSelectedElementId(clipKey);
|
||||
// Scroll the timeline so the selected clip is actually visible.
|
||||
requestClipReveal(clipKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Not added (or no matching clip found) → preview overlay
|
||||
setPreviewAsset(asset, projectId);
|
||||
},
|
||||
[used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
|
||||
[
|
||||
used,
|
||||
elements,
|
||||
asset,
|
||||
projectId,
|
||||
setSelectedElementId,
|
||||
requestClipReveal,
|
||||
setPreviewAsset,
|
||||
clearPreviewAsset,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,8 +43,10 @@ export function AudioRow({
|
||||
// CapCut-style click behavior: drag-threshold gate.
|
||||
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
||||
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
pointerDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
@@ -59,14 +61,29 @@ export function AudioRow({
|
||||
if (used) {
|
||||
const clip = findClipForAsset(elements, asset);
|
||||
if (clip) {
|
||||
setSelectedElementId(clip.key ?? clip.id);
|
||||
// Dismiss any open preview overlay (from another asset) — the reveal
|
||||
// must not leave a stale preview card floating over the canvas.
|
||||
clearPreviewAsset();
|
||||
const clipKey = clip.key ?? clip.id;
|
||||
setSelectedElementId(clipKey);
|
||||
// Scroll the timeline so the selected clip is actually visible.
|
||||
requestClipReveal(clipKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Not added → preview overlay (audio player)
|
||||
setPreviewAsset(asset, projectId);
|
||||
},
|
||||
[used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
|
||||
[
|
||||
used,
|
||||
elements,
|
||||
asset,
|
||||
projectId,
|
||||
setSelectedElementId,
|
||||
requestClipReveal,
|
||||
setPreviewAsset,
|
||||
clearPreviewAsset,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,7 +8,18 @@ export interface DomEditPatchBatch {
|
||||
|
||||
export type CommitDomEditPatchBatches = (
|
||||
batches: DomEditPatchBatch[],
|
||||
options: { label: string; coalesceKey: string },
|
||||
options: {
|
||||
label: string;
|
||||
coalesceKey: string;
|
||||
/**
|
||||
* Request skipping the preview iframe reload after a successful persist.
|
||||
* Only honored when the persist is provably in sync with the live DOM:
|
||||
* every patch operation is inline-style-only AND the server matched every
|
||||
* patch target. Any unmatched target (or a non-style op) falls back to the
|
||||
* reload so the preview reconverges with disk. Default: always reload.
|
||||
*/
|
||||
skipReload?: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
|
||||
export type PersistDomEditOperations = (
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyTimelineStackingReorder,
|
||||
buildTimelineMoveTimingPatch,
|
||||
deleteSelectedKeyframes,
|
||||
extendRootDurationIfNeeded,
|
||||
persistTimelineBatchEdit,
|
||||
type PersistTimelineBatchChange,
|
||||
} from "./timelineEditingHelpers";
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
@@ -108,6 +111,94 @@ describe("extendRootDurationIfNeeded", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistTimelineBatchEdit", () => {
|
||||
const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
|
||||
|
||||
function batchInput(changes: PersistTimelineBatchChange[], writes: Array<[string, string]>) {
|
||||
return {
|
||||
projectId: "p1",
|
||||
activeCompPath: "index.html",
|
||||
label: "Move timeline clips",
|
||||
changes,
|
||||
writeProjectFile: async (path: string, content: string) => {
|
||||
writes.push([path, content]);
|
||||
},
|
||||
recordEdit: async () => {},
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
pendingTimelineEditPathRef: { current: new Set<string>() },
|
||||
};
|
||||
}
|
||||
|
||||
function stubReadFileContent(content: string) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ content }),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function moveMember(
|
||||
id: string,
|
||||
start: number,
|
||||
fromTrack: number,
|
||||
toTrack: number,
|
||||
): PersistTimelineBatchChange {
|
||||
return {
|
||||
element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
|
||||
buildPatches: (original, target) =>
|
||||
buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
|
||||
};
|
||||
}
|
||||
|
||||
async function runBatch(changes: PersistTimelineBatchChange[]) {
|
||||
stubReadFileContent(SOURCE);
|
||||
const writes: Array<[string, string]> = [];
|
||||
await persistTimelineBatchEdit(batchInput(changes, writes));
|
||||
return writes;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
|
||||
// A track-insert renumber can include a member whose attributes already
|
||||
// hold the target values — its patch is string-identical. The batch must
|
||||
// skip it and still persist the members that DID change.
|
||||
const writes = await runBatch([
|
||||
// no-op: data-start already "1", track already 0
|
||||
moveMember("a", 1, 0, 0),
|
||||
// real change: track 1 -> 2
|
||||
moveMember("b", 2, 1, 2),
|
||||
]);
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]![0]).toBe("index.html");
|
||||
expect(writes[0]![1]).toContain('id="b" class="clip" data-start="2" data-track-index="2"');
|
||||
});
|
||||
|
||||
it("saves nothing when every member is a no-op", async () => {
|
||||
const writes = await runBatch([moveMember("a", 1, 0, 0)]);
|
||||
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws on a mistargeted member instead of silently dropping it", async () => {
|
||||
// A member whose target does not resolve in the source (stale id) patches
|
||||
// to the identical string too — but that is a targeting FAILURE, not an
|
||||
// already-at-target no-op, and must abort the batch like the single path.
|
||||
stubReadFileContent(SOURCE);
|
||||
const writes: Array<[string, string]> = [];
|
||||
|
||||
await expect(
|
||||
persistTimelineBatchEdit(batchInput([moveMember("ghost", 3, 0, 2)], writes)),
|
||||
).rejects.toThrow("Unable to patch timeline element ghost in index.html");
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSelectedKeyframes", () => {
|
||||
it("coalesces all removals and reloads only after the last one", () => {
|
||||
usePlayerStore.setState({
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
||||
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
||||
import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
||||
import {
|
||||
formatTimelineAttributeNumber,
|
||||
type TimelineStackingReorderIntent,
|
||||
} from "../player/components/timelineEditing";
|
||||
import { getElementZIndex } from "../player/lib/layerOrdering";
|
||||
import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
|
||||
import {
|
||||
furthestClipEndFromSource,
|
||||
getTimelineElementIdentity,
|
||||
} from "../player/lib/timelineElementHelpers";
|
||||
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
||||
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
|
||||
import { extendRootDurationInSource } from "../utils/rootDuration";
|
||||
import { postRuntimeControlMessage } from "../player/lib/runtimeProtocol";
|
||||
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { readFileContent } from "./timelineTimingSync";
|
||||
export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
|
||||
export { readFileContent };
|
||||
function isHTMLElement(element: Element | null): element is HTMLElement {
|
||||
if (!element) return false;
|
||||
// Use the element's OWN realm's HTMLElement: timeline clips live in the preview
|
||||
@@ -149,16 +152,6 @@ export function patchIframeDomTiming(
|
||||
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
|
||||
}
|
||||
}
|
||||
function postRootDurationToPreview(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
durationSeconds: number,
|
||||
): void {
|
||||
const duration = Number(durationSeconds);
|
||||
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||
postRuntimeControlMessage(iframe?.contentWindow, "set-root-duration", {
|
||||
durationSeconds: duration,
|
||||
});
|
||||
}
|
||||
// fallow-ignore-next-line complexity
|
||||
function resolveResizePlaybackStart(
|
||||
original: string,
|
||||
@@ -211,7 +204,12 @@ export function buildTimelineMoveTimingPatch(
|
||||
value: formatTimelineAttributeNumber(track),
|
||||
});
|
||||
}
|
||||
return extendRootDurationInSource(patched, start + duration);
|
||||
// Content-driven duration: sync data-duration to the furthest clip end read
|
||||
// from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
|
||||
// past the end and shrinks if the furthest clip moved left. Measured from the
|
||||
// source, NOT the store — store durations are runtime-truncated to the current
|
||||
// comp length, which would ratchet the duration down every move.
|
||||
return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
|
||||
}
|
||||
|
||||
export function buildTimelineResizeTimingPatch(
|
||||
@@ -238,7 +236,10 @@ export function buildTimelineResizeTimingPatch(
|
||||
value: formatTimelineAttributeNumber(pbs.value),
|
||||
});
|
||||
}
|
||||
return extendRootDurationInSource(patched, updates.start + updates.duration);
|
||||
// Content-driven duration from the PATCHED SOURCE (raw data-duration) —
|
||||
// grows/shrinks to the furthest clip end. Not from the store, whose
|
||||
// durations are runtime-truncated.
|
||||
return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
|
||||
}
|
||||
|
||||
export interface PersistTimelineEditInput {
|
||||
@@ -318,13 +319,24 @@ export async function persistTimelineBatchEdit(
|
||||
}
|
||||
|
||||
const current = patchedByPath.get(targetPath) ?? original;
|
||||
const patched = change.buildPatches(current, patchTarget);
|
||||
if (patched === current) {
|
||||
// Resolve the target FIRST: byte-identical output below is only a legit
|
||||
// no-op when the member actually resolved in the source. A mistargeted
|
||||
// member (stale id/selector) must fail loudly like the single-edit path,
|
||||
// not be silently dropped as "already at target".
|
||||
if (!findTagByTarget(current, patchTarget)) {
|
||||
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
|
||||
}
|
||||
const patched = change.buildPatches(current, patchTarget);
|
||||
// The target resolved, so a member whose attributes already hold the target
|
||||
// values patches to the identical string — e.g. a track-insert renumber
|
||||
// where one clip's lane is already correct. That is a legitimate no-op:
|
||||
// skip it instead of aborting (and rolling back) the whole batch.
|
||||
if (patched === current) continue;
|
||||
patchedByPath.set(targetPath, patched);
|
||||
}
|
||||
|
||||
if (patchedByPath.size === 0) return;
|
||||
|
||||
const files = Object.fromEntries(patchedByPath);
|
||||
for (const targetPath of Object.keys(files)) {
|
||||
input.pendingTimelineEditPathRef.current.add(targetPath);
|
||||
@@ -343,227 +355,6 @@ export async function persistTimelineBatchEdit(
|
||||
input.domEditSaveTimestampRef.current = Date.now();
|
||||
}
|
||||
|
||||
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
||||
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
||||
throw new Error(`Unsafe path: ${targetPath}`);
|
||||
}
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read ${targetPath}`);
|
||||
}
|
||||
const data = (await response.json()) as { content?: string };
|
||||
if (typeof data.content !== "string") {
|
||||
throw new Error(`Missing file contents for ${targetPath}`);
|
||||
}
|
||||
return data.content;
|
||||
}
|
||||
|
||||
export type GsapMutationStatus = { mutated: boolean };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readMutationStatus(value: unknown): GsapMutationStatus {
|
||||
if (!isRecord(value)) return { mutated: false };
|
||||
return { mutated: value.mutated === true || value.changed === true };
|
||||
}
|
||||
|
||||
function readMutationError(value: unknown, fallback: string): string {
|
||||
if (isRecord(value) && typeof value.error === "string") return value.error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function finishTimelineTimingFallback(input: {
|
||||
iframe: HTMLIFrameElement | null;
|
||||
needsExtension: boolean;
|
||||
rootDurationSeconds: number;
|
||||
reloadPreview: () => void;
|
||||
gsapMutation?: () => Promise<GsapMutationStatus>;
|
||||
onGsapError: (error: unknown) => void;
|
||||
}): Promise<void> {
|
||||
let gsapMutated = false;
|
||||
if (input.gsapMutation) {
|
||||
try {
|
||||
gsapMutated = (await input.gsapMutation()).mutated;
|
||||
} catch (error) {
|
||||
input.onGsapError(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (input.needsExtension) {
|
||||
postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
|
||||
if (gsapMutated) input.reloadPreview();
|
||||
return;
|
||||
}
|
||||
input.reloadPreview();
|
||||
}
|
||||
|
||||
// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
|
||||
// outlast one GSAP server round-trip, never a real second edit.
|
||||
const GSAP_HISTORY_COALESCE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
|
||||
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
|
||||
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
|
||||
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
|
||||
* folding both writes into one undo step. Returns the mutation status for caller reloads.
|
||||
*/
|
||||
export async function foldGsapMutationIntoHistory(input: {
|
||||
projectId: string;
|
||||
paths: string[];
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
gsapMutation: () => Promise<GsapMutationStatus>;
|
||||
}): Promise<GsapMutationStatus> {
|
||||
const uniquePaths = [...new Set(input.paths)];
|
||||
const before = new Map<string, string>();
|
||||
for (const path of uniquePaths) {
|
||||
before.set(path, await readFileContent(input.projectId, path));
|
||||
}
|
||||
const status = await input.gsapMutation();
|
||||
if (status.mutated) {
|
||||
const files: Record<string, { before: string; after: string }> = {};
|
||||
for (const path of uniquePaths) {
|
||||
const priorContent = before.get(path);
|
||||
const finalContent = await readFileContent(input.projectId, path);
|
||||
if (priorContent !== undefined && finalContent !== priorContent) {
|
||||
files[path] = { before: priorContent, after: finalContent };
|
||||
}
|
||||
}
|
||||
if (Object.keys(files).length > 0) {
|
||||
await input.recordEdit({
|
||||
label: input.label,
|
||||
kind: "timeline",
|
||||
coalesceKey: input.coalesceKey,
|
||||
coalesceMs: GSAP_HISTORY_COALESCE_MS,
|
||||
files,
|
||||
});
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift all GSAP animation positions targeting a given element by a time delta.
|
||||
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
|
||||
*/
|
||||
export async function shiftGsapPositions(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
elementId: string,
|
||||
delta: number,
|
||||
): Promise<GsapMutationStatus> {
|
||||
if (delta === 0 || !elementId) return { mutated: false };
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "shift-positions",
|
||||
targetSelector: `#${elementId}`,
|
||||
delta,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(readMutationError(err, "shift-positions failed"));
|
||||
}
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
export async function scaleGsapPositions(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
elementId: string,
|
||||
oldStart: number,
|
||||
oldDuration: number,
|
||||
newStart: number,
|
||||
newDuration: number,
|
||||
): Promise<GsapMutationStatus> {
|
||||
if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
|
||||
if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "scale-positions",
|
||||
targetSelector: `#${elementId}`,
|
||||
oldStart,
|
||||
oldDuration,
|
||||
newStart,
|
||||
newDuration,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(readMutationError(err, "scale-positions failed"));
|
||||
}
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
/** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
|
||||
export function foldedShiftGsapMutation(input: {
|
||||
projectId: string;
|
||||
targetPath: string;
|
||||
domId: string;
|
||||
delta: number;
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
}): () => Promise<GsapMutationStatus> {
|
||||
return () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId: input.projectId,
|
||||
paths: [input.targetPath],
|
||||
label: input.label,
|
||||
coalesceKey: input.coalesceKey,
|
||||
recordEdit: input.recordEdit,
|
||||
gsapMutation: () =>
|
||||
shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
|
||||
});
|
||||
}
|
||||
|
||||
/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
|
||||
export function foldedScaleGsapMutation(input: {
|
||||
projectId: string;
|
||||
targetPath: string;
|
||||
domId: string;
|
||||
from: { start: number; duration: number };
|
||||
to: { start: number; duration: number };
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
}): () => Promise<GsapMutationStatus> {
|
||||
return () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId: input.projectId,
|
||||
paths: [input.targetPath],
|
||||
label: input.label,
|
||||
coalesceKey: input.coalesceKey,
|
||||
recordEdit: input.recordEdit,
|
||||
gsapMutation: () =>
|
||||
scaleGsapPositions(
|
||||
input.projectId,
|
||||
input.targetPath,
|
||||
input.domId,
|
||||
input.from.start,
|
||||
input.from.duration,
|
||||
input.to.start,
|
||||
input.to.duration,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
||||
|
||||
export { patchDocumentRootDuration } from "./timelineEditingGsap";
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
|
||||
|
||||
type MoveArgs = Parameters<typeof persistTimelineMoveEditsAtomically>;
|
||||
|
||||
const element = (id: string, track: number): TimelineElement => ({
|
||||
id,
|
||||
key: id,
|
||||
@@ -11,64 +13,66 @@ const element = (id: string, track: number): TimelineElement => ({
|
||||
track,
|
||||
});
|
||||
|
||||
const twoLaneEdits = (bTrack: number): MoveArgs[0] => [
|
||||
{ element: element("a", 0), updates: { start: 1, track: 1 } },
|
||||
{ element: element("b", bTrack), updates: { start: 3, track: 2 } },
|
||||
];
|
||||
|
||||
const movedPair = (edits: MoveArgs[0]) => [
|
||||
{ element: edits[0].element, start: 1, track: 1 },
|
||||
{ element: edits[1].element, start: 3, track: 2 },
|
||||
];
|
||||
|
||||
const runMove = async (edits: MoveArgs[0], coalesceKey: MoveArgs[1], intent: MoveArgs[2]) => {
|
||||
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
|
||||
await persistTimelineMoveEditsAtomically(edits, coalesceKey, intent, {
|
||||
handleTimelineGroupMove,
|
||||
});
|
||||
return handleTimelineGroupMove;
|
||||
};
|
||||
|
||||
describe("persistTimelineMoveEditsAtomically", () => {
|
||||
it("persists two vertical edits as one group with the gesture coalesce key", async () => {
|
||||
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
|
||||
const edits = [
|
||||
{ element: element("a", 0), updates: { start: 1, track: 1 } },
|
||||
{ element: element("b", 1), updates: { start: 3, track: 2 } },
|
||||
];
|
||||
await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
|
||||
handleTimelineGroupMove,
|
||||
});
|
||||
const edits = twoLaneEdits(1);
|
||||
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "track-insert");
|
||||
expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
|
||||
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
|
||||
[
|
||||
{ element: edits[0].element, start: 1, track: 1 },
|
||||
{ element: edits[1].element, start: 3, track: 2 },
|
||||
],
|
||||
{ coalesceKey: "clip-lane-move:7" },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not persist track attrs for a single z-only lane reorder", async () => {
|
||||
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
|
||||
const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
|
||||
await persistTimelineMoveEditsAtomically([edit], "clip-lane-move:7", "lane-reorder", {
|
||||
handleTimelineGroupMove,
|
||||
});
|
||||
expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
|
||||
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
|
||||
coalesceKey: "clip-lane-move:7",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not persist track attrs for a multi-selection lane drag", async () => {
|
||||
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
|
||||
const edits = [
|
||||
{ element: element("a", 0), updates: { start: 1, track: 1 } },
|
||||
{ element: element("b", 2), updates: { start: 3, track: 2 } },
|
||||
];
|
||||
await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "lane-reorder", {
|
||||
handleTimelineGroupMove,
|
||||
it("omits track attrs for plain timing moves (keeps the SDK fast path eligible)", async () => {
|
||||
const edit = { element: element("a", 0), updates: { start: 1, track: 0 } };
|
||||
const handleTimelineGroupMove = await runMove([edit], undefined, "timing");
|
||||
expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
|
||||
coalesceKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists the track attr for a single lane reorder (stable track lanes)", async () => {
|
||||
// Lane = authored data-track-index; a vertical move that never hits disk
|
||||
// snaps back on the next normalize, so the lane change MUST persist.
|
||||
const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
|
||||
const handleTimelineGroupMove = await runMove([edit], "clip-lane-move:7", "lane-reorder");
|
||||
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
|
||||
[
|
||||
{ element: edits[0].element, start: 1 },
|
||||
{ element: edits[1].element, start: 3 },
|
||||
],
|
||||
[{ element: edit.element, start: 1, track: 1 }],
|
||||
{ coalesceKey: "clip-lane-move:7" },
|
||||
);
|
||||
});
|
||||
|
||||
it("persists track attrs for a multi-selection lane drag (stable track lanes)", async () => {
|
||||
const edits = twoLaneEdits(2);
|
||||
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "lane-reorder");
|
||||
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
|
||||
coalesceKey: "clip-lane-move:7",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects without retrying individual members when the atomic batch fails", async () => {
|
||||
const failure = new Error("batch failed");
|
||||
const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
|
||||
const edits = [
|
||||
{ element: element("a", 0), updates: { start: 1, track: 1 } },
|
||||
{ element: element("b", 1), updates: { start: 3, track: 2 } },
|
||||
];
|
||||
await expect(
|
||||
persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
|
||||
persistTimelineMoveEditsAtomically(twoLaneEdits(1), "clip-lane-move:7", "track-insert", {
|
||||
handleTimelineGroupMove,
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
@@ -28,9 +28,11 @@ export function persistTimelineMoveEditsAtomically(
|
||||
edits.map(({ element, updates }) => ({
|
||||
element,
|
||||
start: updates.start,
|
||||
// A single vertical edit is the z-only reorder path. Multi-edit gestures
|
||||
// are track inserts/ripples and must persist every resulting lane index.
|
||||
track: operation === "track-insert" ? updates.track : undefined,
|
||||
// Stable track lanes: a lane is the authored data-track-index, so every
|
||||
// vertical gesture (lane-reorder AND track-insert) must persist the track;
|
||||
// z is paint order only and is synced separately. Plain horizontal moves
|
||||
// ("timing") omit it so they stay eligible for the SDK fast path.
|
||||
track: operation === "timing" ? undefined : updates.track,
|
||||
})),
|
||||
{ coalesceKey },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import {
|
||||
captureDurationRollback,
|
||||
finishClipTimingFallback,
|
||||
readFileContent,
|
||||
shiftGsapPositions,
|
||||
} from "./timelineTimingSync";
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(input: Parameters<typeof fetch>[0]): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
return input.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub fetch: `/files/` reads return contents from the queue (repeating the
|
||||
* last entry), the GSAP-mutation endpoint answers with `gsapBody` (a thrown
|
||||
* Error rejects the call with a non-ok response).
|
||||
*/
|
||||
function stubFetch(fileContents: string[], gsapBody: unknown | Error) {
|
||||
let readIndex = 0;
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/files/")) {
|
||||
const content = fileContents[Math.min(readIndex, fileContents.length - 1)];
|
||||
readIndex += 1;
|
||||
return jsonResponse({ content });
|
||||
}
|
||||
if (url.includes("/gsap-mutations/")) {
|
||||
if (gsapBody instanceof Error) {
|
||||
return new Response(JSON.stringify({ error: gsapBody.message }), { status: 500 });
|
||||
}
|
||||
return jsonResponse(gsapBody);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function clipFallbackInput(overrides: {
|
||||
reloadPreview: () => void;
|
||||
recordEdit: (edit: unknown) => Promise<void>;
|
||||
}) {
|
||||
return {
|
||||
iframe: null,
|
||||
reloadPreview: overrides.reloadPreview,
|
||||
projectId: "p1",
|
||||
targetPath: "index.html",
|
||||
domId: "clip",
|
||||
label: "Move timeline clip",
|
||||
recordEdit: overrides.recordEdit as never,
|
||||
edit: { kind: "shift", delta: 1 } as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe("finishClipTimingFallback failure domains", () => {
|
||||
it("still syncs the preview when the history-fold step fails after a successful mutation", async () => {
|
||||
// Mutation succeeds (server rewrite already on disk), but recordEdit (the
|
||||
// fold step) throws. The preview MUST still be synced — otherwise stale
|
||||
// GSAP positions stay on screen. iframe=null makes the sync observable as
|
||||
// one reloadPreview() call.
|
||||
stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
|
||||
const reloadPreview = vi.fn();
|
||||
const foldError = new Error("history fold failed");
|
||||
const recordEdit = vi.fn(async () => {
|
||||
throw foldError;
|
||||
});
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
|
||||
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
// The fold error is surfaced, not swallowed silently.
|
||||
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("GSAP"), foldError);
|
||||
});
|
||||
|
||||
it("skips the preview sync when the MUTATION itself fails", async () => {
|
||||
stubFetch(["<before>"], new Error("mutation blew up"));
|
||||
const reloadPreview = vi.fn();
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
|
||||
|
||||
expect(recordEdit).not.toHaveBeenCalled();
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records the fold and syncs on the happy path", async () => {
|
||||
stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
|
||||
const reloadPreview = vi.fn();
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
|
||||
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
|
||||
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureDurationRollback", () => {
|
||||
it("restores the pre-sync duration only when it changed", () => {
|
||||
usePlayerStore.getState().setDuration(4);
|
||||
const rollback = captureDurationRollback(null);
|
||||
|
||||
// No change → rollback is a no-op (no spurious set).
|
||||
rollback();
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
|
||||
usePlayerStore.getState().setDuration(9);
|
||||
rollback();
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch URL encoding (user-influenced segments)", () => {
|
||||
it("URI-encodes the projectId in file reads", async () => {
|
||||
const fetchMock = stubFetch(["<html>"], {});
|
||||
await readFileContent("p/../evil", "index.html");
|
||||
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
|
||||
"/api/projects/p%2F..%2Fevil/files/index.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("URI-encodes the projectId in GSAP mutation calls", async () => {
|
||||
const fetchMock = stubFetch([], { mutated: false, scriptText: null });
|
||||
await shiftGsapPositions("p one", "scenes/intro.html", "clip", 1);
|
||||
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
|
||||
"/api/projects/p%20one/gsap-mutations/scenes%2Fintro.html",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
// Soft-reload-first preview sync for timeline timing edits: server GSAP
|
||||
// position mutations (shift / scale), folding those rewrites into the timing
|
||||
// edit's undo history, and swapping the rewritten script into the live preview
|
||||
// without a full iframe reload when possible.
|
||||
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
||||
import { applySoftReload } from "../utils/gsapSoftReload";
|
||||
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
|
||||
import type { RecordEditInput } from "../utils/studioFileHistory";
|
||||
import { patchDocumentRootDuration } from "./timelineEditingGsap";
|
||||
|
||||
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
||||
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
||||
throw new Error(`Unsafe path: ${targetPath}`);
|
||||
}
|
||||
const response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read ${targetPath}`);
|
||||
}
|
||||
const data = (await response.json()) as { content?: string };
|
||||
if (typeof data.content !== "string") {
|
||||
throw new Error(`Missing file contents for ${targetPath}`);
|
||||
}
|
||||
return data.content;
|
||||
}
|
||||
|
||||
/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
|
||||
function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
|
||||
try {
|
||||
patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
|
||||
} catch {
|
||||
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically push the composition's content-driven length into the player
|
||||
* store right after the live DOM patch, so the duration readout + seek bar
|
||||
* update immediately. The readout binds to store.duration (PlayerControls);
|
||||
* edits only patched store.elements, so the number stayed frozen (esp. on
|
||||
* shrink) until a manual refresh. Read from the just-patched preview DOM (raw
|
||||
* data-duration) so it's immune to the runtime's truncated live durations.
|
||||
*
|
||||
* Also writes the content end into the live root's `data-duration`. Timing
|
||||
* edits take the soft-reload path (no full iframe reload), which lets the
|
||||
* runtime recompute the length from the root's declared duration and post it
|
||||
* back — reading the STALE root would revert this optimistic set.
|
||||
*/
|
||||
export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
|
||||
const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
|
||||
if (end > 0) {
|
||||
usePlayerStore.getState().setDuration(end);
|
||||
patchIframeRootDuration(iframe, end);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the store duration BEFORE an optimistic duration update
|
||||
* (extendRootDurationIfNeeded + syncPreviewContentDuration) and return a
|
||||
* rollback closure for the persist-failure path. The rollback restores BOTH
|
||||
* the store duration and the live root's `data-duration` — otherwise a failed
|
||||
* write leaves the readout/seek bar and the live root advertising a duration
|
||||
* the saved source never got. No-op when the duration never changed.
|
||||
*/
|
||||
export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
|
||||
const previousDuration = usePlayerStore.getState().duration;
|
||||
return () => {
|
||||
if (usePlayerStore.getState().duration === previousDuration) return;
|
||||
usePlayerStore.getState().setDuration(previousDuration);
|
||||
patchIframeRootDuration(iframe, previousDuration);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bits of the server GSAP-mutation response the timeline edit path needs.
|
||||
* `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
|
||||
* swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
|
||||
* when the endpoint didn't return one (older server, or a multi-script comp the
|
||||
* soft path can't scope), in which case the caller full-reloads as before.
|
||||
*/
|
||||
export type GsapMutationStatus = { mutated: boolean; scriptText: string | null };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readMutationStatus(value: unknown): GsapMutationStatus {
|
||||
if (!isRecord(value)) return { mutated: false, scriptText: null };
|
||||
return {
|
||||
mutated: value.mutated === true || value.changed === true,
|
||||
scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
|
||||
};
|
||||
}
|
||||
|
||||
function readMutationError(value: unknown, fallback: string): string {
|
||||
if (isRecord(value) && typeof value.error === "string") return value.error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
|
||||
* soft reload over the full iframe reload that flashes every clip.
|
||||
*
|
||||
* Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
|
||||
* already (a) patched the live DOM timing attributes, (b) updated the store's
|
||||
* elements optimistically (the drag commit calls `updateElement` before the
|
||||
* persist), and (c) had the server rewrite the GSAP tween positions — which is the
|
||||
* `scriptText` we swap in here. `applySoftReload` re-runs that script in the LIVE
|
||||
* document (no navigation), re-seeks to the current playhead, and rebinds the
|
||||
* timeline, so the runtime matches the already-correct store. Nothing structural
|
||||
* changed (no clip added/removed), so `processTimelineMessage` would re-derive the
|
||||
* identical element set — skipping it just avoids the flash.
|
||||
*
|
||||
* Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
|
||||
* result (no gsap runtime / rebind hook / scopable key / script element, or the
|
||||
* re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
|
||||
* already applied the shift; a remount would re-flash for nothing. When the server
|
||||
* returned no `scriptText` (older server, multi-script comp), we also full-reload.
|
||||
*/
|
||||
function syncTimingEditPreview(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
outcome: Pick<GsapMutationStatus, "scriptText">,
|
||||
currentTime: number,
|
||||
reloadPreview: () => void,
|
||||
): void {
|
||||
if (!iframe || !outcome.scriptText) {
|
||||
reloadPreview();
|
||||
return;
|
||||
}
|
||||
const result = applySoftReload(iframe, outcome.scriptText, {
|
||||
onAsyncFailure: reloadPreview,
|
||||
currentTimeOverride: currentTime,
|
||||
});
|
||||
if (result === "cannot-soft-reload") reloadPreview();
|
||||
}
|
||||
|
||||
async function finishTimelineTimingFallback(input: {
|
||||
iframe: HTMLIFrameElement | null;
|
||||
reloadPreview: () => void;
|
||||
gsapMutation?: () => Promise<GsapMutationStatus>;
|
||||
onGsapError: (error: unknown) => void;
|
||||
}): Promise<void> {
|
||||
let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
|
||||
if (input.gsapMutation) {
|
||||
try {
|
||||
outcome = await input.gsapMutation();
|
||||
} catch (error) {
|
||||
input.onGsapError(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
syncTimingEditPreview(
|
||||
input.iframe,
|
||||
outcome,
|
||||
usePlayerStore.getState().currentTime,
|
||||
input.reloadPreview,
|
||||
);
|
||||
}
|
||||
|
||||
// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
|
||||
// outlast one GSAP server round-trip, never a real second edit.
|
||||
const GSAP_HISTORY_COALESCE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
|
||||
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
|
||||
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
|
||||
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
|
||||
* folding both writes into one undo step. Returns the mutation status for caller reloads.
|
||||
*
|
||||
* Failure domains are separate: a MUTATION failure propagates (nothing was applied, so
|
||||
* the caller must skip the preview sync), but a failure in the history-FOLD step
|
||||
* (re-read / recordEdit) after a successful mutation is surfaced via `onFoldError` and
|
||||
* the mutation status is still returned — the server rewrite already landed on disk, so
|
||||
* the caller must still sync the preview or it shows stale GSAP positions.
|
||||
*/
|
||||
async function foldGsapMutationIntoHistory(input: {
|
||||
projectId: string;
|
||||
paths: string[];
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
gsapMutation: () => Promise<GsapMutationStatus>;
|
||||
onFoldError: (error: unknown) => void;
|
||||
}): Promise<GsapMutationStatus> {
|
||||
const uniquePaths = [...new Set(input.paths)];
|
||||
const before = new Map<string, string>();
|
||||
// A `before`-snapshot failure propagates like a mutation failure: the mutation
|
||||
// has not run yet, so nothing landed on disk and skipping the sync is correct.
|
||||
for (const path of uniquePaths) {
|
||||
before.set(path, await readFileContent(input.projectId, path));
|
||||
}
|
||||
const status = await input.gsapMutation();
|
||||
if (status.mutated) {
|
||||
try {
|
||||
const files: Record<string, { before: string; after: string }> = {};
|
||||
for (const path of uniquePaths) {
|
||||
const priorContent = before.get(path);
|
||||
const finalContent = await readFileContent(input.projectId, path);
|
||||
if (priorContent !== undefined && finalContent !== priorContent) {
|
||||
files[path] = { before: priorContent, after: finalContent };
|
||||
}
|
||||
}
|
||||
if (Object.keys(files).length > 0) {
|
||||
await input.recordEdit({
|
||||
label: input.label,
|
||||
kind: "timeline",
|
||||
coalesceKey: input.coalesceKey,
|
||||
coalesceMs: GSAP_HISTORY_COALESCE_MS,
|
||||
files,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
input.onFoldError(error);
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift all GSAP animation positions targeting a given element by a time delta.
|
||||
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
|
||||
* Returns the rewritten script so the caller can soft-reload instead of full-reload.
|
||||
*/
|
||||
export async function shiftGsapPositions(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
elementId: string,
|
||||
delta: number,
|
||||
): Promise<GsapMutationStatus> {
|
||||
if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "shift-positions",
|
||||
targetSelector: `#${elementId}`,
|
||||
delta,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(readMutationError(err, "shift-positions failed"));
|
||||
}
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
export async function scaleGsapPositions(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
elementId: string,
|
||||
oldStart: number,
|
||||
oldDuration: number,
|
||||
newStart: number,
|
||||
newDuration: number,
|
||||
): Promise<GsapMutationStatus> {
|
||||
if (!elementId || oldDuration <= 0 || newDuration <= 0)
|
||||
return { mutated: false, scriptText: null };
|
||||
if (oldStart === newStart && oldDuration === newDuration)
|
||||
return { mutated: false, scriptText: null };
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "scale-positions",
|
||||
targetSelector: `#${elementId}`,
|
||||
oldStart,
|
||||
oldDuration,
|
||||
newStart,
|
||||
newDuration,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(readMutationError(err, "scale-positions failed"));
|
||||
}
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
/** Timing delta a single-clip edit applies to its GSAP tweens. */
|
||||
export type SingleClipGsapEdit =
|
||||
| { kind: "shift"; delta: number }
|
||||
| {
|
||||
kind: "scale";
|
||||
from: { start: number; duration: number };
|
||||
to: { start: number; duration: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
|
||||
* server shift/scale mutation, folds the rewrite into the timing edit's history
|
||||
* entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
|
||||
* the rewritten script — full reload when the mutation is skipped, failed, or
|
||||
* returned no script.
|
||||
*/
|
||||
export function finishClipTimingFallback(input: {
|
||||
iframe: HTMLIFrameElement | null;
|
||||
reloadPreview: () => void;
|
||||
projectId: string | null;
|
||||
targetPath: string;
|
||||
domId: string | undefined;
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
edit: SingleClipGsapEdit;
|
||||
}): Promise<void> {
|
||||
const { projectId, targetPath, domId, edit } = input;
|
||||
const timingChanged =
|
||||
edit.kind === "shift"
|
||||
? edit.delta !== 0
|
||||
: edit.from.start !== edit.to.start || edit.from.duration !== edit.to.duration;
|
||||
const runMutation = (pid: string, id: string): Promise<GsapMutationStatus> =>
|
||||
edit.kind === "shift"
|
||||
? shiftGsapPositions(pid, targetPath, id, edit.delta)
|
||||
: scaleGsapPositions(
|
||||
pid,
|
||||
targetPath,
|
||||
id,
|
||||
edit.from.start,
|
||||
edit.from.duration,
|
||||
edit.to.start,
|
||||
edit.to.duration,
|
||||
);
|
||||
const onGsapError = (err: unknown) =>
|
||||
console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err);
|
||||
return finishTimelineTimingFallback({
|
||||
iframe: input.iframe,
|
||||
reloadPreview: input.reloadPreview,
|
||||
gsapMutation:
|
||||
timingChanged && domId && projectId
|
||||
? () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId,
|
||||
paths: [targetPath],
|
||||
label: input.label,
|
||||
coalesceKey: input.coalesceKey,
|
||||
recordEdit: input.recordEdit,
|
||||
gsapMutation: () => runMutation(projectId, domId),
|
||||
onFoldError: onGsapError,
|
||||
})
|
||||
: undefined,
|
||||
onGsapError,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared post-persist GSAP sync for GROUP timing edits (move / resize): runs the
|
||||
* per-change server mutation for every changed clip, folds the rewrites into the
|
||||
* timing edit's history entry, and soft-reloads the preview when possible.
|
||||
*
|
||||
* The preview is a SINGLE shared iframe showing the ACTIVE composition, so only
|
||||
* the active comp's rewritten script can be soft-reloaded (swapped in place, no
|
||||
* all-clips flash). If any OTHER file changed too — e.g. a sub-comp group in a
|
||||
* multi-file move — no scriptText is passed, so the fallback does ONE full
|
||||
* reload that reflects every changed file.
|
||||
*/
|
||||
export async function finishGroupTimingGsapFallback<C extends { element: TimelineElement }>(input: {
|
||||
projectId: string;
|
||||
iframe: HTMLIFrameElement | null;
|
||||
reloadPreview: () => void;
|
||||
label: string;
|
||||
errorLabel: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
activeCompPath: string | null;
|
||||
changes: readonly C[];
|
||||
resolveChangePath: (element: TimelineElement) => string;
|
||||
/** Per-change GSAP mutation; return null to skip a change with no timing delta. */
|
||||
mutateChange: (change: C, changePath: string) => Promise<GsapMutationStatus> | null;
|
||||
}): Promise<void> {
|
||||
const activePath = input.activeCompPath || "index.html";
|
||||
const otherFileChanged = input.changes.some(
|
||||
(change) => input.resolveChangePath(change.element) !== activePath,
|
||||
);
|
||||
const onGsapError = (err: unknown) => console.error(`[Timeline] ${input.errorLabel}`, err);
|
||||
await finishTimelineTimingFallback({
|
||||
iframe: input.iframe,
|
||||
reloadPreview: input.reloadPreview,
|
||||
gsapMutation: () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId: input.projectId,
|
||||
paths: input.changes.map((change) => input.resolveChangePath(change.element)),
|
||||
label: input.label,
|
||||
coalesceKey: input.coalesceKey,
|
||||
recordEdit: input.recordEdit,
|
||||
gsapMutation: async () => {
|
||||
let mutated = false;
|
||||
let scriptText: GsapMutationStatus["scriptText"] = null;
|
||||
for (const change of input.changes) {
|
||||
const changePath = input.resolveChangePath(change.element);
|
||||
const pending = input.mutateChange(change, changePath);
|
||||
if (!pending) continue;
|
||||
const status = await pending;
|
||||
mutated = mutated || status.mutated;
|
||||
// The LAST mutation against the active comp carries the cumulative
|
||||
// rewritten script for that file.
|
||||
if (changePath === activePath) scriptText = status.scriptText;
|
||||
}
|
||||
return { mutated, scriptText: otherFileChanged ? null : scriptText };
|
||||
},
|
||||
onFoldError: onGsapError,
|
||||
}),
|
||||
onGsapError,
|
||||
});
|
||||
}
|
||||
@@ -265,7 +265,7 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("persists an N-element reorder with one batch POST, one undo entry, and one reload", async () => {
|
||||
it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
|
||||
const original =
|
||||
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
|
||||
const after =
|
||||
@@ -338,12 +338,103 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
||||
coalesceKey: "z-reorder:test",
|
||||
files: { "index.html": { before: original, after } },
|
||||
});
|
||||
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
|
||||
// The live DOM + store already hold the final state and the server matched
|
||||
// every style-only patch, so the reload is provably redundant.
|
||||
expect(rendered.reloadPreview).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rendered.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to reloading when the server response omits matched[]", async () => {
|
||||
// Without a matched[] confirmation the persist can't be proven in sync with
|
||||
// the live DOM — the skip-reload path must not engage.
|
||||
const original = '<div id="a" style="z-index: 1"></div>';
|
||||
const after = '<div id="a" style="z-index: 2"></div>';
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
||||
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
||||
return jsonResponse({ ok: true, changed: true, content: after });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { iframe, element } = createPreviewElement();
|
||||
element.id = "a";
|
||||
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await rendered.hook.handleDomZIndexReorderCommit([
|
||||
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rendered.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
|
||||
// The server reports per-patch matched[]: #b was not found in the source.
|
||||
// The matched subset persisted, so the commit must complete (no rollback of
|
||||
// applied state) while surfacing the partial failure. An unmatched target
|
||||
// also means the live DOM shows z-order the disk lacks, so the skip-reload
|
||||
// path must NOT engage — the reload reconverges the preview with disk.
|
||||
const original = '<div id="a" style="z-index: 1"></div>';
|
||||
const after = '<div id="a" style="z-index: 2"></div>';
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
||||
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
||||
return jsonResponse({ ok: true, changed: true, matched: [true, false], content: after });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { iframe, element } = createPreviewElement(
|
||||
'<div data-hf-id="hf-card"></div><div id="b"></div>',
|
||||
);
|
||||
element.id = "a";
|
||||
const second = iframe.contentDocument!.getElementById("b")!;
|
||||
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await rendered.hook.handleDomZIndexReorderCommit([
|
||||
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
||||
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
|
||||
]);
|
||||
});
|
||||
|
||||
// No throw: the applied live state stays, the matched subset is recorded.
|
||||
expect(element.style.zIndex).toBe("2");
|
||||
expect(second.style.zIndex).toBe("1");
|
||||
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("could not match 1 patch target(s) in index.html"),
|
||||
"b",
|
||||
);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"save_failure",
|
||||
expect.objectContaining({
|
||||
mutation_type: "z-reorder-unmatched",
|
||||
file_path: "index.html",
|
||||
error_message: expect.stringContaining("b"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
rendered.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back live state after a failed batch POST without a disk write-back", async () => {
|
||||
const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
|
||||
@@ -61,6 +61,34 @@ interface RecordEditInput {
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
||||
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
||||
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface server-reported unmatched patches. The matched subset already
|
||||
* persisted, so this must NOT throw (a throw would roll back applied state) —
|
||||
* warn and emit save-failure telemetry with a distinct reason instead.
|
||||
*/
|
||||
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
||||
const unmatchedIds = batch.patches
|
||||
.filter((_, index) => matched[index] === false)
|
||||
.map(describeBatchPatchTarget);
|
||||
if (unmatchedIds.length === 0) return;
|
||||
console.warn(
|
||||
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
||||
`${batch.sourceFile} (their z-order will revert on reload):`,
|
||||
unmatchedIds.join(", "),
|
||||
);
|
||||
trackStudioSaveFailure({
|
||||
source: "dom_edit",
|
||||
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
||||
filePath: batch.sourceFile,
|
||||
mutationType: "z-reorder-unmatched",
|
||||
});
|
||||
}
|
||||
|
||||
async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
|
||||
const before = await readProjectFileContent(projectId, batch.sourceFile);
|
||||
const response = await fetch(
|
||||
@@ -77,16 +105,39 @@ async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
|
||||
}
|
||||
const result = (await response.json()) as {
|
||||
changed?: boolean;
|
||||
matched?: boolean[];
|
||||
content?: string;
|
||||
};
|
||||
if (Array.isArray(result.matched)) reportUnmatchedBatchPatches(batch, result.matched);
|
||||
return {
|
||||
sourceFile: batch.sourceFile,
|
||||
changed: result.changed === true,
|
||||
// Skip-reload safety: the persist is only provably in sync with the live
|
||||
// DOM when the server confirmed EVERY patch target matched. A missing /
|
||||
// short matched[] is treated as unknown (false) so the caller falls back
|
||||
// to reloading rather than silently diverging from disk.
|
||||
allMatched:
|
||||
Array.isArray(result.matched) &&
|
||||
result.matched.length === batch.patches.length &&
|
||||
result.matched.every(Boolean),
|
||||
before,
|
||||
after: typeof result.content === "string" ? result.content : before,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A batch is reload-skippable only when it is style-only: every operation is an
|
||||
* `inline-style` write. The z-reorder commit applies those exact styles to the
|
||||
* live iframe DOM synchronously, so persisting them adds nothing the preview
|
||||
* doesn't already show. Any other op type (attribute / text-content / …) can
|
||||
* have server-side semantics the live DOM hasn't mirrored — reload for those.
|
||||
*/
|
||||
function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
|
||||
return batches.every((batch) =>
|
||||
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseDomEditCommitsParams {
|
||||
activeCompPath: string | null;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
@@ -360,7 +411,18 @@ export function useDomEditCommits({
|
||||
files,
|
||||
});
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
// A z-only reorder already applied its inline styles to the live iframe
|
||||
// DOM (and the store) synchronously, so remounting the iframe here only
|
||||
// produces a visible blink. Skip the reload when the caller asked for it
|
||||
// AND the persist is provably in sync: style-only ops, every target
|
||||
// matched. Any unmatched patch means the live DOM now shows state disk
|
||||
// doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
|
||||
// reload is independently suppressed by domEditSaveTimestampRef above.)
|
||||
const skipSafe =
|
||||
options.skipReload === true &&
|
||||
batchesAreInlineStyleOnly(batches) &&
|
||||
results.every((result) => result.allMatched);
|
||||
if (!skipSafe) reloadPreview();
|
||||
}).catch((error) => {
|
||||
const alreadyToasted =
|
||||
(error instanceof StudioSaveHttpError ||
|
||||
|
||||
@@ -18,6 +18,7 @@ afterEach(() => {
|
||||
interface BatchOptions {
|
||||
label: string;
|
||||
coalesceKey: string;
|
||||
skipReload?: boolean;
|
||||
}
|
||||
|
||||
interface CapturedBatchCall {
|
||||
@@ -36,6 +37,7 @@ type ReorderCommit = (
|
||||
key?: string;
|
||||
}>,
|
||||
coalesceKeyOverride?: string,
|
||||
actionKind?: string,
|
||||
) => Promise<void>;
|
||||
|
||||
function renderReorderHook(
|
||||
@@ -109,6 +111,25 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("requests skipReload on every z-reorder persist (live DOM already final)", async () => {
|
||||
// The commit applies the z-index (and any injected position) to the live
|
||||
// iframe DOM and the store synchronously, so the persisted style-only patch
|
||||
// adds nothing the preview doesn't already show — the batch commit is asked
|
||||
// to skip the iframe remount. commitDomEditPatchBatches still falls back to
|
||||
// reloading when the server can't confirm every patch target matched.
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip-z";
|
||||
|
||||
const { captured, root } = await runReorderCommit(el, [
|
||||
{ element: el, zIndex: 4, id: "clip-z", sourceFile: "index.html" },
|
||||
]);
|
||||
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0]?.options.skipReload).toBe(true);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("preserves a real id when the element has one", async () => {
|
||||
const el = document.createElement("video");
|
||||
el.id = "v-hero";
|
||||
@@ -176,6 +197,150 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps distinct actions in distinct default coalesce keys", async () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip-a";
|
||||
document.body.appendChild(el);
|
||||
const captured: CapturedBatchCall[] = [];
|
||||
let commit: ReorderCommit | undefined;
|
||||
const root = renderReorderHook(captured, (fn) => (commit = fn));
|
||||
|
||||
await act(async () => {
|
||||
await commit!(
|
||||
[{ element: el, zIndex: 1, id: "clip-a", sourceFile: "index.html" }],
|
||||
undefined,
|
||||
"bring-forward",
|
||||
);
|
||||
await commit!(
|
||||
[{ element: el, zIndex: 0, id: "clip-a", sourceFile: "index.html" }],
|
||||
undefined,
|
||||
"send-backward",
|
||||
);
|
||||
});
|
||||
|
||||
// Same element set, different actions — the keys must differ so the two
|
||||
// edits never coalesce into one undo step within the coalesce window.
|
||||
expect(captured).toHaveLength(2);
|
||||
expect(captured[0]?.options.coalesceKey).toBe("z-reorder:bring-forward:clip-a");
|
||||
expect(captured[1]?.options.coalesceKey).toBe("z-reorder:send-backward:clip-a");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("updates the store zIndex synchronously for entries that carry a store key", async () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip-a";
|
||||
document.body.appendChild(el);
|
||||
usePlayerStore.getState().setElements([
|
||||
{
|
||||
id: "clip-a",
|
||||
key: "index.html#clip-a",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
zIndex: 0,
|
||||
hasExplicitZIndex: false,
|
||||
},
|
||||
]);
|
||||
|
||||
let commit: ReorderCommit | undefined;
|
||||
let resolveBatch: (() => void) | undefined;
|
||||
function Harness() {
|
||||
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
|
||||
activeCompPath: "index.html",
|
||||
showToast: vi.fn(),
|
||||
writeProjectFile: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
editHistory: { recordEdit: vi.fn(async () => {}) },
|
||||
projectIdRef: { current: null },
|
||||
reloadPreview: vi.fn(),
|
||||
clearDomSelection: vi.fn(),
|
||||
// Persist stays pending so the assertion below can only be satisfied
|
||||
// by the SYNCHRONOUS store update (the lane-sync path's requirement).
|
||||
commitDomEditPatchBatches: () => new Promise((resolve) => (resolveBatch = resolve)),
|
||||
});
|
||||
commit = handleDomZIndexReorderCommit;
|
||||
return null;
|
||||
}
|
||||
const root = mountReactHarness(<Harness />);
|
||||
|
||||
let pending: Promise<void> | undefined;
|
||||
act(() => {
|
||||
pending = commit!([
|
||||
{
|
||||
element: el,
|
||||
zIndex: 5,
|
||||
id: "clip-a",
|
||||
sourceFile: "index.html",
|
||||
key: "index.html#clip-a",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().elements[0]).toMatchObject({
|
||||
zIndex: 5,
|
||||
hasExplicitZIndex: true,
|
||||
});
|
||||
|
||||
resolveBatch?.();
|
||||
await act(async () => pending);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// The canvas context-menu path: the menu no longer pre-applies styles, so the
|
||||
// hook sees the PRISTINE element — prior styles are captured before any
|
||||
// mutation and a failed persist restores them exactly (previously the menu's
|
||||
// optimistic write made the "rollback" restore the already-mutated values,
|
||||
// and the never-persisted position patch silently reverted on reload).
|
||||
it("rolls back a static, inline-style-free element to pristine styles on failure", async () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip-a";
|
||||
el.style.position = "static"; // happy-dom computes "" for unset position
|
||||
document.body.appendChild(el);
|
||||
const failure = new Error("persist failed");
|
||||
|
||||
let commit: ReorderCommit | undefined;
|
||||
function Harness() {
|
||||
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
|
||||
activeCompPath: "index.html",
|
||||
showToast: vi.fn(),
|
||||
writeProjectFile: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
editHistory: { recordEdit: vi.fn(async () => {}) },
|
||||
projectIdRef: { current: null },
|
||||
reloadPreview: vi.fn(),
|
||||
clearDomSelection: vi.fn(),
|
||||
commitDomEditPatchBatches: vi.fn(async () => {
|
||||
// The live styles were applied by the hook before persist ran.
|
||||
expect(el.style.zIndex).toBe("2");
|
||||
expect(el.style.position).toBe("relative");
|
||||
throw failure;
|
||||
}),
|
||||
});
|
||||
commit = handleDomZIndexReorderCommit;
|
||||
return null;
|
||||
}
|
||||
const root = mountReactHarness(<Harness />);
|
||||
|
||||
let rejection: unknown;
|
||||
await act(async () => {
|
||||
try {
|
||||
await commit!(
|
||||
[{ element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html" }],
|
||||
undefined,
|
||||
"bring-forward",
|
||||
);
|
||||
} catch (error) {
|
||||
rejection = error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(rejection).toBe(failure);
|
||||
expect(el.style.zIndex).toBe("");
|
||||
expect(el.style.position).toBe("static");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("rolls back only live and store state after an atomic reorder failure", async () => {
|
||||
const writeProjectFile = vi.fn(async () => {});
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
|
||||
@@ -146,6 +146,7 @@ export function useElementLifecycleOps({
|
||||
key?: string;
|
||||
}>,
|
||||
gestureCoalesceKey?: string,
|
||||
actionKind?: string,
|
||||
) => {
|
||||
if (entries.length === 0) return Promise.resolve();
|
||||
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
|
||||
@@ -153,9 +154,13 @@ export function useElementLifecycleOps({
|
||||
onReorderShadow?.(
|
||||
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
|
||||
);
|
||||
// The default key carries the action kind so two DIFFERENT actions on the
|
||||
// same element set (e.g. "bring-forward" then "send-backward" within the
|
||||
// coalesce window) never merge into one undo step. Callers that share a
|
||||
// gesture (lane moves) pass an explicit gestureCoalesceKey instead.
|
||||
const coalesceKey =
|
||||
gestureCoalesceKey ??
|
||||
`z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
||||
`z-reorder:${actionKind ?? "reorder"}:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
||||
const patchesBySourceFile = new Map<string, DomEditPatchBatch["patches"]>();
|
||||
const rollbacks: Array<() => void> = [];
|
||||
for (const entry of entries) {
|
||||
@@ -212,12 +217,20 @@ export function useElementLifecycleOps({
|
||||
}));
|
||||
// Resolves once every source-file batch is persisted so a same-file timing write
|
||||
// can be ordered after it (see applyTimelineStackingReorder callers).
|
||||
return commitDomEditPatchBatches(batches, { label: "Reorder layers", coalesceKey }).catch(
|
||||
(error) => {
|
||||
for (const rollback of rollbacks) rollback();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
//
|
||||
// skipReload: the live iframe DOM and the player store already hold the
|
||||
// final z state (applied synchronously above), and the persisted patch is
|
||||
// inline-style-only — a full iframe remount would only blink the preview.
|
||||
// commitDomEditPatchBatches still falls back to reloading whenever the
|
||||
// server reports an unmatched patch target (live DOM ≠ disk).
|
||||
return commitDomEditPatchBatches(batches, {
|
||||
label: "Reorder layers",
|
||||
coalesceKey,
|
||||
skipReload: true,
|
||||
}).catch((error) => {
|
||||
for (const rollback of rollbacks) rollback();
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
[commitDomEditPatchBatches, onReorderShadow],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Asset-drop handlers for the timeline: drop an existing project asset at a
|
||||
// placement, or upload dragged-in OS files and place them sequentially.
|
||||
// Extracted verbatim from useTimelineEditing.ts to keep it under the studio
|
||||
// 600-line cap.
|
||||
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import {
|
||||
buildTimelineAssetId,
|
||||
buildTimelineAssetInsertHtml,
|
||||
buildTimelineFileDropPlacements,
|
||||
fitTimelineAssetGeometry,
|
||||
getTimelineAssetKind,
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetCompositionSize,
|
||||
resolveTimelineAssetSrc,
|
||||
} from "../utils/timelineAssetDrop";
|
||||
import { generateId } from "../utils/generateId";
|
||||
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
||||
import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
|
||||
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
||||
import { readFileContent } from "./timelineTimingSync";
|
||||
|
||||
interface UseTimelineAssetDropOpsOptions {
|
||||
projectIdRef: MutableRefObject<string | null>;
|
||||
activeCompPath: string | null;
|
||||
timelineElements: TimelineElement[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||
isRecordingRef?: RefObject<boolean>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
export function useTimelineAssetDropOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
}: UseTimelineAssetDropOpsOptions) {
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineAssetDrop = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (
|
||||
assetPath: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
durationOverride?: number,
|
||||
) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
|
||||
const kind = getTimelineAssetKind(assetPath);
|
||||
if (!kind) {
|
||||
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
||||
const duration =
|
||||
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
||||
? durationOverride
|
||||
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
||||
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
||||
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
||||
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
||||
|
||||
const resolvedTargetPath = targetPath || "index.html";
|
||||
const relevantElements = timelineElements.filter(
|
||||
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||
);
|
||||
const newElementZIndex = Math.max(1, relevantElements.length + 1);
|
||||
|
||||
const patchedContent = insertTimelineAssetIntoSource(
|
||||
originalContent,
|
||||
buildTimelineAssetInsertHtml({
|
||||
id: newId,
|
||||
hfId: `hf-${generateId()}`,
|
||||
assetPath: resolvedAssetSrc,
|
||||
kind,
|
||||
start: normalizedStart,
|
||||
duration: normalizedDuration,
|
||||
track: placement.track,
|
||||
zIndex: newElementZIndex,
|
||||
geometry: fitTimelineAssetGeometry(
|
||||
null,
|
||||
resolveTimelineAssetCompositionSize(originalContent),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Add timeline asset",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineFileDrop = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const uploaded = await uploadProjectFiles(files);
|
||||
if (uploaded.length === 0) return;
|
||||
const durations: number[] = [];
|
||||
for (const assetPath of uploaded) {
|
||||
const kind = getTimelineAssetKind(assetPath);
|
||||
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
||||
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
||||
}
|
||||
const placements = buildTimelineFileDropPlacements(
|
||||
placement ?? { start: 0, track: 0 },
|
||||
durations,
|
||||
);
|
||||
for (const [index, assetPath] of uploaded.entries()) {
|
||||
await handleTimelineAssetDrop(
|
||||
assetPath,
|
||||
placements[index] ?? placements[0],
|
||||
durations[index],
|
||||
);
|
||||
}
|
||||
},
|
||||
[handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
|
||||
);
|
||||
|
||||
return { handleTimelineAssetDrop, handleTimelineFileDrop };
|
||||
}
|
||||
@@ -77,6 +77,21 @@ function timelineElement(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Mount a harness component under act() and return its unmount hook. */
|
||||
function mountHarness(node: React.ReactElement): { unmount: () => void } {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(node);
|
||||
});
|
||||
return {
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderTimelineEditingHook(input: {
|
||||
timelineElements: TimelineElement[];
|
||||
iframe: HTMLIFrameElement;
|
||||
@@ -92,17 +107,20 @@ function renderTimelineEditingHook(input: {
|
||||
reloadPreview?: () => void;
|
||||
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
|
||||
forceReloadSdkSession?: () => void;
|
||||
showToast?: (message: string, kind?: string) => void;
|
||||
}): {
|
||||
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
||||
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
|
||||
groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
|
||||
groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
|
||||
del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
|
||||
unmount: () => void;
|
||||
} {
|
||||
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
||||
let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
|
||||
let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
|
||||
let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
|
||||
let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
|
||||
|
||||
function Harness() {
|
||||
const commitRef = useRef(input.onZIndexCommit);
|
||||
@@ -111,7 +129,7 @@ function renderTimelineEditingHook(input: {
|
||||
projectId: input.projectId ?? null,
|
||||
activeCompPath: "index.html",
|
||||
timelineElements: input.timelineElements,
|
||||
showToast: vi.fn(),
|
||||
showToast: input.showToast ?? vi.fn(),
|
||||
writeProjectFile: input.writeProjectFile ?? vi.fn(),
|
||||
recordEdit: input.recordEdit ?? vi.fn(),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
@@ -127,29 +145,17 @@ function renderTimelineEditingHook(input: {
|
||||
resize = hook.handleTimelineElementResize;
|
||||
groupMove = hook.handleTimelineGroupMove;
|
||||
groupResize = hook.handleTimelineGroupResize;
|
||||
del = hook.handleTimelineElementDelete;
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
|
||||
const { unmount } = mountHarness(<Harness />);
|
||||
if (!move) throw new Error("Expected hook to expose move handler");
|
||||
if (!resize) throw new Error("Expected hook to expose resize handler");
|
||||
if (!groupMove) throw new Error("Expected hook to expose group move handler");
|
||||
if (!groupResize) throw new Error("Expected hook to expose group resize handler");
|
||||
return {
|
||||
move,
|
||||
resize,
|
||||
groupMove,
|
||||
groupResize,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
},
|
||||
};
|
||||
if (!del) throw new Error("Expected hook to expose delete handler");
|
||||
return { move, resize, groupMove, groupResize, del, unmount };
|
||||
}
|
||||
|
||||
type TimelineRecordEdit = NonNullable<
|
||||
@@ -198,20 +204,9 @@ function renderTimelineEditingHookWithLifecycle(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
|
||||
const { unmount } = mountHarness(<Harness />);
|
||||
if (!move) throw new Error("Expected hook to expose move handler");
|
||||
return {
|
||||
move,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
},
|
||||
};
|
||||
return { move, unmount };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
@@ -233,139 +228,118 @@ async function flushAsyncWork(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub global fetch for project "p1": serves file contents (a single source
|
||||
* string, or a path → content map) and answers the GSAP-mutation endpoint
|
||||
* with `gsapBody`. Returns the mock for call inspection.
|
||||
*/
|
||||
function stubProjectFetch(
|
||||
files: string | Record<string, string>,
|
||||
gsapBody: unknown = { ok: true },
|
||||
) {
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) {
|
||||
if (typeof files === "string") return jsonResponse({ content: files });
|
||||
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
||||
return jsonResponse({ content: files[path] });
|
||||
}
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse(gsapBody);
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
const ROOT_DURATION_FALLBACK_SOURCE = [
|
||||
`<div data-composition-id="main" data-duration="4">`,
|
||||
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
|
||||
/** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */
|
||||
async function setupRootDurationFallback() {
|
||||
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||
const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE);
|
||||
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
||||
const forceReloadSdkSession = vi.fn();
|
||||
const reloadPreview = vi.fn();
|
||||
const iframeWindow = iframe.contentWindow;
|
||||
if (!iframeWindow) throw new Error("Expected iframe window");
|
||||
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
||||
stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, { ok: true, mutated: false });
|
||||
usePlayerStore.getState().setDuration(4);
|
||||
const hook = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
sdkSession,
|
||||
forceReloadSdkSession,
|
||||
reloadPreview,
|
||||
});
|
||||
return {
|
||||
hook,
|
||||
clip,
|
||||
setTimingSpy,
|
||||
writeProjectFile,
|
||||
forceReloadSdkSession,
|
||||
reloadPreview,
|
||||
postMessageSpy,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */
|
||||
function expectRootDurationExtendedViaFallback(
|
||||
ctx: Awaited<ReturnType<typeof setupRootDurationFallback>>,
|
||||
): void {
|
||||
expect(ctx.setTimingSpy).not.toHaveBeenCalled();
|
||||
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain(
|
||||
'data-composition-id="main" data-duration="5"',
|
||||
);
|
||||
expect(usePlayerStore.getState().duration).toBe(5);
|
||||
expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
||||
// The GSAP endpoint returned no rewritten scriptText, so the timing sync
|
||||
// escalates from the flash-free soft reload to ONE full reload. The root
|
||||
// duration travels via the persisted content-driven `data-duration` (above),
|
||||
// not a `set-root-duration` postMessage.
|
||||
expect(ctx.reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.postMessageSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "set-root-duration" }),
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => {
|
||||
const source = [
|
||||
`<div data-composition-id="main" data-duration="4">`,
|
||||
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||
const sdkSession = await openComposition(source);
|
||||
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
||||
const forceReloadSdkSession = vi.fn();
|
||||
const reloadPreview = vi.fn();
|
||||
const iframeWindow = iframe.contentWindow;
|
||||
if (!iframeWindow) throw new Error("Expected iframe window");
|
||||
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
||||
return jsonResponse({ ok: true, mutated: false });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
usePlayerStore.getState().setDuration(4);
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
sdkSession,
|
||||
forceReloadSdkSession,
|
||||
reloadPreview,
|
||||
});
|
||||
const ctx = await setupRootDurationFallback();
|
||||
|
||||
await act(async () => {
|
||||
await move(clip, { start: 3, track: clip.track });
|
||||
await ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track });
|
||||
});
|
||||
|
||||
expect(setTimingSpy).not.toHaveBeenCalled();
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain(
|
||||
'data-composition-id="main" data-duration="5"',
|
||||
);
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
|
||||
expect(usePlayerStore.getState().duration).toBe(5);
|
||||
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "set-root-duration",
|
||||
durationSeconds: 5,
|
||||
protocolVersion: 1,
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
|
||||
expectRootDurationExtendedViaFallback(ctx);
|
||||
|
||||
unmount();
|
||||
ctx.hook.unmount();
|
||||
});
|
||||
|
||||
it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => {
|
||||
const source = [
|
||||
`<div data-composition-id="main" data-duration="4">`,
|
||||
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||
const sdkSession = await openComposition(source);
|
||||
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
||||
const forceReloadSdkSession = vi.fn();
|
||||
const reloadPreview = vi.fn();
|
||||
const iframeWindow = iframe.contentWindow;
|
||||
if (!iframeWindow) throw new Error("Expected iframe window");
|
||||
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
||||
return jsonResponse({ ok: true, mutated: false });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
usePlayerStore.getState().setDuration(4);
|
||||
const { resize, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
sdkSession,
|
||||
forceReloadSdkSession,
|
||||
reloadPreview,
|
||||
});
|
||||
const ctx = await setupRootDurationFallback();
|
||||
|
||||
await act(async () => {
|
||||
await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
|
||||
await ctx.hook.resize(ctx.clip, { start: 0, duration: 5, playbackStart: undefined });
|
||||
});
|
||||
|
||||
expect(setTimingSpy).not.toHaveBeenCalled();
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain(
|
||||
'data-composition-id="main" data-duration="5"',
|
||||
);
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
|
||||
expect(usePlayerStore.getState().duration).toBe(5);
|
||||
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "set-root-duration",
|
||||
durationSeconds: 5,
|
||||
protocolVersion: 1,
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
|
||||
expectRootDurationExtendedViaFallback(ctx);
|
||||
|
||||
unmount();
|
||||
ctx.hook.unmount();
|
||||
});
|
||||
|
||||
it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
|
||||
@@ -640,24 +614,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
||||
const reloadPreview = vi.fn();
|
||||
const fetchMock = vi.fn(
|
||||
async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
_init?: Parameters<typeof fetch>[1],
|
||||
): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) {
|
||||
return jsonResponse({
|
||||
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const fetchMock = stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
@@ -687,6 +644,41 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
|
||||
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
|
||||
// pure lane change routed through onMoveElement (no onMoveElements wired)
|
||||
// wrote NOTHING — the lane snapped back on the next reload.
|
||||
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: commit,
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// Vertical-only: same start, new track (already authored-space on this path).
|
||||
await move(clip, { start: clip.start, track: 2 });
|
||||
});
|
||||
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("Expected iframe document");
|
||||
// Live DOM patched so a pre-reload re-discovery doesn't snap the lane back...
|
||||
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("2");
|
||||
// ...and the file write carries the new data-track-index with start intact.
|
||||
expect(writeProjectFile).toHaveBeenCalled();
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="2"');
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
|
||||
const iframe = createPreviewIframe([
|
||||
{ id: "clip", track: 0, style: "position: relative; z-index: 0" },
|
||||
@@ -699,17 +691,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
});
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) {
|
||||
return jsonResponse({
|
||||
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
||||
const { move, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
@@ -768,15 +750,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
];
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
stubProjectFetch(source);
|
||||
const { groupMove, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: clips,
|
||||
iframe,
|
||||
@@ -825,18 +799,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
});
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) {
|
||||
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
||||
return jsonResponse({ content: files[path] });
|
||||
}
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
stubProjectFetch(files);
|
||||
const { groupMove, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [a, b],
|
||||
iframe,
|
||||
@@ -877,15 +840,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
releaseCommit = resolve;
|
||||
});
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
stubProjectFetch(source);
|
||||
const { groupMove, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
@@ -915,13 +870,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
it("matches the single-clip move output when a group move contains one clip", async () => {
|
||||
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
stubProjectFetch(source);
|
||||
|
||||
const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const single = renderTimelineEditingHook({
|
||||
@@ -954,3 +903,218 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
group.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTimelineEditing duration rollback on failed persist", () => {
|
||||
const ROLLBACK_SOURCE = [
|
||||
`<div data-composition-id="main" data-duration="4">`,
|
||||
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
|
||||
/** Iframe with a comp root so the optimistic sync (and its rollback) can patch data-duration. */
|
||||
function createRootedIframe(source: string = ROLLBACK_SOURCE): HTMLIFrameElement {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("Expected iframe document");
|
||||
doc.body.innerHTML = source;
|
||||
return iframe;
|
||||
}
|
||||
|
||||
function rootDurationAttr(iframe: HTMLIFrameElement): string | null | undefined {
|
||||
return iframe.contentDocument
|
||||
?.querySelector("[data-composition-id]")
|
||||
?.getAttribute("data-duration");
|
||||
}
|
||||
|
||||
function setupFailedPersist() {
|
||||
const iframe = createRootedIframe();
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||
const writeError = new Error("write failed");
|
||||
const writeProjectFile = vi
|
||||
.fn<(...args: unknown[]) => Promise<void>>()
|
||||
.mockRejectedValue(writeError);
|
||||
stubProjectFetch(ROLLBACK_SOURCE);
|
||||
usePlayerStore.getState().setDuration(4);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const hook = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
reloadPreview: vi.fn(),
|
||||
});
|
||||
return { iframe, clip, hook, writeError };
|
||||
}
|
||||
|
||||
it("rolls back the store duration and live root when a move persist fails", async () => {
|
||||
const { iframe, clip, hook, writeError } = setupFailedPersist();
|
||||
|
||||
let rejection: unknown;
|
||||
await act(async () => {
|
||||
// Move past the end: the optimistic sync grows the readout to 5s.
|
||||
await hook.move(clip, { start: 3, track: clip.track }).catch((error) => {
|
||||
rejection = error;
|
||||
});
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
expect(rejection).toBe(writeError);
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
expect(rootDurationAttr(iframe)).toBe("4");
|
||||
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("rolls back the store duration and live root when a resize persist fails", async () => {
|
||||
const { iframe, clip, hook, writeError } = setupFailedPersist();
|
||||
|
||||
let rejection: unknown;
|
||||
await act(async () => {
|
||||
await hook
|
||||
.resize(clip, { start: 0, duration: 6, playbackStart: undefined })
|
||||
.catch((error) => {
|
||||
rejection = error;
|
||||
});
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
expect(rejection).toBe(writeError);
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
expect(rootDurationAttr(iframe)).toBe("4");
|
||||
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("rolls back the store duration and live root when a group move persist fails", async () => {
|
||||
const { iframe, clip, hook, writeError } = setupFailedPersist();
|
||||
|
||||
let rejection: unknown;
|
||||
await act(async () => {
|
||||
await hook.groupMove([{ element: clip, start: 3.5 }]).catch((error) => {
|
||||
rejection = error;
|
||||
});
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
expect(rejection).toBe(writeError);
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
expect(rootDurationAttr(iframe)).toBe("4");
|
||||
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("rolls back the store duration and live root when a group resize persist fails", async () => {
|
||||
const { iframe, clip, hook, writeError } = setupFailedPersist();
|
||||
|
||||
let rejection: unknown;
|
||||
await act(async () => {
|
||||
await hook.groupResize([{ element: clip, start: 0, duration: 7 }]).catch((error) => {
|
||||
rejection = error;
|
||||
});
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
expect(rejection).toBe(writeError);
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
expect(rootDurationAttr(iframe)).toBe("4");
|
||||
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("rolls back the store duration and live root when a delete persist fails", async () => {
|
||||
// Two clips; deleting the furthest one shrinks the content-driven duration
|
||||
// optimistically (4s -> 2s), so a failed write must roll that shrink back.
|
||||
const DELETE_SOURCE = [
|
||||
`<div data-composition-id="main" data-duration="4">`,
|
||||
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
||||
` <div id="tail" data-start="2" data-duration="2" data-track-index="0"></div>`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
const DELETE_REMOVED_SOURCE = [
|
||||
`<div data-composition-id="main" data-duration="4">`,
|
||||
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
|
||||
const iframe = createRootedIframe(DELETE_SOURCE);
|
||||
const tail = timelineElement({ id: "tail", track: 0, zIndex: 0, start: 2, duration: 2 });
|
||||
const writeError = new Error("write failed");
|
||||
const writeProjectFile = vi
|
||||
.fn<(...args: unknown[]) => Promise<void>>()
|
||||
.mockRejectedValue(writeError);
|
||||
// The delete path reads the file, then asks the server-side remove-element
|
||||
// mutation for the post-removal source before persisting it.
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) {
|
||||
return jsonResponse({ content: DELETE_SOURCE });
|
||||
}
|
||||
if (url.includes("/api/projects/p1/file-mutations/remove-element/")) {
|
||||
return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
usePlayerStore.getState().setDuration(4);
|
||||
const showToast = vi.fn();
|
||||
const hook = renderTimelineEditingHook({
|
||||
timelineElements: [tail],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
reloadPreview: vi.fn(),
|
||||
showToast,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// Unlike move/resize, the delete handler swallows the persist failure
|
||||
// into a toast, so the promise resolves.
|
||||
await hook.del(tail);
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
// The optimistic shrink reached the persist attempt (root patched to the
|
||||
// furthest remaining clip end, 2s)...
|
||||
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
||||
expect(String(writeProjectFile.mock.calls[0]![1])).toContain(
|
||||
'data-composition-id="main" data-duration="2"',
|
||||
);
|
||||
expect(showToast).toHaveBeenCalledWith("write failed");
|
||||
// ...and the failed write rolled the readout AND the live root back.
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
expect(rootDurationAttr(iframe)).toBe("4");
|
||||
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("keeps the grown duration when the persist succeeds", async () => {
|
||||
const { iframe, clip, hook } = setupFailedPersist();
|
||||
// Same harness, but with a write that succeeds this time.
|
||||
hook.unmount();
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const succeeding = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
reloadPreview: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await succeeding.move(clip, { start: 3, track: clip.track });
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().duration).toBe(5);
|
||||
expect(rootDurationAttr(iframe)).toBe("5");
|
||||
|
||||
succeeding.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,35 +3,27 @@ import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
import {
|
||||
buildTimelineAssetId,
|
||||
buildTimelineAssetInsertHtml,
|
||||
buildTimelineFileDropPlacements,
|
||||
getTimelineAssetKind,
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetInitialGeometry,
|
||||
resolveTimelineAssetSrc,
|
||||
} from "../utils/timelineAssetDrop";
|
||||
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import {
|
||||
getTimelineElementLabel,
|
||||
collectHtmlIds,
|
||||
resolveDroppedAssetDuration,
|
||||
} from "../utils/studioHelpers";
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
||||
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
||||
import {
|
||||
applyTimelineStackingReorder,
|
||||
buildPatchTarget,
|
||||
patchIframeDomTiming,
|
||||
persistTimelineEdit,
|
||||
readFileContent,
|
||||
foldedShiftGsapMutation,
|
||||
foldedScaleGsapMutation,
|
||||
formatTimelineAttributeNumber,
|
||||
finishTimelineTimingFallback,
|
||||
extendRootDurationIfNeeded,
|
||||
buildTimelineMoveTimingPatch,
|
||||
buildTimelineResizeTimingPatch,
|
||||
} from "./timelineEditingHelpers";
|
||||
import {
|
||||
captureDurationRollback,
|
||||
finishClipTimingFallback,
|
||||
readFileContent,
|
||||
syncPreviewContentDuration,
|
||||
} from "./timelineTimingSync";
|
||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
||||
import {
|
||||
@@ -137,11 +129,22 @@ export function useTimelineEditing({
|
||||
(element: TimelineElement, updates: TimelineMoveUpdates) => {
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const startChanged = updates.start !== element.start;
|
||||
// A vertical-only lane move arrives with start unchanged but track changed
|
||||
// (on this single-element path the drag commit has already folded the
|
||||
// AUTHORED persist track into updates.track). It must persist like any
|
||||
// other move — early-returning on !startChanged alone silently dropped
|
||||
// the file write, so the lane snapped back on reload.
|
||||
const trackChanged = updates.track !== element.track;
|
||||
|
||||
if (startChanged) {
|
||||
patchIframeDomTiming(previewIframeRef.current, element, [
|
||||
["data-start", formatTimelineAttributeNumber(updates.start)],
|
||||
]);
|
||||
if (startChanged || trackChanged) {
|
||||
const liveAttrs: Array<[string, string]> = [];
|
||||
if (startChanged) {
|
||||
liveAttrs.push(["data-start", formatTimelineAttributeNumber(updates.start)]);
|
||||
}
|
||||
if (trackChanged) {
|
||||
liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
|
||||
}
|
||||
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
|
||||
}
|
||||
|
||||
const reorderDone = applyTimelineStackingReorder({
|
||||
@@ -153,62 +156,76 @@ export function useTimelineEditing({
|
||||
commit: handleDomZIndexReorderCommitRef?.current,
|
||||
});
|
||||
|
||||
if (!startChanged) return reorderDone;
|
||||
if (!startChanged && !trackChanged) return reorderDone;
|
||||
|
||||
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
||||
// persist can roll the readout + live root back (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
|
||||
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
|
||||
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
|
||||
syncPreviewContentDuration(previewIframeRef.current);
|
||||
|
||||
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
||||
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
|
||||
// Persist lane changes too — data-start-only writes let reload snap the lane back.
|
||||
const track = trackChanged ? updates.track : undefined;
|
||||
return buildTimelineMoveTimingPatch(
|
||||
original,
|
||||
target,
|
||||
updates.start,
|
||||
element.duration,
|
||||
track,
|
||||
);
|
||||
};
|
||||
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
||||
const moveFallback = () =>
|
||||
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
|
||||
const pid = projectIdRef.current;
|
||||
const delta = updates.start - element.start;
|
||||
const domId = element.domId;
|
||||
return finishTimelineTimingFallback({
|
||||
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
|
||||
// Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
|
||||
// DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
|
||||
finishClipTimingFallback({
|
||||
iframe: previewIframeRef.current,
|
||||
needsExtension,
|
||||
rootDurationSeconds: updates.start + element.duration,
|
||||
reloadPreview,
|
||||
gsapMutation:
|
||||
delta !== 0 && domId && pid
|
||||
? foldedShiftGsapMutation({
|
||||
projectId: pid,
|
||||
targetPath,
|
||||
domId,
|
||||
delta,
|
||||
label: "Move timeline clip",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
})
|
||||
: undefined,
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
|
||||
});
|
||||
});
|
||||
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
|
||||
return reorderDone.then(() => {
|
||||
if (sdkSession && element.hfId && !needsExtension) {
|
||||
return sdkTimingPersist(
|
||||
element.hfId,
|
||||
projectId: projectIdRef.current,
|
||||
targetPath,
|
||||
{ start: updates.start },
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
// Capture on-disk bytes as the undo `before` so undoing a timing move
|
||||
// restores the file verbatim, not a normalized full-DOM re-emit.
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Move timeline clip", coalesceKey },
|
||||
).then((handled) => {
|
||||
if (!handled) return moveFallback();
|
||||
});
|
||||
}
|
||||
return moveFallback();
|
||||
});
|
||||
domId: element.domId,
|
||||
label: "Move timeline clip",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
edit: { kind: "shift", delta: updates.start - element.start },
|
||||
}),
|
||||
);
|
||||
return reorderDone
|
||||
.then(() => {
|
||||
// The SDK setTiming path writes start only — a lane change must take
|
||||
// the fallback, whose patch builder writes data-track-index too.
|
||||
if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
|
||||
return sdkTimingPersist(
|
||||
element.hfId,
|
||||
targetPath,
|
||||
{ start: updates.start },
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
// Capture on-disk bytes as the undo `before` so undoing a timing move
|
||||
// restores the file verbatim, not a normalized full-DOM re-emit.
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Move timeline clip", coalesceKey },
|
||||
).then((handled) => {
|
||||
if (!handled) return moveFallback();
|
||||
});
|
||||
}
|
||||
return moveFallback();
|
||||
})
|
||||
.catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root.
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
[
|
||||
previewIframeRef,
|
||||
@@ -245,6 +262,13 @@ export function useTimelineEditing({
|
||||
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
|
||||
}
|
||||
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
|
||||
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
||||
// persist can roll the readout + live root back (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
|
||||
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
|
||||
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
|
||||
syncPreviewContentDuration(previewIframeRef.current);
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
||||
return buildTimelineResizeTimingPatch(original, target, element, updates);
|
||||
@@ -253,59 +277,55 @@ export function useTimelineEditing({
|
||||
updates.playbackStart != null ||
|
||||
(updates.start !== element.start && element.playbackStart != null);
|
||||
// Server-path fallback: after persisting the attr patch, scale GSAP tween
|
||||
// positions/durations on the server. Extending edits can keep the iframe
|
||||
// live unless a GSAP source rewrite needs a fresh run.
|
||||
// positions/durations on the server, then soft-reload with the rewritten
|
||||
// script (timing-only resize) — same no-flash path as move; full reload is
|
||||
// the fallback.
|
||||
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
|
||||
const timingChanged =
|
||||
updates.start !== element.start || updates.duration !== element.duration;
|
||||
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
|
||||
const resizeFallback = () =>
|
||||
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
|
||||
const pid = projectIdRef.current;
|
||||
const domId = element.domId;
|
||||
return finishTimelineTimingFallback({
|
||||
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
|
||||
finishClipTimingFallback({
|
||||
iframe: previewIframeRef.current,
|
||||
needsExtension,
|
||||
rootDurationSeconds: updates.start + updates.duration,
|
||||
reloadPreview,
|
||||
gsapMutation:
|
||||
timingChanged && domId && pid
|
||||
? foldedScaleGsapMutation({
|
||||
projectId: pid,
|
||||
targetPath,
|
||||
domId,
|
||||
from: { start: element.start, duration: element.duration },
|
||||
to: { start: updates.start, duration: updates.duration },
|
||||
label: "Resize timeline clip",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
})
|
||||
: undefined,
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
|
||||
});
|
||||
});
|
||||
if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
|
||||
return sdkTimingPersist(
|
||||
element.hfId,
|
||||
targetPath,
|
||||
{ start: updates.start, duration: updates.duration },
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
// Capture on-disk bytes as the undo `before` so undoing a timing
|
||||
// resize restores the file verbatim, not a normalized full-DOM re-emit.
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Resize timeline clip", coalesceKey },
|
||||
).then((handled) => {
|
||||
if (!handled) return resizeFallback();
|
||||
});
|
||||
}
|
||||
return resizeFallback();
|
||||
projectId: projectIdRef.current,
|
||||
targetPath,
|
||||
domId: element.domId,
|
||||
label: "Resize timeline clip",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
edit: {
|
||||
kind: "scale",
|
||||
from: { start: element.start, duration: element.duration },
|
||||
to: { start: updates.start, duration: updates.duration },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const persistDone =
|
||||
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
|
||||
? sdkTimingPersist(
|
||||
element.hfId,
|
||||
targetPath,
|
||||
{ start: updates.start, duration: updates.duration },
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
// Capture on-disk bytes as the undo `before` so undoing a timing
|
||||
// resize restores the file verbatim, not a normalized full-DOM re-emit.
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Resize timeline clip", coalesceKey },
|
||||
).then((handled) => {
|
||||
if (!handled) return resizeFallback();
|
||||
})
|
||||
: resizeFallback();
|
||||
return persistDone.catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root.
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
[
|
||||
previewIframeRef,
|
||||
@@ -384,19 +404,37 @@ export function useTimelineEditing({
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
const patchedContent =
|
||||
const removedContent =
|
||||
typeof removeData.content === "string" ? removeData.content : originalContent;
|
||||
// Content-driven duration: shrink the composition to the furthest
|
||||
// remaining clip end, read from the post-removal SOURCE (raw
|
||||
// data-duration), so deleting the last/longest clip removes trailing
|
||||
// empty space. Measured from the source, not the store, whose
|
||||
// durations are runtime-truncated.
|
||||
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
||||
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
|
||||
// Optimistically reflect the shrunk length in the readout/seek bar,
|
||||
// rolling it back if the persist below fails (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
||||
usePlayerStore.getState().setDuration(deleteContentEnd);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
try {
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
}
|
||||
|
||||
usePlayerStore
|
||||
.getState()
|
||||
@@ -422,145 +460,23 @@ export function useTimelineEditing({
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineAssetDrop = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (
|
||||
assetPath: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
durationOverride?: number,
|
||||
) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
|
||||
const kind = getTimelineAssetKind(assetPath);
|
||||
if (!kind) {
|
||||
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
||||
const duration =
|
||||
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
||||
? durationOverride
|
||||
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
||||
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
||||
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
||||
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
||||
|
||||
const resolvedTargetPath = targetPath || "index.html";
|
||||
const relevantElements = timelineElements.filter(
|
||||
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||
);
|
||||
const newElementZIndex = Math.max(1, relevantElements.length + 1);
|
||||
|
||||
const patchedContent = insertTimelineAssetIntoSource(
|
||||
originalContent,
|
||||
buildTimelineAssetInsertHtml({
|
||||
id: newId,
|
||||
assetPath: resolvedAssetSrc,
|
||||
kind,
|
||||
start: normalizedStart,
|
||||
duration: normalizedDuration,
|
||||
track: placement.track,
|
||||
zIndex: newElementZIndex,
|
||||
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
||||
}),
|
||||
);
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Add timeline asset",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineFileDrop = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const uploaded = await uploadProjectFiles(files);
|
||||
if (uploaded.length === 0) return;
|
||||
const durations: number[] = [];
|
||||
for (const assetPath of uploaded) {
|
||||
const kind = getTimelineAssetKind(assetPath);
|
||||
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
||||
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
||||
}
|
||||
const placements = buildTimelineFileDropPlacements(
|
||||
placement ?? { start: 0, track: 0 },
|
||||
durations,
|
||||
timelineElements
|
||||
.filter(
|
||||
(te) =>
|
||||
(te.sourceFile || activeCompPath || "index.html") ===
|
||||
(activeCompPath || "index.html"),
|
||||
)
|
||||
.map((te) => ({
|
||||
start: te.start,
|
||||
duration: te.duration,
|
||||
track: te.track,
|
||||
})),
|
||||
);
|
||||
for (const [index, assetPath] of uploaded.entries()) {
|
||||
await handleTimelineAssetDrop(
|
||||
assetPath,
|
||||
placements[index] ?? placements[0],
|
||||
durations[index],
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
handleTimelineAssetDrop,
|
||||
timelineElements,
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
const { handleTimelineAssetDrop, handleTimelineFileDrop } = useTimelineAssetDropOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
});
|
||||
|
||||
const handleBlockedTimelineEdit = useCallback(
|
||||
(_element: TimelineElement) => {
|
||||
|
||||
@@ -6,17 +6,20 @@ import {
|
||||
buildTimelineMoveTimingPatch,
|
||||
buildTimelineResizeTimingPatch,
|
||||
extendRootDurationIfNeeded,
|
||||
finishTimelineTimingFallback,
|
||||
foldGsapMutationIntoHistory,
|
||||
formatTimelineAttributeNumber,
|
||||
patchIframeDomTiming,
|
||||
persistTimelineBatchEdit,
|
||||
readFileContent,
|
||||
scaleGsapPositions,
|
||||
shiftGsapPositions,
|
||||
type PersistTimelineBatchChange,
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
import {
|
||||
captureDurationRollback,
|
||||
finishGroupTimingGsapFallback,
|
||||
readFileContent,
|
||||
scaleGsapPositions,
|
||||
shiftGsapPositions,
|
||||
syncPreviewContentDuration,
|
||||
} from "./timelineTimingSync";
|
||||
|
||||
export interface TimelineGroupMoveChange {
|
||||
element: TimelineElement;
|
||||
@@ -75,6 +78,15 @@ function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): strin
|
||||
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
||||
}
|
||||
|
||||
function toSdkTimingChanges<T extends { element: TimelineElement }>(
|
||||
changes: readonly T[],
|
||||
timingUpdate: (change: T) => { start: number; duration?: number },
|
||||
): Array<{ hfId: string; timingUpdate: { start: number; duration?: number } } | null> {
|
||||
return changes.map((change) =>
|
||||
change.element.hfId ? { hfId: change.element.hfId, timingUpdate: timingUpdate(change) } : null,
|
||||
);
|
||||
}
|
||||
|
||||
function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
|
||||
return (
|
||||
change.playbackStart != null ||
|
||||
@@ -149,6 +161,55 @@ export function useTimelineGroupEditing({
|
||||
],
|
||||
);
|
||||
|
||||
// Shared SDK fast path for group move/resize: eligible when nothing needs the
|
||||
// server (no root-duration growth, one shared file, every change SDK-addressable
|
||||
// and `eligible` per the caller's own gate). Returns whether the SDK handled it;
|
||||
// false → caller falls through to the server batch persist.
|
||||
const trySdkBatchPersist = useCallback(
|
||||
async (input: {
|
||||
changes: readonly { element: TimelineElement }[];
|
||||
sdkChanges: Array<{
|
||||
hfId: string;
|
||||
timingUpdate: { start: number; duration?: number };
|
||||
} | null>;
|
||||
eligible: boolean;
|
||||
needsExtension: boolean;
|
||||
label: string;
|
||||
coalesceKey: string;
|
||||
}): Promise<boolean> => {
|
||||
const sharedPath = allChangesSharePath(input.changes, activeCompPath);
|
||||
const canUseSdk =
|
||||
!input.needsExtension &&
|
||||
sharedPath !== null &&
|
||||
input.eligible &&
|
||||
input.sdkChanges.every((change) => change !== null);
|
||||
if (!canUseSdk) return false;
|
||||
return sdkTimingBatchPersist(
|
||||
input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
||||
sharedPath,
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: input.label, coalesceKey: input.coalesceKey },
|
||||
);
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineGroupMove = useCallback(
|
||||
(changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
|
||||
if (changes.length === 0) return Promise.resolve();
|
||||
@@ -163,38 +224,27 @@ export function useTimelineGroupEditing({
|
||||
}
|
||||
|
||||
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
|
||||
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
||||
// persist can roll the readout + live root back (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
// needsExtension gates the SDK path (setTiming can't grow the root duration),
|
||||
// so read the store BEFORE the readout sync below optimistically updates it.
|
||||
const needsExtension = extendRootDurationIfNeeded(maxEnd);
|
||||
// Optimistic duration readout: content-driven (grow AND shrink), read from
|
||||
// the just-patched live DOM. See syncPreviewContentDuration.
|
||||
syncPreviewContentDuration(previewIframeRef.current);
|
||||
const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
|
||||
return enqueueGroupOperation("Move timeline clips", async (projectId) => {
|
||||
await options?.beforeTiming;
|
||||
const sharedPath = allChangesSharePath(changes, activeCompPath);
|
||||
const sdkChanges = changes.map((change) =>
|
||||
change.element.hfId
|
||||
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
|
||||
: null,
|
||||
);
|
||||
const canUseSdk =
|
||||
!needsExtension &&
|
||||
sharedPath !== null &&
|
||||
changes.every((change) => change.track == null) &&
|
||||
sdkChanges.every((change) => change !== null);
|
||||
if (canUseSdk) {
|
||||
const handled = await sdkTimingBatchPersist(
|
||||
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
||||
sharedPath,
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Move timeline clips", coalesceKey },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
const handledBySdk = await trySdkBatchPersist({
|
||||
changes,
|
||||
sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
|
||||
eligible: changes.every((change) => change.track == null),
|
||||
needsExtension,
|
||||
label: "Move timeline clips",
|
||||
coalesceKey,
|
||||
});
|
||||
if (handledBySdk) return;
|
||||
|
||||
await persistServerBatch(
|
||||
projectId,
|
||||
@@ -212,50 +262,39 @@ export function useTimelineGroupEditing({
|
||||
})),
|
||||
coalesceKey,
|
||||
);
|
||||
await finishTimelineTimingFallback({
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
needsExtension,
|
||||
rootDurationSeconds: maxEnd,
|
||||
reloadPreview,
|
||||
gsapMutation: () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId,
|
||||
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
|
||||
label: "Move timeline clips",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
gsapMutation: async () => {
|
||||
let mutated = false;
|
||||
for (const change of changes) {
|
||||
const delta = change.start - change.element.start;
|
||||
const domId = change.element.domId;
|
||||
if (delta === 0 || !domId) continue;
|
||||
const status = await shiftGsapPositions(
|
||||
projectId,
|
||||
targetPathFor(change.element, activeCompPath),
|
||||
domId,
|
||||
delta,
|
||||
);
|
||||
mutated = mutated || status.mutated;
|
||||
}
|
||||
return { mutated };
|
||||
},
|
||||
}),
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
|
||||
label: "Move timeline clips",
|
||||
errorLabel: "Failed to shift GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const delta = change.start - change.element.start;
|
||||
const domId = change.element.domId;
|
||||
if (delta === 0 || !domId) return null;
|
||||
return shiftGsapPositions(projectId, changePath, domId, delta);
|
||||
},
|
||||
});
|
||||
}).catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root
|
||||
// alongside the gesture owner's store rollback.
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
enqueueGroupOperation,
|
||||
persistServerBatch,
|
||||
previewIframeRef,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
trySdkBatchPersist,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -278,41 +317,30 @@ export function useTimelineGroupEditing({
|
||||
}
|
||||
|
||||
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
|
||||
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
||||
// persist can roll the readout + live root back (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
// needsExtension gates the SDK path (setTiming can't grow the root duration),
|
||||
// so read the store BEFORE the readout sync below optimistically updates it.
|
||||
const needsExtension = extendRootDurationIfNeeded(maxEnd);
|
||||
// Optimistic duration readout: content-driven (grow AND shrink), read from
|
||||
// the just-patched live DOM. See syncPreviewContentDuration.
|
||||
syncPreviewContentDuration(previewIframeRef.current);
|
||||
const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
|
||||
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
|
||||
await options?.beforeTiming;
|
||||
const sharedPath = allChangesSharePath(changes, activeCompPath);
|
||||
const sdkChanges = changes.map((change) =>
|
||||
change.element.hfId
|
||||
? {
|
||||
hfId: change.element.hfId,
|
||||
timingUpdate: { start: change.start, duration: change.duration },
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const canUseSdk =
|
||||
!needsExtension &&
|
||||
sharedPath !== null &&
|
||||
changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
|
||||
sdkChanges.every((change) => change !== null);
|
||||
if (canUseSdk) {
|
||||
const handled = await sdkTimingBatchPersist(
|
||||
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
||||
sharedPath,
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Resize timeline clips", coalesceKey },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
const handledBySdk = await trySdkBatchPersist({
|
||||
changes,
|
||||
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
||||
start: change.start,
|
||||
duration: change.duration,
|
||||
})),
|
||||
eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
|
||||
needsExtension,
|
||||
label: "Resize timeline clips",
|
||||
coalesceKey,
|
||||
});
|
||||
if (handledBySdk) return;
|
||||
|
||||
await persistServerBatch(
|
||||
projectId,
|
||||
@@ -328,55 +356,48 @@ export function useTimelineGroupEditing({
|
||||
})),
|
||||
coalesceKey,
|
||||
);
|
||||
await finishTimelineTimingFallback({
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
needsExtension,
|
||||
rootDurationSeconds: maxEnd,
|
||||
reloadPreview,
|
||||
gsapMutation: () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
label: "Resize timeline clips",
|
||||
errorLabel: "Failed to scale GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const domId = change.element.domId;
|
||||
const timingChanged =
|
||||
change.start !== change.element.start || change.duration !== change.element.duration;
|
||||
if (!timingChanged || !domId) return null;
|
||||
return scaleGsapPositions(
|
||||
projectId,
|
||||
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
|
||||
label: "Resize timeline clips",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
gsapMutation: async () => {
|
||||
let mutated = false;
|
||||
for (const change of changes) {
|
||||
const domId = change.element.domId;
|
||||
const timingChanged =
|
||||
change.start !== change.element.start ||
|
||||
change.duration !== change.element.duration;
|
||||
if (!timingChanged || !domId) continue;
|
||||
const status = await scaleGsapPositions(
|
||||
projectId,
|
||||
targetPathFor(change.element, activeCompPath),
|
||||
domId,
|
||||
change.element.start,
|
||||
change.element.duration,
|
||||
change.start,
|
||||
change.duration,
|
||||
);
|
||||
mutated = mutated || status.mutated;
|
||||
}
|
||||
return { mutated };
|
||||
},
|
||||
}),
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
|
||||
changePath,
|
||||
domId,
|
||||
change.element.start,
|
||||
change.element.duration,
|
||||
change.start,
|
||||
change.duration,
|
||||
);
|
||||
},
|
||||
});
|
||||
}).catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root
|
||||
// alongside the gesture owner's store rollback.
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
enqueueGroupOperation,
|
||||
persistServerBatch,
|
||||
previewIframeRef,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
trySdkBatchPersist,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,10 +9,8 @@ import { Tooltip } from "../../components/ui";
|
||||
import { ShortcutsPanel } from "./ShortcutsPanel";
|
||||
import { SpeedMenu } from "./SpeedMenu";
|
||||
import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
|
||||
import { useState } from "react";
|
||||
|
||||
export { resolveSeekPercent };
|
||||
type TimeDisplayMode = "time" | "frame";
|
||||
|
||||
/* ── Icon sub-components ─────────────────────────────────────────── */
|
||||
|
||||
@@ -369,7 +367,8 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
const outPoint = usePlayerStore((s) => s.outPoint);
|
||||
const setInPoint = usePlayerStore.getState().setInPoint;
|
||||
const setOutPoint = usePlayerStore.getState().setOutPoint;
|
||||
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
|
||||
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
||||
const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
|
||||
|
||||
const progressFillRef = useRef<HTMLDivElement>(null);
|
||||
const progressThumbRef = useRef<HTMLDivElement>(null);
|
||||
@@ -428,10 +427,11 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
return (
|
||||
<div
|
||||
// No own background/border: the transport blends into the preview
|
||||
// panel's surface — buttons carry their own chrome.
|
||||
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
||||
aria-disabled={disabled || undefined}
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
|
||||
}}
|
||||
>
|
||||
@@ -456,7 +456,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
|
||||
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
||||
disabled={disabled}
|
||||
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
|
||||
style={{ color: "#A1A1AA", cursor: "pointer" }}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
|
||||
* FILLED while the playhead is actively held/scrubbed (`scrubbing`).
|
||||
*/
|
||||
import { PLAYHEAD_HEAD_W } from "./timelineLayout";
|
||||
|
||||
interface PlayheadIndicatorProps {
|
||||
/** CSS color, defaults to the HF accent variable */
|
||||
color?: string;
|
||||
@@ -31,8 +33,11 @@ export function PlayheadIndicator({
|
||||
}: PlayheadIndicatorProps) {
|
||||
// Head chip dimensions — used to compute the centering offset and the
|
||||
// point where the vertical line starts (so it begins at the head's bottom
|
||||
// edge rather than running through the hollow diamond center).
|
||||
const HEAD_W = 9;
|
||||
// edge rather than running through the hollow diamond center). The width is
|
||||
// the shared PLAYHEAD_HEAD_W constant: getTimelinePlayheadLeft shifts the
|
||||
// wrapper by -PLAYHEAD_HEAD_W/2 so the 1px line (centered at 50% of the
|
||||
// wrapper) lands exactly on GUTTER + time * pps — the ruler ticks' center x.
|
||||
const HEAD_W = PLAYHEAD_HEAD_W;
|
||||
const HEAD_H = 9;
|
||||
// marginTop(1) + HEAD_H = where the line should start.
|
||||
const HEAD_TOTAL_H = 1 + HEAD_H;
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
shouldAutoScrollTimeline,
|
||||
} from "./Timeline";
|
||||
import {
|
||||
FIT_ZOOM_HEADROOM,
|
||||
GUTTER,
|
||||
MIN_TIMELINE_EXTENT_S,
|
||||
PLAYHEAD_HEAD_W,
|
||||
RULER_H,
|
||||
TRACK_H,
|
||||
getTimelineDisplayContentWidth,
|
||||
@@ -327,6 +329,60 @@ describe("generateTicks", () => {
|
||||
const { major } = generateTicks(180, 80);
|
||||
expect(major[1] - major[0]).toBe(2);
|
||||
});
|
||||
|
||||
it("picks 'nice' NLE steps across zoom levels (no 7s-style intervals)", () => {
|
||||
// step = first nice interval whose px spacing >= 88 at that pps.
|
||||
const cases: Array<[number, number]> = [
|
||||
[2, 60], // 60s * 2pps = 120px
|
||||
[10, 10], // 10s * 10pps = 100px
|
||||
[20, 5], // 5s * 20pps = 100px
|
||||
[50, 2], // 2s * 50pps = 100px
|
||||
[100, 1], // 1s * 100pps = 100px
|
||||
];
|
||||
for (const [pps, expected] of cases) {
|
||||
const { major } = generateTicks(600, pps);
|
||||
expect(major[1] - major[0]).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses minute/hour steps when zoomed far out instead of colliding 10m labels", () => {
|
||||
// 0.05 pps → 600s step would be 30px apart (labels collide); 1800s = 90px.
|
||||
const { major } = generateTicks(7200, 0.05);
|
||||
expect(major[1] - major[0]).toBe(1800);
|
||||
expect(major).toContain(3600);
|
||||
});
|
||||
|
||||
it("does not drift on long rulers (ticks are exact multiples of the step)", () => {
|
||||
const { major } = generateTicks(600, 100); // 1s step, 601 ticks
|
||||
expect(major[599]).toBe(599);
|
||||
});
|
||||
|
||||
describe("frame display mode (frameRate provided)", () => {
|
||||
it("snaps sub-frame steps up to one whole frame (no duplicate frame labels)", () => {
|
||||
// 4400 pps would pick a 0.02s step = 0.6 frames at 30fps → snapped to 1 frame.
|
||||
const { major } = generateTicks(2, 4400, 30);
|
||||
const frames = major.map((t) => Math.round(t * 30));
|
||||
// Frame labels are consecutive integers — no duplicates, no gaps.
|
||||
frames.forEach((f, i) => expect(f).toBe(i));
|
||||
});
|
||||
|
||||
it("keeps major AND minor ticks on whole frames", () => {
|
||||
// 200 pps → 0.5s step (15 frames); quarters (3.75f) are rejected in
|
||||
// frame mode in favour of fifths (3f).
|
||||
const { major, minor } = generateTicks(20, 200, 30);
|
||||
expect(major[1]).toBeCloseTo(0.5);
|
||||
expect(minor).toContain(0.1); // 3 frames
|
||||
for (const t of [...major, ...minor]) {
|
||||
const frames = t * 30;
|
||||
expect(Math.abs(frames - Math.round(frames))).toBeLessThan(1e-3);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves whole-second steps unchanged", () => {
|
||||
const { major } = generateTicks(60, 100, 30);
|
||||
expect(major[1] - major[0]).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
@@ -397,19 +453,33 @@ describe("shouldAutoScrollTimeline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineFitPps (min 60s extent)", () => {
|
||||
describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
|
||||
const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
|
||||
|
||||
it("computes fit pps against the 60s floor for short compositions", () => {
|
||||
// A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
|
||||
// (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
|
||||
const pps = getTimelineFitPps(viewport, 10);
|
||||
expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
|
||||
expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
|
||||
});
|
||||
|
||||
it("keeps filling the viewport with the composition when it is 60s or longer", () => {
|
||||
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo((viewport - GUTTER - 2) / 60);
|
||||
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo((viewport - GUTTER - 2) / 120);
|
||||
it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
|
||||
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
|
||||
(viewport - GUTTER - 2) / (60 * FIT_ZOOM_HEADROOM),
|
||||
);
|
||||
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
|
||||
(viewport - GUTTER - 2) / (120 * FIT_ZOOM_HEADROOM),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
|
||||
const usable = viewport - GUTTER - 2;
|
||||
const pps = getTimelineFitPps(viewport, 120);
|
||||
// Composition content occupies usable/1.2 px; the remaining ~17% is empty
|
||||
// droppable ruler/lane surface past the end.
|
||||
expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
|
||||
expect(120 * pps).toBeLessThan(usable);
|
||||
});
|
||||
|
||||
it("falls back to 100 pps before the viewport is measured", () => {
|
||||
@@ -529,13 +599,20 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
});
|
||||
|
||||
describe("getTimelinePlayheadLeft", () => {
|
||||
it("converts time to a pixel offset from the gutter", () => {
|
||||
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
||||
it("offsets the wrapper by half the head width so the line CENTER = GUTTER + t*pps", () => {
|
||||
// Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
|
||||
// equal GUTTER + t*pps at any zoom.
|
||||
expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
|
||||
expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
|
||||
});
|
||||
|
||||
it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
|
||||
expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
|
||||
});
|
||||
|
||||
it("guards invalid input", () => {
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getTimelineCanvasHeight,
|
||||
shouldShowTimelineShortcutHint,
|
||||
} from "./timelineLayout";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
|
||||
@@ -95,6 +96,7 @@ export const Timeline = memo(function Timeline({
|
||||
[beatAnalysis, musicElement, beatEdits],
|
||||
);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
||||
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
@@ -156,11 +158,8 @@ export const Timeline = memo(function Timeline({
|
||||
containerRef.current = el;
|
||||
}, []);
|
||||
|
||||
// Last horizontal scroll offset, tracked so it can be RESTORED across the
|
||||
// post-edit iframe reload: an edit re-derives elements (and may shrink the
|
||||
// content width), which the browser clamps into a scroll jump. Paired with the
|
||||
// pinned zoom (which keeps pps constant so the pixel offset stays meaningful),
|
||||
// restoring this keeps the user parked at the same spot after any edit.
|
||||
// Last horizontal scroll offset, RESTORED across the post-edit iframe reload (which clamps into
|
||||
// a scroll jump); with the pinned zoom this keeps the user parked at the same spot after edits.
|
||||
const lastScrollLeftRef = useRef(0);
|
||||
const setScrollRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
@@ -395,9 +394,11 @@ export const Timeline = memo(function Timeline({
|
||||
}
|
||||
});
|
||||
|
||||
// Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
|
||||
const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
|
||||
const { major, minor } = useMemo(
|
||||
() => generateTicks(displayDuration, pps),
|
||||
[displayDuration, pps],
|
||||
() => generateTicks(displayDuration, pps, tickFps),
|
||||
[displayDuration, pps, tickFps],
|
||||
);
|
||||
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
|
||||
|
||||
@@ -526,8 +527,7 @@ export const Timeline = memo(function Timeline({
|
||||
const elKey = el.key ?? el.id;
|
||||
setSelectedElementId(elKey);
|
||||
onSelectElement?.(el);
|
||||
// Visually select the clicked diamond (matches shift-click / motion-path
|
||||
// selection); cleared above so this single-selects it.
|
||||
// Select the clicked diamond (matches shift-click); cleared above so this single-selects.
|
||||
toggleSelectedKeyframe(`${elKey}:${pct}`);
|
||||
const absTime = el.start + (pct / 100) * el.duration;
|
||||
onSeek?.(absTime);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
CLIP_Y,
|
||||
TRACKS_TOP_PAD,
|
||||
TRACKS_BOTTOM_PAD,
|
||||
PLAYHEAD_HEAD_W,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineRowTop,
|
||||
} from "./timelineLayout";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
@@ -20,6 +22,7 @@ import type { Rect } from "../../utils/marqueeGeometry";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
|
||||
import { renderClipChildren } from "./timelineClipChildren";
|
||||
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
||||
|
||||
interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
||||
major: number[];
|
||||
@@ -41,6 +44,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
|
||||
useTimelineEditContextOptional();
|
||||
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
||||
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
|
||||
useTimelineRevealClip(scrollRef);
|
||||
const draggedElement = draggedClip?.element ?? null;
|
||||
const activeDraggedElement =
|
||||
draggedClip?.started === true && draggedElement
|
||||
@@ -259,12 +264,16 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
)}
|
||||
|
||||
{/* Playhead — hidden while dragging a beat so its guideline doesn't
|
||||
track the scrub and clutter the beat being moved. */}
|
||||
track the scrub and clutter the beat being moved. Explicit width +
|
||||
the half-head offset baked into getTimelinePlayheadLeft keep the
|
||||
inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler
|
||||
ticks' center), instead of relying on shrink-wrap sizing. */}
|
||||
<div
|
||||
ref={props.playheadRef}
|
||||
className="absolute top-0 bottom-0 pointer-events-none"
|
||||
style={{
|
||||
left: `${GUTTER}px`,
|
||||
left: `${getTimelinePlayheadLeft(0, 0)}px`,
|
||||
width: PLAYHEAD_HEAD_W,
|
||||
zIndex: 100,
|
||||
display: beatDragging ? "none" : undefined,
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { memo } from "react";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { secondsToFrame } from "../lib/time";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
@@ -26,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
theme,
|
||||
beatAnalysis,
|
||||
}: TimelineRulerProps) {
|
||||
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
||||
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
||||
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
|
||||
|
||||
@@ -38,27 +41,13 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Grid lines (major ticks + beat lines) — behind the tracks (background).
|
||||
Opaque track rows hide them; only the beat dots show on tracks. */}
|
||||
{/* Background SVG — beat lines only; major-tick gridlines removed so only
|
||||
the ruler's own small ticks mark intervals (no full-height lines). */}
|
||||
<svg
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
|
||||
height={totalH}
|
||||
>
|
||||
{major.map((t) => {
|
||||
const x = t * pps;
|
||||
return (
|
||||
<line
|
||||
key={`g-${t}`}
|
||||
x1={x}
|
||||
y1={RULER_H}
|
||||
x2={x}
|
||||
y2={totalH}
|
||||
stroke={theme.tickMinor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showBeats &&
|
||||
beatTimes.map((t, i) => {
|
||||
const x = t * pps;
|
||||
@@ -79,41 +68,62 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Ruler. The bar fills the full panel width (canvas is min 100% wide);
|
||||
calc(100% - GUTTER) equals trackContentWidth when zoomed in and extends
|
||||
past the content when zoomed out. Ticks stay at composition coordinates. */}
|
||||
{/* Ruler — sticky so the timestamps stay visible while the tracks scroll
|
||||
vertically. Opaque background (plus the gutter corner block) so clips
|
||||
scrolling underneath don't bleed through; z-index sits above the track
|
||||
rows and drag overlays but below the playhead (z 100). */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
height: RULER_H,
|
||||
marginLeft: GUTTER,
|
||||
width: `calc(100% - ${GUTTER}px)`,
|
||||
background: theme.gutterBackground,
|
||||
borderBottom: `1px solid ${theme.rulerBorder}`,
|
||||
}}
|
||||
className="sticky top-0 flex"
|
||||
style={{ height: RULER_H, width: GUTTER + trackContentWidth, zIndex: 70 }}
|
||||
>
|
||||
{minor.map((t) => (
|
||||
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
||||
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="sticky left-0 z-[12] flex-shrink-0"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
// Ruler corner uses the panel surface — same as the ruler strip itself.
|
||||
background: theme.shellBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
height: RULER_H,
|
||||
width: trackContentWidth,
|
||||
// Ruler background = panel surface (#0A0A0B) — no bottom border,
|
||||
// no tick lines (CapCut-style clean ruler, labels only).
|
||||
background: theme.shellBackground,
|
||||
}}
|
||||
>
|
||||
{/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
|
||||
t * pps — matching the playhead line, which is also centered on
|
||||
GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
|
||||
a tick spans [x, x+1) and its center is half a pixel right. */}
|
||||
{minor.map((t) => (
|
||||
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
|
||||
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{major.map((t) => (
|
||||
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
|
||||
<span
|
||||
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
|
||||
style={{
|
||||
color: theme.tickText,
|
||||
left: 5,
|
||||
top: 5,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
||||
</span>
|
||||
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
|
||||
</div>
|
||||
))}
|
||||
{major.map((t) => (
|
||||
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
|
||||
<span
|
||||
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
|
||||
style={{
|
||||
color: theme.tickText,
|
||||
left: 5,
|
||||
top: 5,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{timeDisplayMode === "frame"
|
||||
? secondsToFrame(t)
|
||||
: formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
||||
</span>
|
||||
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -219,6 +219,88 @@ describe("commitDraggedClipMove", () => {
|
||||
expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
|
||||
});
|
||||
|
||||
it("persists a lane change in AUTHORED track space when the file is sparse", () => {
|
||||
// Discovery normalized authored tracks {1, 2} to display lanes {0, 1}
|
||||
// (authoredTrack records the file value). Moving 'a' onto b's lane must
|
||||
// write b's AUTHORED track (2) — writing the display lane (1) would target
|
||||
// a's own authored row and silently no-op in the file.
|
||||
const elements = [
|
||||
{ ...el("a", 0, 0, 3), authoredTrack: 1 },
|
||||
{ ...el("b", 1, 10, 3), authoredTrack: 2 },
|
||||
];
|
||||
const { updateElement, onMoveElements } = runClipMove(
|
||||
drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
|
||||
{ elements, trackOrder: [0, 1] },
|
||||
);
|
||||
// Store stays in display-lane space, but authoredTrack is refreshed to the
|
||||
// value just written to the file so a SECOND drag before any reload resolves
|
||||
// authored tracks from current data, not the stale pre-edit value.
|
||||
expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1, authoredTrack: 2 });
|
||||
// ...while the persist is translated to the target lane's authored track.
|
||||
const map = editMap(onMoveElements.mock.calls[0][0]);
|
||||
expect(map.a).toEqual({ start: 20, track: 2 });
|
||||
});
|
||||
|
||||
it("resolves the authored track from occupants of the dragged clip's OWN source file", () => {
|
||||
// Expanded sub-comp children live on synthetic display lanes next to host
|
||||
// clips. Their authoredTrack is in THEIR file's coordinate space, so a lane
|
||||
// occupied by a clip from a DIFFERENT file must never lend its authored
|
||||
// value. Here lane 1 holds both a host-file clip (authored 12) and a
|
||||
// same-file sibling (authored 7): the sibling answers.
|
||||
const child3 = { ...el("c3", 0, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
|
||||
const child7 = { ...el("c7", 1, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
|
||||
const hostClip = { ...el("h", 1, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
|
||||
const elements = [child3, child7, hostClip];
|
||||
const { onMoveElements } = runClipMove(
|
||||
drag(child3, { previewStart: 0, previewTrack: 1, desiredTrack: 1 }),
|
||||
{ elements, trackOrder: [0, 1] },
|
||||
);
|
||||
const map = editMap(onMoveElements.mock.calls[0][0]);
|
||||
expect(map.c3).toEqual({ start: 0, track: 7 });
|
||||
});
|
||||
|
||||
it("never persists the display-lane integer when the target lane has no same-file occupant", () => {
|
||||
// Reviewer scenario: an expanded child of a SPARSE file (authored tracks 3
|
||||
// and 7 → display lanes 4 and 5) dragged onto display lane 6, which holds
|
||||
// only another file's clip. Persisting 6 (the display integer) or 12 (the
|
||||
// foreign authored value) would corrupt the sparse file; the fallback
|
||||
// offsets from the NEAREST same-file lane instead: authored 7 at lane 5,
|
||||
// one lane further down → 8.
|
||||
const child3 = { ...el("c3", 4, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
|
||||
const child7 = { ...el("c7", 5, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
|
||||
const foreign = { ...el("f", 6, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
|
||||
const elements = [child3, child7, foreign];
|
||||
const { onMoveElements } = runClipMove(
|
||||
drag(child3, { previewStart: 0, previewTrack: 6, desiredTrack: 6 }),
|
||||
{ elements, trackOrder: [4, 5, 6] },
|
||||
);
|
||||
const map = editMap(onMoveElements.mock.calls[0][0]);
|
||||
expect(map.c3.track).not.toBe(6); // not the display-lane integer
|
||||
expect(map.c3.track).not.toBe(12); // not the foreign file's authored value
|
||||
expect(map.c3).toEqual({ start: 0, track: 8 });
|
||||
});
|
||||
|
||||
it("dropping onto an overlap spill sub-lane persists the base lane's shared authored track", () => {
|
||||
// Authored track 2 holds two time-overlapping clips, which packTrackLanes
|
||||
// spills onto display sub-lanes 1 and 2 (both authoredTrack 2). Dropping
|
||||
// 'a' onto the spill sub-lane (2) is a legitimate same-track join: the
|
||||
// persisted value is the shared authored track (2), even though the clip
|
||||
// may re-pack onto a different sub-lane on the next normalize.
|
||||
const elements = normalizeToZones([
|
||||
{ ...el("a", 0, 30, 3), authoredTrack: 1 },
|
||||
{ ...el("b1", 2, 0, 5), authoredTrack: 2 },
|
||||
{ ...el("b2", 2, 3, 5), authoredTrack: 2 }, // overlaps b1 → spills
|
||||
]);
|
||||
expect(elements.map((e) => e.track)).toEqual([0, 1, 2]); // spill happened
|
||||
const spillLane = elements.find((e) => e.id === "b2")!.track;
|
||||
const { onMoveElements } = runClipMove(
|
||||
drag(elements[0], { previewStart: 30, previewTrack: spillLane, desiredTrack: spillLane }),
|
||||
{ elements, trackOrder: [0, 1, 2] },
|
||||
);
|
||||
const map = editMap(onMoveElements.mock.calls[0][0]);
|
||||
expect(map.a).toEqual({ start: 30, track: 2 });
|
||||
});
|
||||
|
||||
it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
|
||||
const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
|
||||
// Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
|
||||
@@ -283,8 +365,9 @@ describe("commitDraggedClipMove", () => {
|
||||
const map = editMap(onMoveElements.mock.calls[0][0]);
|
||||
// Lanes are contiguous and distinct (no two overlapping clips share a lane).
|
||||
expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
|
||||
// The z-aware normalization may reverse the authored lane numbers, but the
|
||||
// insert must still leave three distinct, contiguous visual lanes.
|
||||
expect(map.a.track).toBe(0); // above the insert → unchanged
|
||||
expect(map.c.track).toBe(1); // dragged clip lands on the new lane
|
||||
expect(map.b.track).toBe(2); // at/below the insert → +1 shift
|
||||
});
|
||||
|
||||
describe("lane ↔ stacking sync", () => {
|
||||
@@ -440,9 +523,9 @@ describe("commitDraggedClipMove", () => {
|
||||
drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
|
||||
[0, 1],
|
||||
);
|
||||
// Non-overlapping clips retain their authored/z-derived ordering; a lane
|
||||
// gesture cannot invent a DOM stacking relationship where none overlaps.
|
||||
expect(lane.dragged).toBe(2);
|
||||
expect(lane.dragged).toBe(0); // aimed at the very top
|
||||
expect(lane.top).toBe(1);
|
||||
expect(lane.mid).toBe(2);
|
||||
});
|
||||
|
||||
it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
|
||||
@@ -453,8 +536,8 @@ describe("commitDraggedClipMove", () => {
|
||||
[0, 1, 2],
|
||||
);
|
||||
expect(lane.a).toBe(0);
|
||||
expect(lane.b).toBe(1);
|
||||
expect(lane.x).toBe(2);
|
||||
expect(lane.x).toBe(1); // between a and b, as aimed
|
||||
expect(lane.b).toBe(2);
|
||||
});
|
||||
|
||||
it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
|
||||
@@ -466,7 +549,7 @@ describe("commitDraggedClipMove", () => {
|
||||
drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
|
||||
[0, 1, 2],
|
||||
);
|
||||
expect(lane.X).toBe(1); // X reorders against overlapping M, not disjoint T
|
||||
expect(lane.X).toBe(0); // aimed top, cleared the non-overlapping T
|
||||
});
|
||||
|
||||
it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
|
||||
|
||||
@@ -13,6 +13,13 @@ type StartTrack = Pick<TimelineElement, "start" | "track">;
|
||||
export interface TimelineMoveEdit {
|
||||
element: TimelineElement;
|
||||
updates: StartTrack;
|
||||
/**
|
||||
* File-space track override for the persist. The store's `updates.track` is a
|
||||
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
|
||||
* 1,2,... or gaps), the file write must target the lane's AUTHORED track or it
|
||||
* silently re-targets the wrong row. Omitted → persist `updates.track` as-is.
|
||||
*/
|
||||
persistTrack?: number;
|
||||
}
|
||||
|
||||
export interface DragCommitDeps {
|
||||
@@ -114,21 +121,41 @@ function persistMoveEdits(
|
||||
key: keyOf(e.element),
|
||||
start: e.element.start,
|
||||
track: e.element.track,
|
||||
authoredTrack: e.element.authoredTrack,
|
||||
}));
|
||||
const revision = beginTimelineOptimisticGesture(
|
||||
updateElement,
|
||||
edits.map((edit) => keyOf(edit.element)),
|
||||
);
|
||||
for (const e of edits) updateElement(keyOf(e.element), e.updates);
|
||||
// The file write below targets `persistTrack` (authored space) when supplied,
|
||||
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
|
||||
// that written value into the store's `authoredTrack` so a SECOND drag before
|
||||
// any reload resolves authored tracks from what the file now says, not stale
|
||||
// pre-edit data. Pure time-moves leave authoredTrack untouched.
|
||||
for (const e of edits) {
|
||||
const writtenTrack =
|
||||
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
|
||||
updateElement(
|
||||
keyOf(e.element),
|
||||
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
|
||||
);
|
||||
}
|
||||
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
||||
// track when one was resolved (see TimelineMoveEdit.persistTrack).
|
||||
const persistEdits = edits.map((e) =>
|
||||
e.persistTrack == null || e.persistTrack === e.updates.track
|
||||
? e
|
||||
: { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
|
||||
);
|
||||
const persisted = onMoveElements
|
||||
? onMoveElements(edits, coalesceKey, operation)
|
||||
: Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
||||
? onMoveElements(persistEdits, coalesceKey, operation)
|
||||
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
||||
return Promise.resolve(persisted).then(
|
||||
() => true,
|
||||
(error) => {
|
||||
for (const p of prev) {
|
||||
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
||||
updateElement(p.key, { start: p.start, track: p.track });
|
||||
updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
|
||||
}
|
||||
}
|
||||
console.error("[Timeline] Failed to persist clip edits", error);
|
||||
@@ -143,6 +170,56 @@ function persistMoveEdits(
|
||||
* then compacts it to a distinct integer lane between its neighbours, and the
|
||||
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
||||
*/
|
||||
/** Same-source-file predicate: authored track numbers only compare within ONE
|
||||
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
|
||||
* file, not the host timeline's). `undefined` means the active composition. */
|
||||
const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
||||
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
||||
|
||||
/**
|
||||
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
|
||||
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
|
||||
* file — an occupant from a different file (e.g. an expanded sub-comp child, or
|
||||
* a host clip next to expanded rows) carries authored values in a different
|
||||
* coordinate space, and borrowing them would write a foreign file's numbering.
|
||||
*
|
||||
* Lane semantics after normalizeToZones: each distinct authored track owns one
|
||||
* base lane, and time-overlapping same-track clips spill onto adjacent display
|
||||
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
|
||||
* trackOrder lists it): its occupants share the base lane's authored track by
|
||||
* construction, so the same-file occupant lookup returns that authored track and
|
||||
* the drop persists as a same-track join. The clip may then DISPLAY on a
|
||||
* different sub-lane than it was dropped on — the spill re-packs
|
||||
* deterministically by stable id, first-fit — but the persisted track is
|
||||
* correct.
|
||||
*
|
||||
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
|
||||
* dropped on a lane holding only other files' clips — the display-lane integer
|
||||
* must NOT be persisted into a sparse file):
|
||||
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
|
||||
* preserving "one lane up = one authored track up" in the clip's own file.
|
||||
* 2. No same-file peers at all → the lane value itself (single-clip files:
|
||||
* display and authored spaces coincide for want of any other anchor).
|
||||
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
|
||||
* never here.
|
||||
*/
|
||||
function authoredTrackForLane(
|
||||
lane: number,
|
||||
elements: TimelineElement[],
|
||||
dragged: TimelineElement,
|
||||
): number {
|
||||
const dragKey = keyOf(dragged);
|
||||
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
|
||||
const occupant = peers.find((e) => e.track === lane);
|
||||
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
||||
let nearest: TimelineElement | null = null;
|
||||
for (const p of peers) {
|
||||
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
|
||||
}
|
||||
if (!nearest) return lane;
|
||||
return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
|
||||
}
|
||||
|
||||
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
||||
if (trackOrder.length === 0) return 0;
|
||||
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
||||
@@ -251,6 +328,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
||||
const dragEdit: TimelineMoveEdit = {
|
||||
element: drag.element,
|
||||
updates: { start: drag.previewStart, track: drag.previewTrack },
|
||||
persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
|
||||
};
|
||||
const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
|
||||
|
||||
@@ -265,9 +343,13 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
||||
// The drop-intent set for the z-sync: the dragged clip at its new lane, others
|
||||
// as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
|
||||
// move; computeStackingPatches only compares lanes relatively.
|
||||
const candidate = elements.map((e) =>
|
||||
keyOf(e) === dragKey ? { ...e, start: drag.previewStart, track: drag.previewTrack } : e,
|
||||
);
|
||||
const candidate = elements.map((e) => {
|
||||
if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: drag.previewTrack };
|
||||
// Selection members shift in time with the drag — the z-sync must reason on
|
||||
// their POST-move overlap sets, same as the insert branch's candidate.
|
||||
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
||||
return e;
|
||||
});
|
||||
const multiKeys = multi ? multi.keys : null;
|
||||
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
|
||||
if (moved && isVertical) {
|
||||
@@ -292,6 +374,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
||||
* The whole affected set is persisted atomically (single undo), and the deliberate
|
||||
* vertical move syncs the dragged clip's stacking afterwards.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function commitTrackInsert(
|
||||
drag: DraggedClipState,
|
||||
deps: DragCommitDeps,
|
||||
@@ -314,12 +397,25 @@ function commitTrackInsert(
|
||||
// shifts the at/below clips down by one — the sanctioned +1 index renumber.
|
||||
const normalized = normalizeToZones(candidate);
|
||||
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
|
||||
// The renumber is only correct as a WHOLE-SET write: skipping an unwritable
|
||||
// clip whose lane shifts leaves its track colliding with a renumbered
|
||||
// neighbour, and the next normalize merges the two lanes. If any shifted clip
|
||||
// can't be written, refuse the insert instead of persisting a broken layout.
|
||||
for (const norm of normalized) {
|
||||
const src = bySrc.get(keyOf(norm));
|
||||
if (src && !canMoveElement(src) && norm.track !== src.track) {
|
||||
console.warn(
|
||||
`[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const edits: TimelineMoveEdit[] = [];
|
||||
for (const norm of normalized) {
|
||||
const src = bySrc.get(keyOf(norm));
|
||||
if (!src) continue;
|
||||
// Capabilities gate: never write a locked/implicit clip, even one only swept
|
||||
// along by the renumber (not just a marquee member).
|
||||
// Capabilities gate (unchanged-lane clips only reach here now): never write
|
||||
// a locked/implicit clip.
|
||||
if (!canMoveElement(src)) continue;
|
||||
const start =
|
||||
keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
|
||||
@@ -391,6 +487,7 @@ function syncStackingForEdit(
|
||||
zIndex: readZIndex(el),
|
||||
isAudio: classifyZone(el) === "audio",
|
||||
domIndex,
|
||||
stackingContextId: el.stackingContextId ?? null,
|
||||
}));
|
||||
|
||||
const editedKeys = [dragKey];
|
||||
|
||||
@@ -67,51 +67,105 @@ export const DRAG_EXTEND_MARGIN_PX = 160;
|
||||
* with 60s of ruler after it.
|
||||
*/
|
||||
export const MIN_TIMELINE_EXTENT_S = 60;
|
||||
/**
|
||||
* Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
|
||||
* duration — onto the viewport, so the composition ends at ~83% of the width
|
||||
* and the trailing ~17% stays empty ruler + droppable lane surface (room to
|
||||
* drag clips past the current end without first zooming out). Applied ONLY
|
||||
* inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
|
||||
* lanes, playhead, marquee, and drag math all inherit it consistently. Manual
|
||||
* zoom percentages stay defined relative to this fit basis (100% == fit).
|
||||
*/
|
||||
export const FIT_ZOOM_HEADROOM = 1.2;
|
||||
|
||||
/* ── Tick generation ──────────────────────────────────────────────── */
|
||||
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
||||
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
||||
// fallow-ignore-next-line complexity
|
||||
function getMajorTickInterval(
|
||||
duration: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
): number {
|
||||
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
|
||||
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
|
||||
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
|
||||
const zoomIntervals = [
|
||||
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
|
||||
];
|
||||
let interval: number;
|
||||
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
||||
const targetMajorPx = 88;
|
||||
return (
|
||||
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
|
||||
);
|
||||
interval =
|
||||
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
|
||||
3600;
|
||||
} else {
|
||||
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
||||
const target = duration / 6;
|
||||
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
|
||||
}
|
||||
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
||||
const target = duration / 6;
|
||||
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
||||
// Frame display mode: labels are frame numbers, so a major step must be a
|
||||
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
|
||||
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
|
||||
// the label spacing never drops below the readability target.
|
||||
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
|
||||
const fps = frameRate ?? 0;
|
||||
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
|
||||
// How many equal parts to split each major interval into for minor ticks. Prefer
|
||||
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
|
||||
// none (0) as ticks get too dense to read (< ~8px apart).
|
||||
function getMinorSubdivisions(majorInterval: number, pixelsPerSecond?: number): number {
|
||||
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
|
||||
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
|
||||
// sub-frame time is not a seekable position), so only divisors of the major
|
||||
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
|
||||
// thirds, halves.
|
||||
// fallow-ignore-next-line complexity
|
||||
function getMinorSubdivisions(
|
||||
majorInterval: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
): number {
|
||||
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
|
||||
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
|
||||
if ((majorInterval / 4) * pps >= 8) return 4;
|
||||
if ((majorInterval / 2) * pps >= 8) return 2;
|
||||
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
|
||||
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
|
||||
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
|
||||
for (const parts of candidates) {
|
||||
if (fps > 0 && majorFrames % parts !== 0) continue;
|
||||
if ((majorInterval / parts) * pps >= 8) return parts;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ticks are exact multiples of the interval (multiplied per index, never
|
||||
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
|
||||
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
|
||||
function roundTickValue(t: number): number {
|
||||
return Math.round(t * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
export function generateTicks(
|
||||
duration: number,
|
||||
pixelsPerSecond?: number,
|
||||
frameRate?: number,
|
||||
): { major: number[]; minor: number[] } {
|
||||
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
||||
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
|
||||
return { major: [], minor: [] };
|
||||
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
||||
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond);
|
||||
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
|
||||
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
|
||||
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
|
||||
const major: number[] = [];
|
||||
const minor: number[] = [];
|
||||
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
||||
for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
|
||||
const rounded = Math.round(t * 100) / 100;
|
||||
major.push(rounded);
|
||||
for (let i = 0; major.length < maxTicks; i++) {
|
||||
const t = i * majorInterval;
|
||||
if (t > duration + 0.001) break;
|
||||
major.push(roundTickValue(t));
|
||||
// Emit the (subdivisions - 1) minor ticks between this major and the next.
|
||||
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
|
||||
const m = Math.round((t + k * minorInterval) * 100) / 100;
|
||||
if (m <= duration + 0.001) minor.push(m);
|
||||
const m = t + k * minorInterval;
|
||||
if (m <= duration + 0.001) minor.push(roundTickValue(m));
|
||||
}
|
||||
}
|
||||
return { major, minor };
|
||||
@@ -144,15 +198,17 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
|
||||
|
||||
/* ── Width / duration derivation ──────────────────────────────────── */
|
||||
/**
|
||||
* Fit-mode pixels-per-second: fill the viewport with the composition, but
|
||||
* never map fewer than MIN_TIMELINE_EXTENT_S seconds onto it — a short comp
|
||||
* takes a fraction of the width and the remaining ruler runs to 1:00.
|
||||
* Fit-mode pixels-per-second: fill the viewport with the composition plus
|
||||
* FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
|
||||
* into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
|
||||
* seconds onto it — a short comp takes a fraction of the width and the
|
||||
* remaining ruler runs to 1:00.
|
||||
* Manual zoom multiplies this base, so the floor only anchors the default.
|
||||
*/
|
||||
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
|
||||
const safeDuration =
|
||||
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
||||
const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
|
||||
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
||||
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
|
||||
return (viewportWidth - GUTTER - 2) / span;
|
||||
}
|
||||
@@ -227,9 +283,26 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
||||
}
|
||||
|
||||
/* ── Playhead / canvas ────────────────────────────────────────────── */
|
||||
/**
|
||||
* Width of the playhead wrapper element (== the diamond head chip's layout
|
||||
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
|
||||
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
|
||||
* shifted LEFT by half this width for the line's center to land exactly on
|
||||
* `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
|
||||
*/
|
||||
export const PLAYHEAD_HEAD_W = 9;
|
||||
|
||||
/**
|
||||
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
|
||||
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
|
||||
* on) at every zoom level. Without the half-head offset the line sat
|
||||
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
|
||||
*/
|
||||
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
||||
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
||||
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
||||
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
|
||||
return GUTTER - PLAYHEAD_HEAD_W / 2;
|
||||
}
|
||||
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
|
||||
}
|
||||
|
||||
export function getTimelineCanvasHeight(trackCount: number): number {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeRevealScroll,
|
||||
REVEAL_SCROLL_PADDING_PX,
|
||||
type RevealScrollInput,
|
||||
} from "./timelineRevealScroll";
|
||||
|
||||
/** A 1000×400 viewport with a 32px sticky gutter and 24px sticky ruler. */
|
||||
function makeInput(overrides: Partial<RevealScrollInput> = {}): RevealScrollInput {
|
||||
return {
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
viewportWidth: 1000,
|
||||
viewportHeight: 400,
|
||||
clipLeft: 100,
|
||||
clipRight: 200,
|
||||
clipTop: 100,
|
||||
clipBottom: 148,
|
||||
stickyLeft: 32,
|
||||
stickyTop: 24,
|
||||
allowHorizontal: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeRevealScroll", () => {
|
||||
it("returns null on both axes when the clip is fully visible", () => {
|
||||
expect(computeRevealScroll(makeInput())).toEqual({ left: null, top: null });
|
||||
});
|
||||
|
||||
it("scrolls right minimally when the clip end is past the right edge", () => {
|
||||
const result = computeRevealScroll(makeInput({ clipLeft: 1500, clipRight: 1600 }));
|
||||
// Clip end lands padding px inside the right edge.
|
||||
expect(result.left).toBe(1600 - 1000 + REVEAL_SCROLL_PADDING_PX);
|
||||
expect(result.top).toBeNull();
|
||||
});
|
||||
|
||||
it("scrolls left when the clip start is hidden (including under the sticky gutter)", () => {
|
||||
const result = computeRevealScroll(
|
||||
makeInput({ scrollLeft: 500, clipLeft: 510, clipRight: 610 }),
|
||||
);
|
||||
// clipLeft 510 sits under the 32px sticky gutter (window starts at 500+32+pad).
|
||||
expect(result.left).toBe(510 - 32 - REVEAL_SCROLL_PADDING_PX);
|
||||
});
|
||||
|
||||
it("aligns the start edge when the clip is wider than the viewport", () => {
|
||||
const result = computeRevealScroll(makeInput({ clipLeft: 2000, clipRight: 4000 }));
|
||||
expect(result.left).toBe(2000 - 32 - REVEAL_SCROLL_PADDING_PX);
|
||||
});
|
||||
|
||||
it("never returns a negative scroll target", () => {
|
||||
const result = computeRevealScroll(
|
||||
makeInput({
|
||||
scrollLeft: 300,
|
||||
clipLeft: 10,
|
||||
clipRight: 60,
|
||||
scrollTop: 200,
|
||||
clipTop: 4,
|
||||
clipBottom: 20,
|
||||
}),
|
||||
);
|
||||
expect(result.left).toBe(0);
|
||||
expect(result.top).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses horizontal scroll when allowHorizontal is false (fit zoom)", () => {
|
||||
const result = computeRevealScroll(
|
||||
makeInput({
|
||||
allowHorizontal: false,
|
||||
clipLeft: 1500,
|
||||
clipRight: 1600,
|
||||
clipTop: 700,
|
||||
clipBottom: 748,
|
||||
}),
|
||||
);
|
||||
expect(result.left).toBeNull();
|
||||
// Vertical reveal still happens.
|
||||
expect(result.top).toBe(748 - 400 + REVEAL_SCROLL_PADDING_PX);
|
||||
});
|
||||
|
||||
it("scrolls up when the clip lane is hidden under the sticky ruler", () => {
|
||||
const result = computeRevealScroll(
|
||||
makeInput({ scrollTop: 200, clipTop: 210, clipBottom: 258 }),
|
||||
);
|
||||
// clipTop 210 sits under the 24px sticky ruler (window starts at 200+24+pad).
|
||||
expect(result.top).toBe(210 - 24 - REVEAL_SCROLL_PADDING_PX);
|
||||
});
|
||||
|
||||
it("scrolls down minimally when the clip lane is below the viewport", () => {
|
||||
const result = computeRevealScroll(makeInput({ clipTop: 500, clipBottom: 548 }));
|
||||
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
|
||||
expect(result.left).toBeNull();
|
||||
});
|
||||
|
||||
it("never scrolls on an axis whose visible window is degenerate", () => {
|
||||
// Horizontal window collapses: 40px viewport minus the 32px gutter and
|
||||
// 2x12px padding is negative; vertical is intact and still reveals.
|
||||
const result = computeRevealScroll(
|
||||
makeInput({
|
||||
viewportWidth: 40,
|
||||
clipLeft: 1500,
|
||||
clipRight: 1600,
|
||||
clipTop: 500,
|
||||
clipBottom: 548,
|
||||
}),
|
||||
);
|
||||
expect(result.left).toBeNull();
|
||||
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
|
||||
|
||||
// Exactly-zero window (viewport == sticky + 2x padding) is degenerate too.
|
||||
const zero = computeRevealScroll(
|
||||
makeInput({
|
||||
viewportWidth: 32 + 2 * REVEAL_SCROLL_PADDING_PX,
|
||||
clipLeft: 1500,
|
||||
clipRight: 1600,
|
||||
}),
|
||||
);
|
||||
expect(zero.left).toBeNull();
|
||||
|
||||
// Both axes degenerate: no scroll at all.
|
||||
const both = computeRevealScroll(
|
||||
makeInput({
|
||||
viewportWidth: 10,
|
||||
viewportHeight: 10,
|
||||
clipLeft: 1500,
|
||||
clipRight: 1600,
|
||||
clipTop: 500,
|
||||
clipBottom: 548,
|
||||
}),
|
||||
);
|
||||
expect(both).toEqual({ left: null, top: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Pure scroll-target math for revealing a timeline clip inside the timeline's
|
||||
* scroll container (the overflow div in Timeline.tsx).
|
||||
*
|
||||
* Coordinates are content-space: a clip edge measured from the scroll
|
||||
* container's content origin (rect delta + current scroll offset). The visible
|
||||
* window on each axis is reduced by the sticky chrome that occludes it — the
|
||||
* track gutter on the left (GUTTER) and the ruler on top (RULER_H) — so a clip
|
||||
* "hidden" under the sticky gutter still counts as off-screen.
|
||||
*
|
||||
* Scrolls minimally: an axis already fully visible returns null for that axis;
|
||||
* otherwise the nearest edge is brought just inside the window (plus padding).
|
||||
* A clip larger than the window aligns its start edge. Pure — unit-tested.
|
||||
*/
|
||||
|
||||
export interface RevealScrollInput {
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
/** Scroll container clientWidth / clientHeight. */
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
/** Clip bounds in content-space (relative to the scroll content origin). */
|
||||
clipLeft: number;
|
||||
clipRight: number;
|
||||
clipTop: number;
|
||||
clipBottom: number;
|
||||
/** Width of the sticky left gutter occluding the viewport's left edge. */
|
||||
stickyLeft: number;
|
||||
/** Height of the sticky ruler occluding the viewport's top edge. */
|
||||
stickyTop: number;
|
||||
/** False in "fit" zoom mode, where horizontal scrolling is disabled. */
|
||||
allowHorizontal: boolean;
|
||||
}
|
||||
|
||||
export interface RevealScrollTarget {
|
||||
/** Target scrollLeft, or null when the horizontal axis needs no scroll. */
|
||||
left: number | null;
|
||||
/** Target scrollTop, or null when the vertical axis needs no scroll. */
|
||||
top: number | null;
|
||||
}
|
||||
|
||||
/** Breathing room between the revealed clip edge and the window edge. */
|
||||
export const REVEAL_SCROLL_PADDING_PX = 12;
|
||||
|
||||
/**
|
||||
* Minimal scroll on one axis to bring [start, end] inside the visible window
|
||||
* [scroll + stickyStart, scroll + viewport], with padding. Returns null when
|
||||
* the range is already fully visible.
|
||||
*/
|
||||
function revealAxis(
|
||||
scroll: number,
|
||||
viewport: number,
|
||||
stickyStart: number,
|
||||
start: number,
|
||||
end: number,
|
||||
): number | null {
|
||||
const windowStart = scroll + stickyStart + REVEAL_SCROLL_PADDING_PX;
|
||||
const windowEnd = scroll + viewport - REVEAL_SCROLL_PADDING_PX;
|
||||
const windowSize = windowEnd - windowStart;
|
||||
// Degenerate viewport (container smaller than the sticky chrome + padding):
|
||||
// there is no visible window to reveal into, so never scroll on this axis.
|
||||
if (windowSize <= 0) return null;
|
||||
if (start >= windowStart && end <= windowEnd) return null;
|
||||
// Oversized range (or start hidden): align the start edge to the window start.
|
||||
if (end - start > windowSize || start < windowStart) {
|
||||
return Math.max(0, start - stickyStart - REVEAL_SCROLL_PADDING_PX);
|
||||
}
|
||||
// Only the end is clipped: pull it just inside the window's far edge.
|
||||
return Math.max(0, end - viewport + REVEAL_SCROLL_PADDING_PX);
|
||||
}
|
||||
|
||||
export function computeRevealScroll(input: RevealScrollInput): RevealScrollTarget {
|
||||
return {
|
||||
left: input.allowHorizontal
|
||||
? revealAxis(
|
||||
input.scrollLeft,
|
||||
input.viewportWidth,
|
||||
input.stickyLeft,
|
||||
input.clipLeft,
|
||||
input.clipRight,
|
||||
)
|
||||
: null,
|
||||
top: revealAxis(
|
||||
input.scrollTop,
|
||||
input.viewportHeight,
|
||||
input.stickyTop,
|
||||
input.clipTop,
|
||||
input.clipBottom,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,66 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("stacking-context partitioning", () => {
|
||||
it("never compares or patches across stacking contexts", () => {
|
||||
// X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
|
||||
// with a lower leaf z, overlapping in time. Their leaf z values are NOT
|
||||
// comparable (the ancestors' z decides paint order), so moving X's lane above
|
||||
// Y must not reason on Y or patch either based on the 10-vs-5 comparison.
|
||||
const x: StackingElement = {
|
||||
key: "x",
|
||||
track: 0,
|
||||
start: 0,
|
||||
duration: 5,
|
||||
zIndex: 10,
|
||||
isAudio: false,
|
||||
stackingContextId: "scene-1",
|
||||
};
|
||||
const y: StackingElement = {
|
||||
key: "y",
|
||||
track: 1,
|
||||
start: 0,
|
||||
duration: 5,
|
||||
zIndex: 5,
|
||||
isAudio: false,
|
||||
stackingContextId: null,
|
||||
};
|
||||
// X edited: only same-context neighbours participate — none here, so X keeps
|
||||
// its z (nothing to fix WITHIN its context) and Y is never touched.
|
||||
expect(patchMap([x, y], ["x"])).toEqual({});
|
||||
});
|
||||
|
||||
it("still resolves within the edited clip's own context", () => {
|
||||
const a: StackingElement = {
|
||||
key: "a",
|
||||
track: 1,
|
||||
start: 0,
|
||||
duration: 5,
|
||||
zIndex: 1,
|
||||
isAudio: false,
|
||||
stackingContextId: "scene-1",
|
||||
};
|
||||
const b: StackingElement = {
|
||||
key: "b",
|
||||
track: 0,
|
||||
start: 0,
|
||||
duration: 5,
|
||||
zIndex: 5,
|
||||
isAudio: false,
|
||||
stackingContextId: "scene-1",
|
||||
};
|
||||
// 'a' moved BELOW b's lane... a (track 1) is under b (track 0): a's z (1)
|
||||
// is already below b's (5) — consistent, no patch. Move a ABOVE (track -1
|
||||
// relative ordering) is covered by existing suites; here we just prove the
|
||||
// same-context pair still participates (no patch ≠ no participation: verify
|
||||
// by flipping z so a MUST be lifted).
|
||||
const aWrong = { ...a, track: 0, zIndex: 1 };
|
||||
const bLow = { ...b, track: 1, zIndex: 5 };
|
||||
const patches = patchMap([aWrong, bLow], ["a"]);
|
||||
expect(patches.a).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("laneIsAbove", () => {
|
||||
it("lower track renders above (top of timeline wins)", () => {
|
||||
expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
|
||||
|
||||
@@ -43,6 +43,13 @@ export interface StackingElement {
|
||||
zIndex: number;
|
||||
/** Audio clips have no visual stacking and are excluded from the computation. */
|
||||
isAudio: boolean;
|
||||
/**
|
||||
* CSS stacking context the clip's node lives in (TimelineElement.stackingContextId).
|
||||
* Leaf z-indexes are only comparable WITHIN one context — across contexts the
|
||||
* ancestors' z decides paint order — so the sync partitions by this key and
|
||||
* never patches across contexts. Null/undefined ⇒ the root context.
|
||||
*/
|
||||
stackingContextId?: string | null;
|
||||
/**
|
||||
* Discovery / DOM document position (optional). Two clips with EQUAL z paint by
|
||||
* DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
|
||||
@@ -62,6 +69,14 @@ export interface StackingPatch {
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
/**
|
||||
* Canonical stacking-context key: null/undefined both mean the root context.
|
||||
* The ONLY place the normalization lives — context partitioning, membership
|
||||
* checks, and pairwise equality must all go through it.
|
||||
*/
|
||||
const contextKey = (el: { stackingContextId?: string | null }): string | null =>
|
||||
el.stackingContextId ?? null;
|
||||
|
||||
/**
|
||||
* Two clips overlap in time when their half-open [start, end) intervals intersect.
|
||||
*
|
||||
@@ -284,7 +299,14 @@ export function computeStackingPatches(
|
||||
// z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
|
||||
// unresolved clip is neither a neighbour nor resolvable as an edit, so it is
|
||||
// excluded outright (item 13).
|
||||
const resolved = elements.filter((e) => Number.isFinite(e.zIndex));
|
||||
const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
|
||||
|
||||
// Leaf z is only meaningful within ONE stacking context: across contexts the
|
||||
// ancestor contexts' z decides paint order, so comparing (or patching) leaf
|
||||
// values across contexts is nonsense. Restrict the computation to the edited
|
||||
// clips' own context(s); cross-context lane relations are out of scope.
|
||||
const editedContexts = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(contextKey));
|
||||
const resolved = allResolved.filter((e) => editedContexts.has(contextKey(e)));
|
||||
|
||||
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
|
||||
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
|
||||
@@ -304,8 +326,11 @@ export function computeStackingPatches(
|
||||
// The full live set, so the transitive cascade can reach clips that overlap a
|
||||
// LIFTED neighbour without overlapping the edited clip itself (#2198).
|
||||
const all = [...byKey.values()];
|
||||
const sameContext = (a: MutZ, b: MutZ) => contextKey(a) === contextKey(b);
|
||||
const overlappersOf = (clip: MutZ): MutZ[] =>
|
||||
all.filter((o) => o.key !== clip.key && !o.isAudio && overlapsInTime(clip, o));
|
||||
all.filter(
|
||||
(o) => o.key !== clip.key && !o.isAudio && sameContext(clip, o) && overlapsInTime(clip, o),
|
||||
);
|
||||
|
||||
for (const clip of edited) {
|
||||
resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
||||
import { buildExpandedElements } from "../hooks/useExpandedTimelineElements";
|
||||
import { normalizeToZones } from "./timelineZones";
|
||||
import { commitDraggedClipMove, type TimelineMoveEdit } from "./timelineClipDragCommit";
|
||||
import type { DraggedClipState } from "./useTimelineClipDrag";
|
||||
|
||||
/**
|
||||
* Pipeline test across the REAL manifest→element boundary: no hand-injected
|
||||
* `authoredTrack` / `stackingContextId`. A runtime-manifest-shaped payload with
|
||||
* SPARSE authored tracks flows through createTimelineElementFromManifestClip →
|
||||
* (normalizeToZones | buildChildElements) → commitDraggedClipMove, and the
|
||||
* persisted data-track-index must be the AUTHORED target, never the display lane.
|
||||
*/
|
||||
|
||||
const manifestClip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
||||
id: "x",
|
||||
label: "x",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track: 0,
|
||||
stackingContextId: "root",
|
||||
kind: "element",
|
||||
tagName: "div",
|
||||
compositionId: null,
|
||||
parentCompositionId: null,
|
||||
compositionSrc: null,
|
||||
assetUrl: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
function fromManifest(clips: ClipManifestClip[]): TimelineElement[] {
|
||||
return clips.map((clip, index) =>
|
||||
createTimelineElementFromManifestClip({ clip, fallbackIndex: index }),
|
||||
);
|
||||
}
|
||||
|
||||
function drag(
|
||||
element: TimelineElement,
|
||||
opts: { previewStart: number; previewTrack: number },
|
||||
): DraggedClipState {
|
||||
return {
|
||||
element,
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
pointerClientX: 0,
|
||||
pointerClientY: 0,
|
||||
pointerOffsetX: 0,
|
||||
pointerOffsetY: 0,
|
||||
previewStart: opts.previewStart,
|
||||
previewTrack: opts.previewTrack,
|
||||
desiredTrack: opts.previewTrack,
|
||||
insertRow: null,
|
||||
snapTime: null,
|
||||
snapType: null,
|
||||
started: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Commit a lane-change drag and return the single persisted edit batch. */
|
||||
function commitLaneChange(
|
||||
element: TimelineElement,
|
||||
previewTrack: number,
|
||||
elements: TimelineElement[],
|
||||
trackOrder: number[],
|
||||
): TimelineMoveEdit[] {
|
||||
const onMoveElements = vi.fn();
|
||||
commitDraggedClipMove(drag(element, { previewStart: element.start, previewTrack }), {
|
||||
elements,
|
||||
trackOrder,
|
||||
updateElement: vi.fn(),
|
||||
onMoveElements,
|
||||
});
|
||||
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
||||
return onMoveElements.mock.calls[0][0] as TimelineMoveEdit[];
|
||||
}
|
||||
|
||||
describe("track persist pipeline (manifest → factory → lanes → drag commit)", () => {
|
||||
// Sparse authored tracks 3 and 7 (mixed kinds), plus audio on 5, exactly as a
|
||||
// runtime manifest would ship them (clip.track is the verbatim data-track-index).
|
||||
const sparseManifest = [
|
||||
manifestClip({ id: "v", kind: "video", tagName: "video", track: 3, start: 0 }),
|
||||
manifestClip({ id: "g", kind: "element", tagName: "div", track: 7, start: 10 }),
|
||||
manifestClip({ id: "m", kind: "audio", tagName: "audio", track: 5, start: 0 }),
|
||||
];
|
||||
|
||||
it("factory records the authored track and stacking context from the manifest clip", () => {
|
||||
const [v, g, m] = fromManifest(sparseManifest);
|
||||
expect([v.authoredTrack, g.authoredTrack, m.authoredTrack]).toEqual([3, 7, 5]);
|
||||
expect(v.stackingContextId).toBe("root");
|
||||
});
|
||||
|
||||
it("a lane change on a sparse file persists the AUTHORED target track, not the display lane", () => {
|
||||
// normalizeToZones packs visual tracks {3, 7} onto display lanes {0, 1} and
|
||||
// the audio track 5 onto lane 2, preserving the factory-set authoredTrack.
|
||||
const elements = normalizeToZones(fromManifest(sparseManifest));
|
||||
const byId = new Map(elements.map((e) => [e.id, e]));
|
||||
expect(byId.get("v")).toMatchObject({ track: 0, authoredTrack: 3 });
|
||||
expect(byId.get("g")).toMatchObject({ track: 1, authoredTrack: 7 });
|
||||
expect(byId.get("m")).toMatchObject({ track: 2, authoredTrack: 5 });
|
||||
|
||||
// Drag the video (lane 0) onto the div's lane (display 1, authored 7).
|
||||
const down = commitLaneChange(byId.get("v")!, 1, elements, [0, 1, 2]);
|
||||
expect(down).toHaveLength(1);
|
||||
expect(down[0].updates.track).toBe(7); // authored, NOT display lane 1
|
||||
expect(down[0].updates.track).not.toBe(1);
|
||||
|
||||
// And the reverse: the div (lane 1) onto the video's lane (display 0, authored 3).
|
||||
const up = commitLaneChange(byId.get("g")!, 0, elements, [0, 1, 2]);
|
||||
expect(up[0].updates.track).toBe(3); // authored, NOT display lane 0
|
||||
});
|
||||
|
||||
it("an expanded sub-comp child's lane change persists the sibling's authored track from ITS file", () => {
|
||||
// Host timeline: the sub-comp host plus a root clip, discovered through the
|
||||
// factory and lane-normalized like the store does.
|
||||
const hostManifest = [
|
||||
manifestClip({
|
||||
id: "scene",
|
||||
kind: "composition",
|
||||
tagName: "div",
|
||||
track: 0,
|
||||
start: 0,
|
||||
duration: 10,
|
||||
compositionId: "scene",
|
||||
compositionSrc: "scene.html",
|
||||
}),
|
||||
manifestClip({ id: "root-clip", kind: "video", tagName: "video", track: 1, start: 0 }),
|
||||
];
|
||||
// scene.html has SPARSE authored tracks 3 and 7.
|
||||
const childClips = [
|
||||
manifestClip({ id: "c3", track: 3, start: 1, duration: 2, parentCompositionId: "scene" }),
|
||||
manifestClip({ id: "c7", track: 7, start: 4, duration: 2, parentCompositionId: "scene" }),
|
||||
];
|
||||
const storeElements = normalizeToZones(fromManifest(hostManifest));
|
||||
const parentMap = new Map([
|
||||
["c3", "scene"],
|
||||
["c7", "scene"],
|
||||
]);
|
||||
const expanded = buildExpandedElements(
|
||||
storeElements,
|
||||
[...hostManifest, ...childClips],
|
||||
parentMap,
|
||||
"scene",
|
||||
"scene",
|
||||
);
|
||||
|
||||
// The children replaced the host row: synthetic display lanes, but the
|
||||
// authored track (in scene.html's coordinate space) survived the expansion.
|
||||
const c3 = expanded.find((e) => e.domId === "c3")!;
|
||||
const c7 = expanded.find((e) => e.domId === "c7")!;
|
||||
expect(c3).toMatchObject({ authoredTrack: 3, sourceFile: "scene.html" });
|
||||
expect(c7).toMatchObject({ authoredTrack: 7, sourceFile: "scene.html" });
|
||||
expect(c3.stackingContextId).toBe("root");
|
||||
expect(c3.track).not.toBe(3); // display row is synthetic
|
||||
|
||||
// Drag c3 onto c7's display lane: the persist target is c3's OWN file, so
|
||||
// the written track must be c7's authored 7 — not the display-lane integer.
|
||||
const trackOrder = [...new Set(expanded.map((e) => e.track))].sort((a, b) => a - b);
|
||||
const edits = commitLaneChange(c3, c7.track, expanded, trackOrder);
|
||||
expect(edits).toHaveLength(1);
|
||||
expect(edits[0].updates.track).toBe(7);
|
||||
expect(edits[0].updates.track).not.toBe(c7.track);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { classifyZone, normalizeToZones } from "./timelineZones";
|
||||
import { computeStackingPatches, type StackingElement } from "./timelineStackingSync";
|
||||
|
||||
function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
|
||||
return { id, tag, start: 0, duration, track };
|
||||
@@ -29,16 +28,6 @@ function expectZoningIdempotent(input: TimelineElement[]): void {
|
||||
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
}
|
||||
|
||||
/** The exact qa-clean live repro (array order = DOM order); fresh objects per call. */
|
||||
function qaCleanRepro(): TimelineElement[] {
|
||||
return [
|
||||
zClip("blue-logo", 6.37, 3, 0, 3, "img"),
|
||||
zClip("ralu", 6.37, 3, 0, 0, "img"),
|
||||
zClip("black-logo", 11.92, 3, 1, 1, "img"),
|
||||
zClip("video", 0.84, 20, 3, 2, "video"),
|
||||
];
|
||||
}
|
||||
|
||||
describe("classifyZone", () => {
|
||||
it("audio → audio; video / image / everything else → visual", () => {
|
||||
expect(classifyZone(el("m", "audio", 3))).toBe("audio");
|
||||
@@ -66,52 +55,77 @@ describe("classifyZone", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeToZones", () => {
|
||||
it("orders visual (top) → audio (bottom); equal-z overlap stacks by DOM order", () => {
|
||||
// img and vid are both z=0, start=0 → they OVERLAP in time. CSS paints
|
||||
// equal-z siblings by DOM order (later paints on top), so the later-in-array
|
||||
// `vid` must own the upper lane. (Was: pinned img=0/vid=1 to authored order,
|
||||
// which contradicts the canvas — updated for per-clip DOM-order tie-break.)
|
||||
describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
|
||||
it("orders visual lanes by authored track-index (ascending), audio at the bottom", () => {
|
||||
// img (track 0), vid (track 2), mus (audio, track 5). Lanes follow the track
|
||||
// index: the LOWER visual track owns the upper lane. z is irrelevant (absent).
|
||||
const out = normalizeToZones([
|
||||
el("img", "img", 0),
|
||||
el("vid", "video", 2),
|
||||
el("mus", "audio", 5),
|
||||
]);
|
||||
expect(trackOf(out, "vid")).toBe(0); // later in DOM → paints on top → upper lane
|
||||
expect(trackOf(out, "img")).toBe(1); // earlier in DOM → below
|
||||
expect(trackOf(out, "mus")).toBe(2); // audio (bottom)
|
||||
expect(trackOf(out, "img")).toBe(0); // track 0 → top lane
|
||||
expect(trackOf(out, "vid")).toBe(1); // track 2 → below it
|
||||
expect(trackOf(out, "mus")).toBe(2); // audio → bottom
|
||||
});
|
||||
|
||||
it("keeps all visual lanes together on top; equal-z overlap stacks by DOM order", () => {
|
||||
// All three z=0, start=0 → mutually overlapping. DOM order (later on top)
|
||||
// decides the stack: v3 (last) top, then i1, then v0. (Was: pinned to authored
|
||||
// order v0=0/i1=1/v3=2, which the canvas contradicts for overlapping equal-z.)
|
||||
const out = normalizeToZones([el("v0", "video", 0), el("i1", "img", 1), el("v3", "video", 3)]);
|
||||
expect(trackOf(out, "v3")).toBe(0);
|
||||
expect(trackOf(out, "i1")).toBe(1);
|
||||
expect(trackOf(out, "v0")).toBe(2);
|
||||
it("compacts sparse visual track-indexes to contiguous lanes, preserving ascending order", () => {
|
||||
// Distinct authored tracks 0, 3, 7 → three adjacent lanes in the same order.
|
||||
const out = normalizeToZones([el("a", "video", 7), el("b", "img", 0), el("c", "video", 3)]);
|
||||
expect(trackOf(out, "b")).toBe(0); // track 0
|
||||
expect(trackOf(out, "c")).toBe(1); // track 3
|
||||
expect(trackOf(out, "a")).toBe(2); // track 7
|
||||
});
|
||||
|
||||
it("drops audio below the visual lanes even when it holds a LOWER authored index", () => {
|
||||
// Audio authored at track 0, video at track 5 — audio must still sink below.
|
||||
const out = normalizeToZones([zClip("a", 0, 10, 0, 0, "audio"), zClip("v", 0, 10, 5, 0)]);
|
||||
expect(trackOf(out, "v")).toBe(0); // visual on top
|
||||
expect(trackOf(out, "a")).toBe(1); // audio below, despite its lower track index
|
||||
});
|
||||
|
||||
it("drops audio below the visual lanes even when sharing a track index", () => {
|
||||
const out = normalizeToZones([el("v", "video", 0), el("a", "audio", 0)]);
|
||||
expect(trackOf(out, "v")).toBe(0); // visual
|
||||
expect(trackOf(out, "a")).toBe(1); // audio, below
|
||||
expect(trackOf(out, "v")).toBe(0);
|
||||
expect(trackOf(out, "a")).toBe(1);
|
||||
});
|
||||
|
||||
it("groups multiple audio tracks at the bottom preserving relative order", () => {
|
||||
const out = normalizeToZones([el("v", "video", 0), el("a1", "audio", 1), el("a2", "audio", 4)]);
|
||||
it("groups multiple audio tracks at the bottom, ordered by their track index", () => {
|
||||
const out = normalizeToZones([el("v", "video", 0), el("a2", "audio", 4), el("a1", "audio", 1)]);
|
||||
expect(trackOf(out, "v")).toBe(0);
|
||||
expect(trackOf(out, "a1")).toBe(1);
|
||||
expect(trackOf(out, "a1")).toBe(1); // audio track 1 above audio track 4
|
||||
expect(trackOf(out, "a2")).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores z-index entirely — a high-z clip does NOT jump above a lower-track clip", () => {
|
||||
// lo on track 0 with z=1; hi on track 1 with z=99. They fully overlap in time.
|
||||
// The old z-rank pack lifted hi above lo; the CapCut rule keeps lane = track.
|
||||
const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 1, 99)]);
|
||||
expect(trackOf(out, "lo")).toBe(0); // track 0 stays on top
|
||||
expect(trackOf(out, "hi")).toBe(1); // higher z does NOT lift it
|
||||
});
|
||||
|
||||
it("ignores z-index across authored tracks (scattered z, lanes still by track)", () => {
|
||||
const out = normalizeToZones([
|
||||
zClip("t0", 0, 10, 0, 3),
|
||||
zClip("t1", 0, 10, 1, 26),
|
||||
zClip("t2", 0, 10, 2, 0),
|
||||
]);
|
||||
expect(trackOf(out, "t0")).toBe(0);
|
||||
expect(trackOf(out, "t1")).toBe(1);
|
||||
expect(trackOf(out, "t2")).toBe(2);
|
||||
});
|
||||
|
||||
it("sequential (non-overlapping) same-track clips share a lane", () => {
|
||||
const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]);
|
||||
expect(trackOf(out, "a")).toBe(0);
|
||||
expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of z
|
||||
});
|
||||
|
||||
it("returns the same array (identity) when already zoned", () => {
|
||||
// A fixed-point layout: the two overlapping equal-z visual clips are already
|
||||
// in DOM-order-consistent lanes (later-in-array `v` on the upper lane 0), so
|
||||
// re-zoning is a no-op and the SAME array reference comes back. (Was: i=0/v=1,
|
||||
// which the new per-clip DOM-order pack would flip — so it wasn't a fixed
|
||||
// point under the corrected canvas semantics; swapped to the stable order.)
|
||||
const input = [el("v", "video", 1), el("i", "img", 0), el("a", "audio", 2)];
|
||||
// i on track 0, v on track 1, a (audio) on track 2 — already contiguous, visual
|
||||
// above audio, so re-zoning is a no-op and the SAME reference comes back.
|
||||
const input = [el("i", "img", 0), el("v", "video", 1), el("a", "audio", 2)];
|
||||
expect(normalizeToZones(input)).toBe(input);
|
||||
});
|
||||
|
||||
@@ -125,112 +139,11 @@ describe("normalizeToZones", () => {
|
||||
expectZoningIdempotent(input);
|
||||
});
|
||||
|
||||
it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => {
|
||||
const clip = (id: string, start: number, duration: number): TimelineElement => ({
|
||||
id,
|
||||
tag: "video",
|
||||
start,
|
||||
duration,
|
||||
track: 1, // all authored on the SAME track, some overlapping in time
|
||||
});
|
||||
// a [0,5), b [2,7) overlaps a, c [6,9) fits after a. All equal-z (absent).
|
||||
// Per-clip pack (DOM-order tie-break): c is last in DOM so it places on the
|
||||
// top lane 0; b overlaps c and lands on lane 1; a overlaps b (and is earlier
|
||||
// in DOM than b, so it must paint BELOW b) → lane 2. a can NOT drop onto c's
|
||||
// lane 0 even though it doesn't overlap c, because that would place a ABOVE b
|
||||
// in lane order while a paints below b — the canvas-correct constraint the old
|
||||
// whole-track packer ignored (it shared a/c on lane 0, contradicting paint).
|
||||
const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]);
|
||||
expect(trackOf(out, "c")).toBe(0); // last in DOM → top lane
|
||||
expect(trackOf(out, "b")).toBe(1); // overlaps c → below it
|
||||
expect(trackOf(out, "a")).toBe(2); // paints below b (earlier DOM, equal z) → lane below b
|
||||
|
||||
// No two time-overlapping clips share a lane (the real NLE invariant).
|
||||
expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
|
||||
expect(trackOf(out, "b")).not.toBe(trackOf(out, "c"));
|
||||
|
||||
// Idempotent: re-laying the split result changes nothing.
|
||||
const twice = normalizeToZones(out);
|
||||
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeToZones — reverse z→lane mapping", () => {
|
||||
it("orders overlapping same-zone clips by z: higher z → higher (upper) lane", () => {
|
||||
// lo (z=1) and hi (z=9) fully overlap in time on the same authored track.
|
||||
const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 0, 9)]);
|
||||
expect(trackOf(out, "hi")).toBe(0); // higher z → upper lane (top)
|
||||
expect(trackOf(out, "lo")).toBe(1); // lower z → below
|
||||
});
|
||||
|
||||
it("orders three overlapping clips strictly by descending z", () => {
|
||||
const out = normalizeToZones([
|
||||
zClip("mid", 0, 10, 0, 5),
|
||||
zClip("top", 0, 10, 0, 8),
|
||||
zClip("bot", 0, 10, 0, 2),
|
||||
]);
|
||||
expect(trackOf(out, "top")).toBe(0);
|
||||
expect(trackOf(out, "mid")).toBe(1);
|
||||
expect(trackOf(out, "bot")).toBe(2);
|
||||
});
|
||||
|
||||
it("does NOT reorder non-overlapping (sequential) clips by z — they share a lane", () => {
|
||||
// a [0,5) z=1 then c [6,9) z=9 — no time overlap, so z is irrelevant.
|
||||
const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]);
|
||||
expect(trackOf(out, "a")).toBe(0);
|
||||
expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of higher z
|
||||
});
|
||||
|
||||
it("leaves the audio zone unaffected by z", () => {
|
||||
const out = normalizeToZones([
|
||||
zClip("v", 0, 10, 0, 1),
|
||||
zClip("m1", 0, 10, 1, 99, "audio"),
|
||||
zClip("m2", 0, 10, 1, 0, "audio"),
|
||||
]);
|
||||
// Two overlapping audio clips split onto lanes below the visual clip; their
|
||||
// relative z does not lift one above a visual clip.
|
||||
expect(trackOf(out, "v")).toBe(0);
|
||||
expect(trackOf(out, "m1")).toBeGreaterThan(trackOf(out, "v"));
|
||||
expect(trackOf(out, "m2")).toBeGreaterThan(trackOf(out, "v"));
|
||||
});
|
||||
|
||||
it("treats missing / auto z as 0 (undefined z clip sinks below a positive-z overlap)", () => {
|
||||
const out = normalizeToZones([
|
||||
{ id: "noz", tag: "video", start: 0, duration: 10, track: 0 }, // no zIndex
|
||||
zClip("pos", 0, 10, 0, 3),
|
||||
]);
|
||||
expect(trackOf(out, "pos")).toBe(0); // z=3 → upper
|
||||
expect(trackOf(out, "noz")).toBe(1); // absent z ⇒ 0 → below
|
||||
});
|
||||
|
||||
it("tie-breaks equal-z overlapping clips on the STABLE id, not the mutated lane", () => {
|
||||
// Equal z + full overlap: order must be deterministic (id asc) and survive
|
||||
// re-normalization — the historical oscillation bug tie-broke on the track.
|
||||
const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]);
|
||||
expect(trackOf(out, "a")).toBe(0); // "a" < "b"
|
||||
expect(trackOf(out, "b")).toBe(1);
|
||||
const twice = normalizeToZones(out);
|
||||
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
});
|
||||
|
||||
it("FIXED POINT: normalizeToZones(normalizeToZones(x)) === normalizeToZones(x) with z present", () => {
|
||||
const input = [
|
||||
zClip("hi", 0, 10, 0, 9),
|
||||
zClip("lo", 0, 10, 0, 1),
|
||||
zClip("mid", 2, 6, 0, 5),
|
||||
zClip("seq", 12, 4, 0, 7),
|
||||
zClip("music", 0, 16, 1, 3, "audio"),
|
||||
];
|
||||
expectZoningIdempotent(input);
|
||||
});
|
||||
|
||||
it("reload simulation: re-deriving lanes from the SAME z values yields identical lanes", () => {
|
||||
// Simulate two independent discovery passes producing fresh element objects
|
||||
// carrying the same z — lane assignment must be stable across reloads.
|
||||
it("re-derives identical lanes from fresh objects carrying the same tracks (reload-stable)", () => {
|
||||
const build = (): TimelineElement[] => [
|
||||
zClip("hi", 0, 10, 0, 9),
|
||||
zClip("lo", 0, 10, 0, 1),
|
||||
zClip("mid", 3, 5, 0, 5),
|
||||
zClip("lo", 0, 10, 1, 1),
|
||||
zClip("mid", 3, 5, 2, 5),
|
||||
];
|
||||
const first = normalizeToZones(build());
|
||||
const second = normalizeToZones(build());
|
||||
@@ -238,188 +151,87 @@ describe("normalizeToZones — reverse z→lane mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeToZones — cross-track z→lane (real qa-clean shape)", () => {
|
||||
// Derived from /tmp/hf-dnd-qa/qa-clean: a full-length video on authored track 0
|
||||
// (z=0), two logo SVGs on track 1 (z=26 and z=0), an icon on track 3 (z=5), and
|
||||
// background music on track 2. In the canvas the z=26 / z=5 icons paint ON TOP of
|
||||
// the z=0 video; the timeline must agree — the higher-z tracks sit on upper lanes.
|
||||
const realProject = (): TimelineElement[] => [
|
||||
zClip("ralu", 6.14, 3, 3, 5, "img"),
|
||||
zClip("video", 1, 20, 0, 0, "video"),
|
||||
zClip("blueLogo", 5.93, 3, 1, 26, "img"),
|
||||
zClip("blackLogo", 1, 3, 1, 0, "img"),
|
||||
zClip("music", 8.93, 8, 2, 0, "audio"),
|
||||
describe("normalizeToZones — legacy overlap spill (display-only, deterministic)", () => {
|
||||
it("splits time-overlapping SAME-track clips onto adjacent sub-lanes (no visible overlap)", () => {
|
||||
// a [0,5), b [2,7) overlaps a, c [6,9) sequential — all authored on track 1.
|
||||
// The editor forbids per-track overlap, but a legacy file can carry it; the
|
||||
// spill orders by stable id (a, b, c) and first-fits: a→lane0, b overlaps a→
|
||||
// lane1, c fits back on lane0 (no overlap with a).
|
||||
const clip = (id: string, start: number, duration: number): TimelineElement => ({
|
||||
id,
|
||||
tag: "video",
|
||||
start,
|
||||
duration,
|
||||
track: 1,
|
||||
});
|
||||
const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]);
|
||||
expect(trackOf(out, "a")).toBe(0);
|
||||
expect(trackOf(out, "b")).toBe(1); // overlaps a → adjacent sub-lane
|
||||
expect(trackOf(out, "c")).toBe(0); // sequential to a → shares lane 0
|
||||
// No two time-overlapping clips share a lane.
|
||||
expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
|
||||
// Idempotent: re-laying the split result changes nothing.
|
||||
const twice = normalizeToZones(out);
|
||||
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
});
|
||||
|
||||
it("spills two fully-overlapping same-track clips by stable id (a above b)", () => {
|
||||
const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]);
|
||||
expect(trackOf(out, "a")).toBe(0); // "a" < "b"
|
||||
expect(trackOf(out, "b")).toBe(1);
|
||||
// Survives re-normalization (stable id tie-break, never the mutated lane).
|
||||
const twice = normalizeToZones(out);
|
||||
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeToZones — legacy file with scattered z (requirement 6)", () => {
|
||||
// Mirrors /tmp/hf-fixwave/userproj/index.html: many visual clips on contiguous
|
||||
// authored tracks (0..17) each carrying an unrelated, scattered inline z-index,
|
||||
// and audio on the highest tracks (18..). The display must follow the
|
||||
// track-index, NOT the z, and a well-formed (contiguous, audio-last) legacy file
|
||||
// must not be re-laned at all — normalize is the identity.
|
||||
const legacy = (): TimelineElement[] => [
|
||||
zClip("sub-0", 3, 1.15, 0, 0, "div"), // no explicit z (0)
|
||||
zClip("cap-hit", 4.51, 1.73, 3, 26, "div"), // scattered z
|
||||
zClip("cap-send", 5.85, 1.27, 4, 25, "div"),
|
||||
zClip("avatar", 6.4, 1.148, 1, 26),
|
||||
zClip("v-opener", 0, 3, 15, 12),
|
||||
zClip("v-letters", 30.08, 4.39, 5, 25),
|
||||
zClip("music", 3, 42.95, 18, 10, "audio"),
|
||||
zClip("vo", 3.2, 10.3, 19, 4, "audio"),
|
||||
];
|
||||
|
||||
it("stacks a higher-z track ABOVE a lower-z track on a different authored track", () => {
|
||||
const out = normalizeToZones(realProject());
|
||||
// Track 1 (max z 26) tops the visual zone, then track 3 (z 5), then track 0 (z 0).
|
||||
expect(trackOf(out, "blueLogo")).toBe(0);
|
||||
expect(trackOf(out, "blackLogo")).toBe(0); // sequential to blueLogo → shares lane
|
||||
expect(trackOf(out, "ralu")).toBe(1);
|
||||
expect(trackOf(out, "video")).toBe(2);
|
||||
// Audio stays at the very bottom regardless of its authored track index.
|
||||
expect(trackOf(out, "music")).toBe(3);
|
||||
it("lanes follow the track-index, not the scattered z", () => {
|
||||
const out = normalizeToZones(legacy());
|
||||
// Visual tracks 0,1,3,4,5,15 compact to lanes 0..5 in ascending track order —
|
||||
// z (0,26,25,26,12,25) is ignored.
|
||||
expect(trackOf(out, "sub-0")).toBe(0); // track 0
|
||||
expect(trackOf(out, "avatar")).toBe(1); // track 1
|
||||
expect(trackOf(out, "cap-hit")).toBe(2); // track 3
|
||||
expect(trackOf(out, "cap-send")).toBe(3); // track 4
|
||||
expect(trackOf(out, "v-letters")).toBe(4); // track 5
|
||||
expect(trackOf(out, "v-opener")).toBe(5); // track 15
|
||||
// Audio stays below every visual lane.
|
||||
expect(trackOf(out, "music")).toBe(6);
|
||||
expect(trackOf(out, "vo")).toBe(7);
|
||||
// The z=26 caption does NOT ride above the z=0 subtitle on track 0.
|
||||
expect(trackOf(out, "cap-hit")).toBeGreaterThan(trackOf(out, "sub-0"));
|
||||
});
|
||||
|
||||
it("the video (z=0) no longer sits above the z=26 / z=5 icons — canvas & timeline agree", () => {
|
||||
const out = normalizeToZones(realProject());
|
||||
expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "blueLogo"));
|
||||
expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "ralu"));
|
||||
it("does not rewrite a well-formed (contiguous, audio-last) legacy set — identity", () => {
|
||||
// Same shape but authored tracks already contiguous 0..7 with audio last.
|
||||
const input = [
|
||||
zClip("a", 0, 3, 0, 12),
|
||||
zClip("b", 0, 3, 1, 26),
|
||||
zClip("c", 0, 3, 2, 3),
|
||||
zClip("m", 0, 3, 3, 9, "audio"),
|
||||
];
|
||||
// Every clip already sits on its track-index lane, so normalize is a no-op.
|
||||
expect(normalizeToZones(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("is idempotent on the real-project shape (no lane drift on re-discovery)", () => {
|
||||
const once = normalizeToZones(realProject());
|
||||
const twice = normalizeToZones(once);
|
||||
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
});
|
||||
|
||||
it("re-derives identical lanes from fresh objects carrying the same z (reload-stable)", () => {
|
||||
const first = normalizeToZones(realProject());
|
||||
const second = normalizeToZones(realProject());
|
||||
for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
|
||||
});
|
||||
|
||||
it("all-equal-z overlapping clips stack by DOM order (later on top), lanes contiguous", () => {
|
||||
// Was: "keeps ascending authored track order when all tracks share z" — that
|
||||
// pinned t0=0/t1=1/t3=2 to the authored track index. But these three all
|
||||
// start at 0 and OVERLAP, all z=0, so CSS paints them by DOM order: t3 (last)
|
||||
// on top. The per-clip pack now reflects that (t3 lane 0, then t1, then t0),
|
||||
// which is what the canvas actually shows. Lanes stay contiguous 0..2.
|
||||
const out = normalizeToZones([
|
||||
zClip("t0", 0, 2, 0, 0),
|
||||
zClip("t1", 0, 2, 1, 0),
|
||||
zClip("t3", 0, 2, 3, 0),
|
||||
]);
|
||||
expect(trackOf(out, "t3")).toBe(0); // last in DOM → paints on top
|
||||
expect(trackOf(out, "t1")).toBe(1);
|
||||
expect(trackOf(out, "t0")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeToZones — EXACT qa-clean repro (per-clip constrained pack)", () => {
|
||||
// The live repro from /tmp/hf-dnd-qa/qa-clean/index.html:
|
||||
// blue-logo authored track 0, z=3, 6.37–9.37
|
||||
// ralu image authored track 0, z=0, 6.37–9.37 (shares track 0 with blue-logo)
|
||||
// black-logo authored track 1, z=1, 11.92–14.92
|
||||
// video authored track 3, z=2, 0.84–20.84
|
||||
// Canvas truth: video (z=2) covers ralu (z=0). The OLD whole-track packer
|
||||
// ordered track 0 by its MAX z (3, from blue-logo), so ralu rode above the
|
||||
// z=2 video — the timeline↔canvas contradiction. Array order below = DOM order.
|
||||
|
||||
it("ACCEPTANCE: lane order top→bottom is blue-logo, video, black-logo, ralu", () => {
|
||||
const out = normalizeToZones(qaCleanRepro());
|
||||
expect(trackOf(out, "blue-logo")).toBe(0); // z=3 → top
|
||||
expect(trackOf(out, "video")).toBe(1); // z=2, overlaps blue-logo → below it
|
||||
expect(trackOf(out, "black-logo")).toBe(2); // z=1, overlaps video (11.92–14.92 ∩ 0.84–20.84)
|
||||
expect(trackOf(out, "ralu")).toBe(3); // z=0, overlaps blue-logo AND video → bottom
|
||||
});
|
||||
|
||||
it("REGRESSION: a low-z clip must not ride its authored trackmate's high z above a clip that covers it", () => {
|
||||
const out = normalizeToZones(qaCleanRepro());
|
||||
// ralu (z=0) shares authored track 0 with blue-logo (z=3) but must sink BELOW
|
||||
// the video (z=2) that overlaps and paints over it — the whole-track bug.
|
||||
expect(trackOf(out, "ralu")).toBeGreaterThan(trackOf(out, "video"));
|
||||
// black-logo (z=1) below video (z=2) because they overlap in time.
|
||||
expect(trackOf(out, "black-logo")).toBeGreaterThan(trackOf(out, "video"));
|
||||
});
|
||||
|
||||
it("FIXED POINT: running the NEW pack on its own output changes nothing", () => {
|
||||
const once = normalizeToZones(qaCleanRepro());
|
||||
const twice = normalizeToZones(once);
|
||||
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
// And a third pass, to be sure convergence is genuine.
|
||||
const thrice = normalizeToZones(twice);
|
||||
for (const e of twice) expect(trackOf(thrice, e.id)).toBe(e.track);
|
||||
});
|
||||
});
|
||||
|
||||
describe("z ↔ lane round-trip convergence (both directions agree)", () => {
|
||||
// Project a normalized TimelineElement onto the StackingElement view the
|
||||
// forward (lane→z) mapping reasons over.
|
||||
const toStacking = (els: TimelineElement[]): StackingElement[] =>
|
||||
els.map((e, domIndex) => ({
|
||||
key: e.key ?? e.id,
|
||||
start: e.start,
|
||||
duration: e.duration,
|
||||
track: e.track,
|
||||
zIndex: Number.isFinite(e.zIndex) ? (e.zIndex as number) : 0,
|
||||
isAudio: classifyZone(e) === "audio",
|
||||
domIndex,
|
||||
}));
|
||||
|
||||
it("lane-move → z patch → re-discovery orders lanes by that same z → identical lanes (no oscillation)", () => {
|
||||
// Two fully-overlapping visual clips. Authored: a below (z=1), b above (z=5).
|
||||
const authored: TimelineElement[] = [zClip("a", 0, 10, 0, 1), zClip("b", 0, 10, 0, 5)];
|
||||
const normalized = normalizeToZones(authored);
|
||||
// z→lane placed b (z=5) on the upper lane 0, a on lane 1.
|
||||
expect(trackOf(normalized, "b")).toBe(0);
|
||||
expect(trackOf(normalized, "a")).toBe(1);
|
||||
|
||||
// USER lane-move: drag a to the TOP (lane 0) and push b down (lane 1).
|
||||
const afterMove = normalized.map((e) =>
|
||||
e.id === "a" ? { ...e, track: 0 } : e.id === "b" ? { ...e, track: 1 } : e,
|
||||
);
|
||||
|
||||
// FORWARD: a lane-move writes the minimal z patch for the edited clip.
|
||||
const patches = computeStackingPatches(toStacking(afterMove), ["a"]);
|
||||
expect(patches).toEqual([{ key: "a", zIndex: 6 }]); // lifted above b (5)
|
||||
|
||||
// Apply the z patch back onto the elements (what handleDomZIndexReorderCommit
|
||||
// persists; next discovery re-reads it as TimelineElement.zIndex).
|
||||
const rediscovered = afterMove.map((e) => {
|
||||
const p = patches.find((pp) => pp.key === (e.key ?? e.id));
|
||||
return p ? { ...e, zIndex: p.zIndex } : e;
|
||||
});
|
||||
|
||||
// REVERSE: re-normalize from the new z. a (z=6) must now own the upper lane —
|
||||
// the same lane the user moved it to. Directions converge, they do not fight.
|
||||
const renormalized = normalizeToZones(rediscovered);
|
||||
expect(trackOf(renormalized, "a")).toBe(0);
|
||||
expect(trackOf(renormalized, "b")).toBe(1);
|
||||
|
||||
// FIXED POINT: forward on the converged state produces NO further patch, and
|
||||
// reverse is idempotent — the round-trip is stable.
|
||||
expect(computeStackingPatches(toStacking(renormalized), ["a"])).toEqual([]);
|
||||
const twice = normalizeToZones(renormalized);
|
||||
for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
});
|
||||
|
||||
it("qa-clean: drag video BELOW ralu → z patch → re-pack keeps it below, no oscillation", () => {
|
||||
// EXACT repro fixture (array order = DOM order).
|
||||
const normalized = normalizeToZones(qaCleanRepro());
|
||||
// Baseline lanes: blue-logo 0, video 1, black-logo 2, ralu 3.
|
||||
expect(trackOf(normalized, "video")).toBe(1);
|
||||
expect(trackOf(normalized, "ralu")).toBe(3);
|
||||
|
||||
// USER lane-move: drag video to the lane BELOW ralu (bottom). ralu is at
|
||||
// lane 3, so video goes to a lane strictly greater — model it as lane 4.
|
||||
const afterMove = normalized.map((e) => (e.id === "video" ? { ...e, track: 4 } : e));
|
||||
|
||||
// FORWARD: video (z=2) must drop below ralu (z=0). No integer z ≥ 0 fits
|
||||
// strictly below 0, so the tie-aware sync cascades: video→0 and the clips that
|
||||
// must stay above it (ralu z=0, black-logo z=1, blue-logo z=3) are bumped as
|
||||
// needed so video paints below ralu with all z ≥ 0.
|
||||
const patches = computeStackingPatches(toStacking(afterMove), ["video"]);
|
||||
const patchByKey = new Map(patches.map((p) => [p.key, p.zIndex]));
|
||||
// Video was moved; it must now be strictly below ralu in paint order.
|
||||
const zAfter = (id: string): number =>
|
||||
patchByKey.get(id) ?? (afterMove.find((e) => e.id === id)!.zIndex as number);
|
||||
expect(zAfter("video")).toBeLessThan(zAfter("ralu"));
|
||||
expect(zAfter("video")).toBeGreaterThanOrEqual(0);
|
||||
expect(patchByKey.size).toBeGreaterThan(0);
|
||||
|
||||
// Apply patches and re-pack: video's lane must now be BELOW ralu's.
|
||||
const rediscovered = afterMove.map((e) => {
|
||||
const z = patchByKey.get(e.id);
|
||||
return z != null ? { ...e, zIndex: z } : e;
|
||||
});
|
||||
const renormalized = normalizeToZones(rediscovered);
|
||||
expect(trackOf(renormalized, "video")).toBeGreaterThan(trackOf(renormalized, "ralu"));
|
||||
|
||||
// FIXED POINT: re-running BOTH directions on the converged state is a no-op.
|
||||
expect(computeStackingPatches(toStacking(renormalized), ["video"])).toEqual([]);
|
||||
const twice = normalizeToZones(renormalized);
|
||||
for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
|
||||
it("is idempotent on the scattered-z legacy shape (no drift on re-discovery)", () => {
|
||||
expectZoningIdempotent(legacy());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
||||
|
||||
/**
|
||||
* Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
|
||||
* — layering is CSS z-index (the renderer ignores track index), so the timeline's
|
||||
* only job is to keep visual clips grouped above audio clips.
|
||||
* — canvas layering is CSS z-index (the renderer ignores track index), so the
|
||||
* timeline's only job is to keep visual clips grouped above audio clips.
|
||||
*/
|
||||
export type TrackZone = "visual" | "audio";
|
||||
|
||||
@@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone {
|
||||
|
||||
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
||||
|
||||
/** Stacking order for a clip: missing / "auto" z is treated as 0. */
|
||||
const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
/** Two clips overlap when their half-open [start, end) intervals intersect. */
|
||||
@@ -26,173 +23,123 @@ function overlaps(a: TimelineElement, b: TimelineElement): boolean {
|
||||
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
|
||||
}
|
||||
|
||||
/** A clip paired with its position in the discovery/document (input) order. */
|
||||
interface IndexedClip {
|
||||
el: TimelineElement;
|
||||
/** Index in the input `elements` array = discovery/DOM order. */
|
||||
domIndex: number;
|
||||
}
|
||||
|
||||
/** One display lane: the clips packed onto it, in placement order. */
|
||||
interface Lane {
|
||||
occupants: IndexedClip[];
|
||||
/** The single authored track all occupants share, or null once mixed (never
|
||||
* happens — we only ever add same-track clips to an existing lane). */
|
||||
track: number;
|
||||
/** Deterministic order on the stable clip id (never the mutated lane/track). */
|
||||
function byStableId(a: TimelineElement, b: TimelineElement): number {
|
||||
const ka = keyOf(a);
|
||||
const kb = keyOf(b);
|
||||
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowest lane index a clip may occupy: strictly above every already-placed lane
|
||||
* holding a clip it overlaps in time (all of which out-stack it by the z-desc
|
||||
* placement order).
|
||||
*/
|
||||
function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
|
||||
let minLane = 0;
|
||||
for (let i = 0; i < lanes.length; i++) {
|
||||
if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
|
||||
}
|
||||
return minLane;
|
||||
}
|
||||
|
||||
/**
|
||||
* First lane at index ≥ minLane that holds solely this clip's authored track and
|
||||
* nothing overlapping (so sequential same-track clips share a lane); -1 when none
|
||||
* qualifies and a fresh lane must open.
|
||||
*/
|
||||
function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
|
||||
for (let i = minLane; i < lanes.length; i++) {
|
||||
const lane = lanes[i];
|
||||
if (lane.track !== item.el.track) continue;
|
||||
if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack a WHOLE zone's clips onto display lanes with a single constrained pass so
|
||||
* that, for EVERY pair of time-overlapping clips, lane order (upper = lower index)
|
||||
* equals canvas stacking order. This replaces the old two-stage
|
||||
* `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored
|
||||
* tracks by their MAX z and so lifted a low-z clip above a clip that covers it
|
||||
* whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a
|
||||
* low-z image rode its z=3 trackmate above the z=2 video that paints over it). No
|
||||
* whole-track mapping can fix that; the mapping must be per-clip.
|
||||
* Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
|
||||
* share a lane. Clips are ordered by their STABLE id (a function of the input, not
|
||||
* of the lane being computed — the historical oscillation bug tie-broke on the
|
||||
* mutated track) and placed first-fit, so sequential (non-overlapping) clips
|
||||
* collapse onto a single lane and only genuine time overlaps spill onto adjacent
|
||||
* sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
|
||||
* number of lanes used (≥ 1 when non-empty).
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Order clips by z DESC; z-tie → INPUT-ARRAY-INDEX (DOM order) DESC (CSS
|
||||
* paints equal-z siblings by DOM order — LATER in DOM paints on top, so it
|
||||
* must place first / upper); final tie → stable key. NEVER tie-break on the
|
||||
* mutated lane/track index (historic oscillation bug — the tie-break must be
|
||||
* a stable function of the input, not of the output being computed).
|
||||
* 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
|
||||
* OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
|
||||
* clip out-stacks this one (higher z, or equal z but later in DOM), so this
|
||||
* guarantees lane order == stacking order for every overlapping pair.
|
||||
* 3. To preserve the "distinct authored tracks stay distinct / sequential
|
||||
* same-track clips share a lane" feel, reuse an existing lane at index ≥ that
|
||||
* minimum ONLY when the lane's occupants are all from the SAME authored track
|
||||
* AND none overlaps this clip in time; otherwise open a fresh lane.
|
||||
* The editor enforces no per-track time overlap, so the spill only fires on legacy
|
||||
* files. It is DISPLAY-ONLY — a drag commit persists just the dragged clip, never
|
||||
* this re-lane — so it never rewrites the source.
|
||||
*
|
||||
* Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
|
||||
* returns the number of lanes used (≥ 1 when non-empty).
|
||||
* Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
|
||||
* display lane). Because every occupant of a sub-lane shares the base lane's
|
||||
* authored track by construction, dropping a clip onto one persists that shared
|
||||
* authored track — a legitimate same-track join. On the next normalize the
|
||||
* joined track re-packs (stable-id first-fit), so the clip may display on a
|
||||
* DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
|
||||
* the persisted source value is correct either way.
|
||||
*/
|
||||
function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map<string, number>): number {
|
||||
const ordered = [...clips].sort(
|
||||
(a, b) =>
|
||||
zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
|
||||
);
|
||||
const lanes: Lane[] = [];
|
||||
for (const item of ordered) {
|
||||
const minLane = lowestAllowedLane(lanes, item);
|
||||
let placed = findReusableLane(lanes, minLane, item);
|
||||
if (placed === -1) {
|
||||
placed = lanes.length;
|
||||
lanes.push({ occupants: [], track: item.el.track });
|
||||
}
|
||||
lanes[placed].occupants.push(item);
|
||||
laneOf.set(keyOf(item.el), base + placed);
|
||||
}
|
||||
return lanes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one
|
||||
* authored track's clips onto sub-lanes so no two overlap in time — sequential
|
||||
* clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by
|
||||
* start (then stable key) so the layout is deterministic and idempotent. Returns
|
||||
* the number of lanes used (≥ 1 when non-empty).
|
||||
*/
|
||||
function packAudioTrackLanes(
|
||||
clips: IndexedClip[],
|
||||
function packTrackLanes(
|
||||
clips: TimelineElement[],
|
||||
base: number,
|
||||
laneOf: Map<string, number>,
|
||||
): number {
|
||||
const ordered = [...clips].sort(
|
||||
(a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
|
||||
);
|
||||
const lanes: IndexedClip[][] = [];
|
||||
for (const item of ordered) {
|
||||
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
|
||||
const ordered = [...clips].sort(byStableId);
|
||||
const lanes: TimelineElement[][] = [];
|
||||
for (const el of ordered) {
|
||||
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
|
||||
if (sub === -1) {
|
||||
sub = lanes.length;
|
||||
lanes.push([]);
|
||||
}
|
||||
lanes[sub].push(item);
|
||||
laneOf.set(keyOf(item.el), base + sub);
|
||||
lanes[sub].push(el);
|
||||
laneOf.set(keyOf(el), base + sub);
|
||||
}
|
||||
return Math.max(1, lanes.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
|
||||
* follow the authored `data-track-index` (ASCENDING; ties by stable id) — NEVER a
|
||||
* z-rank. Each distinct authored track owns its own lane (in ascending order);
|
||||
* sequential same-track clips share it; time-overlapping same-track clips spill to
|
||||
* adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
|
||||
*
|
||||
* This REPLACES the old global-z-rank interval pack. That pack ordered visual
|
||||
* lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
|
||||
* whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
|
||||
* product decision is the opposite: a clip's lane is its track, period — z is
|
||||
* canvas paint order only, and lane assignment must ignore it.
|
||||
*/
|
||||
function packZoneLanes(
|
||||
clips: TimelineElement[],
|
||||
base: number,
|
||||
laneOf: Map<string, number>,
|
||||
): number {
|
||||
const byTrack = new Map<number, TimelineElement[]>();
|
||||
for (const el of clips) {
|
||||
const list = byTrack.get(el.track);
|
||||
if (list) list.push(el);
|
||||
else byTrack.set(el.track, [el]);
|
||||
}
|
||||
let used = 0;
|
||||
for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
|
||||
used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign display lanes for the timeline: visual lanes on top, audio lanes below.
|
||||
*
|
||||
* The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical
|
||||
* order matches the canvas's CSS stacking for EVERY time-overlapping pair — a
|
||||
* low-z clip sinks below a clip that covers it even if it shares an authored track
|
||||
* with a higher-z clip. Time-overlapping clips still split onto separate lanes
|
||||
* (standard NLE), sequential same-track clips still share a lane, and distinct
|
||||
* authored tracks stay distinct.
|
||||
*
|
||||
* The AUDIO zone keeps the original behavior — authored-track order, per-track
|
||||
* interval packing — because audio has no z / stacking semantics.
|
||||
* Both zones are packed the SAME way — by authored track-index, ascending (see
|
||||
* packZoneLanes) — so the timeline's vertical order follows each clip's track and
|
||||
* nothing else. z-index does not participate in lane assignment (it is canvas
|
||||
* paint order only; the lane ↔ z stacking sync in timelineStackingSync runs the
|
||||
* other direction, only on a deliberate vertical edit). Time-overlapping same-track
|
||||
* clips still split onto separate sub-lanes (legacy files only — the editor forbids
|
||||
* per-track overlap), and that split is display-only, never persisted.
|
||||
*
|
||||
* Pure — returns a new array; unchanged clips keep their identity. Display-only
|
||||
* (runs on discovery); it does not rewrite the source. Idempotent (running it on
|
||||
* its own output is a fixed point).
|
||||
* its own output is a fixed point): the lanes it emits are contiguous integers in
|
||||
* ascending track order, and re-running groups by those same integers unchanged.
|
||||
*/
|
||||
export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
|
||||
if (elements.length === 0) return elements;
|
||||
|
||||
const laneOf = new Map<string, number>();
|
||||
const visual: TimelineElement[] = [];
|
||||
const audio: TimelineElement[] = [];
|
||||
for (const el of elements) {
|
||||
(classifyZone(el) === "audio" ? audio : visual).push(el);
|
||||
}
|
||||
|
||||
let nextLane = 0;
|
||||
|
||||
const visual: IndexedClip[] = [];
|
||||
const audio: IndexedClip[] = [];
|
||||
elements.forEach((el, domIndex) => {
|
||||
(classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
|
||||
});
|
||||
|
||||
nextLane += packZoneLanes(visual, nextLane, laneOf);
|
||||
|
||||
// Audio: preserve legacy behavior — group by authored track (ascending), pack
|
||||
// each track's overlapping clips onto sub-lanes.
|
||||
const audioByTrack = new Map<number, IndexedClip[]>();
|
||||
for (const item of audio) {
|
||||
const list = audioByTrack.get(item.el.track);
|
||||
if (list) list.push(item);
|
||||
else audioByTrack.set(item.el.track, [item]);
|
||||
}
|
||||
for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
|
||||
nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
|
||||
}
|
||||
packZoneLanes(audio, nextLane, laneOf);
|
||||
|
||||
let changed = false;
|
||||
const remapped = elements.map((el) => {
|
||||
const lane = laneOf.get(keyOf(el));
|
||||
if (lane == null || lane === el.track) return el;
|
||||
changed = true;
|
||||
return { ...el, track: lane };
|
||||
// Record the source-file track the first time a clip is remapped so lane
|
||||
// edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
|
||||
// Re-normalizing already-remapped elements must keep the original value.
|
||||
return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
|
||||
});
|
||||
return changed ? remapped : elements;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Consumes playerStore.clipRevealRequest: when another surface (the sidebar
|
||||
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
|
||||
* timeline's scroll container so that clip is visible — horizontally to its
|
||||
* time and vertically to its lane.
|
||||
*
|
||||
* The request is consumed (cleared) whether or not the clip node is found, so
|
||||
* a stale request can never replay a scroll later. Respects zoom mode: in
|
||||
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
|
||||
* only the vertical axis is scrolled there.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { GUTTER, RULER_H } from "./timelineLayout";
|
||||
import { computeRevealScroll } from "./timelineRevealScroll";
|
||||
|
||||
export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
|
||||
const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealRequest) return;
|
||||
// Consume the request first — reveal is one-shot, even when the clip node
|
||||
// isn't currently rendered (e.g. drilled into a different composition).
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
|
||||
if (!(clip instanceof HTMLElement)) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const clipRect = clip.getBoundingClientRect();
|
||||
const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
|
||||
const clipTop = clipRect.top - containerRect.top + container.scrollTop;
|
||||
|
||||
const target = computeRevealScroll({
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
viewportWidth: container.clientWidth,
|
||||
viewportHeight: container.clientHeight,
|
||||
clipLeft,
|
||||
clipRight: clipLeft + clipRect.width,
|
||||
clipTop,
|
||||
clipBottom: clipTop + clipRect.height,
|
||||
stickyLeft: GUTTER,
|
||||
stickyTop: RULER_H,
|
||||
allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
|
||||
});
|
||||
if (target.left === null && target.top === null) return;
|
||||
container.scrollTo({
|
||||
left: target.left ?? container.scrollLeft,
|
||||
top: target.top ?? container.scrollTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [revealRequest, scrollRef]);
|
||||
}
|
||||
@@ -57,7 +57,17 @@ describe("useTimelineStackingSync", () => {
|
||||
});
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ element: node, zIndex: 8, sourceFile: "nested.html" })],
|
||||
[
|
||||
expect.objectContaining({
|
||||
element: node,
|
||||
zIndex: 8,
|
||||
sourceFile: "nested.html",
|
||||
// The patch's store key rides along so the commit updates the store
|
||||
// zIndex synchronously on the lane-sync path (and rolls it back on
|
||||
// failure) instead of waiting for a preview reload.
|
||||
key: "a",
|
||||
}),
|
||||
],
|
||||
"clip-lane-move:7",
|
||||
);
|
||||
act(() => root.unmount());
|
||||
|
||||
@@ -45,10 +45,16 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
|
||||
[zSyncPreviewIframeRef, zSyncActiveCompPath],
|
||||
);
|
||||
|
||||
// NaN (NOT 0) when the element can't be resolved in the preview iframe — a
|
||||
// nested / unmounted sub-comp node, or one outside the active file. Fabricating
|
||||
// z=0 would enter computeStackingPatches as a real overlapping neighbour at the
|
||||
// z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this
|
||||
// clip instead. NaN (rather than null) keeps the return assignable to the
|
||||
// `(el) => number` reader contract the drag hook / commit deps declare.
|
||||
const readClipZIndex = useCallback(
|
||||
(el: TimelineElement): number => {
|
||||
const node = resolveIframeElement(el);
|
||||
return node ? readEffectiveZIndex(node) : 0;
|
||||
return node ? readEffectiveZIndex(node) : Number.NaN;
|
||||
},
|
||||
[resolveIframeElement],
|
||||
);
|
||||
@@ -68,10 +74,15 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
|
||||
selector: el.selector,
|
||||
selectorIndex: el.selectorIndex,
|
||||
sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
|
||||
// The store key: lets the commit update the store's zIndex
|
||||
// synchronously (and roll it back on failure).
|
||||
key: p.key,
|
||||
},
|
||||
];
|
||||
});
|
||||
if (entries.length) return handleDomZIndexReorderCommit(entries, coalesceKey);
|
||||
// Forward the drag-commit's shared coalesce key so the z-reorder history
|
||||
// entry merges with the lane change's move entry into one undo step.
|
||||
if (entries.length) handleDomZIndexReorderCommit(entries, coalesceKey);
|
||||
},
|
||||
[handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
|
||||
);
|
||||
|
||||
@@ -163,7 +163,14 @@ function buildChildElements(
|
||||
key,
|
||||
start: clamped.start,
|
||||
duration: clamped.duration,
|
||||
// `track` becomes a synthetic display row under the expanded host, but the
|
||||
// factory-set `authoredTrack` (the child's data-track-index in ITS OWN
|
||||
// file's coordinate space) and the runtime-computed `stackingContextId`
|
||||
// must survive verbatim — lane persists and z-sync read them, they are
|
||||
// never reconstructed from display lanes.
|
||||
track: display.track + result.length,
|
||||
authoredTrack: base.authoredTrack,
|
||||
stackingContextId: base.stackingContextId,
|
||||
expandedParentStart: editBasis.start,
|
||||
domId,
|
||||
selector,
|
||||
|
||||
@@ -113,6 +113,15 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
start: clip.start,
|
||||
duration: clip.duration,
|
||||
track: clip.track,
|
||||
// clip.track IS the authored data-track-index verbatim (the runtime honors
|
||||
// it; see parseAuthoredTrack in core/runtime/timeline.ts). Record it at this
|
||||
// translation boundary so later display-lane remaps (normalizeToZones,
|
||||
// expanded-child rows) can persist in AUTHORED space instead of
|
||||
// reconstructing it from lane occupants.
|
||||
authoredTrack: clip.track,
|
||||
// Runtime-computed stacking context — authoritative; helpers read it, never
|
||||
// re-derive it.
|
||||
stackingContextId: clip.stackingContextId ?? null,
|
||||
domId,
|
||||
hfId,
|
||||
selector,
|
||||
|
||||
@@ -298,6 +298,25 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline store key for a z-reorder entry built OUTSIDE the timeline
|
||||
* expansion (canvas context menu / LayersPanel), so the reorder commit can
|
||||
* update the store's zIndex synchronously. Matches buildTimelineElementKey's
|
||||
* stable branches (`sourceFile#domId`, else the selector-based key). Undefined
|
||||
* when the element has neither a DOM id nor a selector — the timeline's
|
||||
* fallback branch needs its own fallbackIndex, which these callers don't have,
|
||||
* so such an entry simply skips the synchronous store update.
|
||||
*/
|
||||
export function deriveTimelineStoreKey(params: {
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile?: string;
|
||||
}): string | undefined {
|
||||
if (!params.domId && !params.selector) return undefined;
|
||||
return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM node querying
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
|
||||
|
||||
/** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
|
||||
function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
|
||||
expect(state.isPlaying).toBe(false);
|
||||
expect(state.currentTime).toBe(0);
|
||||
expect(state.duration).toBe(0);
|
||||
expect(state.timelineReady).toBe(false);
|
||||
expect(state.elements).toEqual([]);
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
}
|
||||
|
||||
describe("usePlayerStore", () => {
|
||||
beforeEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
@@ -9,12 +19,7 @@ describe("usePlayerStore", () => {
|
||||
describe("initial state", () => {
|
||||
it("has correct defaults", () => {
|
||||
const state = usePlayerStore.getState();
|
||||
expect(state.isPlaying).toBe(false);
|
||||
expect(state.currentTime).toBe(0);
|
||||
expect(state.duration).toBe(0);
|
||||
expect(state.timelineReady).toBe(false);
|
||||
expect(state.elements).toEqual([]);
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
expectResettableDefaults(state);
|
||||
expect(state.playbackRate).toBe(1);
|
||||
expect(state.audioMuted).toBe(false);
|
||||
expect(state.loopEnabled).toBe(false);
|
||||
@@ -384,6 +389,32 @@ describe("usePlayerStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipRevealRequest", () => {
|
||||
it("starts null and carries the requested element id", () => {
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
usePlayerStore.getState().requestClipReveal("el-1");
|
||||
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
|
||||
});
|
||||
|
||||
it("bumps the nonce on repeat requests for the same clip", () => {
|
||||
usePlayerStore.getState().requestClipReveal("el-1");
|
||||
const first = usePlayerStore.getState().clipRevealRequest;
|
||||
usePlayerStore.getState().requestClipReveal("el-1");
|
||||
const second = usePlayerStore.getState().clipRevealRequest;
|
||||
expect(second?.nonce).not.toBe(first?.nonce);
|
||||
});
|
||||
|
||||
it("clears via clearClipRevealRequest and on reset", () => {
|
||||
usePlayerStore.getState().requestClipReveal("el-1");
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
|
||||
usePlayerStore.getState().requestClipReveal("el-2");
|
||||
usePlayerStore.getState().reset();
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets all state to defaults", () => {
|
||||
// Mutate everything
|
||||
@@ -398,13 +429,7 @@ describe("usePlayerStore", () => {
|
||||
// Reset
|
||||
usePlayerStore.getState().reset();
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect(state.isPlaying).toBe(false);
|
||||
expect(state.currentTime).toBe(0);
|
||||
expect(state.duration).toBe(0);
|
||||
expect(state.timelineReady).toBe(false);
|
||||
expect(state.elements).toEqual([]);
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
expectResettableDefaults(usePlayerStore.getState());
|
||||
});
|
||||
|
||||
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
||||
|
||||
@@ -29,6 +29,17 @@ export interface TimelineElement {
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
/**
|
||||
* The data-track-index as written in the source file. Set at the manifest
|
||||
* translation boundary (createTimelineElementFromManifestClip) from the
|
||||
* runtime clip's verbatim track, and preserved through display-lane remaps
|
||||
* (normalizeToZones packs sparse authored tracks onto contiguous display
|
||||
* lanes; expanded sub-comp children get synthetic display rows). Lane edits
|
||||
* must persist THIS space — writing a display-lane number into a sparse file
|
||||
* re-targets the wrong track. For an expanded child the value is in its OWN
|
||||
* source file's coordinate space, not the host timeline's.
|
||||
*/
|
||||
authoredTrack?: number;
|
||||
/** Resolved z-index for stacking-aware timeline ordering. */
|
||||
zIndex?: number;
|
||||
/** True when the effective z-index was authored inline or through CSS, not auto. */
|
||||
@@ -153,6 +164,9 @@ interface PlayerState {
|
||||
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
|
||||
timelineSnapEnabled: boolean;
|
||||
setTimelineSnapEnabled: (enabled: boolean) => void;
|
||||
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
|
||||
timeDisplayMode: "time" | "frame";
|
||||
setTimeDisplayMode: (mode: "time" | "frame") => void;
|
||||
/**
|
||||
* Pin the timeline zoom to its current visual scale before a duration-changing
|
||||
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
||||
@@ -210,6 +224,16 @@ interface PlayerState {
|
||||
requestSeek: (time: number) => void;
|
||||
clearSeekRequest: () => void;
|
||||
|
||||
/**
|
||||
* Request the timeline to scroll a clip into view (e.g. clicking an
|
||||
* already-added asset card in the sidebar). Consumed and cleared by
|
||||
* useTimelineRevealClip. The nonce makes repeat requests for the same
|
||||
* clip observable so a second click re-reveals after the user scrolls away.
|
||||
*/
|
||||
clipRevealRequest: { elementId: string; nonce: number } | null;
|
||||
requestClipReveal: (elementId: string) => void;
|
||||
clearClipRevealRequest: () => void;
|
||||
|
||||
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
|
||||
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
|
||||
|
||||
@@ -341,6 +365,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
requestSeek: (time) => set({ requestedSeekTime: time }),
|
||||
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
||||
|
||||
clipRevealRequest: null,
|
||||
requestClipReveal: (elementId) =>
|
||||
set((s) => ({
|
||||
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
|
||||
})),
|
||||
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
|
||||
|
||||
lintFindingsByElement: new Map(),
|
||||
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
|
||||
|
||||
@@ -416,6 +447,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
|
||||
set({ timelineSnapEnabled: enabled });
|
||||
},
|
||||
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
|
||||
setTimeDisplayMode: (mode) => {
|
||||
writeStudioUiPreferences({ timeDisplayMode: mode });
|
||||
set({ timeDisplayMode: mode });
|
||||
},
|
||||
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
|
||||
set((s) => {
|
||||
// Already pinned (or the user manually zoomed) — never clobber that.
|
||||
@@ -522,6 +558,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
activeTool: "select",
|
||||
selectedKeyframes: new Set(),
|
||||
selectedElementIds: new Set(),
|
||||
clipRevealRequest: null,
|
||||
keyframeCache: new Map(),
|
||||
beatAnalysis: null,
|
||||
beatEdits: null,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldDismissAssetPreview } from "./assetPreviewDismiss";
|
||||
|
||||
describe("shouldDismissAssetPreview", () => {
|
||||
const idle = { isPlaying: false, currentTime: 3.5, requestedSeekTime: null };
|
||||
|
||||
it("keeps the preview open while nothing moves", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, idle)).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates sub-epsilon float noise in currentTime echoes", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 3.5 + 1e-9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("dismisses when playback starts", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, isPlaying: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("dismisses when the playhead is scrubbed/seeked to a new time", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 4.2 })).toBe(true);
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it("dismisses on a pending out-of-loop seek request", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, requestedSeekTime: 3.5 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Dismissal rule for the sidebar asset preview overlay (AssetPreviewOverlay):
|
||||
* the preview is a transient "look at this asset" state, so any playhead
|
||||
* activity — starting playback, or seeking/scrubbing away from where the
|
||||
* playhead sat when the preview opened — hands focus back to the canvas and
|
||||
* closes it.
|
||||
*
|
||||
* Pure — unit-tested. The overlay captures `openedTime` when the preview
|
||||
* opens and feeds every subsequent player-store snapshot through this.
|
||||
*/
|
||||
|
||||
export interface AssetPreviewDismissSnapshot {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
/** Pending out-of-loop seek request (playerStore.requestedSeekTime). */
|
||||
requestedSeekTime: number | null;
|
||||
}
|
||||
|
||||
/** Tolerance for float noise in currentTime echoes (well under one frame). */
|
||||
const TIME_EPSILON_S = 1e-6;
|
||||
|
||||
export function shouldDismissAssetPreview(
|
||||
openedTime: number,
|
||||
snapshot: AssetPreviewDismissSnapshot,
|
||||
): boolean {
|
||||
if (snapshot.isPlaying) return true;
|
||||
if (snapshot.requestedSeekTime !== null) return true;
|
||||
return Math.abs(snapshot.currentTime - openedTime) > TIME_EPSILON_S;
|
||||
}
|
||||
@@ -1,13 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTimelineFileDropPlacements,
|
||||
buildTimelineAssetInsertHtml,
|
||||
extendCompositionDurationIfNeeded,
|
||||
fitTimelineAssetGeometry,
|
||||
getTimelineAssetKind,
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetInitialGeometry,
|
||||
resolveTimelineAssetCompositionSize,
|
||||
resolveTimelineAssetSrc,
|
||||
setCompositionDurationToContent,
|
||||
} from "./timelineAssetDrop";
|
||||
|
||||
describe("setCompositionDurationToContent", () => {
|
||||
const src = (dur: number) =>
|
||||
`<div id="root" data-composition-id="c" data-duration="${dur}">x</div>`;
|
||||
|
||||
it("shrinks the root duration to the content end", () => {
|
||||
expect(setCompositionDurationToContent(src(20), 8)).toContain('data-duration="8"');
|
||||
});
|
||||
|
||||
it("grows the root duration to the content end", () => {
|
||||
expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"');
|
||||
});
|
||||
|
||||
it("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => {
|
||||
expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12));
|
||||
});
|
||||
|
||||
it("is a no-op when already equal", () => {
|
||||
expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9));
|
||||
});
|
||||
|
||||
// Reviewer round-2 finding #3: attribute-order and single-quote variants that
|
||||
// the old order-dependent, double-quotes-only regex silently ignored.
|
||||
it("patches when data-duration precedes data-composition-id", () => {
|
||||
const source = `<div data-duration="20" data-composition-id="c">x</div>`;
|
||||
expect(setCompositionDurationToContent(source, 8)).toBe(
|
||||
`<div data-duration="8" data-composition-id="c">x</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("patches single-quoted attributes and keeps the quote style", () => {
|
||||
const source = `<div data-composition-id='c' data-duration='20'>x</div>`;
|
||||
expect(setCompositionDurationToContent(source, 8)).toBe(
|
||||
`<div data-composition-id='c' data-duration='8'>x</div>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extendCompositionDurationIfNeeded", () => {
|
||||
it("grows the root duration when a clip lands past the end", () => {
|
||||
const source = `<div data-composition-id="c" data-duration="5">x</div>`;
|
||||
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
|
||||
`<div data-composition-id="c" data-duration="8">x</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op when the required end fits within the current duration", () => {
|
||||
const source = `<div data-composition-id="c" data-duration="10">x</div>`;
|
||||
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
|
||||
});
|
||||
|
||||
it("grows even when the attribute order is swapped and quotes are single", () => {
|
||||
const source = `<div data-duration='5' data-composition-id='c'>x</div>`;
|
||||
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
|
||||
`<div data-duration='8' data-composition-id='c'>x</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op when there is no composition root", () => {
|
||||
const source = `<div data-duration="5">x</div>`;
|
||||
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineAssetKind", () => {
|
||||
it("detects image, video, and audio assets", () => {
|
||||
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
|
||||
@@ -16,12 +83,28 @@ describe("getTimelineAssetKind", () => {
|
||||
expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
|
||||
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
|
||||
});
|
||||
|
||||
it("classifies svg as image", () => {
|
||||
expect(getTimelineAssetKind("assets/logo.svg")).toBe("image");
|
||||
expect(getTimelineAssetKind("assets/ICON.SVG")).toBe("image");
|
||||
});
|
||||
|
||||
it("classifies avif and webp as image", () => {
|
||||
expect(getTimelineAssetKind("assets/photo.avif")).toBe("image");
|
||||
expect(getTimelineAssetKind("assets/photo.webp")).toBe("image");
|
||||
});
|
||||
|
||||
it("returns null for unknown extensions", () => {
|
||||
expect(getTimelineAssetKind("assets/data.json")).toBeNull();
|
||||
expect(getTimelineAssetKind("assets/font.woff2")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTimelineAssetInsertHtml", () => {
|
||||
it("builds an image clip with explicit timing and track", () => {
|
||||
const html = buildTimelineAssetInsertHtml({
|
||||
id: "photo_asset",
|
||||
hfId: "hf-abc123",
|
||||
assetPath: "assets/photo.png",
|
||||
kind: "image",
|
||||
start: 1.25,
|
||||
@@ -40,6 +123,7 @@ describe("buildTimelineAssetInsertHtml", () => {
|
||||
it("builds an audio clip without visual layout styles", () => {
|
||||
const html = buildTimelineAssetInsertHtml({
|
||||
id: "music_asset",
|
||||
hfId: "hf-xyz789",
|
||||
assetPath: "assets/music.wav",
|
||||
kind: "audio",
|
||||
start: 0.5,
|
||||
@@ -52,15 +136,13 @@ describe("buildTimelineAssetInsertHtml", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineAssetInitialGeometry", () => {
|
||||
describe("resolveTimelineAssetCompositionSize", () => {
|
||||
it("uses the target composition dimensions for visual media", () => {
|
||||
expect(
|
||||
resolveTimelineAssetInitialGeometry(
|
||||
resolveTimelineAssetCompositionSize(
|
||||
`<div data-composition-id="main" data-width="330" data-height="228"></div>`,
|
||||
),
|
||||
).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 330,
|
||||
height: 228,
|
||||
});
|
||||
@@ -84,7 +166,9 @@ describe("buildTimelineFileDropPlacements", () => {
|
||||
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the dropped start and spaces multiple files by duration on the same track", () => {
|
||||
it("spaces multiple files by duration and keeps every one on the dropped track", () => {
|
||||
// A clip placed onto an occupied track stays there (overlap is allowed); it is
|
||||
// NOT bumped to a new track — that produced surprise empty tracks for users.
|
||||
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
|
||||
{ start: 1.5, track: 2 },
|
||||
{ start: 2.7, track: 2 },
|
||||
@@ -99,52 +183,6 @@ describe("buildTimelineFileDropPlacements", () => {
|
||||
{ start: 7.7, track: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 1.5, track: 2 },
|
||||
[1.2, 1.6, 1.1],
|
||||
[
|
||||
{ start: 0, duration: 8, track: 2 },
|
||||
{ start: 0, duration: 4, track: 5 },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 1.5, track: 6 },
|
||||
{ start: 2.7, track: 6 },
|
||||
{ start: 4.3, track: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a requested track above occupied rows when that track is clear", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 1.5, track: 8 },
|
||||
[1.2, 1.6],
|
||||
[
|
||||
{ start: 0, duration: 8, track: 2 },
|
||||
{ start: 0, duration: 4, track: 5 },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 1.5, track: 8 },
|
||||
{ start: 2.7, track: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 0, track: 0 },
|
||||
[1.2, 1.6],
|
||||
[{ start: 0, duration: 8, track: 0 }],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 0, track: 1 },
|
||||
{ start: 1.2, track: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertTimelineAssetIntoSource", () => {
|
||||
@@ -159,3 +197,57 @@ describe("insertTimelineAssetIntoSource", () => {
|
||||
expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTimelineAssetInsertHtml markup quality", () => {
|
||||
const base = {
|
||||
id: "clip_1",
|
||||
hfId: "hf-test-1",
|
||||
assetPath: "assets/a.mp4",
|
||||
start: 1,
|
||||
duration: 4,
|
||||
track: 2,
|
||||
zIndex: 3,
|
||||
};
|
||||
|
||||
it("stamps data-hf-id on all kinds", () => {
|
||||
for (const kind of ["image", "video", "audio"] as const) {
|
||||
expect(buildTimelineAssetInsertHtml({ ...base, kind })).toContain('data-hf-id="hf-test-1"');
|
||||
}
|
||||
});
|
||||
|
||||
it("audio gets an explicit data-volume", () => {
|
||||
expect(buildTimelineAssetInsertHtml({ ...base, kind: "audio" })).toContain('data-volume="1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitTimelineAssetGeometry", () => {
|
||||
const comp = { width: 1920, height: 1080 };
|
||||
|
||||
it("centers a smaller-than-comp asset at natural size", () => {
|
||||
expect(fitTimelineAssetGeometry({ width: 640, height: 360 }, comp)).toEqual({
|
||||
left: 640,
|
||||
top: 360,
|
||||
width: 640,
|
||||
height: 360,
|
||||
});
|
||||
});
|
||||
|
||||
it("scales an oversized asset down to fit, preserving aspect, centered", () => {
|
||||
// 4000x1000 → capped to 1920 wide → 1920x480, centered vertically
|
||||
expect(fitTimelineAssetGeometry({ width: 4000, height: 1000 }, comp)).toEqual({
|
||||
left: 0,
|
||||
top: 300,
|
||||
width: 1920,
|
||||
height: 480,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to full-frame when natural size is unknown", () => {
|
||||
expect(fitTimelineAssetGeometry(null, comp)).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
||||
import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
|
||||
import { roundToCenti } from "./rounding";
|
||||
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
||||
import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
|
||||
|
||||
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
|
||||
export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block";
|
||||
@@ -49,125 +49,44 @@ export function resolveTimelineAssetSrc(targetPath: string, assetPath: string):
|
||||
return relative || assetPath.split("/").pop() || assetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence one or more dropped files end-to-end starting at the drop point, all on
|
||||
* the track the user dropped onto. The clip lands where the ghost showed it — we do
|
||||
* NOT bump to a different track on overlap (that produced surprise "new tracks" and,
|
||||
* because it jumped past high indices like a grain-overlay track, wild numbers).
|
||||
* HyperFrames allows time-overlap on a track; the user can nudge if they want a gap.
|
||||
*/
|
||||
export function buildTimelineFileDropPlacements(
|
||||
placement: { start: number; track: number },
|
||||
durations: number[],
|
||||
occupiedClips: Array<{ start: number; duration: number; track: number }> = [],
|
||||
): Array<{ start: number; track: number }> {
|
||||
let nextStart = roundToCenti(Math.max(0, placement.start));
|
||||
const sequenceStart = nextStart;
|
||||
const resolvedDurations = durations.map((duration) =>
|
||||
Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION,
|
||||
);
|
||||
const sequenceEnd = resolvedDurations.reduce(
|
||||
(end, duration) => roundToCenti(end + duration),
|
||||
sequenceStart,
|
||||
);
|
||||
const overlapsDropTrack = occupiedClips.some((clip) => {
|
||||
if (clip.track !== placement.track) return false;
|
||||
const clipStart = Math.max(0, clip.start);
|
||||
const clipEnd = clipStart + Math.max(0, clip.duration);
|
||||
return sequenceStart < clipEnd && sequenceEnd > clipStart;
|
||||
});
|
||||
const track = overlapsDropTrack
|
||||
? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1
|
||||
: placement.track;
|
||||
|
||||
return resolvedDurations.map((duration) => {
|
||||
return durations.map((rawDuration) => {
|
||||
const duration =
|
||||
Number.isFinite(rawDuration) && rawDuration > 0
|
||||
? rawDuration
|
||||
: FALLBACK_TIMELINE_FILE_DROP_DURATION;
|
||||
const start = nextStart;
|
||||
nextStart = roundToCenti(nextStart + duration);
|
||||
return { start, track };
|
||||
return { start, track: placement.track };
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTimelineAssetInitialGeometry(source: string): {
|
||||
left: number;
|
||||
top: number;
|
||||
export function resolveTimelineAssetCompositionSize(source: string): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTimelineAssetInsertHtml(input: {
|
||||
id: string;
|
||||
/** Stable hf-id stamped as data-hf-id by the NLE drop path (optional in the legacy path). */
|
||||
hfId?: string;
|
||||
assetPath: string;
|
||||
kind: TimelineAssetKind;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
geometry?: { left: number; top: number; width: number; height: number };
|
||||
}): string {
|
||||
const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
||||
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
|
||||
const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
|
||||
|
||||
if (input.kind === "image") {
|
||||
return `<img ${sharedAttrs} style="${visualStyles}" />`;
|
||||
}
|
||||
|
||||
if (input.kind === "video") {
|
||||
return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
|
||||
}
|
||||
|
||||
return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;
|
||||
}
|
||||
|
||||
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
|
||||
const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
|
||||
if (!match || match.index == null) {
|
||||
throw new Error("No composition root found in target source");
|
||||
}
|
||||
const insertAt = match.index + match[0].length;
|
||||
const lineStart = source.lastIndexOf("\n", match.index);
|
||||
const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
|
||||
const childIndent = leadingWhitespace + " ";
|
||||
const indented = assetHtml
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : childIndent + line))
|
||||
.join("\n");
|
||||
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
|
||||
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null) return source;
|
||||
const next = roundToCenti(contentEnd);
|
||||
if (rootDur === next) return source;
|
||||
return patchRootCompositionDuration(source, String(next));
|
||||
}
|
||||
|
||||
export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
|
||||
return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
* CapCut-style placement: natural size when it fits, scaled-to-fit when
|
||||
* oversized, always centered. Unknown natural size → full-frame.
|
||||
*/
|
||||
export function fitTimelineAssetGeometry(
|
||||
natural: { width: number; height: number } | null,
|
||||
@@ -187,14 +106,71 @@ export function fitTimelineAssetGeometry(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTimelineAssetCompositionSize(source: string): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
return {
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
|
||||
};
|
||||
export function buildTimelineAssetInsertHtml(input: {
|
||||
id: string;
|
||||
hfId: string;
|
||||
assetPath: string;
|
||||
kind: TimelineAssetKind;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
geometry?: { left: number; top: number; width: number; height: number };
|
||||
}): string {
|
||||
const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
||||
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
|
||||
const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
|
||||
|
||||
if (input.kind === "image") {
|
||||
return `<img ${sharedAttrs} style="${visualStyles}" />`;
|
||||
}
|
||||
|
||||
if (input.kind === "video") {
|
||||
return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
|
||||
}
|
||||
|
||||
return `<audio ${sharedAttrs} data-volume="1" style="z-index: ${input.zIndex}"></audio>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A clip inserted past the composition end would exist in the HTML but never
|
||||
* appear on the timeline or in playback. Extend the root's data-duration to
|
||||
* cover it (mirrors blockInstaller's behavior for installed blocks).
|
||||
*/
|
||||
export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
|
||||
return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
|
||||
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null) return source;
|
||||
const next = roundToCenti(contentEnd);
|
||||
if (rootDur === next) return source;
|
||||
return patchRootCompositionDuration(source, String(next));
|
||||
}
|
||||
|
||||
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
|
||||
const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
|
||||
if (!match || match.index == null) {
|
||||
throw new Error("No composition root found in target source");
|
||||
}
|
||||
const insertAt = match.index + match[0].length;
|
||||
const lineStart = source.lastIndexOf("\n", match.index);
|
||||
const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
|
||||
const childIndent = leadingWhitespace + " ";
|
||||
const indented = assetHtml
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : childIndent + line))
|
||||
.join("\n");
|
||||
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user