diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx
index e82a64e691..b97cf7b161 100644
--- a/packages/studio/src/components/nle/TimelinePane.tsx
+++ b/packages/studio/src/components/nle/TimelinePane.tsx
@@ -95,8 +95,14 @@ export function TimelinePane({
const handleMoveElements = useCallback(
(
edits: Array<{ element: TimelineElement; updates: Pick
}>,
- ) =>
- onMoveElements?.(
+ coalesceKey?: string,
+ ) => {
+ // Match the sibling handlers: report the telemetry when the batch touches at
+ // least one expanded sub-comp child (the clips being rebased to local coords).
+ if (edits.some(({ element }) => element.expandedParentStart !== undefined)) {
+ trackStudioExpandedClipEdit({ action: "move" });
+ }
+ return onMoveElements?.(
edits.map(({ element, updates }) => {
const basis = element.expandedParentStart;
if (basis === undefined) return { element, updates };
@@ -105,7 +111,9 @@ export function TimelinePane({
updates: { ...updates, start: Math.max(0, updates.start - basis) },
};
}),
- ),
+ coalesceKey,
+ );
+ },
[onMoveElements, toLocalElement],
);
diff --git a/packages/studio/src/components/nle/TimelineResizeDivider.tsx b/packages/studio/src/components/nle/TimelineResizeDivider.tsx
index d3bb1c84ee..fc27d76969 100644
--- a/packages/studio/src/components/nle/TimelineResizeDivider.tsx
+++ b/packages/studio/src/components/nle/TimelineResizeDivider.tsx
@@ -73,6 +73,9 @@ export function TimelineResizeDivider({
);
return (
+ // Horizontal resize divider: 3px visible seam (h-[3px]), 8px pointer-capture
+ // zone via the absolutely-positioned inner hit area so the layout gap stays
+ // at 3px while draggability is preserved over the full 8px band.
-
+ {/* Expanded hit zone: 8px tall, centered on the 3px seam */}
+
+ {/* Visible hairline — invisible at rest, subtle wash on hover/drag/focus */}
+
);
}
diff --git a/packages/studio/src/components/nle/useCompositionStack.ts b/packages/studio/src/components/nle/useCompositionStack.ts
index 5f7f7b8dcf..e7e0e2dc7f 100644
--- a/packages/studio/src/components/nle/useCompositionStack.ts
+++ b/packages/studio/src/components/nle/useCompositionStack.ts
@@ -1,4 +1,4 @@
-// Composition drill-down stack management for NLELayout
+// Composition drill-down stack management for NLEContext/EditorShell
import { useState, useCallback, useRef, useEffect } from "react";
import { usePlayerStore } from "../../player";
import type { CompositionLevel } from "./CompositionBreadcrumb";
diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts
index 169888b4cf..2f9cc4878f 100644
--- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts
+++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts
@@ -19,6 +19,7 @@ export interface TimelineEditCallbackDeps {
) => Promise | void;
handleTimelineElementsMove: (
edits: Array<{ element: TimelineElement; updates: Pick }>,
+ coalesceKey?: string,
) => Promise | void;
handleTimelineElementResize: (
element: TimelineElement,
diff --git a/packages/studio/src/components/sidebar/AssetContextMenu.tsx b/packages/studio/src/components/sidebar/AssetContextMenu.tsx
index b23a74fbd0..d4e2e187c4 100644
--- a/packages/studio/src/components/sidebar/AssetContextMenu.tsx
+++ b/packages/studio/src/components/sidebar/AssetContextMenu.tsx
@@ -80,33 +80,3 @@ export function ContextMenu({
);
}
-
-export function DeleteConfirm({
- name,
- onConfirm,
- onCancel,
-}: {
- name: string;
- onConfirm: () => void;
- onCancel: () => void;
-}) {
- return (
-
-
Delete {name}?
-
-
- Delete
-
-
- Cancel
-
-
-
- );
-}
diff --git a/packages/studio/src/components/sidebar/AssetsTab.test.ts b/packages/studio/src/components/sidebar/AssetsTab.test.ts
index 74b5c916e5..2ed54db44c 100644
--- a/packages/studio/src/components/sidebar/AssetsTab.test.ts
+++ b/packages/studio/src/components/sidebar/AssetsTab.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { filterByUsage, countUsage, deriveUsedPaths } from "./AssetsTab";
+import { truncateMiddle, formatDuration } from "./assetHelpers";
import { globalAssetRows } from "./GlobalAssetsView";
const assets = ["bgm.mp3", "logo.png", "orphan.wav"];
@@ -47,6 +48,39 @@ describe("deriveUsedPaths", () => {
"assets/logo.png",
]);
});
+
+ it("handles fully-absolute URLs produced by the core runtime (toAbsoluteAssetUrl)", () => {
+ // The runtime calls new URL(raw, document.baseURI).toString() which produces
+ // "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4"
+ const used = deriveUsedPaths([
+ { src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4" },
+ { src: "http://localhost:3012/api/projects/abc123/preview/assets/logo.png" },
+ ]);
+ expect(used.has("assets/clip.mp4")).toBe(true);
+ expect(used.has("assets/logo.png")).toBe(true);
+ expect(used.size).toBe(2);
+ });
+
+ it("decodes percent-encoded filenames (spaces, parens) so they match the asset list", () => {
+ // Files with spaces/parens: "assets/my file (1).mp4" authored in HTML
+ // → runtime resolves to "http://…/assets/my%20file%20(1).mp4"
+ const used = deriveUsedPaths([
+ { src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4" },
+ { src: "/api/projects/p/preview/assets/track%20one.mp3" },
+ ]);
+ expect(used.has("assets/my file (1).mp4")).toBe(true);
+ expect(used.has("assets/track one.mp3")).toBe(true);
+ expect(used.size).toBe(2);
+ });
+
+ it("round-trips: absolute URL with spaces matches filterByUsage against plain asset list", () => {
+ const used = deriveUsedPaths([
+ { src: "http://localhost:3012/api/projects/demo/preview/assets/my%20video.mp4" },
+ ]);
+ expect(filterByUsage(["assets/my video.mp4", "assets/other.png"], used, "used")).toEqual([
+ "assets/my video.mp4",
+ ]);
+ });
});
describe("countUsage", () => {
@@ -59,6 +93,68 @@ describe("countUsage", () => {
});
});
+describe("truncateMiddle", () => {
+ it("returns the original string when it fits within maxLen", () => {
+ expect(truncateMiddle("short.mp4", 20)).toBe("short.mp4");
+ expect(truncateMiddle("exact_length_str.mp4", 20)).toBe("exact_length_str.mp4");
+ });
+
+ it("truncates longer strings with an ellipsis in the middle", () => {
+ const result = truncateMiddle("2a37eabf-long-uuid-887d8.mp4", 20);
+ expect(result.length).toBeLessThanOrEqual(20);
+ expect(result).toContain("…");
+ // Preserves head
+ expect(result.startsWith("2a37eabf-long-uuid-8")).toBe(false); // head is shortened
+ expect(result.startsWith("2a37eabf")).toBe(true);
+ // Preserves tail
+ expect(result.endsWith("887d8.mp4")).toBe(false); // tail portion only
+ expect(result.endsWith(".mp4")).toBe(true);
+ });
+
+ it("preserves the full filename extension in the tail", () => {
+ const result = truncateMiddle("verylongnamehere12345.mp4", 14);
+ expect(result.endsWith(".mp4")).toBe(true);
+ expect(result.length).toBeLessThanOrEqual(14);
+ });
+
+ it("handles maxLen of 1 (degenerate)", () => {
+ const result = truncateMiddle("abcdef", 1);
+ // head = 0, tail = 0 → just the ellipsis
+ expect(result).toBe("…");
+ });
+
+ it("handles a string of exactly maxLen+1 chars", () => {
+ const result = truncateMiddle("abcdefgh", 7);
+ expect(result.length).toBeLessThanOrEqual(7);
+ expect(result).toContain("…");
+ });
+});
+
+describe("formatDuration", () => {
+ it("formats whole seconds as MM:SS", () => {
+ expect(formatDuration(28)).toBe("00:28");
+ expect(formatDuration(60)).toBe("01:00");
+ expect(formatDuration(90)).toBe("01:30");
+ expect(formatDuration(3661)).toBe("61:01");
+ });
+
+ it("rounds fractional seconds to nearest whole", () => {
+ expect(formatDuration(28.4)).toBe("00:28");
+ expect(formatDuration(28.6)).toBe("00:29");
+ });
+
+ it("returns empty string for non-positive values", () => {
+ expect(formatDuration(0)).toBe("");
+ expect(formatDuration(-1)).toBe("");
+ });
+
+ it("returns empty string for non-finite values", () => {
+ expect(formatDuration(NaN)).toBe("");
+ expect(formatDuration(Infinity)).toBe("");
+ expect(formatDuration(-Infinity)).toBe("");
+ });
+});
+
describe("globalAssetRows", () => {
const recs = [
{ id: "bgm_001", type: "bgm", description: "calm ambient" },
diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx
index fb99cc3765..e1344c88e6 100644
--- a/packages/studio/src/components/sidebar/AssetsTab.tsx
+++ b/packages/studio/src/components/sidebar/AssetsTab.tsx
@@ -1,21 +1,12 @@
// fallow-ignore-file code-duplication
import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
-import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
-import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
-import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
+import { MEDIA_EXT, FONT_EXT } from "../../utils/mediaTypes";
import { copyTextToClipboard } from "../../utils/clipboard";
-import { ContextMenu } from "./AssetContextMenu";
import { usePlayerStore } from "../../player/store/playerStore";
-import {
- type MediaCategory,
- getCategory,
- basename,
- ext,
- CATEGORY_LABELS,
- FILTER_ORDER,
-} from "./assetHelpers";
+import { type MediaCategory, getCategory, CATEGORY_LABELS, FILTER_ORDER } from "./assetHelpers";
import { AudioRow } from "./AudioRow";
import { GlobalAssetsView } from "./GlobalAssetsView";
+import { AssetCard, FontRow } from "./AssetCard";
interface AssetsTabProps {
projectId: string;
@@ -23,155 +14,7 @@ interface AssetsTabProps {
onImport?: (files: FileList) => void;
onDelete?: (path: string) => void;
onRename?: (oldPath: string, newPath: string) => void;
-}
-
-// fallow-ignore-next-line complexity
-function ImageCard({
- projectId,
- asset,
- used,
- onCopy,
- isCopied,
- onDelete,
- onRename,
- size,
-}: {
- projectId: string;
- asset: string;
- used: boolean;
- onCopy: (path: string) => void;
- isCopied: boolean;
- onDelete?: (path: string) => void;
- onRename?: (oldPath: string, newPath: string) => void;
- size: "large" | "small";
-}) {
- const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
- const [hovered, setHovered] = useState(false);
- const name = basename(asset);
- const extension = ext(asset);
- const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
- const isVideo = VIDEO_EXT.test(asset);
- const isImage = IMAGE_EXT.test(asset);
-
- const thumbW = size === "large" ? "w-full" : "w-[50px]";
- const thumbH = size === "large" ? "h-[100px]" : "h-[32px]";
-
- return (
- <>
-
onCopy(asset)}
- onDragStart={(e) => {
- e.dataTransfer.effectAllowed = "copy";
- e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
- e.dataTransfer.setData("text/plain", asset);
- }}
- onContextMenu={(e) => {
- e.preventDefault();
- setContextMenu({ x: e.clientX, y: e.clientY });
- }}
- onPointerEnter={() => setHovered(true)}
- onPointerLeave={() => setHovered(false)}
- className={`transition-colors cursor-pointer ${
- size === "large"
- ? `px-2.5 py-1 ${isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/30"}`
- : `px-2.5 py-1.5 flex items-center gap-2.5 ${
- isCopied
- ? "bg-studio-accent/10 border-l-2 border-studio-accent"
- : "border-l-2 border-transparent hover:bg-neutral-800/50"
- }`
- }`}
- >
- {size === "large" ? (
-
-
- {isImage && (
-
{
- (e.target as HTMLImageElement).style.display = "none";
- }}
- />
- )}
- {isVideo &&
}
- {isVideo && hovered && (
-
- )}
-
-
-
- {name}
-
- {extension}
- {used && (
-
- in use
-
- )}
-
-
- ) : (
- <>
-
- {isImage && (
-
{
- (e.target as HTMLImageElement).style.display = "none";
- }}
- />
- )}
- {!isImage && (
-
{extension}
- )}
-
-
-
- {name}
-
-
- {extension}
- {used && (
-
- in use
-
- )}
-
-
- >
- )}
-
-
- {contextMenu && (
-
setContextMenu(null)}
- onCopy={onCopy}
- onDelete={onDelete}
- onRename={onRename}
- />
- )}
- >
- );
+ onAddAssetToTimeline?: (path: string) => void;
}
export type UsageFilter = "all" | "used" | "unused";
@@ -200,20 +43,48 @@ export function countUsage(
/**
* Project-relative asset paths referenced by composition elements — the set the
* "in use" badge, used-first sort, and usage filter all key on. Element src is
- * the raw authored value (timelineElementHelpers sets entry.src =
- * getAttribute("src")), so it can be a relative path ("assets/x.png"), a
- * "./"-prefixed path, the served "/api/projects//preview/assets/x.png" form,
- * or carry a ?query — normalize all of them to the bare project path so they
- * match the asset-list entries. Pure — unit-tested.
+ * populated from the core runtime's `resolveNodeAssetUrl` which calls
+ * `new URL(raw, document.baseURI).toString()`, turning authored relative paths
+ * into fully-absolute URLs with percent-encoded characters, e.g.
+ * "assets/my file (1).mp4"
+ * → "http://localhost:3012/api/projects/demo/preview/assets/my%20file%20(1).mp4"
+ *
+ * This function normalizes every src shape to the bare project-relative path so
+ * it matches the asset-list entries:
+ * - Absolute URL → strip origin + /api/projects//preview/ prefix, decode %XX
+ * - Server-relative /api/…preview/… → same strip + decode
+ * - Relative "./"-prefixed or bare → strip leading ./ or /
+ * - ?query / #hash → dropped
+ *
+ * Pure — unit-tested.
*/
export function deriveUsedPaths(elements: Array<{ src?: string }>): Set {
const paths = new Set();
for (const el of elements) {
if (!el.src) continue;
- const s = el.src
+ let s = el.src;
+
+ // Strip absolute origin if present (http://host/path → /path)
+ try {
+ const u = new URL(s);
+ s = u.pathname + (u.search ? u.search : "") + (u.hash ? u.hash : "");
+ } catch {
+ // Not a valid absolute URL — leave as-is (relative path)
+ }
+
+ s = s
.replace(/^\/api\/projects\/[^/]+\/preview\//, "") // strip the dev serve prefix
.replace(/^\.?\//, "") // strip leading ./ or /
.split(/[?#]/)[0]; // drop query / hash
+
+ // Decode percent-encoded characters (spaces, parens, etc.) so the path
+ // matches the plain-text asset-list entries the server returns.
+ try {
+ s = decodeURIComponent(s);
+ } catch {
+ // Malformed encoding — use as-is
+ }
+
if (s) paths.add(s);
}
return paths;
@@ -225,6 +96,7 @@ export const AssetsTab = memo(function AssetsTab({
onImport,
onDelete,
onRename,
+ onAddAssetToTimeline,
}: AssetsTabProps) {
const fileInputRef = useRef(null);
const [dragOver, setDragOver] = useState(false);
@@ -232,17 +104,11 @@ export const AssetsTab = memo(function AssetsTab({
const [activeFilter, setActiveFilter] = useState("all");
const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all");
const [searchQuery, setSearchQuery] = useState("");
- // Cross-project view: the global media-use cache (~/.media). The view itself
- // (GlobalAssetsView) owns its fetch — AssetsTab only tracks which scope is active.
const [viewMode, setViewMode] = useState<"local" | "global">("local");
const [manifest, setManifest] = useState<
Map
>(new Map());
- // Projects whose media manifest 404'd — most don't have one. Cache the miss so
- // we don't re-fetch (and spam the console) on every re-render; the effect was
- // also keyed on the `assets` array reference, which changes each render, so it
- // re-fired constantly. Key on a stable join + skip known-missing manifests.
const manifest404Ref = useRef>(new Set());
const assetsKey = assets.join("|");
useEffect(() => {
@@ -278,7 +144,6 @@ export const AssetsTab = memo(function AssetsTab({
cancelled = true;
};
}, [projectId, assetsKey]);
-
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
@@ -287,7 +152,6 @@ export const AssetsTab = memo(function AssetsTab({
},
[onImport],
);
-
const handleCopyPath = useCallback(async (path: string) => {
const copied = await copyTextToClipboard(path);
if (copied) {
@@ -295,22 +159,27 @@ export const AssetsTab = memo(function AssetsTab({
setTimeout(() => setCopiedPath(null), 1500);
}
}, []);
-
const elements = usePlayerStore((s) => s.elements);
const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]);
-
const mediaAssets = useMemo(() => {
const media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
const all = filterByUsage(media, usedPaths, usageFilter);
if (!searchQuery) return all;
const q = searchQuery.toLowerCase();
return all.filter((a) => {
- if (basename(a).toLowerCase().includes(q)) return true;
+ if (
+ a
+ .split("/")
+ .pop()
+ ?.replace(/\.[^.]*$/, "")
+ .toLowerCase()
+ .includes(q)
+ )
+ return true;
const rec = manifest.get(a);
return rec?.description?.toLowerCase().includes(q);
});
}, [assets, searchQuery, manifest, usageFilter, usedPaths]);
-
const categorized = useMemo(() => {
const groups: Record = { audio: [], images: [], video: [], fonts: [] };
for (const a of mediaAssets) {
@@ -327,15 +196,11 @@ export const AssetsTab = memo(function AssetsTab({
}
return groups;
}, [mediaAssets, usedPaths]);
-
const counts = useMemo(() => {
const c: Record = { all: mediaAssets.length };
for (const cat of FILTER_ORDER) c[cat] = categorized[cat].length;
return c;
}, [mediaAssets, categorized]);
-
- // Usage counts over the full media set (independent of the active usage filter,
- // so the chips don't show their own filtered totals).
const usageCounts = useMemo(
() =>
countUsage(
@@ -344,12 +209,10 @@ export const AssetsTab = memo(function AssetsTab({
),
[assets, usedPaths],
);
-
const visibleCategories =
activeFilter === "all"
? FILTER_ORDER.filter((c) => categorized[c].length > 0)
: [activeFilter as MediaCategory].filter((c) => categorized[c].length > 0);
-
return (
{/* Header — matches design panel Section pattern */}
- {/* Scope toggle — this project's assets vs the global media-use cache */}
+ {/* Scope toggle */}
{(["local", "global"] as const).map((m) => (
)}
- {/* Filter chips — panel-input style (local view only) */}
+ {/* Filter chips */}
{viewMode === "local" && mediaAssets.length > 0 && (
) : null,
)}
- {/* Usage filter — show only assets the composition references, or only the unused ones */}
{usageCounts.used > 0 && usageCounts.unused > 0 && (
<>
@@ -505,7 +367,6 @@ export const AssetsTab = memo(function AssetsTab({
)}
- {/* Asset list */}
{viewMode === "global" ? (
@@ -553,34 +414,38 @@ export const AssetsTab = memo(function AssetsTab({
isCopied={copiedPath === a}
onDelete={onDelete}
onRename={onRename}
+ onAddAssetToTimeline={onAddAssetToTimeline}
/>
))}
- {(cat === "images" || cat === "video") &&
- categorized[cat].map((a) => (
-
- ))}
+ {(cat === "images" || cat === "video") && (
+
+ {categorized[cat].map((a) => (
+
+ ))}
+
+ )}
{cat === "fonts" &&
categorized[cat].map((a) => (
-
))}
diff --git a/packages/studio/src/components/sidebar/AudioRow.tsx b/packages/studio/src/components/sidebar/AudioRow.tsx
index 4c9b442d31..46532420a4 100644
--- a/packages/studio/src/components/sidebar/AudioRow.tsx
+++ b/packages/studio/src/components/sidebar/AudioRow.tsx
@@ -2,6 +2,9 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { ContextMenu } from "./AssetContextMenu";
import { basename, getAudioSubtype } from "./assetHelpers";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
+import { usePlayerStore } from "../../player/store/playerStore";
+import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
+import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
export function AudioRow({
projectId,
@@ -12,6 +15,7 @@ export function AudioRow({
isCopied,
onDelete,
onRename,
+ onAddAssetToTimeline,
}: {
projectId: string;
asset: string;
@@ -21,6 +25,7 @@ export function AudioRow({
isCopied: boolean;
onDelete?: (path: string) => void;
onRename?: (oldPath: string, newPath: string) => void;
+ onAddAssetToTimeline?: (path: string) => void;
}) {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [playing, setPlaying] = useState(false);
@@ -34,6 +39,35 @@ export function AudioRow({
const subtype = getAudioSubtype(asset);
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
+ // CapCut-style click behavior: drag-threshold gate.
+ const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
+ const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
+ const elements = usePlayerStore((s) => s.elements);
+ const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
+
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
+ pointerDownRef.current = { x: e.clientX, y: e.clientY };
+ }, []);
+
+ const handlePointerUp = useCallback(
+ (e: React.PointerEvent) => {
+ const origin = pointerDownRef.current;
+ pointerDownRef.current = null;
+ if (!origin) return;
+ if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return;
+ if (used) {
+ const clip = findClipForAsset(elements, asset);
+ if (clip) {
+ setSelectedElementId(clip.key ?? clip.id);
+ return;
+ }
+ }
+ // Not added → preview overlay (audio player)
+ setPreviewAsset(asset, projectId);
+ },
+ [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
+ );
+
useEffect(() => {
return () => {
cancelAnimationFrame(animRef.current);
@@ -109,7 +143,8 @@ export function AudioRow({
<>
onCopy(asset)}
+ onPointerDown={handlePointerDown}
+ onPointerUp={handlePointerUp}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
@@ -195,6 +230,7 @@ export function AudioRow({
onCopy={onCopy}
onDelete={onDelete}
onRename={onRename}
+ onAddAtPlayhead={onAddAssetToTimeline}
/>
)}
>
diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx
index 2ca8b4120f..acaed6cda6 100644
--- a/packages/studio/src/components/sidebar/LeftSidebar.tsx
+++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx
@@ -60,6 +60,7 @@ interface LeftSidebarProps {
onAddBlock?: (blockName: string) => void;
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
takeoverContent?: ReactNode;
+ onAddAssetToTimeline?: (path: string) => void;
}
export const LeftSidebar = memo(
@@ -92,6 +93,7 @@ export const LeftSidebar = memo(
onAddBlock,
onPreviewBlock,
takeoverContent,
+ onAddAssetToTimeline,
},
ref,
) {
@@ -111,7 +113,7 @@ export const LeftSidebar = memo(
return (
{takeoverContent ? (
@@ -230,6 +232,7 @@ export const LeftSidebar = memo(
onImport={onImportFiles}
onDelete={onDeleteFile}
onRename={onRenameFile}
+ onAddAssetToTimeline={onAddAssetToTimeline}
/>
)}
{tab === "code" && (
diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx
index 6cc56116ed..7c7e35b617 100644
--- a/packages/studio/src/contexts/StudioContext.tsx
+++ b/packages/studio/src/contexts/StudioContext.tsx
@@ -31,8 +31,6 @@ export interface StudioShellValue {
compositionDimensions: CompositionDimensions | null;
waitForPendingDomEditSaves: () => Promise
;
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
- timelineVisible: boolean;
- toggleTimelineVisibility: () => void;
}
export interface StudioPlaybackValue {
@@ -99,8 +97,6 @@ export function StudioShellProvider({
compositionDimensions,
waitForPendingDomEditSaves,
handlePreviewIframeRef,
- timelineVisible,
- toggleTimelineVisibility,
} = value;
const stable = useMemo(
@@ -117,14 +113,11 @@ export function StudioShellProvider({
compositionDimensions,
waitForPendingDomEditSaves,
handlePreviewIframeRef,
- timelineVisible,
- toggleTimelineVisibility,
}),
[
projectId,
activeCompPath,
compositionDimensions,
- timelineVisible,
editHistory,
renderQueue,
setActiveCompPath,
@@ -134,7 +127,6 @@ export function StudioShellProvider({
handleRedo,
waitForPendingDomEditSaves,
handlePreviewIframeRef,
- toggleTimelineVisibility,
],
);
return {children} ;
diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx
index bfc47c6bd7..c2b6edc6ce 100644
--- a/packages/studio/src/contexts/TimelineEditContext.tsx
+++ b/packages/studio/src/contexts/TimelineEditContext.tsx
@@ -10,7 +10,7 @@ export function useTimelineEditContext(): TimelineEditCallbacks {
}
/**
- * Optional access, returns an empty object when outside a provider.
+ * Optional access — returns an empty object when outside a provider.
* Useful in components that can render both inside and outside the NLE.
*/
export function useTimelineEditContextOptional(): TimelineEditCallbacks {
@@ -26,18 +26,14 @@ export function TimelineEditProvider({
}) {
const memoized = useMemo(
() => value,
- // Each callback is a stable reference from the parent, memoize the bag
+ // Each callback is a stable reference from the parent — memoize the bag
// so consumers don't re-render when unrelated parent state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
value.onMoveElement,
- value.onResizeElement,
value.onMoveElements,
- value.onResizeElements,
- value.onPreviewMoveElements,
- value.onPreviewResizeElements,
+ value.onResizeElement,
value.onToggleTrackHidden,
- value.onToggleElementHidden,
value.onBlockedEditAttempt,
value.onSplitElement,
value.onRazorSplit,
diff --git a/packages/studio/src/hooks/domEditCommitTypes.ts b/packages/studio/src/hooks/domEditCommitTypes.ts
index b55a18e32d..ffa41cb8f1 100644
--- a/packages/studio/src/hooks/domEditCommitTypes.ts
+++ b/packages/studio/src/hooks/domEditCommitTypes.ts
@@ -7,6 +7,7 @@ export type PersistDomEditOperations = (
options?: {
label?: string;
coalesceKey?: string;
+ coalesceMs?: number;
skipRefresh?: boolean;
prepareContent?: (html: string, sourceFile: string) => string;
shouldSave?: () => boolean;
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
index 0d31cb50cc..818cc17338 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
@@ -1,104 +1,138 @@
-// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
-import { applyTimelineStackingReorder, extendRootDurationIfNeeded } from "./timelineEditingHelpers";
-import type { TimelineElement } from "../player/store/playerStore";
-import { usePlayerStore } from "../player/store/playerStore";
+import type { SoftReloadResult } from "../utils/gsapSoftReload";
+
+// Mock the soft-reload primitive so we can assert syncTimingEditPreview's
+// decision (soft-reload vs. escalate to full reload) without a live GSAP iframe.
+const applySoftReloadMock =
+ vi.fn<
+ (
+ iframe: HTMLIFrameElement | null,
+ scriptText: string,
+ onAsyncFailure?: () => void,
+ currentTimeOverride?: number,
+ ) => SoftReloadResult
+ >();
+vi.mock("../utils/gsapSoftReload", () => ({
+ applySoftReload: (...args: Parameters) =>
+ applySoftReloadMock(...args),
+}));
+
+// Imported after the mock is registered.
+const { syncTimingEditPreview, buildTimelineMoveTimingPatch } =
+ await import("./timelineEditingHelpers");
+
+// A stand-in iframe — syncTimingEditPreview only forwards it to applySoftReload.
+const fakeIframe = {} as HTMLIFrameElement;
afterEach(() => {
- usePlayerStore.getState().reset();
+ applySoftReloadMock.mockReset();
});
-function makeIframeWith(html: string): HTMLIFrameElement {
- const iframe = document.createElement("iframe");
- document.body.append(iframe);
- const doc = iframe.contentDocument;
- if (!doc) throw new Error("expected iframe document");
- doc.body.innerHTML = html;
- return iframe;
-}
-
-function el(input: Partial & { id: string; tag: string }): TimelineElement {
- return {
- label: input.id,
- start: 0,
- duration: 5,
- track: 0,
- zIndex: 0,
- hasExplicitZIndex: false,
- stackingContextId: null,
- ...input,
- };
-}
+describe("syncTimingEditPreview (timing-only edit classifier)", () => {
+ it("full-reloads and never soft-reloads when the server returned no scriptText", () => {
+ const reloadPreview = vi.fn();
+ syncTimingEditPreview(fakeIframe, { scriptText: null }, 1.5, reloadPreview);
+ expect(applySoftReloadMock).not.toHaveBeenCalled();
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
+ });
-describe("applyTimelineStackingReorder", () => {
- it("commits via the change's own locator even when the element is not in timelineElements", () => {
- // Sub-comp children live in the preview iframe but NOT in the top-level
- // timelineElements list — the intent must be self-contained.
- const iframe = makeIframeWith(`
`);
- const commit = vi.fn<(entries: unknown[]) => void>();
+ it("full-reloads when there is no iframe", () => {
+ const reloadPreview = vi.fn();
+ syncTimingEditPreview(null, { scriptText: "gsap.timeline()" }, 0, reloadPreview);
+ expect(applySoftReloadMock).not.toHaveBeenCalled();
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
+ });
- applyTimelineStackingReorder({
- element: el({ id: "chip", tag: "div" }),
- stackingReorder: {
- contextKey: "scene",
- placement: { type: "above", layerId: "layer:scene:x" },
- zIndexChanges: [
- {
- key: "scenes/scene.html#chip",
- zIndex: 5,
- domId: "chip",
- sourceFile: "scenes/scene.html",
- },
- ],
- },
- timelineElements: [], // element intentionally absent from the top-level list
- iframe,
- activeCompPath: "index.html",
- commit,
+ it("soft-reloads (no full reload) on the applied result, forwarding the current time", () => {
+ applySoftReloadMock.mockReturnValue("applied");
+ const reloadPreview = vi.fn();
+ syncTimingEditPreview(fakeIframe, { scriptText: "gsap.timeline()" }, 2.25, reloadPreview);
+ expect(applySoftReloadMock).toHaveBeenCalledWith(fakeIframe, "gsap.timeline()", {
+ onAsyncFailure: reloadPreview,
+ currentTimeOverride: 2.25,
});
-
- expect(commit).toHaveBeenCalledTimes(1);
- const entries = commit.mock.calls[0]![0] as Array<{
- zIndex: number;
- id?: string;
- sourceFile: string;
- }>;
- expect(entries).toHaveLength(1);
- expect(entries[0]!.zIndex).toBe(5);
- expect(entries[0]!.id).toBe("chip");
- expect(entries[0]!.sourceFile).toBe("scenes/scene.html");
+ expect(reloadPreview).not.toHaveBeenCalled();
});
- it("never commits when the dragged clip is audio", () => {
- const iframe = makeIframeWith(` `);
- const commit = vi.fn<(entries: unknown[]) => void>();
+ it("BUG 2: a single-file active-comp timing move with a successful script swap does NOT full-reload (no blink)", () => {
+ // The live blink came from the move being (mis)routed through a reloading path.
+ // Once a plain horizontal move is a single-clip timing edit, the fallback shifts
+ // the GSAP script and swaps it in place — a successful swap ("applied") must NOT
+ // trigger reloadPreview(), so the iframe never remounts and the preview never
+ // blinks / re-fetches files/index.html.
+ applySoftReloadMock.mockReturnValue("applied");
+ const reloadPreview = vi.fn();
+ syncTimingEditPreview(
+ fakeIframe,
+ { scriptText: 'window.__timelines["main"] = tl;' },
+ 3.2,
+ reloadPreview,
+ );
+ expect(applySoftReloadMock).toHaveBeenCalledTimes(1);
+ expect(reloadPreview).not.toHaveBeenCalled();
+ });
- applyTimelineStackingReorder({
- element: el({ id: "track", tag: "audio" }),
- stackingReorder: {
- contextKey: "main",
- placement: { type: "above", layerId: "layer:main:x" },
- zIndexChanges: [{ key: "track", zIndex: 5, domId: "track" }],
- },
- timelineElements: [],
- iframe,
- activeCompPath: "index.html",
- commit,
- });
+ it("does NOT escalate on the transient verify-failed result (live state is already correct)", () => {
+ applySoftReloadMock.mockReturnValue("verify-failed");
+ const reloadPreview = vi.fn();
+ syncTimingEditPreview(fakeIframe, { scriptText: "gsap.timeline()" }, 0, reloadPreview);
+ expect(reloadPreview).not.toHaveBeenCalled();
+ });
- expect(commit).not.toHaveBeenCalled();
+ it("escalates to a full reload on the permanent cannot-soft-reload result", () => {
+ applySoftReloadMock.mockReturnValue("cannot-soft-reload");
+ const reloadPreview = vi.fn();
+ syncTimingEditPreview(fakeIframe, { scriptText: "gsap.timeline()" }, 0, reloadPreview);
+ // reloadPreview is passed as the async-failure callback AND invoked directly on
+ // the permanent result. The direct escalation is the one that matters here.
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
});
});
-describe("extendRootDurationIfNeeded", () => {
- it("extends the player duration only when the new end is larger", () => {
- usePlayerStore.getState().setDuration(4);
+// ── ITEM 3 — #2212 NaN insurance ────────────────────────────────────────────
+describe("buildTimelineMoveTimingPatch (non-finite guard)", () => {
+ it("emits both timing patches for a finite move (start + track-index, both dialects)", () => {
+ expect(buildTimelineMoveTimingPatch({ start: 1.5, track: 2 })).toEqual([
+ { property: "start", attr: "data-start", value: "1.5" },
+ { property: "track-index", attr: "data-track-index", value: "2" },
+ ]);
+ });
- expect(extendRootDurationIfNeeded(5)).toBe(true);
- expect(usePlayerStore.getState().duration).toBe(5);
+ it("SKIPS a non-finite start (undefined→NaN) and warns once naming the field, keeping the track patch", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ // The mid-stack deploy shape: `start` arrives undefined. Number.isFinite(NaN)
+ // is false, so the start patch is dropped — never serialized as "NaN".
+ const patches = buildTimelineMoveTimingPatch({
+ start: undefined as unknown as number,
+ track: 3,
+ });
+ expect(patches).toEqual([{ property: "track-index", attr: "data-track-index", value: "3" }]);
+ expect(patches.some((p) => p.attr === "data-start")).toBe(false);
+ expect(warn.mock.calls.some((call) => String(call[0]).includes("start"))).toBe(true);
+ warn.mockRestore();
+ });
+
+ it("SKIPS a non-finite track and keeps the start patch", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const patches = buildTimelineMoveTimingPatch({ start: 4, track: Number.NaN });
+ expect(patches).toEqual([{ property: "start", attr: "data-start", value: "4" }]);
+ expect(patches.some((p) => p.attr === "data-track-index")).toBe(false);
+ warn.mockRestore();
+ });
- expect(extendRootDurationIfNeeded(5)).toBe(false);
- expect(extendRootDurationIfNeeded(3)).toBe(false);
- expect(usePlayerStore.getState().duration).toBe(5);
+ it("never serializes a NaN string — the surviving field's value is byte-identical to the finite-only patch (#2212)", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ // Baseline: the track patch for a fully finite move.
+ const finiteTrackPatch = buildTimelineMoveTimingPatch({ start: 1, track: 3 }).find(
+ (p) => p.attr === "data-track-index",
+ );
+ // Guard fires on the non-finite start: the surviving track patch's serialized
+ // string must be UNCHANGED (dropping start must not perturb it)...
+ const guarded = buildTimelineMoveTimingPatch({ start: Number.NaN, track: 3 });
+ expect(guarded.find((p) => p.attr === "data-track-index")).toEqual(finiteTrackPatch);
+ // ...and no returned patch may ever carry the poison "NaN" string (the whole
+ // point of the guard — String(NaN)/formatTimelineAttributeNumber(NaN)="NaN").
+ expect(guarded.every((p) => !p.value.includes("NaN"))).toBe(true);
+ warn.mockRestore();
});
});
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts
index d3d7347f36..6d3160e05b 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.ts
@@ -1,122 +1,9 @@
+import type { TimelineElement } from "../player/store/playerStore";
import { applySoftReload } from "../utils/gsapSoftReload";
-import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
import { applyPatchByTarget, 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 { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
-import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
import type { EditHistoryKind } from "../utils/editHistory";
-import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
-import { extendRootDurationInSource } from "../utils/rootDuration";
-
-function isHTMLElement(element: Element | null): element is HTMLElement {
- if (!element) return false;
- // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
- // iframe, and cross-realm `element instanceof HTMLElement` (main window) is
- // always false — which silently dropped every timeline z-index commit.
- const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
- return element instanceof Ctor;
-}
-
-/**
- * Resolve a timeline vertical move to a z-index stacking reorder and commit it
- * through the shared layers-panel reorder path. Reads live sibling z-index from
- * the preview DOM, remaps with the dup-preserving reorder math, and writes only
- * z-index (never data-track-index). No-op when the move isn't a reorder, the
- * dragged clip is audio (no visual layer to restack), or the live siblings can't
- * be resolved. Extracted from StudioApp's timeline hook to keep it under the
- * studio 600-LOC cap.
- */
-// fallow-ignore-next-line complexity
-export function applyTimelineStackingReorder(input: {
- element: TimelineElement;
- stackingReorder: TimelineStackingReorderIntent | null | undefined;
- timelineElements: readonly TimelineElement[];
- iframe: HTMLIFrameElement | null;
- activeCompPath: string | null;
- commit: TimelineZIndexReorderCommit | null | undefined;
-}): Promise {
- // Audio has no visual stacking; a vertical drag on it must never write z-index.
- if (input.element.tag === "audio") return Promise.resolve();
-
- const intent = input.stackingReorder ?? null;
- if (intent == null || intent.zIndexChanges.length === 0) return Promise.resolve();
-
- // Resolve each change's live element from the change's OWN locator (the intent
- // is self-contained), falling back to the top-level element list. Sub-comp
- // children aren't in `timelineElements`, so a list-only lookup would miss them.
- const siblingByKey = new Map(
- input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]),
- );
- const doc = input.iframe?.contentDocument ?? null;
- const findLive = (domId?: string, selector?: string, selectorIndex?: number): Element | null => {
- if (!doc) return null;
- if (domId) return doc.getElementById(domId);
- if (selector) return doc.querySelectorAll(selector)[selectorIndex ?? 0] ?? null;
- return null;
- };
- const commitEntries: Array<{
- element: HTMLElement;
- zIndex: number;
- id?: string;
- selector?: string;
- selectorIndex?: number;
- sourceFile: string;
- key: string;
- }> = [];
-
- for (const change of intent.zIndexChanges) {
- const sibling = siblingByKey.get(change.key);
- const domId = change.domId ?? sibling?.domId;
- const selector = change.selector ?? sibling?.selector;
- const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex;
- const element = findLive(domId, selector, selectorIndex);
- if (!isHTMLElement(element)) return Promise.resolve();
- if (getElementZIndex(element) === change.zIndex) continue;
- commitEntries.push({
- element,
- zIndex: change.zIndex,
- id: domId ?? sibling?.id ?? change.key,
- selector,
- selectorIndex,
- sourceFile: change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html",
- key: change.key,
- });
- }
-
- if (commitEntries.length === 0) return Promise.resolve();
- return input.commit?.(commitEntries) ?? Promise.resolve();
-}
-
-/**
- * Remove the keyframes currently selected in the player store from the active
- * element's GSAP animation. Reads selection lazily so it stays correct when
- * invoked from a ref callback. Extracted from StudioApp to keep it under the
- * studio 600-LOC cap.
- */
-export function deleteSelectedKeyframes(session: {
- selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[];
- handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
-}): void {
- const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
- const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
- if (!animation) return;
- // Only the active element's keyframes; a stale cross-element selection must not delete here.
- for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
- session.handleGsapRemoveKeyframe(animation.id, pct);
- }
-}
-
-export function extendRootDurationIfNeeded(newEnd: number): boolean {
- const store = usePlayerStore.getState();
- if (newEnd <= store.duration) return false;
- store.setDuration(newEnd);
- return true;
-}
// ── Types ──
@@ -124,8 +11,6 @@ export interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
- /** Per-entry coalesce window override (ms); lets a slow follow-up still merge. */
- coalesceMs?: number;
files: Record;
}
@@ -154,6 +39,56 @@ export function buildPatchTarget(element: {
export type PatchTarget = NonNullable>;
+/** One clip-timing attribute to write for a move, in both patch dialects. */
+export interface TimelineMoveTimingPatch {
+ /** Source-patcher attribute name (no `data-` prefix): fed to applyPatchByTarget. */
+ property: "start" | "track-index";
+ /** Live-DOM attribute name (`data-` prefixed): fed to patchIframeDomTiming. */
+ attr: "data-start" | "data-track-index";
+ value: string;
+}
+
+let warnedNonFiniteMoveTiming = false;
+
+/**
+ * Build the timing-attribute patches (data-start + data-track-index) for one clip
+ * move, SKIPPING any field whose numeric value is non-finite.
+ *
+ * #2212 insurance: in a mid-stack deploy window a stale handler can receive a move
+ * whose `start` (or `track`) is `undefined`, and `formatTimelineAttributeNumber`
+ * / `String` would then serialize `NaN`/`"NaN"` straight into `data-start`,
+ * poisoning the source and the live DOM (the runtime re-reads the attribute and
+ * renders the clip at NaN). Dropping the non-finite field leaves the clip's prior
+ * value intact instead of persisting garbage, and warns once (naming the field) so
+ * the upstream shape bug is still visible.
+ */
+export function buildTimelineMoveTimingPatch(
+ updates: Pick,
+): TimelineMoveTimingPatch[] {
+ const patches: TimelineMoveTimingPatch[] = [];
+ const push = (
+ field: "start" | "track",
+ property: TimelineMoveTimingPatch["property"],
+ attr: TimelineMoveTimingPatch["attr"],
+ value: number,
+ format: (v: number) => string,
+ ): void => {
+ if (!Number.isFinite(value)) {
+ if (!warnedNonFiniteMoveTiming) {
+ warnedNonFiniteMoveTiming = true;
+ console.warn(
+ `[Timeline] Skipping non-finite move timing patch for "${field}" (value=${String(value)}) — not persisting NaN into ${attr}`,
+ );
+ }
+ return;
+ }
+ patches.push({ property, attr, value: format(value) });
+ };
+ push("start", "start", "data-start", updates.start, formatTimelineAttributeNumber);
+ push("track", "track-index", "data-track-index", updates.track, String);
+ return patches;
+}
+
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
// picked up automatically on the next frame without a rebind call.
@@ -188,25 +123,48 @@ export function patchIframeDomTiming(
}
}
-function postRootDurationToPreview(
+/**
+ * Pure: find the TOP-LEVEL composition root in `doc` (the `[data-composition-id]`
+ * with no ancestor composition, matching the runtime's own root resolution) and
+ * write `contentEnd` into its `data-duration`. Returns whether a write happened.
+ *
+ * A timing edit optimistically patches the moved clip's `data-start`/`-duration`
+ * in the live iframe, but NOT the root's `data-duration`. Timing edits now take
+ * the soft-reload path (no full iframe reload), which re-runs the GSAP script and
+ * lets the runtime recompute the composition length — it reads the root's
+ * `data-duration` as the authored floor (core/runtime/init.ts) and posts it back,
+ * so the studio store's duration is set from the STALE root and the readout
+ * reverts to the pre-edit length. Patching the root here keeps the runtime's
+ * post-soft-reload duration report in agreement with the optimistic readout, so
+ * the number stays live (grow AND shrink) instead of snapping back.
+ */
+export function patchDocumentRootDuration(
+ doc: Document | null | undefined,
+ contentEnd: number,
+): boolean {
+ if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false;
+ const nodes = Array.from(doc.querySelectorAll("[data-composition-id]"));
+ const root =
+ nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null;
+ if (!root) return false;
+ root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd));
+ return true;
+}
+
+/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see above). */
+export function patchIframeRootDuration(
iframe: HTMLIFrameElement | null,
- durationSeconds: number,
+ contentEnd: number,
): void {
- const duration = Number(durationSeconds);
- if (!Number.isFinite(duration) || duration <= 0) return;
- iframe?.contentWindow?.postMessage(
- {
- source: "hf-parent",
- type: "control",
- action: "set-root-duration",
- durationSeconds: duration,
- },
- "*",
- );
+ try {
+ patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
+ } catch {
+ // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
+ }
}
// fallow-ignore-next-line complexity
-function resolveResizePlaybackStart(
+export function resolveResizePlaybackStart(
original: string,
target: PatchTarget,
element: TimelineElement,
@@ -232,57 +190,6 @@ function resolveResizePlaybackStart(
};
}
-export function buildTimelineMoveTimingPatch(
- original: string,
- target: PatchTarget,
- start: number,
- duration: number,
-): string {
- // Coexistence-window guard: a caller resolving the legacy context handler can
- // receive the new engine's {element, updates} change shape and read a field
- // that doesn't exist — start arrives undefined/NaN and would be serialized as
- // data-start="NaN" into the file. Never persist a non-finite timing value.
- if (!Number.isFinite(start) || !Number.isFinite(duration)) {
- console.warn(
- `[Timeline] buildTimelineMoveTimingPatch: non-finite timing (start=${start}, duration=${duration}) — patch skipped`,
- );
- return original;
- }
- const patched = applyPatchByTarget(original, target, {
- type: "attribute",
- property: "start",
- value: formatTimelineAttributeNumber(start),
- });
- return extendRootDurationInSource(patched, start + duration);
-}
-
-export function buildTimelineResizeTimingPatch(
- original: string,
- target: PatchTarget,
- element: TimelineElement,
- updates: Pick,
-): string {
- const pbs = resolveResizePlaybackStart(original, target, element, updates);
- let patched = applyPatchByTarget(original, target, {
- type: "attribute",
- property: "start",
- value: formatTimelineAttributeNumber(updates.start),
- });
- patched = applyPatchByTarget(patched, target, {
- type: "attribute",
- property: "duration",
- value: formatTimelineAttributeNumber(updates.duration),
- });
- if (pbs) {
- patched = applyPatchByTarget(patched, target, {
- type: "attribute",
- property: pbs.attrName,
- value: formatTimelineAttributeNumber(pbs.value),
- });
- }
- return extendRootDurationInSource(patched, updates.start + updates.duration);
-}
-
export interface PersistTimelineEditInput {
projectId: string;
element: TimelineElement;
@@ -325,66 +232,6 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
input.domEditSaveTimestampRef.current = Date.now();
}
-export interface PersistTimelineBatchChange {
- element: TimelineElement;
- buildPatches: (original: string, target: PatchTarget) => string;
-}
-
-export interface PersistTimelineBatchEditInput {
- projectId: string;
- activeCompPath: string | null;
- label: string;
- changes: PersistTimelineBatchChange[];
- writeProjectFile: (path: string, content: string) => Promise;
- recordEdit: (input: RecordEditInput) => Promise;
- domEditSaveTimestampRef: React.MutableRefObject;
- pendingTimelineEditPathRef: React.MutableRefObject>;
- coalesceKey?: string;
-}
-
-export async function persistTimelineBatchEdit(
- input: PersistTimelineBatchEditInput,
-): Promise {
- const originals = new Map();
- const patchedByPath = new Map();
-
- for (const change of input.changes) {
- const targetPath = change.element.sourceFile || input.activeCompPath || "index.html";
- const original =
- originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath));
- originals.set(targetPath, original);
-
- const patchTarget = buildPatchTarget(change.element);
- if (!patchTarget) {
- throw new Error(`Timeline element ${change.element.id} is missing a patchable target`);
- }
-
- const current = patchedByPath.get(targetPath) ?? original;
- const patched = change.buildPatches(current, patchTarget);
- if (patched === current) {
- throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
- }
- patchedByPath.set(targetPath, patched);
- }
-
- const files = Object.fromEntries(patchedByPath);
- for (const targetPath of Object.keys(files)) {
- input.pendingTimelineEditPathRef.current.add(targetPath);
- }
- input.domEditSaveTimestampRef.current = Date.now();
- await saveProjectFilesWithHistory({
- projectId: input.projectId,
- label: input.label,
- kind: "timeline",
- coalesceKey: input.coalesceKey,
- files,
- readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
- writeFile: input.writeProjectFile,
- recordEdit: input.recordEdit,
- });
- input.domEditSaveTimestampRef.current = Date.now();
-}
-
export async function readFileContent(projectId: string, targetPath: string): Promise {
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
@@ -402,105 +249,35 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
return data.content;
}
-export type GsapMutationStatus = { mutated: boolean };
-
-function isRecord(value: unknown): value is Record {
- 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;
- onGsapError: (error: unknown) => void;
-}): Promise {
- 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.
+ * 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 async function foldGsapMutationIntoHistory(input: {
- projectId: string;
- paths: string[];
- label: string;
- coalesceKey?: string;
- recordEdit: (edit: RecordEditInput) => Promise;
- gsapMutation: () => Promise;
-}): Promise {
- const uniquePaths = [...new Set(input.paths)];
- const before = new Map();
- for (const path of uniquePaths) {
- before.set(path, await readFileContent(input.projectId, path));
- }
- const status = await input.gsapMutation();
- if (status.mutated) {
- const files: Record = {};
- 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;
+export interface GsapMutationOutcome {
+ scriptText: string | null;
+}
+
+function readGsapMutationScriptText(body: unknown): string | null {
+ if (typeof body !== "object" || body === null) return null;
+ const text = (body as { scriptText?: unknown }).scriptText;
+ return typeof text === "string" ? text : null;
}
/**
* 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 {
- if (delta === 0 || !elementId) return { mutated: false };
+): Promise {
+ if (delta === 0 || !elementId) return { scriptText: null };
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
@@ -515,138 +292,9 @@ export async function shiftGsapPositions(
);
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 {
- 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;
-}): () => Promise {
- 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;
-}): () => Promise {
- 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,
- ),
- });
-}
-
-// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
-export { applyPatchByTarget, formatTimelineAttributeNumber };
-
-/**
- * Pure: find the TOP-LEVEL composition root in `doc` (the `[data-composition-id]`
- * with no ancestor composition, matching the runtime's own root resolution) and
- * write `contentEnd` into its `data-duration`. Returns whether a write happened.
- *
- * A timing edit optimistically patches the moved clip's `data-start`/`-duration`
- * in the live iframe, but NOT the root's `data-duration`. Timing edits now take
- * the soft-reload path (no full iframe reload), which re-runs the GSAP script and
- * lets the runtime recompute the composition length — it reads the root's
- * `data-duration` as the authored floor (core/runtime/init.ts) and posts it back,
- * so the studio store's duration is set from the STALE root and the readout
- * reverts to the pre-edit length. Patching the root here keeps the runtime's
- * post-soft-reload duration report in agreement with the optimistic readout, so
- * the number stays live (grow AND shrink) instead of snapping back.
- */
-export function patchDocumentRootDuration(
- doc: Document | null | undefined,
- contentEnd: number,
-): boolean {
- if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false;
- const nodes = Array.from(doc.querySelectorAll("[data-composition-id]"));
- const root =
- nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null;
- if (!root) return false;
- root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd));
- return true;
-}
-
-/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see above). */
-export 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.
+ throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
}
+ return { scriptText: readGsapMutationScriptText(await res.json().catch(() => null)) };
}
/**
@@ -680,6 +328,39 @@ export async function shiftGsapPositionsBatch(
return { scriptText: readGsapMutationScriptText(await res.json().catch(() => null)) };
}
+export async function scaleGsapPositions(
+ projectId: string,
+ filePath: string,
+ elementId: string,
+ oldStart: number,
+ oldDuration: number,
+ newStart: number,
+ newDuration: number,
+): Promise {
+ if (!elementId || oldDuration <= 0 || newDuration <= 0) return { scriptText: null };
+ if (oldStart === newStart && oldDuration === newDuration) return { scriptText: null };
+ 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((err as { error?: string })?.error ?? "scale-positions failed");
+ }
+ return { scriptText: readGsapMutationScriptText(await res.json().catch(() => null)) };
+}
+
/**
* Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
* soft reload over the full iframe reload that flashes every clip.
@@ -718,20 +399,4 @@ export function syncTimingEditPreview(
}
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
-
-/**
- * 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 interface GsapMutationOutcome {
- scriptText: string | null;
-}
-
-function readGsapMutationScriptText(body: unknown): string | null {
- if (typeof body !== "object" || body === null) return null;
- const text = (body as { scriptText?: unknown }).scriptText;
- return typeof text === "string" ? text : null;
-}
+export { applyPatchByTarget, formatTimelineAttributeNumber };
diff --git a/packages/studio/src/hooks/timelineElementsMove.test.ts b/packages/studio/src/hooks/timelineElementsMove.test.ts
index 2aaa3e33f9..cc86b7f7d8 100644
--- a/packages/studio/src/hooks/timelineElementsMove.test.ts
+++ b/packages/studio/src/hooks/timelineElementsMove.test.ts
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { TimelineElement } from "../player";
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
import { persistTimelineElementsMove, type TimelineElementMoveEdit } from "./timelineElementsMove";
+import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
+import { shiftGsapPositionsBatch } from "./timelineEditingHelpers";
function el(over: Partial & Pick): TimelineElement {
return { tag: "div", start: 0, duration: 1, track: 0, ...over };
@@ -52,10 +54,24 @@ describe("furthestClipEndFromSource", () => {
// duration. Without the source-based calc this shrinks/stalls the composition.
const h = vi.hoisted(() => ({ source: "", savedFiles: [] as Record[] }));
-// Stub the player store so persist's optimistic setDuration() doesn't mutate the
-// real store and leak duration into later suite tests (test isolation).
+// Stateful player-store stub: records setDuration calls and exposes a settable
+// `duration`/`currentTime` so the duration-rollback path (ITEM 11) is observable
+// without touching the real store.
+const storeState = vi.hoisted(() => {
+ const s = {
+ duration: 0,
+ currentTime: 0,
+ setDurationCalls: [] as number[],
+ setDuration: (value: number) => {
+ s.duration = value;
+ s.setDurationCalls.push(value);
+ },
+ };
+ return s;
+});
+
vi.mock("../player", () => ({
- usePlayerStore: { getState: () => ({ setDuration: () => {} }) },
+ usePlayerStore: { getState: () => storeState },
}));
vi.mock("../utils/studioFileHistory", () => ({
@@ -74,7 +90,7 @@ vi.mock("./timelineEditingHelpers", async (importActual) => {
...actual,
readFileContent: readFileContentMock,
patchIframeDomTiming: vi.fn(),
- shiftGsapPositionsBatch: vi.fn(async () => {}),
+ shiftGsapPositionsBatch: vi.fn(async () => ({ scriptText: null })),
};
});
@@ -86,6 +102,32 @@ function move(
return { element, updates: { start, track } };
}
+type PersistOpts = Parameters[1];
+
+// The persist options are identical across tests except for `reloadPreview`
+// (some tests spy on it). Build the shared defaults, allow per-test overrides.
+function persistOpts(over: Partial = {}): PersistOpts {
+ return {
+ projectId: "p",
+ activeCompPath: "index.html",
+ previewIframe: null,
+ writeProjectFile: async () => {},
+ recordEdit: async () => {},
+ reloadPreview: () => {},
+ domEditSaveTimestampRef: { current: 0 },
+ ...over,
+ };
+}
+
+// A move spanning the root comp + a sub-comp: distinct source per path.
+const twoFileSource: Record = {
+ "index.html": bed(15.18, 7.18),
+ "scenes/intro.html": bed(15.18, 2),
+};
+function mockTwoFileSource(): void {
+ readFileContentMock.mockImplementation(async (_pid: string, path: string) => twoFileSource[path]);
+}
+
describe("persistTimelineElementsMove — writes source-derived duration", () => {
beforeEach(() => {
h.savedFiles.length = 0;
@@ -98,15 +140,7 @@ describe("persistTimelineElementsMove — writes source-derived duration", () =>
h.source = bed(15.18, 7.18);
const music = el({ id: "hf-music", hfId: "hf-music", start: 7.18, duration: 8, track: 2 });
- await persistTimelineElementsMove([move(music, 11.53)], {
- projectId: "p",
- activeCompPath: "index.html",
- previewIframe: null,
- writeProjectFile: async () => {},
- recordEdit: async () => {},
- reloadPreview: () => {},
- domEditSaveTimestampRef: { current: 0 },
- });
+ await persistTimelineElementsMove([move(music, 11.53)], persistOpts());
expect(h.savedFiles).toHaveLength(1);
const saved = h.savedFiles[0]["index.html"];
@@ -122,11 +156,7 @@ describe("persistTimelineElementsMove — writes source-derived duration", () =>
// all-or-nothing on disk that matches the caller's all-or-nothing store rollback.
it("folds clips from two source files into ONE atomic save (single history entry)", async () => {
// Distinct source per path; a single move edit touches each file.
- const perPath: Record = {
- "index.html": bed(15.18, 7.18),
- "scenes/intro.html": bed(15.18, 2),
- };
- readFileContentMock.mockImplementation(async (_pid: string, path: string) => perPath[path]);
+ mockTwoFileSource();
const rootMusic = el({ id: "hf-music", hfId: "hf-music", start: 7.18, duration: 8, track: 2 });
const subMusic = el({
@@ -138,15 +168,7 @@ describe("persistTimelineElementsMove — writes source-derived duration", () =>
sourceFile: "scenes/intro.html",
});
- await persistTimelineElementsMove([move(rootMusic, 9), move(subMusic, 4)], {
- projectId: "p",
- activeCompPath: "index.html",
- previewIframe: null,
- writeProjectFile: async () => {},
- recordEdit: async () => {},
- reloadPreview: () => {},
- domEditSaveTimestampRef: { current: 0 },
- });
+ await persistTimelineElementsMove([move(rootMusic, 9), move(subMusic, 4)], persistOpts());
// ONE save call carrying BOTH files (not two per-file saves).
expect(h.savedFiles).toHaveLength(1);
@@ -155,3 +177,99 @@ describe("persistTimelineElementsMove — writes source-derived duration", () =>
expect(h.savedFiles[0]["scenes/intro.html"]).toContain('data-start="4"');
});
});
+
+// ── ITEM 11 — duration rollback on a failed write ───────────────────────────
+describe("persistTimelineElementsMove — reverts optimistic duration on write failure", () => {
+ const save = saveProjectFilesWithHistory as unknown as Mock;
+
+ beforeEach(() => {
+ readFileContentMock.mockReset();
+ readFileContentMock.mockImplementation(async () => h.source);
+ storeState.setDurationCalls.length = 0;
+ });
+
+ it("restores the store/root duration to its previous value when the atomic save throws", async () => {
+ storeState.duration = 15.18; // the length before the move
+ h.source = bed(15.18, 7.18); // audio (8s) currently ends at 15.18
+ save.mockImplementationOnce(async () => {
+ throw new Error("disk full");
+ });
+ const music = el({ id: "hf-music", hfId: "hf-music", start: 7.18, duration: 8, track: 2 });
+
+ await expect(persistTimelineElementsMove([move(music, 11.53)], persistOpts())).rejects.toThrow(
+ "disk full",
+ );
+
+ // Optimistically grew to the moved audio's 19.53 end, then rolled back to 15.18.
+ expect(storeState.setDurationCalls).toEqual([19.53, 15.18]);
+ });
+});
+
+// ── ITEM 10 — multi-file move must not clobber the shared preview iframe ─────
+describe("persistTimelineElementsMove — preview sync scopes soft-reload to the active comp", () => {
+ const shiftBatch = shiftGsapPositionsBatch as unknown as Mock;
+
+ beforeEach(() => {
+ readFileContentMock.mockReset();
+ shiftBatch.mockClear();
+ shiftBatch.mockResolvedValue({ scriptText: null });
+ });
+
+ it("runs the durable GSAP shift for EVERY changed group and full-reloads once when a non-active file also changed", async () => {
+ mockTwoFileSource();
+ const reloadPreview = vi.fn();
+ const rootMusic = el({
+ id: "hf-music",
+ hfId: "hf-music",
+ domId: "bg-music",
+ start: 7.18,
+ duration: 8,
+ track: 2,
+ });
+ const subMusic = el({
+ id: "hf-music",
+ hfId: "hf-music",
+ domId: "bg-music",
+ start: 2,
+ duration: 8,
+ track: 2,
+ sourceFile: "scenes/intro.html",
+ });
+
+ await persistTimelineElementsMove(
+ [move(rootMusic, 9), move(subMusic, 4)],
+ persistOpts({ reloadPreview }),
+ );
+
+ // shiftGsapPositionsBatch is a DURABLE server file rewrite, not a cosmetic
+ // preview step — it must run once PER changed group (each against its own
+ // targetPath), not be skipped for the non-active sub-comp. Skipping it left the
+ // sub-comp's persisted tween positions desynced from its clip timings forever.
+ expect(shiftBatch).toHaveBeenCalledTimes(2);
+ const shiftedPaths = shiftBatch.mock.calls.map((c) => c[1]).sort();
+ expect(shiftedPaths).toEqual(["index.html", "scenes/intro.html"]);
+ // The shared iframe still can't soft-reload a sub-comp, so exactly ONE full
+ // reload reflects every changed file.
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
+ });
+
+ it("soft-reloads (batched GSAP shift) when only the active comp changed", async () => {
+ h.source = bed(15.18, 7.18);
+ readFileContentMock.mockImplementation(async () => h.source);
+ const reloadPreview = vi.fn();
+ const music = el({
+ id: "hf-music",
+ hfId: "hf-music",
+ domId: "bg-music",
+ start: 7.18,
+ duration: 8,
+ track: 2,
+ });
+
+ await persistTimelineElementsMove([move(music, 11.53)], persistOpts({ reloadPreview }));
+
+ // Only the active comp changed → soft path: exactly one batched GSAP shift, for it.
+ expect(shiftBatch).toHaveBeenCalledTimes(1);
+ expect(shiftBatch.mock.calls[0][1]).toBe("index.html");
+ });
+});
diff --git a/packages/studio/src/hooks/timelineElementsMove.ts b/packages/studio/src/hooks/timelineElementsMove.ts
index cfd788261e..69026d12b6 100644
--- a/packages/studio/src/hooks/timelineElementsMove.ts
+++ b/packages/studio/src/hooks/timelineElementsMove.ts
@@ -9,7 +9,7 @@ import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
import {
applyPatchByTarget,
buildPatchTarget,
- formatTimelineAttributeNumber,
+ buildTimelineMoveTimingPatch,
patchIframeDomTiming,
patchIframeRootDuration,
readFileContent,
@@ -69,16 +69,15 @@ function applyGroupTimingPatches(source: string, groupEdits: TimelineElementMove
for (const { element, updates } of groupEdits) {
const target = buildPatchTarget(element);
if (!target) continue;
- patched = applyPatchByTarget(patched, target, {
- type: "attribute",
- property: "start",
- value: formatTimelineAttributeNumber(updates.start),
- });
- patched = applyPatchByTarget(patched, target, {
- type: "attribute",
- property: "track-index",
- value: String(updates.track),
- });
+ // buildTimelineMoveTimingPatch drops any non-finite field (#2212 NaN guard),
+ // so a bad {start|track} never gets serialized into data-* here.
+ for (const patch of buildTimelineMoveTimingPatch(updates)) {
+ patched = applyPatchByTarget(patched, target, {
+ type: "attribute",
+ property: patch.property,
+ value: patch.value,
+ });
+ }
}
return patched;
}
@@ -97,10 +96,14 @@ async function patchTimelineMoveGroups(
groupResults: MoveGroupResult[];
filesToSave: Record;
originals: Map;
+ /** True if the optimistic root-duration set below ran (must be rolled back on a
+ * failed write, same as the caller rolls back start/track). */
+ durationChanged: boolean;
}> {
const groupResults: MoveGroupResult[] = [];
const filesToSave: Record = {};
const originals = new Map();
+ let durationChanged = false;
for (const [targetPath, groupEdits] of groups) {
const original = await readFileContent(deps.projectId, targetPath);
let patched = applyGroupTimingPatches(original, groupEdits);
@@ -118,52 +121,86 @@ async function patchTimelineMoveGroups(
if (contentEnd > 0 && targetPath === (deps.activeCompPath || "index.html")) {
usePlayerStore.getState().setDuration(contentEnd);
patchIframeRootDuration(deps.previewIframe, contentEnd);
+ durationChanged = true;
}
groupResults.push({ targetPath, original, patched, groupEdits });
filesToSave[targetPath] = patched;
originals.set(targetPath, original);
}
- return { groupResults, filesToSave, originals };
+ return { groupResults, filesToSave, originals, durationChanged };
+}
+
+/** The batched GSAP position shifts for one group's clips whose start changed. */
+function groupGsapShifts(group: MoveGroupResult): Array<{ elementId: string; delta: number }> {
+ return group.groupEdits
+ .filter((e) => e.element.domId && e.updates.start - e.element.start !== 0)
+ .map((e) => ({
+ elementId: e.element.domId as string,
+ delta: e.updates.start - e.element.start,
+ }));
}
/**
- * Phase 3 — post-save preview sync, per group. Non-fatal cosmetic steps (a GSAP
- * shift miss re-syncs on the next reload), so they run after the durable write
- * and never throw back into the caller's rollback path.
+ * Phase 3 — post-save preview sync.
+ *
+ * `shiftGsapPositionsBatch` is a DURABLE server mutation (shift-positions-batch →
+ * file write) that rewrites each file's GSAP tween positions to match the moved
+ * clip timings. It is NOT a cosmetic preview step, so it must run for EVERY changed
+ * group (against that group's own targetPath). Skipping it for a non-active group
+ * leaves that file's persisted clip timings out of sync with its GSAP script
+ * PERMANENTLY — a reload re-reads the same desynced file, so it never heals. (Only
+ * the endpoint FAILING is non-fatal and logged: the write simply didn't happen.)
+ *
+ * The preview is a SINGLE shared iframe showing the ACTIVE composition, so only the
+ * active comp's group can be soft-reloaded (its rewritten script swapped in place).
+ * If any OTHER file changed too — e.g. a sub-comp group in a multi-file move — a
+ * per-group soft-reload would either apply that file's script to the wrong (root)
+ * document or fire a second clobbering reloadPreview; do ONE full reload instead so
+ * every changed file is reflected.
*/
async function syncTimelineMovePreviews(
groupResults: MoveGroupResult[],
- deps: Pick,
+ deps: Pick<
+ PersistTimelineElementsMoveDeps,
+ "projectId" | "activeCompPath" | "previewIframe" | "reloadPreview"
+ >,
): Promise {
- for (const { targetPath, groupEdits } of groupResults) {
- // One batched GSAP position shift for every clip whose start changed.
- const shifts = groupEdits
- .filter((e) => e.element.domId && e.updates.start - e.element.start !== 0)
- .map((e) => ({
- elementId: e.element.domId as string,
- delta: e.updates.start - e.element.start,
- }));
- let shiftOutcome: GsapMutationOutcome = { scriptText: null };
- if (shifts.length > 0) {
- shiftOutcome = await shiftGsapPositionsBatch(deps.projectId, targetPath, shifts).catch(
- (err) => {
- console.error("[Timeline] Failed to batch-shift GSAP positions", err);
- return { scriptText: null };
- },
- );
- }
+ const activePath = deps.activeCompPath || "index.html";
+ const activeGroup = groupResults.find((g) => g.targetPath === activePath);
+ const otherFileChanged = groupResults.some((g) => g.targetPath !== activePath);
- // Soft-reload with the batch's rewritten script — a multi-clip move is
- // timing-only (DOM + store already patched), so swap the script in place to
- // avoid the all-clips flash; full reload is the fallback.
- syncTimingEditPreview(
- deps.previewIframe,
- shiftOutcome,
- usePlayerStore.getState().currentTime,
- deps.reloadPreview,
+ // Run the durable batch shift for every changed group, each against its own file.
+ let activeShiftOutcome: GsapMutationOutcome = { scriptText: null };
+ for (const group of groupResults) {
+ const shifts = groupGsapShifts(group);
+ if (shifts.length === 0) continue;
+ const outcome = await shiftGsapPositionsBatch(deps.projectId, group.targetPath, shifts).catch(
+ (err) => {
+ console.error("[Timeline] Failed to batch-shift GSAP positions", err);
+ return { scriptText: null };
+ },
);
+ if (group === activeGroup) activeShiftOutcome = outcome;
+ }
+
+ if (otherFileChanged || !activeGroup) {
+ // A non-active file changed (or nothing touched the active comp) — the shared
+ // iframe can't soft-reload those, so full-reload once to reflect them all. The
+ // durable shifts above have already been written to every file.
+ deps.reloadPreview();
+ return;
}
+
+ // Only the active comp changed → soft-reload with its rewritten script — a
+ // multi-clip move is timing-only (DOM + store already patched), so swap the script
+ // in place to avoid the all-clips flash; full reload is the fallback.
+ syncTimingEditPreview(
+ deps.previewIframe,
+ activeShiftOutcome,
+ usePlayerStore.getState().currentTime,
+ deps.reloadPreview,
+ );
}
/**
@@ -185,12 +222,16 @@ async function syncTimelineMovePreviews(
* here for single-undo atomicity on that path too.
*
* Throws if a source write fails (so the caller can roll back its optimistic
- * store update). GSAP-shift failures are logged, not thrown (matches the
- * single-clip path — a shift miss is non-fatal, the next reload re-syncs).
+ * store update). A GSAP shift-batch ENDPOINT failure is logged, not thrown (matches
+ * the single-clip path) — but note it does NOT self-heal: shiftGsapPositionsBatch is
+ * a durable file rewrite, so if it fails the file's tween positions stay desynced
+ * from the clip timings until the next successful shift; a reload just re-reads the
+ * un-rewritten file. The console error is the signal.
*/
export async function persistTimelineElementsMove(
edits: TimelineElementMoveEdit[],
deps: PersistTimelineElementsMoveDeps,
+ coalesceKey?: string,
): Promise {
if (edits.length === 0) return;
const {
@@ -205,20 +246,22 @@ export async function persistTimelineElementsMove(
} = deps;
// 1. Optimistic live DOM patch — instant feedback before the write lands.
+ // Non-finite fields are dropped by buildTimelineMoveTimingPatch (#2212).
for (const { element, updates } of edits) {
- patchIframeDomTiming(previewIframe, element, [
- ["data-start", formatTimelineAttributeNumber(updates.start)],
- ["data-track-index", String(updates.track)],
- ]);
+ patchIframeDomTiming(
+ previewIframe,
+ element,
+ buildTimelineMoveTimingPatch(updates).map((p): [string, string] => [p.attr, p.value]),
+ );
}
// Phase 1 — group by owning file and patch each group's source in memory.
const groups = groupEditsByFile(edits, activeCompPath);
- const { groupResults, filesToSave, originals } = await patchTimelineMoveGroups(groups, {
- projectId,
- activeCompPath,
- previewIframe,
- });
+ const previousDuration = usePlayerStore.getState().duration;
+ const { groupResults, filesToSave, originals, durationChanged } = await patchTimelineMoveGroups(
+ groups,
+ { projectId, activeCompPath, previewIframe },
+ );
if (groupResults.length === 0) return;
@@ -231,19 +274,56 @@ export async function persistTimelineElementsMove(
// store all the way back. All-or-nothing on disk now matches the caller's
// all-or-nothing store rollback, and undo is a single step.
domEditSaveTimestampRef.current = Date.now();
- await saveProjectFilesWithHistory({
- projectId,
- label: edits.length > 1 ? "Move timeline clips" : "Move timeline clip",
- kind: "timeline",
- files: filesToSave,
- readFile: async (path) => originals.get(path) ?? "",
- writeFile: writeProjectFile,
- recordEdit,
- });
+ try {
+ await saveProjectFilesWithHistory({
+ projectId,
+ label: edits.length > 1 ? "Move timeline clips" : "Move timeline clip",
+ kind: "timeline",
+ // Shared per-gesture key (from the drag commit) so a lane change's move
+ // entry merges with its follow-up z-reorder entry into one undo step.
+ coalesceKey,
+ // The z entry lands only after this persist's round-trip — the default
+ // 300ms merge window can miss it, silently splitting the gesture into two
+ // undo steps. A generous window keeps the pair one step (same key required).
+ coalesceMs: coalesceKey ? 5000 : undefined,
+ files: filesToSave,
+ readFile: async (path) => originals.get(path) ?? "",
+ writeFile: writeProjectFile,
+ recordEdit,
+ });
+ } catch (error) {
+ // Revert the optimistic live-iframe timing attrs patched in step 1. The caller
+ // rolls back the store's {start,track}, but the iframe DOM would otherwise keep
+ // the un-persisted values (data-start/data-track-index the saved source never
+ // got), desyncing the preview from disk until the next reload. The element's own
+ // pre-move start/track are the restore values (always finite).
+ for (const { element } of edits) {
+ patchIframeDomTiming(
+ previewIframe,
+ element,
+ buildTimelineMoveTimingPatch({ start: element.start, track: element.track }).map(
+ (p): [string, string] => [p.attr, p.value],
+ ),
+ );
+ }
+ // Roll back the optimistic root-duration set alongside the caller's start/track
+ // rollback — otherwise a failed write leaves the store + live root advertising a
+ // duration the saved source never got.
+ if (durationChanged) {
+ usePlayerStore.getState().setDuration(previousDuration);
+ patchIframeRootDuration(previewIframe, previousDuration);
+ }
+ throw error;
+ }
- // Phase 3 — post-save preview sync, per group.
+ // Phase 3 — post-save preview sync.
forceReloadSdkSession?.();
- await syncTimelineMovePreviews(groupResults, { projectId, previewIframe, reloadPreview });
+ await syncTimelineMovePreviews(groupResults, {
+ projectId,
+ activeCompPath,
+ previewIframe,
+ reloadPreview,
+ });
}
export interface UseTimelineElementsMoveDeps {
@@ -274,23 +354,27 @@ export function useTimelineElementsMove(deps: UseTimelineElementsMoveDeps) {
showToast,
} = deps;
return useCallback(
- (edits: TimelineElementMoveEdit[]): Promise => {
+ (edits: TimelineElementMoveEdit[], coalesceKey?: string): Promise => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return Promise.resolve();
}
const pid = projectIdRef.current;
if (!pid) return Promise.resolve();
- return persistTimelineElementsMove(edits, {
- projectId: pid,
- activeCompPath,
- previewIframe: previewIframeRef.current,
- writeProjectFile,
- recordEdit,
- reloadPreview,
- forceReloadSdkSession,
- domEditSaveTimestampRef,
- });
+ return persistTimelineElementsMove(
+ edits,
+ {
+ projectId: pid,
+ activeCompPath,
+ previewIframe: previewIframeRef.current,
+ writeProjectFile,
+ recordEdit,
+ reloadPreview,
+ forceReloadSdkSession,
+ domEditSaveTimestampRef,
+ },
+ coalesceKey,
+ );
},
[
projectIdRef,
diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts
index 77f6f57988..165bab8e69 100644
--- a/packages/studio/src/hooks/useAppHotkeys.ts
+++ b/packages/studio/src/hooks/useAppHotkeys.ts
@@ -4,7 +4,7 @@ import type { TimelineElement } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
-import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
+import { isEditableTarget } from "../utils/timelineDiscovery";
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
import { canSplitElement } from "../utils/timelineElementSplit";
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
@@ -81,6 +81,8 @@ interface HistoryResult {
reason?: string;
label?: string;
paths?: string[];
+ /** Per-file restored/previous content, used to soft-apply the preview. */
+ files?: Record;
}
interface HistoryFileCallbacks {
readFile: (path: string) => Promise;
@@ -96,7 +98,6 @@ interface EditHistoryHandle {
}
interface UseAppHotkeysParams {
- toggleTimelineVisibility: () => void;
handleTimelineElementDelete: (element: TimelineElement) => Promise;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise;
handleDomEditElementDelete: (selection: DomEditSelection) => Promise;
@@ -108,7 +109,10 @@ interface UseAppHotkeysParams {
writeProjectFile: (path: string, content: string) => Promise;
domEditSaveTimestampRef: React.MutableRefObject;
showToast: (message: string, tone?: "error" | "info") => void;
- syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise;
+ syncHistoryPreviewAfterApply: (restore: {
+ paths?: string[];
+ files?: Record;
+ }) => Promise;
waitForPendingDomEditSaves: () => Promise;
leftSidebarRef: React.RefObject;
handleCopy: () => boolean;
@@ -135,7 +139,6 @@ interface UseAppHotkeysParams {
// ── Extracted keydown dispatch (pure function, no hooks) ──
interface HotkeyCallbacks {
- toggleTimelineVisibility: () => void;
handleTimelineElementDelete: (element: TimelineElement) => Promise;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise;
handleDomEditElementDelete: (selection: DomEditSelection) => Promise;
@@ -291,9 +294,14 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
return;
}
}
- const { selectedElementId, elements } = usePlayerStore.getState();
- if (selectedElementId) {
- const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
+ // Delete acts on the primary selection OR the marquee multi-selection —
+ // the delete handler expands a clip that is part of the multi-selection
+ // into an atomic delete of the whole selection (single undo).
+ const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
+ const selectionKeys = new Set(selectedElementIds);
+ if (selectedElementId) selectionKeys.add(selectedElementId);
+ if (selectionKeys.size > 0) {
+ const el = elements.find((e) => selectionKeys.has(e.key ?? e.id));
if (el) {
event.preventDefault();
void cb.handleTimelineElementDelete(el);
@@ -317,7 +325,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
// ── Hook ──
export function useAppHotkeys({
- toggleTimelineVisibility,
handleTimelineElementDelete,
handleTimelineElementSplit,
handleDomEditElementDelete,
@@ -346,15 +353,6 @@ export function useAppHotkeys({
const previewHotkeyWindowRef = useRef(null);
const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
- const handleTimelineToggleHotkey = useCallback(
- (event: KeyboardEvent) => {
- if (!shouldHandleTimelineToggleHotkey(event)) return;
- event.preventDefault();
- toggleTimelineVisibility();
- },
- [toggleTimelineVisibility],
- );
-
// ── Undo / Redo ──
const readHistoryFile = useCallback(
@@ -397,7 +395,7 @@ export function useAppHotkeys({
if (activeCompPath && result.paths?.includes(activeCompPath)) {
forceReloadSdkSession?.();
}
- await syncHistoryPreviewAfterApply(result.paths);
+ await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
}
},
@@ -421,7 +419,6 @@ export function useAppHotkeys({
const cbRef = useRef(null!);
cbRef.current = {
- toggleTimelineVisibility,
handleTimelineElementDelete,
handleTimelineElementSplit,
handleDomEditElementDelete,
@@ -444,11 +441,6 @@ export function useAppHotkeys({
const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
const cb = cbRef.current;
- if (shouldHandleTimelineToggleHotkey(event)) {
- event.preventDefault();
- cb.toggleTimelineVisibility();
- return;
- }
const key = event.key.toLowerCase();
if (event.metaKey || event.ctrlKey) {
dispatchModifierKey(event, key, cb);
@@ -537,6 +529,5 @@ export function useAppHotkeys({
handleRedo,
syncPreviewTimelineHotkey,
syncPreviewHistoryHotkey,
- handleTimelineToggleHotkey,
};
}
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index 3ae522f837..3c379896ec 100644
--- a/packages/studio/src/hooks/useDomEditCommits.ts
+++ b/packages/studio/src/hooks/useDomEditCommits.ts
@@ -48,6 +48,7 @@ interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
+ coalesceMs?: number;
files: Record;
}
@@ -271,6 +272,7 @@ export function useDomEditCommits({
label: options?.label ?? "Edit layer",
kind: "manual",
coalesceKey: options?.coalesceKey,
+ coalesceMs: options?.coalesceMs,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
forceReloadSdkSession?.();
diff --git a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
index 335658011e..1141a91d7c 100644
--- a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
+++ b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
@@ -15,6 +15,7 @@ interface UseDomEditPositionPatchCommitParams {
interface PositionPatchOptions {
label: string;
coalesceKey: string;
+ coalesceMs?: number;
skipRefresh?: boolean;
}
@@ -30,6 +31,7 @@ export function useDomEditPositionPatchCommit({
await persistDomEditOperations(selection, patches, {
label: options.label,
coalesceKey: options.coalesceKey,
+ coalesceMs: options.coalesceMs,
skipRefresh: options.skipRefresh ?? true,
});
}).catch((error) => {
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index 182cc5251e..f8cba7f987 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { trackStudioEvent } from "../utils/studioTelemetry";
-import type { TimelineElement } from "../player";
+import type { SelectElementOptions, TimelineElement } from "../player";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
import type { RightPanelTab } from "../utils/studioHelpers";
@@ -38,7 +38,7 @@ export interface UseDomEditSessionParams {
compositionLoading: boolean;
previewIframeRef: React.MutableRefObject;
timelineElements: TimelineElement[];
- setSelectedTimelineElementId: (id: string | null) => void;
+ setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
setRightCollapsed: (collapsed: boolean) => void;
setRightPanelTab: (tab: RightPanelTab) => void;
showToast: (message: string, tone?: "error" | "info") => void;
diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts
index 72892dd744..83eced6cbb 100644
--- a/packages/studio/src/hooks/useDomSelection.ts
+++ b/packages/studio/src/hooks/useDomSelection.ts
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from "react";
-import type { TimelineElement } from "../player";
+import type { SelectElementOptions, TimelineElement } from "../player";
+import { usePlayerStore } from "../player";
import {
getAllPreviewTargetsFromPointer,
getPreviewTargetFromPointer,
@@ -47,7 +48,7 @@ export interface UseDomSelectionParams {
captionEditMode: boolean;
previewIframeRef: React.MutableRefObject;
timelineElements: TimelineElement[];
- setSelectedTimelineElementId: (id: string | null) => void;
+ setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
setRightCollapsed: (collapsed: boolean) => void;
setRightPanelTab: (tab: RightPanelTab) => void;
previewIframe: HTMLIFrameElement | null;
@@ -126,11 +127,16 @@ export function useDomSelection({
// ── Refs ──
+ const rightPanelTabRef = useRef(rightPanelTab);
+ rightPanelTabRef.current = rightPanelTab;
const domEditSelectionRef = useRef(domEditSelection);
const domEditGroupSelectionsRef = useRef(domEditGroupSelections);
const domEditHoverSelectionRef = useRef(domEditHoverSelection);
const activeGroupElementRef = useRef(activeGroupElement);
const compositionIdentityRef = useRef({ activeCompPath, projectId });
+ // Monotonic token so a rapid A->B timeline-clip select can't let A's slower async
+ // resolution land after B and restore the wrong selection.
+ const timelineSelectSeqRef = useRef(0);
// Keep refs in sync with state
domEditSelectionRef.current = domEditSelection;
@@ -205,7 +211,11 @@ export function useDomSelection({
if (nextSelection) {
if (options?.revealPanel !== false) {
setRightCollapsed(false);
- setRightPanelTab("design");
+ // Keep the Variables tab in place — selecting elements is part of the bind
+ // flow there; yanking to Design would lose the context.
+ if (rightPanelTabRef.current !== "variables") {
+ setRightPanelTab("design");
+ }
}
const nextSelectedTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
@@ -214,7 +224,14 @@ export function useDomSelection({
timelineElements,
nextSelection.sourceFile || "index.html",
);
- setSelectedTimelineElementId(nextSelectedTimelineId);
+ // Late marquee notify: a primary already in the live set must not collapse it.
+ const inSet =
+ nextSelectedTimelineId !== null &&
+ usePlayerStore.getState().selectedElementIds.has(nextSelectedTimelineId);
+ setSelectedTimelineElementId(
+ nextSelectedTimelineId,
+ inSet ? { preserveSet: true } : undefined,
+ );
return;
}
@@ -360,12 +377,15 @@ export function useDomSelection({
const handleTimelineElementSelect = useCallback(
async (element: TimelineElement | null) => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
+ const seq = ++timelineSelectSeqRef.current;
if (!element) {
applyDomSelection(null, { revealPanel: false });
return;
}
const selection = await buildDomSelectionForTimelineElement(element);
+ // A newer selection superseded this one while we were resolving — drop the stale result.
+ if (seq !== timelineSelectSeqRef.current) return;
if (selection) applyDomSelection(selection);
},
[applyDomSelection, buildDomSelectionForTimelineElement],
@@ -496,8 +516,7 @@ export function useDomSelection({
const applyMarqueeSelection = useCallback(
// fallow-ignore-next-line complexity
(selections: DomEditSelection[], additive: boolean) => {
- // Honor the inspector-panels kill switch like applyDomSelection does, so
- // marquee can't land selections while the inspector UI is suppressed.
+ // Honor the inspector-panels kill switch like applyDomSelection does.
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
domEditSelectionRef.current = null;
domEditGroupSelectionsRef.current = [];
@@ -518,8 +537,7 @@ export function useDomSelection({
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
}
} else {
- // Dedupe by target: under select-as-unit several marquee'd members collapse
- // to the same group, which must count as one selection, not many duplicates.
+ // Dedupe by target: select-as-unit collapses marquee'd members to one group.
nextGroup = [];
for (const s of selections) {
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s);
diff --git a/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts b/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts
new file mode 100644
index 0000000000..dce0655946
--- /dev/null
+++ b/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts
@@ -0,0 +1,276 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
+import type { DomEditSelection } from "../components/editor/domEditing";
+import type { SelectElementOptions, TimelineElement } from "../player";
+import { usePlayerStore } from "../player";
+
+installReactActEnvironment();
+
+// ── Module mocks ──
+// Control the async selection-resolution ordering so the race guard can be
+// exercised deterministically, and neutralise the DOM re-apply side effect.
+vi.mock("../components/editor/manualEdits", () => ({
+ reapplyPositionEditsAfterSeek: () => undefined,
+}));
+
+const deferreds = new Map; resolve: () => void }>();
+
+function deferredFor(el: HTMLElement): Promise {
+ const id = el.id;
+ let resolveFn: () => void = () => undefined;
+ const promise = new Promise((resolve) => {
+ resolveFn = () => resolve(makeSelection(id.toUpperCase(), el));
+ });
+ deferreds.set(id, { promise, resolve: resolveFn });
+ return promise;
+}
+
+vi.mock("../components/editor/domEditing", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ findElementForTimelineElement: (doc: Document, element: { id?: string }) =>
+ element.id ? doc.getElementById(element.id) : null,
+ resolveDomEditSelection: (startEl: HTMLElement | null) =>
+ startEl ? deferredFor(startEl) : Promise.resolve(null),
+ };
+});
+
+// Imported after the mocks so the hook picks up the mocked modules.
+const { useDomSelection } = await import("./useDomSelection");
+
+interface HarnessProps {
+ rightPanelTab: "design" | "variables";
+ setRightPanelTab: (tab: "design" | "variables") => void;
+ iframe: HTMLIFrameElement | null;
+ timelineElements: TimelineElement[];
+ setSelectedTimelineElementId?: (id: string | null, options?: SelectElementOptions) => void;
+}
+
+function renderHarness(props: HarnessProps) {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ let currentHook: ReturnType | null = null;
+
+ function Harness() {
+ currentHook = useDomSelection({
+ projectId: "project-1",
+ activeCompPath: "index.html",
+ isMasterView: false,
+ compIdToSrc: new Map(),
+ captionEditMode: false,
+ previewIframeRef: { current: props.iframe },
+ timelineElements: props.timelineElements,
+ setSelectedTimelineElementId: props.setSelectedTimelineElementId ?? vi.fn(),
+ setRightCollapsed: vi.fn(),
+ setRightPanelTab: props.setRightPanelTab,
+ previewIframe: props.iframe,
+ refreshKey: 0,
+ rightPanelTab: props.rightPanelTab,
+ });
+ return null;
+ }
+
+ act(() => root.render(React.createElement(Harness)));
+
+ return {
+ current: () => {
+ if (!currentHook) throw new Error("Expected hook result");
+ return currentHook;
+ },
+ cleanup: () => {
+ act(() => root.unmount());
+ host.remove();
+ },
+ };
+}
+
+describe("useDomSelection — Variables tab preservation", () => {
+ it("does not yank the user off the Variables tab when selecting on canvas", () => {
+ const setRightPanelTab = vi.fn();
+ const el = document.createElement("div");
+ el.id = "headline";
+ const harness = renderHarness({
+ rightPanelTab: "variables",
+ setRightPanelTab,
+ iframe: null,
+ timelineElements: [],
+ });
+
+ act(() => harness.current().applyDomSelection(makeSelection("Headline", el)));
+
+ expect(setRightPanelTab).not.toHaveBeenCalled();
+ harness.cleanup();
+ });
+
+ it("switches to the Design tab when not on Variables (control)", () => {
+ const setRightPanelTab = vi.fn();
+ const el = document.createElement("div");
+ el.id = "headline";
+ const harness = renderHarness({
+ rightPanelTab: "design",
+ setRightPanelTab,
+ iframe: null,
+ timelineElements: [],
+ });
+
+ act(() => harness.current().applyDomSelection(makeSelection("Headline", el)));
+
+ expect(setRightPanelTab).toHaveBeenCalledWith("design");
+ harness.cleanup();
+ });
+});
+
+describe("useDomSelection — timeline-select race guard", () => {
+ beforeEach(() => deferreds.clear());
+ afterEach(() => deferreds.clear());
+
+ it("a stale async resolution never clobbers a newer selection", async () => {
+ const iframe = document.createElement("iframe");
+ document.body.append(iframe);
+ const doc = iframe.contentDocument!;
+ const elA = doc.createElement("div");
+ elA.id = "a";
+ const elB = doc.createElement("div");
+ elB.id = "b";
+ doc.body.append(elA, elB);
+
+ const elementA: TimelineElement = { id: "a", tag: "div", start: 0, duration: 1, track: 0 };
+ const elementB: TimelineElement = { id: "b", tag: "div", start: 0, duration: 1, track: 0 };
+
+ const harness = renderHarness({
+ rightPanelTab: "design",
+ setRightPanelTab: vi.fn(),
+ iframe,
+ timelineElements: [elementA, elementB],
+ });
+
+ // Fire A then B; both suspend on their (pending) resolveDomEditSelection.
+ let pA: Promise = Promise.resolve();
+ let pB: Promise = Promise.resolve();
+ act(() => {
+ pA = harness.current().handleTimelineElementSelect(elementA);
+ pB = harness.current().handleTimelineElementSelect(elementB);
+ });
+
+ // Resolve the NEWER select (B) first, then the older one (A) last.
+ await act(async () => {
+ deferreds.get("b")?.resolve();
+ await pB;
+ });
+ await act(async () => {
+ deferreds.get("a")?.resolve();
+ await pA;
+ });
+
+ // The stale A resolution must be dropped: B wins.
+ expect(harness.current().domEditSelection?.id).toBe("b");
+ harness.cleanup();
+ iframe.remove();
+ });
+});
+
+describe("useDomSelection — marquee multi-select survives the late async primary", () => {
+ beforeEach(() => {
+ deferreds.clear();
+ const store = usePlayerStore.getState();
+ store.setSelectedElementId(null);
+ store.clearSelectedElementIds();
+ });
+ afterEach(() => {
+ deferreds.clear();
+ const store = usePlayerStore.getState();
+ store.setSelectedElementId(null);
+ store.clearSelectedElementIds();
+ });
+
+ function timelineEl(id: string): TimelineElement {
+ return { id, domId: id, tag: "div", start: 0, duration: 1, track: 0 };
+ }
+
+ // Reproduces the reported regression: a marquee over N clips leaves N members in
+ // selectedElementIds, then the inspector-open notify (finishMarquee →
+ // handleTimelineElementSelect → applyDomSelection) resolves LATE and writes the
+ // primary again. Before the fix that late write collapsed the set to one clip.
+ it("keeps all N members when a late primary-set targets a set member", async () => {
+ const iframe = document.createElement("iframe");
+ document.body.append(iframe);
+ const doc = iframe.contentDocument!;
+ for (const id of ["a", "b", "c"]) {
+ const el = doc.createElement("div");
+ el.id = id;
+ doc.body.append(el);
+ }
+
+ const store = usePlayerStore.getState();
+ // Marquee end state: primary written first, then the full set (real ordering).
+ store.setSelectedElementId("a");
+ store.setSelectedElementIds(new Set(["a", "b", "c"]));
+
+ const harness = renderHarness({
+ rightPanelTab: "design",
+ setRightPanelTab: vi.fn(),
+ iframe,
+ timelineElements: [timelineEl("a"), timelineEl("b"), timelineEl("c")],
+ // Wire the real store write so applyDomSelection's collapse decision is exercised.
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
+ });
+
+ let pending: Promise = Promise.resolve();
+ act(() => {
+ pending = harness.current().handleTimelineElementSelect(timelineEl("a"));
+ });
+ await act(async () => {
+ deferreds.get("a")?.resolve();
+ await pending;
+ });
+
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(3);
+ expect(usePlayerStore.getState().selectedElementId).toBe("a");
+ harness.cleanup();
+ iframe.remove();
+ });
+
+ it("collapses the set when a late primary-set targets a non-member (fresh click)", async () => {
+ const iframe = document.createElement("iframe");
+ document.body.append(iframe);
+ const doc = iframe.contentDocument!;
+ for (const id of ["a", "b", "d"]) {
+ const el = doc.createElement("div");
+ el.id = id;
+ doc.body.append(el);
+ }
+
+ const store = usePlayerStore.getState();
+ // Stale set left over from a previous gesture.
+ store.setSelectedElementId("a");
+ store.setSelectedElementIds(new Set(["a", "b"]));
+
+ const harness = renderHarness({
+ rightPanelTab: "design",
+ setRightPanelTab: vi.fn(),
+ iframe,
+ timelineElements: [timelineEl("a"), timelineEl("b"), timelineEl("d")],
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
+ });
+
+ let pending: Promise = Promise.resolve();
+ act(() => {
+ pending = harness.current().handleTimelineElementSelect(timelineEl("d"));
+ });
+ await act(async () => {
+ deferreds.get("d")?.resolve();
+ await pending;
+ });
+
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
+ expect(usePlayerStore.getState().selectedElementId).toBe("d");
+ harness.cleanup();
+ iframe.remove();
+ });
+});
diff --git a/packages/studio/src/hooks/useElementLifecycleOps.test.tsx b/packages/studio/src/hooks/useElementLifecycleOps.test.tsx
new file mode 100644
index 0000000000..af7336c79b
--- /dev/null
+++ b/packages/studio/src/hooks/useElementLifecycleOps.test.tsx
@@ -0,0 +1,119 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { DomEditSelection } from "../components/editor/domEditingTypes";
+import type { PatchOperation } from "../utils/sourcePatcher";
+import { useElementLifecycleOps } from "./useElementLifecycleOps";
+import { mountReactHarness } from "./domSelectionTestHarness";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+type CommitPositionPatch = (
+ selection: DomEditSelection,
+ patches: PatchOperation[],
+ options: { label: string; coalesceKey: string; skipRefresh?: boolean },
+) => Promise;
+
+type ReorderCommit = (
+ entries: Array<{
+ element: HTMLElement;
+ zIndex: number;
+ id?: string;
+ selector?: string;
+ selectorIndex?: number;
+ sourceFile: string;
+ }>,
+ coalesceKeyOverride?: string,
+) => void;
+
+/** Render the hook, capturing every selection handed to commitPositionPatchToHtml. */
+function renderReorderHook(
+ capturedSelections: DomEditSelection[],
+ onReady: (commit: ReorderCommit) => void,
+) {
+ function Harness() {
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
+ activeCompPath: "index.html",
+ showToast: vi.fn(),
+ writeProjectFile: vi.fn(async () => {}),
+ domEditSaveTimestampRef: { current: 0 },
+ editHistory: { recordEdit: vi.fn(async () => {}) },
+ projectIdRef: { current: "demo" },
+ reloadPreview: vi.fn(),
+ clearDomSelection: vi.fn(),
+ commitPositionPatchToHtml: (async (selection: DomEditSelection) => {
+ capturedSelections.push(selection);
+ }) as CommitPositionPatch,
+ });
+ onReady(handleDomZIndexReorderCommit);
+ return null;
+ }
+ return mountReactHarness( );
+}
+
+/** Append the element, mount the reorder hook, and run one commit through act. */
+async function runReorderCommit(el: HTMLElement, entries: Parameters[0]) {
+ document.body.appendChild(el);
+
+ const captured: DomEditSelection[] = [];
+ let commit: ReorderCommit | undefined;
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
+
+ await act(async () => {
+ commit!(entries);
+ });
+
+ return { captured, root };
+}
+
+describe("useElementLifecycleOps — z-index reorder payload", () => {
+ // Regression: an id-less canvas element (e.g. a caption `.sub` div, which
+ // carries only data-hf-id + class) once had its absent id coerced to `null`
+ // (`entry.id ?? null`). The DOM-patch guard rejects a null `body.target.id`,
+ // so "move to back" toasted "unsafe values" and nothing persisted. The target
+ // id must be `undefined` (dropped on the wire), letting hfId / selector match.
+ it("never sends a null target id for an id-less element", async () => {
+ const el = document.createElement("div");
+ el.className = "sub clip";
+ el.setAttribute("data-hf-id", "hf-card");
+
+ const { captured, root } = await runReorderCommit(el, [
+ {
+ element: el,
+ zIndex: 0,
+ // id intentionally absent — the id-less element case.
+ selector: ".sub.clip",
+ selectorIndex: 3,
+ sourceFile: "index.html",
+ },
+ ]);
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]!.id).toBeUndefined();
+ expect(captured[0]!.id).not.toBeNull();
+ // The element stays addressable via hfId (and selector) instead.
+ expect(captured[0]!.hfId).toBe("hf-card");
+
+ act(() => root.unmount());
+ });
+
+ it("preserves a real id when the element has one", async () => {
+ const el = document.createElement("video");
+ el.id = "v-hero";
+ el.setAttribute("data-hf-id", "hf-ezl2");
+
+ const { captured, root } = await runReorderCommit(el, [
+ { element: el, zIndex: 2, id: "v-hero", selector: "#v-hero", sourceFile: "index.html" },
+ ]);
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]!.id).toBe("v-hero");
+
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts
index e59e1b042f..8109e03861 100644
--- a/packages/studio/src/hooks/useElementLifecycleOps.ts
+++ b/packages/studio/src/hooks/useElementLifecycleOps.ts
@@ -17,6 +17,29 @@ interface RecordEditInput {
files: Record;
}
+/**
+ * Resolve the store timeline-element key for a z-reorder entry. Callers pass the
+ * live DOM node plus best-effort id/selector, not the store key, so match against
+ * the store's elements (hf-id first, then dom id, then selector+index). Returns
+ * null when the element isn't a tracked clip (e.g. a runtime-generated sibling).
+ */
+function resolveReorderStoreKey(entry: {
+ element: HTMLElement;
+ id?: string;
+ selector?: string;
+ selectorIndex?: number;
+}): string | null {
+ const els = usePlayerStore.getState().elements;
+ const hfId = readHfId(entry.element);
+ const match =
+ (hfId ? els.find((el) => el.hfId === hfId) : undefined) ??
+ (entry.id ? els.find((el) => el.domId === entry.id || el.id === entry.id) : undefined) ??
+ (entry.selector
+ ? els.find((el) => el.selector === entry.selector && el.selectorIndex === entry.selectorIndex)
+ : undefined);
+ return match ? (match.key ?? match.id) : null;
+}
+
interface UseElementLifecycleOpsParams {
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
@@ -35,7 +58,7 @@ interface UseElementLifecycleOpsParams {
commitPositionPatchToHtml: (
selection: DomEditSelection,
patches: PatchOperation[],
- options: { label: string; coalesceKey: string; skipRefresh?: boolean },
+ options: { label: string; coalesceKey: string; coalesceMs?: number; skipRefresh?: boolean },
) => Promise;
/** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */
onElementDeleted?: (selection: DomEditSelection) => void;
@@ -159,98 +182,89 @@ export function useElementLifecycleOps({
// ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml →
// persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle.
// No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
- const handleDomZIndexReorderCommit = useCallback(
- // fallow-ignore-next-line complexity
+ // Commit one z-reorder entry: mutate the live element's z (and `position`, so a
+ // static element can stack), sync the store z, then persist the inline-style
+ // patch. skipRefresh — the element is already restacked optimistically, so a
+ // reload would only blank the iframe (matches the property-panel convention).
+ const commitZReorderEntry = useCallback(
(
- entries: Array<{
+ entry: {
element: HTMLElement;
zIndex: number;
id?: string;
selector?: string;
selectorIndex?: number;
sourceFile: string;
- key?: string;
- }>,
+ },
+ coalesceKey: string,
+ coalesceMs?: number,
+ ) => {
+ entry.element.style.zIndex = String(entry.zIndex);
+ const patches: Array<{ type: "inline-style"; property: string; value: string }> = [
+ { type: "inline-style", property: "z-index", value: String(entry.zIndex) },
+ ];
+ try {
+ const win = entry.element.ownerDocument?.defaultView;
+ if (win && win.getComputedStyle(entry.element).position === "static") {
+ entry.element.style.position = "relative";
+ patches.push({ type: "inline-style", property: "position", value: "relative" });
+ }
+ } catch {
+ /* cross-origin or detached — skip */
+ }
+ // Sync the store's z with the just-committed value. Display lanes are
+ // track-derived and unaffected, but the canvas paint order and the explicit
+ // vertical stacking sync read store z; skipRefresh skips the rediscover
+ // reload, so without this write the two sources disagree.
+ const storeKey = resolveReorderStoreKey(entry);
+ if (storeKey) {
+ usePlayerStore.getState().updateElement(storeKey, { zIndex: entry.zIndex });
+ }
+ void commitPositionPatchToHtml(
+ {
+ element: entry.element,
+ // Absent id must stay `undefined`, never `null`: the DOM-patch value
+ // guard (findUnsafeDomPatchValues) rejects a null anywhere except an
+ // operation value, so coercing an id-less element (e.g. a caption
+ // `.sub` div, or a duplicate-id element that resolved by selector) to
+ // `null` here bricked the whole z-reorder. undefined is dropped on the
+ // wire and the server matches via hfId / selector + selectorIndex.
+ id: entry.id ?? undefined,
+ hfId: readHfId(entry.element),
+ selector: entry.selector,
+ selectorIndex: entry.selectorIndex,
+ sourceFile: entry.sourceFile,
+ } as unknown as DomEditSelection,
+ patches,
+ { label: "Reorder layers", coalesceKey, coalesceMs, skipRefresh: true },
+ ).catch(() => undefined);
+ },
+ [commitPositionPatchToHtml],
+ );
+
+ const handleDomZIndexReorderCommit = useCallback(
+ (
+ entries: Array[0]>,
+ // Optional override: the timeline lane-change drag passes its shared
+ // per-gesture key so this z-reorder entry merges with the move entry into
+ // one undo step. Absent (canvas menu / LayersPanel) ⇒ derive the usual key.
+ coalesceKeyOverride?: string,
) => {
- if (entries.length === 0) return Promise.resolve();
+ if (entries.length === 0) return;
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
// the SDK resolves each reordered element — the reorderElements op's targets.
onReorderShadow?.(
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
);
- const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
- const saves: Array> = [];
- const rollbacks: Array<() => void> = [];
- for (let i = 0; i < entries.length; i++) {
- const entry = entries[i];
- const priorZIndex = entry.element.style.zIndex;
- const priorPosition = entry.element.style.position;
- const priorStoreEntry = entry.key
- ? usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === entry.key)
- : undefined;
- let positionChanged = false;
- entry.element.style.zIndex = String(entry.zIndex);
- const patches: Array<{ type: "inline-style"; property: string; value: string }> = [
- { type: "inline-style", property: "z-index", value: String(entry.zIndex) },
- ];
- try {
- const win = entry.element.ownerDocument?.defaultView;
- if (win && win.getComputedStyle(entry.element).position === "static") {
- entry.element.style.position = "relative";
- positionChanged = true;
- patches.push({ type: "inline-style", property: "position", value: "relative" });
- }
- } catch {
- /* cross-origin or detached — skip */
- }
- if (entry.key) {
- usePlayerStore
- .getState()
- .updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true });
- }
- rollbacks.push(() => {
- entry.element.style.zIndex = priorZIndex;
- if (positionChanged) entry.element.style.position = priorPosition;
- if (entry.key && priorStoreEntry) {
- usePlayerStore.getState().updateElement(entry.key, {
- zIndex: priorStoreEntry.zIndex,
- hasExplicitZIndex: priorStoreEntry.hasExplicitZIndex,
- });
- }
- });
- saves.push(
- commitPositionPatchToHtml(
- {
- element: entry.element,
- id: entry.id ?? null,
- hfId: readHfId(entry.element),
- selector: entry.selector,
- selectorIndex: entry.selectorIndex,
- sourceFile: entry.sourceFile,
- } as unknown as DomEditSelection,
- patches,
- {
- label: "Reorder layers",
- coalesceKey,
- skipRefresh: i < entries.length - 1,
- },
- ),
- );
+ const coalesceKey =
+ coalesceKeyOverride ??
+ `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
+ for (const entry of entries) {
+ // Match the move side's widened window when merging a lane-change gesture.
+ commitZReorderEntry(entry, coalesceKey, coalesceKeyOverride ? 5000 : undefined);
}
- // Resolves once every z-index patch is persisted so a same-file timing write
- // can be ordered after it (see applyTimelineStackingReorder callers).
- return Promise.allSettled(saves).then((settled) => {
- const rejected = settled.find(
- (result): result is PromiseRejectedResult => result.status === "rejected",
- );
- if (rejected) {
- for (const rollback of rollbacks) rollback();
- return Promise.reject(rejected.reason);
- }
- return undefined;
- });
},
- [commitPositionPatchToHtml, onReorderShadow],
+ [commitZReorderEntry, onReorderShadow],
);
return {
diff --git a/packages/studio/src/hooks/usePersistentEditHistory.test.ts b/packages/studio/src/hooks/usePersistentEditHistory.test.ts
index 3128ab0bb1..8b14c4605f 100644
--- a/packages/studio/src/hooks/usePersistentEditHistory.test.ts
+++ b/packages/studio/src/hooks/usePersistentEditHistory.test.ts
@@ -213,6 +213,39 @@ describe("createPersistentEditHistoryController", () => {
expect(store.snapshot().canRedo).toBe(true);
});
+ it("returns per-file restored/previous content so the preview can soft-apply", async () => {
+ const storage = createMemoryEditHistoryStorage();
+ const store = createPersistentEditHistoryStore({
+ projectId: "project-1",
+ storage,
+ initialState: createEmptyEditHistory(),
+ now: () => 100,
+ onChange: () => {},
+ });
+ await store.recordEdit({
+ label: "Move layer",
+ kind: "manual",
+ files: { "index.html": { before: "OLD", after: "NEW" } },
+ });
+ const disk: Record = { "index.html": "NEW" };
+ const undo = await store.undo({
+ readFile: async (p) => disk[p],
+ writeFile: async (p, c) => {
+ disk[p] = c;
+ },
+ });
+ // `restored` = bytes written (the undo target), `previous` = the current live bytes.
+ expect(undo.files).toEqual({ "index.html": { previous: "NEW", restored: "OLD" } });
+
+ const redo = await store.redo({
+ readFile: async (p) => disk[p],
+ writeFile: async (p, c) => {
+ disk[p] = c;
+ },
+ });
+ expect(redo.files).toEqual({ "index.html": { previous: "OLD", restored: "NEW" } });
+ });
+
it("rolls back files when an undo write fails partway through", async () => {
const storage = createMemoryEditHistoryStorage();
const store = createPersistentEditHistoryStore({
diff --git a/packages/studio/src/hooks/usePersistentEditHistory.ts b/packages/studio/src/hooks/usePersistentEditHistory.ts
index de1af2867c..61779eddec 100644
--- a/packages/studio/src/hooks/usePersistentEditHistory.ts
+++ b/packages/studio/src/hooks/usePersistentEditHistory.ts
@@ -7,8 +7,10 @@ import {
redoEditHistory,
undoEditHistory,
type BuildEditHistoryEntryInput,
+ type EditHistoryEntry,
type EditHistoryKind,
type EditHistoryState,
+ type EditHistoryTransitionResult,
} from "../utils/editHistory";
import {
createIndexedDbEditHistoryStorage,
@@ -21,6 +23,7 @@ interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
+ coalesceMs?: number;
files: BuildEditHistoryEntryInput["files"];
}
@@ -35,11 +38,24 @@ interface UsePersistentEditHistoryOptions {
now?: () => number;
}
+/**
+ * Per-file content the restore just applied. `restored` is the bytes written to
+ * disk (the undo/redo target); `previous` is what was on disk immediately before
+ * (the current live preview state). The undo preview-sync diffs these to decide
+ * whether the restore is soft-reloadable (attributes/style/GSAP-script only) or
+ * needs a full iframe reload.
+ */
+interface ApplyRestoredFile {
+ previous: string;
+ restored: string;
+}
+
interface ApplyResult {
ok: boolean;
reason?: "empty" | "content-mismatch";
label?: string;
paths?: string[];
+ files?: Record;
}
interface PersistentEditHistoryStoreOptions {
@@ -55,6 +71,18 @@ type EditHistoryMutation = (state: EditHistoryState) => Promise<{
result: T;
}>;
+/** Pair the just-written (`restored`) bytes with the pre-write (`previous`) bytes per path. */
+function restoredFilesMap(
+ filesToWrite: Record,
+ currentFiles: Record,
+): Record {
+ const out: Record = {};
+ for (const [path, restored] of Object.entries(filesToWrite)) {
+ out[path] = { previous: currentFiles[path] ?? "", restored };
+ }
+ return out;
+}
+
function createEntryId(now: number): string {
return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
@@ -120,6 +148,52 @@ async function writeFilesWithRollback({
}
}
+/**
+ * Apply one undo/redo step: read current on-disk hashes, run the direction's
+ * transition, write the restored files with rollback, and shape the ApplyResult.
+ * `entry` is the stack top used to know which paths to hash before applying.
+ */
+async function applyHistoryStep(
+ currentState: EditHistoryState,
+ entry: EditHistoryEntry | undefined,
+ transition: (
+ state: EditHistoryState,
+ currentHashes: Record,
+ now: number,
+ ) => EditHistoryTransitionResult,
+ now: () => number,
+ callbacks: ApplyCallbacks,
+): Promise<{ state: EditHistoryState; result: ApplyResult }> {
+ if (!entry) {
+ return { state: currentState, result: { ok: false, reason: "empty" } };
+ }
+ const { currentFiles, currentHashes } = await readCurrentFileHashes(
+ Object.keys(entry.files),
+ callbacks.readFile,
+ );
+ const result = transition(currentState, currentHashes, now());
+ if (!result.ok) {
+ return {
+ state: currentState,
+ result: { ok: false, reason: result.reason },
+ };
+ }
+ await writeFilesWithRollback({
+ files: result.filesToWrite,
+ rollbackFiles: currentFiles,
+ writeFile: callbacks.writeFile,
+ });
+ return {
+ state: result.state,
+ result: {
+ ok: true,
+ label: result.entry.label,
+ paths: Object.keys(result.entry.files),
+ files: restoredFilesMap(result.filesToWrite, currentFiles),
+ },
+ };
+}
+
export function createPersistentEditHistoryStore({
projectId,
storage,
@@ -171,66 +245,26 @@ export function createPersistentEditHistoryStore({
});
},
async undo(callbacks: ApplyCallbacks): Promise {
- return mutate(async (currentState) => {
- const entry = currentState.undo[currentState.undo.length - 1];
- if (!entry) {
- return {
- state: currentState,
- result: { ok: false, reason: "empty" },
- };
- }
- const { currentFiles, currentHashes } = await readCurrentFileHashes(
- Object.keys(entry.files),
- callbacks.readFile,
- );
- const result = undoEditHistory(currentState, currentHashes, now());
- if (!result.ok) {
- return {
- state: currentState,
- result: { ok: false, reason: result.reason },
- };
- }
- await writeFilesWithRollback({
- files: result.filesToWrite,
- rollbackFiles: currentFiles,
- writeFile: callbacks.writeFile,
- });
- return {
- state: result.state,
- result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
- };
- });
+ return mutate((currentState) =>
+ applyHistoryStep(
+ currentState,
+ currentState.undo[currentState.undo.length - 1],
+ undoEditHistory,
+ now,
+ callbacks,
+ ),
+ );
},
async redo(callbacks: ApplyCallbacks): Promise {
- return mutate(async (currentState) => {
- const entry = currentState.redo[currentState.redo.length - 1];
- if (!entry) {
- return {
- state: currentState,
- result: { ok: false, reason: "empty" },
- };
- }
- const { currentFiles, currentHashes } = await readCurrentFileHashes(
- Object.keys(entry.files),
- callbacks.readFile,
- );
- const result = redoEditHistory(currentState, currentHashes, now());
- if (!result.ok) {
- return {
- state: currentState,
- result: { ok: false, reason: result.reason },
- };
- }
- await writeFilesWithRollback({
- files: result.filesToWrite,
- rollbackFiles: currentFiles,
- writeFile: callbacks.writeFile,
- });
- return {
- state: result.state,
- result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
- };
- });
+ return mutate((currentState) =>
+ applyHistoryStep(
+ currentState,
+ currentState.redo[currentState.redo.length - 1],
+ redoEditHistory,
+ now,
+ callbacks,
+ ),
+ );
},
};
}
diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts
index 657fb5dad9..b6759485b9 100644
--- a/packages/studio/src/hooks/usePreviewPersistence.ts
+++ b/packages/studio/src/hooks/usePreviewPersistence.ts
@@ -10,6 +10,14 @@ import type { EditHistoryKind } from "../utils/editHistory";
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
import { trackStudioEvent } from "../utils/studioTelemetry";
+import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapSoftReload";
+import { usePlayerStore } from "../player";
+
+/** The restore payload the undo/redo preview-sync consumes (from the history store). */
+interface HistoryPreviewRestore {
+ paths?: string[];
+ files?: Record;
+}
// ── Types ──
@@ -105,13 +113,12 @@ export function usePreviewPersistence({
writeProjectFile: _writeProjectFile,
recordEdit: _recordEdit,
previewIframeRef,
- activeCompPathRef: _activeCompPathRef,
+ activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview,
pendingTimelineEditPathRef,
}: UsePreviewPersistenceParams) {
void _recordEdit;
- void _activeCompPathRef;
const [domEditSaveQueuePaused, setDomEditSaveQueuePaused] = useState(null);
@@ -190,12 +197,23 @@ export function usePreviewPersistence({
// ── Sync preview after undo/redo ──
const syncHistoryPreviewAfterApply = useCallback(
- async (_paths: string[] | undefined) => {
- // Motion data is now stored in HTML attributes — any undo/redo that touches HTML
- // files triggers a full reload which picks up the changes automatically.
- reloadPreview();
+ async (restore: HistoryPreviewRestore) => {
+ // Prefer an in-place soft reload for a soft-reloadable restore (the change
+ // is confined to the active comp's element attributes / inline-style and/or
+ // its GSAP script) — a full iframe remount blanks the frame black and
+ // re-flashes the WebGL context. applyUndoRestoreToPreview syncs the reverted
+ // attributes onto the live DOM and re-runs the timeline at the SAME playhead,
+ // falling back to reloadPreview for anything structural (split/delete undo),
+ // multi-file, sub-comp, or a permanent soft-reload failure.
+ applyUndoRestoreToPreview(
+ previewIframeRef.current,
+ activeCompPathRef.current,
+ restore.files,
+ usePlayerStore.getState().currentTime,
+ reloadPreview,
+ );
},
- [reloadPreview],
+ [previewIframeRef, activeCompPathRef, reloadPreview],
);
// ── Migrate legacy studio-motion.json ──
diff --git a/packages/studio/src/hooks/useRazorSplit.test.ts b/packages/studio/src/hooks/useRazorSplit.test.ts
new file mode 100644
index 0000000000..33b3934485
--- /dev/null
+++ b/packages/studio/src/hooks/useRazorSplit.test.ts
@@ -0,0 +1,330 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { TimelineElement } from "../player";
+import { usePlayerStore } from "../player";
+import { useRazorSplit } from "./useRazorSplit";
+import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
+import { createEmptyEditHistory } from "../utils/editHistory";
+import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const ROOT_FILE = "index.html";
+const SUBCOMP_FILE = "scenes/intro.html";
+
+// A root-level clip lives in index.html and is authored in local time already.
+const rootElement: TimelineElement = {
+ id: "root-clip",
+ tag: "div",
+ start: 0,
+ duration: 10,
+ track: 0,
+ domId: "root-clip",
+ sourceFile: ROOT_FILE,
+ timingSource: "authored",
+};
+
+// An expanded sub-comp child: `start` is in MASTER coordinates (offset by the
+// host's master start), `sourceFile` is the sub-comp, and `expandedParentStart`
+// is that host master start. Its authored time in the file is start - basis.
+const expandedChild: TimelineElement = {
+ id: "child-clip",
+ tag: "div",
+ start: 2,
+ duration: 6,
+ track: 1,
+ domId: "child-clip",
+ sourceFile: SUBCOMP_FILE,
+ timingSource: "authored",
+ expandedParentStart: 2,
+};
+
+interface SplitRequest {
+ path: string;
+ splitTime: number;
+ elementStart: number;
+ elementDuration: number;
+}
+
+type SingleSplit = (element: TimelineElement, splitTime: number) => Promise;
+type SplitAll = (splitTime: number) => Promise;
+
+interface Harness {
+ splitRequests: SplitRequest[];
+ singleRef: { current: SingleSplit | undefined };
+ allRef: { current: SplitAll | undefined };
+ root: ReturnType;
+}
+
+function decodePathFromUrl(url: string, marker: string): string {
+ const encoded = url.slice(url.indexOf(marker) + marker.length);
+ return decodeURIComponent(encoded);
+}
+
+interface SplitBody {
+ splitTime: number;
+ elementStart: number;
+ elementDuration: number;
+}
+
+/**
+ * Fetch mock shared by both harnesses: GSAP mutations 400 (no script in fixtures),
+ * split-element writes a `` marker so `changed` is true, and file reads
+ * echo the in-memory `disk`. `onSplit` (when set) records each split request's body.
+ */
+function createSplitFetchMock(
+ disk: Record,
+ onSplit?: (path: string, body: SplitBody) => void,
+) {
+ return vi.fn(async (url: string, init?: RequestInit) => {
+ const u = String(url);
+ if (u.includes("/gsap-mutations/")) {
+ // No GSAP script in the fixtures — mirror the server's 400 response.
+ return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (u.includes("/file-mutations/split-element/")) {
+ const path = decodePathFromUrl(u, "/file-mutations/split-element/");
+ if (onSplit) onSplit(path, JSON.parse(String(init?.body)) as SplitBody);
+ // Return content that differs from the original so `changed` is true.
+ const after = `${disk[path]}`;
+ disk[path] = after; // server writes the split to disk
+ return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (u.includes("/files/")) {
+ const path = decodePathFromUrl(u, "/files/").replace(/\?.*$/, "");
+ return new Response(JSON.stringify({ content: disk[path] ?? "" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ void init;
+ throw new Error(`unexpected fetch: ${u}`);
+ });
+}
+
+/** Mount a render-only probe component into a fresh host and return its root. */
+function mountComponent(Component: React.ComponentType): ReturnType {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => root.render(React.createElement(Component)));
+ return root;
+}
+
+function mountRazorSplit(): Harness {
+ const disk: Record = {
+ [ROOT_FILE]: `
`,
+ [SUBCOMP_FILE]: `
`,
+ };
+ const splitRequests: SplitRequest[] = [];
+
+ const fetchMock = createSplitFetchMock(disk, (path, body) => {
+ splitRequests.push({
+ path,
+ splitTime: body.splitTime,
+ elementStart: body.elementStart,
+ elementDuration: body.elementDuration,
+ });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const singleRef: { current: SingleSplit | undefined } = { current: undefined };
+ const allRef: { current: SplitAll | undefined } = { current: undefined };
+
+ function Component() {
+ const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
+ projectId: "p1",
+ activeCompPath: ROOT_FILE,
+ showToast: () => {},
+ writeProjectFile: async (path, content) => {
+ disk[path] = content;
+ },
+ recordEdit: async () => {},
+ domEditSaveTimestampRef: { current: 0 },
+ reloadPreview: () => {},
+ });
+ singleRef.current = handleRazorSplit;
+ allRef.current = handleRazorSplitAll;
+ return null;
+ }
+
+ const root = mountComponent(Component);
+ return { splitRequests, singleRef, allRef, root };
+}
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ usePlayerStore.setState({ elements: [] });
+ vi.unstubAllGlobals();
+});
+
+describe("useRazorSplit — sub-comp coordinate rebasing", () => {
+ let harness: Harness;
+ beforeEach(() => {
+ harness = mountRazorSplit();
+ });
+ afterEach(() => {
+ act(() => harness.root.unmount());
+ });
+
+ it("rebases an expanded sub-comp child split into the sub-comp's local time", async () => {
+ // Master split time T = 5; host starts at 2, so local time is 3.
+ await act(async () => {
+ await harness.singleRef.current!(expandedChild, 5);
+ });
+
+ expect(harness.splitRequests).toHaveLength(1);
+ const req = harness.splitRequests[0];
+ expect(req.path).toBe(SUBCOMP_FILE);
+ expect(req.splitTime).toBe(3); // 5 - expandedParentStart(2), NOT 5
+ expect(req.elementStart).toBe(0); // 2 - 2, NOT the master start 2
+ expect(req.elementDuration).toBe(6);
+ });
+
+ it("leaves a root-level clip's coordinates unchanged", async () => {
+ // Master split time T = 4; no expandedParentStart, so nothing is rebased.
+ await act(async () => {
+ await harness.singleRef.current!(rootElement, 4);
+ });
+
+ expect(harness.splitRequests).toHaveLength(1);
+ const req = harness.splitRequests[0];
+ expect(req.path).toBe(ROOT_FILE);
+ expect(req.splitTime).toBe(4);
+ expect(req.elementStart).toBe(0);
+ expect(req.elementDuration).toBe(10);
+ });
+
+ it("rebases each element individually in a mixed razor-split-all gesture", async () => {
+ usePlayerStore.setState({ elements: [rootElement, expandedChild] });
+
+ // Master split time T = 3 lies inside both clips.
+ await act(async () => {
+ await harness.allRef.current!(3);
+ });
+
+ expect(harness.splitRequests).toHaveLength(2);
+ const rootReq = harness.splitRequests.find((r) => r.path === ROOT_FILE)!;
+ const childReq = harness.splitRequests.find((r) => r.path === SUBCOMP_FILE)!;
+
+ // Root clip: already local — master coordinates pass through untouched.
+ expect(rootReq.splitTime).toBe(3);
+ expect(rootReq.elementStart).toBe(0);
+
+ // Expanded child: rebased by its OWN expandedParentStart, not the root's.
+ expect(childReq.splitTime).toBe(1); // 3 - 2
+ expect(childReq.elementStart).toBe(0); // 2 - 2
+ });
+});
+
+// ── Bug 1: split must resync the SDK session so undo isn't refused ────────────
+
+const memoryStorage = (): EditHistoryStorageAdapter => {
+ const store = new Map();
+ return {
+ load: async (k) => store.get(k) ?? null,
+ save: async (k, v) => {
+ store.set(k, v);
+ },
+ } as unknown as EditHistoryStorageAdapter;
+};
+
+interface UndoHarness {
+ singleRef: { current: SingleSplit | undefined };
+ disk: Record;
+ store: ReturnType;
+ forceReloadSdkSession: ReturnType;
+ root: ReturnType;
+}
+
+function mountRazorSplitWithHistory(): UndoHarness {
+ const disk: Record = {
+ [ROOT_FILE]: `
`,
+ };
+ const store = createPersistentEditHistoryStore({
+ projectId: "p1",
+ storage: memoryStorage(),
+ initialState: createEmptyEditHistory(),
+ now: () => Date.now(),
+ onChange: () => {},
+ });
+ const forceReloadSdkSession = vi.fn();
+
+ const fetchMock = createSplitFetchMock(disk);
+ vi.stubGlobal("fetch", fetchMock);
+
+ const singleRef: { current: SingleSplit | undefined } = { current: undefined };
+ function Component() {
+ const { handleRazorSplit } = useRazorSplit({
+ projectId: "p1",
+ activeCompPath: ROOT_FILE,
+ showToast: () => {},
+ writeProjectFile: async (path, content) => {
+ disk[path] = content;
+ },
+ recordEdit: (input) => store.recordEdit(input),
+ domEditSaveTimestampRef: { current: 0 },
+ reloadPreview: () => {},
+ forceReloadSdkSession,
+ });
+ singleRef.current = handleRazorSplit;
+ return null;
+ }
+ const root = mountComponent(Component);
+ return { singleRef, disk, store, forceReloadSdkSession, root };
+}
+
+describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
+ let h: UndoHarness;
+ beforeEach(() => {
+ h = mountRazorSplitWithHistory();
+ });
+ afterEach(() => {
+ act(() => h.root.unmount());
+ });
+
+ const readFile = () => ({
+ readFile: async (p: string) => h.disk[p],
+ writeFile: async (p: string, c: string) => {
+ h.disk[p] = c;
+ },
+ });
+
+ it("resyncs the SDK session after a split (matches every other server-write path)", async () => {
+ await act(async () => {
+ await h.singleRef.current!(rootElement, 4);
+ });
+ expect(h.forceReloadSdkSession).toHaveBeenCalledTimes(1);
+ });
+
+ it("applies undo after a split without an external-change refusal", async () => {
+ await act(async () => {
+ await h.singleRef.current!(rootElement, 4);
+ });
+ const result = await h.store.undo(readFile());
+ expect(result.ok).toBe(true);
+ expect(result.reason).toBeUndefined();
+ // The file is restored to its pre-split bytes.
+ expect(h.disk[ROOT_FILE]).not.toContain("");
+ });
+
+ it("still trips the guard when the file is edited externally after a split", async () => {
+ await act(async () => {
+ await h.singleRef.current!(rootElement, 4);
+ });
+ // Simulate the user editing the file in their own editor after the split.
+ h.disk[ROOT_FILE] = `${h.disk[ROOT_FILE]}`;
+ const result = await h.store.undo(readFile());
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe("content-mismatch");
+ });
+});
diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts
index 7fd8a72a69..f0425ad55b 100644
--- a/packages/studio/src/hooks/useRazorSplit.ts
+++ b/packages/studio/src/hooks/useRazorSplit.ts
@@ -20,6 +20,16 @@ interface UseRazorSplitOptions {
recordEdit: (input: RecordEditInput) => Promise;
domEditSaveTimestampRef: React.MutableRefObject;
reloadPreview: () => void;
+ /**
+ * Resync the in-memory SDK session after the server-side split write (the
+ * split-element / split-gsap endpoints write the file directly, so the SDK's
+ * linkedom doc is now stale). Without this, a later SDK-routed edit serializes
+ * the PRE-split doc and reverts the split on disk — the undo baseline recorded
+ * here then no longer matches disk, so Cmd+Z trips the "changed externally"
+ * guard. Every other server-side-write timeline path (move / resize / delete /
+ * drop / visibility) already calls this; the split path was the sole omission.
+ */
+ forceReloadSdkSession?: () => void;
isRecordingRef?: React.RefObject;
}
@@ -122,13 +132,23 @@ async function executeSplit(
const originalContent = await readFileContent(pid, targetPath);
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
+ // An expanded sub-comp child arrives in MASTER-timeline coordinates — both its
+ // `start` and the incoming `splitTime` are offset by the host's master start
+ // (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are
+ // authored in LOCAL time. Rebase both onto local time before the server patches
+ // the file, exactly as TimelinePane.handleSplitElement does for non-razor edits.
+ // Root-level clips (no expandedParentStart) are already local, so pass through.
+ const basis = element.expandedParentStart;
+ const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis);
+ const localElementStart = basis === undefined ? element.start : element.start - basis;
+
const splitResult = await splitHtmlElement(
pid,
targetPath,
patchTarget,
- splitTime,
+ localSplitTime,
newId,
- element.start,
+ localElementStart,
element.duration,
);
if (!splitResult.ok) throw new Error("Failed to split clip.");
@@ -147,8 +167,8 @@ async function executeSplit(
targetPath,
element.domId,
newId,
- splitTime,
- element.start,
+ localSplitTime,
+ localElementStart,
element.duration,
);
if (gsapResult.content) patchedContent = gsapResult.content;
@@ -172,6 +192,7 @@ export function useRazorSplit({
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
+ forceReloadSdkSession,
isRecordingRef,
}: UseRazorSplitOptions) {
const projectIdRef = useRef(projectId);
@@ -208,6 +229,11 @@ export function useRazorSplit({
recordEdit,
});
+ // Server wrote the file; resync the stale in-memory SDK doc BEFORE the
+ // reload so a later SDK-routed edit can't serialize the pre-split doc and
+ // revert this split (which would desync disk from the undo baseline just
+ // recorded → Cmd+Z reports "changed externally").
+ forceReloadSdkSession?.();
reloadPreview();
trackStudioRazorSplit({ mode: "single", count: 1 });
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
@@ -229,6 +255,7 @@ export function useRazorSplit({
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
+ forceReloadSdkSession,
isRecordingRef,
],
);
@@ -288,6 +315,9 @@ export function useRazorSplit({
),
});
+ // Resync the stale SDK doc after the batched server write (see the
+ // single-split path above for why this precedes the reload).
+ forceReloadSdkSession?.();
reloadPreview();
trackStudioRazorSplit({ mode: "all", count: splitCount });
showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
@@ -303,6 +333,7 @@ export function useRazorSplit({
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
+ forceReloadSdkSession,
isRecordingRef,
],
);
diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts
index a8c5d0e0b8..460028bdfb 100644
--- a/packages/studio/src/hooks/useStudioContextValue.ts
+++ b/packages/studio/src/hooks/useStudioContextValue.ts
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
import type { StudioContextValue } from "../contexts/StudioContext";
import type { RightInspectorPanes } from "../utils/studioHelpers";
+import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
+import { usePlayerStore } from "../player";
interface StudioContextInput {
projectId: string;
@@ -34,8 +36,6 @@ interface StudioContextInput {
waitForPendingDomEditSaves: () => Promise;
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
refreshPreviewDocumentVersion: () => void;
- timelineVisible: boolean;
- toggleTimelineVisibility: () => void;
}
// fallow-ignore-next-line complexity
@@ -61,8 +61,6 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
waitForPendingDomEditSaves: input.waitForPendingDomEditSaves,
handlePreviewIframeRef: input.handlePreviewIframeRef,
refreshPreviewDocumentVersion: input.refreshPreviewDocumentVersion,
- timelineVisible: input.timelineVisible,
- toggleTimelineVisibility: input.toggleTimelineVisibility,
};
}
@@ -108,7 +106,7 @@ export function useInspectorState(
}
// fallow-ignore-next-line complexity
-export function useDragOverlay(onImportFiles: (files: FileList) => void) {
+function useDragOverlay(onImportFiles: (files: FileList) => void) {
const [active, setActive] = useState(false);
const counterRef = useRef(0);
const onDragOver = useCallback((e: DragEvent) => {
@@ -137,3 +135,15 @@ export function useDragOverlay(onImportFiles: (files: FileList) => void) {
);
return { active, onDragOver, onDragEnter, onDragLeave, onDrop };
}
+
+/** Global OS file drop: imports and places at the playhead position. */
+export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandler) {
+ const onDrop = useCallback(
+ (files: FileList) => {
+ const start = usePlayerStore.getState().currentTime;
+ void handleTimelineFileDrop(Array.from(files), { start, track: 0 });
+ },
+ [handleTimelineFileDrop],
+ );
+ return useDragOverlay(onDrop);
+}
diff --git a/packages/studio/src/hooks/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts
index 7407c38e92..e67e6d346a 100644
--- a/packages/studio/src/hooks/useStudioUrlState.ts
+++ b/packages/studio/src/hooks/useStudioUrlState.ts
@@ -20,7 +20,6 @@ interface UseStudioUrlStateParams {
previewIframeRef: React.MutableRefObject;
rightPanelTab: RightPanelTab;
rightCollapsed: boolean;
- timelineVisible: boolean;
activeCompPathHydrated: boolean;
domEditSelection: DomEditSelection | null;
buildDomSelectionFromTarget: (
@@ -66,7 +65,6 @@ export function useStudioUrlState({
previewIframeRef,
rightPanelTab,
rightCollapsed,
- timelineVisible,
activeCompPathHydrated,
domEditSelection,
buildDomSelectionFromTarget,
@@ -91,12 +89,12 @@ export function useStudioUrlState({
currentTime: stableTimeRef.current,
rightPanelTab,
rightCollapsed,
- timelineVisible,
+ timelineVisible: null,
selection: hydratedSelectionRef.current
? toPersistedSelection(domEditSelection)
: pendingSelectionRef.current,
}),
- [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
+ [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab],
);
// Resolve a URL selection to a live element and apply it. Shared by the initial
diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx
deleted file mode 100644
index cd3f88fdc6..0000000000
--- a/packages/studio/src/hooks/useTimelineEditing.test.tsx
+++ /dev/null
@@ -1,963 +0,0 @@
-// @vitest-environment happy-dom
-
-import React, { act, useRef } from "react";
-import { createRoot } from "react-dom/client";
-import { openComposition } from "@hyperframes/sdk";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { usePlayerStore, type TimelineElement } from "../player";
-import { useElementLifecycleOps } from "./useElementLifecycleOps";
-import { useTimelineEditing } from "./useTimelineEditing";
-
-(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
-
-type ZIndexEntry = {
- element: HTMLElement;
- zIndex: number;
- id?: string;
- selector?: string;
- selectorIndex?: number;
- sourceFile: string;
-};
-
-afterEach(() => {
- document.body.innerHTML = "";
- usePlayerStore.getState().reset();
- vi.restoreAllMocks();
- vi.unstubAllGlobals();
-});
-
-function createPreviewIframe(
- clips: Array<{
- id: string;
- track: number;
- style?: string;
- }> = [
- { id: "front", track: 0 },
- { id: "back", track: 1 },
- ],
-): HTMLIFrameElement {
- const iframe = document.createElement("iframe");
- document.body.append(iframe);
- const doc = iframe.contentDocument;
- if (!doc) throw new Error("Expected iframe document");
- doc.body.innerHTML = clips
- .map(
- (clip) =>
- `
`,
- )
- .join("\n");
- return iframe;
-}
-
-function timelineElement(input: {
- id: string;
- track: number;
- zIndex: number;
- tag?: string;
- start?: number;
- duration?: number;
- sourceFile?: string;
-}): TimelineElement {
- return {
- id: input.id,
- domId: input.id,
- hfId: `hf-${input.id}`,
- tag: input.tag ?? "div",
- start: input.start ?? 0,
- duration: input.duration ?? 2,
- track: input.track,
- zIndex: input.zIndex,
- stackingContextId: "root",
- parentCompositionId: null,
- compositionAncestors: ["root"],
- sourceFile: input.sourceFile ?? "index.html",
- timingSource: "authored",
- };
-}
-
-function renderTimelineEditingHook(input: {
- timelineElements: TimelineElement[];
- iframe: HTMLIFrameElement;
- onZIndexCommit: (entries: ZIndexEntry[]) => Promise;
- projectId?: string | null;
- writeProjectFile?: (path: string, content: string) => Promise;
- recordEdit?: (input: {
- label: string;
- kind: string;
- coalesceKey?: string;
- files: Record;
- }) => Promise;
- reloadPreview?: () => void;
- sdkSession?: Awaited> | null;
- forceReloadSdkSession?: () => void;
-}): {
- move: ReturnType["handleTimelineElementMove"];
- resize: ReturnType["handleTimelineElementResize"];
- groupMove: ReturnType["handleTimelineGroupMove"];
- groupResize: ReturnType["handleTimelineGroupResize"];
- unmount: () => void;
-} {
- let move: ReturnType["handleTimelineElementMove"] | null = null;
- let resize: ReturnType["handleTimelineElementResize"] | null = null;
- let groupMove: ReturnType["handleTimelineGroupMove"] | null = null;
- let groupResize: ReturnType["handleTimelineGroupResize"] | null = null;
-
- function Harness() {
- const commitRef = useRef(input.onZIndexCommit);
- commitRef.current = input.onZIndexCommit;
- const hook = useTimelineEditing({
- projectId: input.projectId ?? null,
- activeCompPath: "index.html",
- timelineElements: input.timelineElements,
- showToast: vi.fn(),
- writeProjectFile: input.writeProjectFile ?? vi.fn(),
- recordEdit: input.recordEdit ?? vi.fn(),
- domEditSaveTimestampRef: { current: 0 },
- reloadPreview: input.reloadPreview ?? vi.fn(),
- previewIframeRef: { current: input.iframe },
- pendingTimelineEditPathRef: { current: new Set() },
- uploadProjectFiles: vi.fn(),
- sdkSession: input.sdkSession,
- forceReloadSdkSession: input.forceReloadSdkSession,
- handleDomZIndexReorderCommitRef: commitRef,
- });
- move = hook.handleTimelineElementMove;
- resize = hook.handleTimelineElementResize;
- groupMove = hook.handleTimelineGroupMove;
- groupResize = hook.handleTimelineGroupResize;
- return null;
- }
-
- const host = document.createElement("div");
- document.body.append(host);
- const root = createRoot(host);
- act(() => {
- root.render( );
- });
-
- 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());
- },
- };
-}
-
-type TimelineRecordEdit = NonNullable<
- Parameters[0]["recordEdit"]
->;
-
-function renderTimelineEditingHookWithLifecycle(input: {
- timelineElements: TimelineElement[];
- iframe: HTMLIFrameElement;
- commitPositionPatchToHtml: ReturnType Promise>>;
-}): {
- move: ReturnType["handleTimelineElementMove"];
- unmount: () => void;
-} {
- let move: ReturnType["handleTimelineElementMove"] | null = null;
-
- function Harness() {
- const lifecycle = useElementLifecycleOps({
- activeCompPath: "index.html",
- showToast: vi.fn(),
- writeProjectFile: vi.fn(),
- domEditSaveTimestampRef: { current: 0 },
- editHistory: { recordEdit: vi.fn() },
- projectIdRef: { current: "p1" },
- reloadPreview: vi.fn(),
- clearDomSelection: vi.fn(),
- commitPositionPatchToHtml: input.commitPositionPatchToHtml,
- });
- const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);
- commitRef.current = lifecycle.handleDomZIndexReorderCommit;
- const hook = useTimelineEditing({
- projectId: null,
- activeCompPath: "index.html",
- timelineElements: input.timelineElements,
- showToast: vi.fn(),
- writeProjectFile: vi.fn(),
- recordEdit: vi.fn(),
- domEditSaveTimestampRef: { current: 0 },
- reloadPreview: vi.fn(),
- previewIframeRef: { current: input.iframe },
- pendingTimelineEditPathRef: { current: new Set() },
- uploadProjectFiles: vi.fn(),
- handleDomZIndexReorderCommitRef: commitRef,
- });
- move = hook.handleTimelineElementMove;
- return null;
- }
-
- const host = document.createElement("div");
- document.body.append(host);
- const root = createRoot(host);
- act(() => {
- root.render( );
- });
-
- if (!move) throw new Error("Expected hook to expose move handler");
- return {
- move,
- unmount: () => {
- act(() => root.unmount());
- },
- };
-}
-
-function jsonResponse(body: unknown): Response {
- return new Response(JSON.stringify(body), {
- status: 200,
- headers: { "content-type": "application/json" },
- });
-}
-
-function requestUrl(input: Parameters[0]): string {
- if (typeof input === "string") return input;
- if (input instanceof URL) return input.toString();
- return input.url;
-}
-
-async function flushAsyncWork(): Promise {
- for (let i = 0; i < 8; i += 1) {
- await Promise.resolve();
- }
-}
-
-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 = [
- ``,
- ].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>(async () => {});
- const recordEdit = vi.fn(async () => {});
- const forceReloadSdkSession = vi.fn();
- const reloadPreview = vi.fn();
- const iframeWindow = iframe.contentWindow;
- if (!iframeWindow) throw new Error("Expected iframe window");
- const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
- vi.stubGlobal(
- "fetch",
- vi.fn(async (input: Parameters[0]): Promise => {
- const url = requestUrl(input);
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
- return jsonResponse({ ok: true, mutated: false });
- }
- throw new Error(`Unexpected fetch: ${url}`);
- }),
- );
- usePlayerStore.getState().setDuration(4);
- const { move, unmount } = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe,
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile,
- recordEdit,
- sdkSession,
- forceReloadSdkSession,
- reloadPreview,
- });
-
- await act(async () => {
- await move(clip, { start: 3, track: clip.track });
- });
-
- expect(setTimingSpy).not.toHaveBeenCalled();
- expect(writeProjectFile.mock.calls[0]![1]).toContain(
- 'data-composition-id="main" data-duration="5"',
- );
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
- expect(usePlayerStore.getState().duration).toBe(5);
- expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
- expect(reloadPreview).not.toHaveBeenCalled();
- expect(postMessageSpy).toHaveBeenCalledWith(
- {
- source: "hf-parent",
- type: "control",
- action: "set-root-duration",
- durationSeconds: 5,
- },
- "*",
- );
-
- unmount();
- });
-
- it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => {
- const source = [
- ``,
- ].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>(async () => {});
- const recordEdit = vi.fn(async () => {});
- const forceReloadSdkSession = vi.fn();
- const reloadPreview = vi.fn();
- const iframeWindow = iframe.contentWindow;
- if (!iframeWindow) throw new Error("Expected iframe window");
- const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
- vi.stubGlobal(
- "fetch",
- vi.fn(async (input: Parameters[0]): Promise => {
- const url = requestUrl(input);
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
- return jsonResponse({ ok: true, mutated: false });
- }
- throw new Error(`Unexpected fetch: ${url}`);
- }),
- );
- usePlayerStore.getState().setDuration(4);
- const { resize, unmount } = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe,
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile,
- recordEdit,
- sdkSession,
- forceReloadSdkSession,
- reloadPreview,
- });
-
- await act(async () => {
- await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
- });
-
- expect(setTimingSpy).not.toHaveBeenCalled();
- expect(writeProjectFile.mock.calls[0]![1]).toContain(
- 'data-composition-id="main" data-duration="5"',
- );
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"> ');
- expect(usePlayerStore.getState().duration).toBe(5);
- expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
- expect(reloadPreview).not.toHaveBeenCalled();
- expect(postMessageSpy).toHaveBeenCalledWith(
- {
- source: "hf-parent",
- type: "control",
- action: "set-root-duration",
- durationSeconds: 5,
- },
- "*",
- );
-
- unmount();
- });
-
- it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
- const iframe = createPreviewIframe([
- { id: "front", track: 0, style: "position: relative; z-index: 10" },
- { id: "back", track: 2, style: "position: relative; z-index: 1" },
- ]);
- const front = timelineElement({ id: "front", track: 0, zIndex: 10 });
- const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise
>().mockResolvedValue(undefined);
- const { move, unmount } = renderTimelineEditingHook({
- timelineElements: [front, back],
- iframe,
- onZIndexCommit: commit,
- });
-
- await act(async () => {
- await move(back, {
- start: back.start,
- track: back.track,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "onto", layerId: "layer-front" },
- zIndexChanges: [{ key: "back", zIndex: 10 }],
- },
- });
- });
-
- const doc = iframe.contentDocument;
- if (!doc) throw new Error("Expected iframe document");
-
- expect(commit).toHaveBeenCalledTimes(1);
- expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
- ["back", 10],
- ]);
- expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2");
-
- unmount();
- });
-
- it("never writes z-index when the dragged clip is audio (no visual layer)", async () => {
- const iframe = createPreviewIframe([
- { id: "front", track: 0 },
- { id: "music", track: 1 },
- ]);
- const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
- const music = timelineElement({ id: "music", track: 1, zIndex: 0, tag: "audio" });
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined);
- const { move, unmount } = renderTimelineEditingHook({
- timelineElements: [front, music],
- iframe,
- onZIndexCommit: commit,
- });
-
- await act(async () => {
- await move(music, {
- start: music.start,
- track: music.track,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "onto", layerId: "layer-front" },
- zIndexChanges: [{ key: "music", zIndex: 2 }],
- },
- });
- });
-
- expect(commit).not.toHaveBeenCalled();
-
- unmount();
- });
-
- it("commits only the minimum z-index changes resolved by the timeline drag", async () => {
- const iframe = createPreviewIframe([
- { id: "front", track: 0, style: "position: relative; z-index: 2" },
- { id: "back", track: 1, style: "position: relative; z-index: 1" },
- { id: "dragged", track: 2, style: "position: relative; z-index: 0" },
- ]);
- const front = timelineElement({ id: "front", track: 0, zIndex: 2 });
- const back = timelineElement({ id: "back", track: 1, zIndex: 1 });
- const dragged = timelineElement({ id: "dragged", track: 2, zIndex: 0 });
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined);
- const { move, unmount } = renderTimelineEditingHook({
- timelineElements: [front, back, dragged],
- iframe,
- onZIndexCommit: commit,
- });
-
- await act(async () => {
- await move(dragged, {
- start: dragged.start,
- track: dragged.track,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
- zIndexChanges: [
- { key: "dragged", zIndex: 2 },
- { key: "front", zIndex: 3 },
- ],
- },
- });
- });
-
- expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
- ["dragged", 2],
- ["front", 3],
- ]);
-
- unmount();
- });
-
- it("uses the shared lifecycle commit so static clips receive position relative", async () => {
- const iframe = createPreviewIframe([
- { id: "front", track: 0, style: "position: static" },
- { id: "back", track: 1, style: "position: static" },
- ]);
- const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
- const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
- const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const { move, unmount } = renderTimelineEditingHookWithLifecycle({
- timelineElements: [front, back],
- iframe,
- commitPositionPatchToHtml,
- });
-
- await act(async () => {
- await move(back, {
- start: back.start,
- track: back.track,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "above", layerId: "front" },
- zIndexChanges: [{ key: "back", zIndex: 2 }],
- },
- });
- await flushAsyncWork();
- });
-
- expect(commitPositionPatchToHtml).toHaveBeenCalled();
- expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
- { type: "inline-style", property: "z-index", value: "2" },
- { type: "inline-style", property: "position", value: "relative" },
- ]);
-
- unmount();
- });
-
- it("rejects and rolls back DOM and store z-index changes when a reorder save fails", async () => {
- const iframe = createPreviewIframe([
- { id: "front", track: 0, style: "position: relative; z-index: 7" },
- { id: "back", track: 1, style: "position: static" },
- ]);
- const front = timelineElement({ id: "front", track: 0, zIndex: 7 });
- const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
- usePlayerStore.getState().setElements([
- { ...front, hasExplicitZIndex: true },
- { ...back, hasExplicitZIndex: false },
- ]);
- const saveError = new Error("save failed");
- const commitPositionPatchToHtml = vi
- .fn<(...args: unknown[]) => Promise>()
- .mockResolvedValueOnce(undefined)
- .mockRejectedValueOnce(saveError);
- const { move, unmount } = renderTimelineEditingHookWithLifecycle({
- timelineElements: [front, back],
- iframe,
- commitPositionPatchToHtml,
- });
- const doc = iframe.contentDocument;
- if (!doc) throw new Error("Expected iframe document");
- const frontElement = doc.getElementById("front") as HTMLElement | null;
- const backElement = doc.getElementById("back") as HTMLElement | null;
- if (!frontElement || !backElement) throw new Error("Expected reordered elements");
-
- let rejection: unknown;
- await act(async () => {
- try {
- await move(back, {
- start: back.start,
- track: back.track,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "above", layerId: "front" },
- zIndexChanges: [
- { key: "front", zIndex: 2 },
- { key: "back", zIndex: 5 },
- ],
- },
- });
- } catch (error) {
- rejection = error;
- }
- await flushAsyncWork();
- });
-
- expect(rejection).toBe(saveError);
- expect(frontElement.style.zIndex).toBe("7");
- expect(frontElement.style.position).toBe("relative");
- expect(backElement.style.zIndex).toBe("");
- expect(backElement.style.position).toBe("static");
- const storeEntries = usePlayerStore.getState().elements;
- expect(storeEntries.find((entry) => entry.id === "front")).toMatchObject({
- zIndex: 7,
- hasExplicitZIndex: true,
- });
- expect(storeEntries.find((entry) => entry.id === "back")).toMatchObject({
- zIndex: 0,
- hasExplicitZIndex: false,
- });
-
- unmount();
- });
-
- it("waits for every lifecycle z-index save before resolving a reorder", async () => {
- const iframe = createPreviewIframe([
- { id: "front", track: 0, style: "position: relative; z-index: 1" },
- { id: "back", track: 1, style: "position: relative; z-index: 0" },
- ]);
- const front = timelineElement({ id: "front", track: 0, zIndex: 1 });
- const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
- let releaseFirst!: () => void;
- let releaseSecond!: () => void;
- const firstSave = new Promise((resolve) => {
- releaseFirst = resolve;
- });
- const secondSave = new Promise((resolve) => {
- releaseSecond = resolve;
- });
- const commitPositionPatchToHtml = vi
- .fn<(...args: unknown[]) => Promise>()
- .mockReturnValueOnce(firstSave)
- .mockReturnValueOnce(secondSave);
- const { move, unmount } = renderTimelineEditingHookWithLifecycle({
- timelineElements: [front, back],
- iframe,
- commitPositionPatchToHtml,
- });
- let settled = false;
-
- let movePromise!: Promise;
- await act(async () => {
- movePromise = move(back, {
- start: back.start,
- track: back.track,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "above", layerId: "front" },
- zIndexChanges: [
- { key: "front", zIndex: 2 },
- { key: "back", zIndex: 3 },
- ],
- },
- }).then(() => {
- settled = true;
- });
- await flushAsyncWork();
- });
-
- expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(2);
- expect(settled).toBe(false);
-
- await act(async () => {
- releaseFirst();
- await flushAsyncWork();
- });
- expect(settled).toBe(false);
-
- await act(async () => {
- releaseSecond();
- await movePromise;
- await flushAsyncWork();
- });
- expect(settled).toBe(true);
-
- unmount();
- });
-
- it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => {
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined);
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const recordEdit = vi.fn(async (_entry) => {});
- const reloadPreview = vi.fn();
- const fetchMock = vi.fn(
- async (
- input: Parameters[0],
- _init?: Parameters[1],
- ): Promise => {
- const url = requestUrl(input);
- if (url.includes("/api/projects/p1/files/")) {
- return jsonResponse({
- content: '
',
- });
- }
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
- return jsonResponse({ ok: true });
- }
- throw new Error(`Unexpected fetch: ${url}`);
- },
- );
- vi.stubGlobal("fetch", fetchMock);
- const { move, unmount } = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe,
- onZIndexCommit: commit,
- projectId: "p1",
- writeProjectFile,
- recordEdit,
- reloadPreview,
- });
-
- await act(async () => {
- await move(clip, { start: 1.25, track: clip.track });
- });
-
- const doc = iframe.contentDocument;
- if (!doc) throw new Error("Expected iframe document");
- expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25");
- expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0");
- expect(commit).not.toHaveBeenCalled();
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
- expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
- expect(
- fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
- ).toBe(true);
-
- unmount();
- });
-
- it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
- const iframe = createPreviewIframe([
- { id: "clip", track: 0, style: "position: relative; z-index: 0" },
- ]);
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
- // Gate the z-index commit so we can observe whether the timing write waits.
- let releaseCommit!: () => void;
- const commitGate = new Promise((resolve) => {
- releaseCommit = resolve;
- });
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockReturnValue(commitGate);
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const fetchMock = vi.fn(async (input: Parameters[0]): Promise => {
- const url = requestUrl(input);
- if (url.includes("/api/projects/p1/files/")) {
- return jsonResponse({
- content: '
',
- });
- }
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
- throw new Error(`Unexpected fetch: ${url}`);
- });
- vi.stubGlobal("fetch", fetchMock);
- const { move, unmount } = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe,
- onZIndexCommit: commit,
- projectId: "p1",
- writeProjectFile,
- recordEdit: vi.fn(async () => {}),
- });
-
- // Diagonal drag: both a time move (start change) and a restack (z-index change).
- let movePromise!: Promise;
- await act(async () => {
- movePromise = move(clip, {
- start: 1.25,
- track: clip.track,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "onto", layerId: "layer-clip" },
- zIndexChanges: [{ key: "clip", zIndex: 5 }],
- },
- });
- await flushAsyncWork();
- });
-
- // The z-index commit is in flight but gated; the full-file timing write must
- // not have run yet, or it would overwrite the file without the z-index change.
- expect(commit).toHaveBeenCalledTimes(1);
- expect(writeProjectFile).not.toHaveBeenCalled();
-
- // Release the z-index commit → the timing write now proceeds, on top of it.
- await act(async () => {
- releaseCommit();
- await movePromise;
- await flushAsyncWork();
- });
- expect(writeProjectFile).toHaveBeenCalled();
-
- unmount();
- });
-
- it("persists a same-file group move with one write containing every clip timing", async () => {
- const source = [
- '
',
- '
',
- '
',
- ].join("\n");
- const iframe = createPreviewIframe([
- { id: "a", track: 0 },
- { id: "b", track: 1 },
- { id: "c", track: 2 },
- ]);
- const clips = [
- timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }),
- timelineElement({ id: "b", track: 1, zIndex: 0, start: 1, duration: 1 }),
- timelineElement({ id: "c", track: 2, zIndex: 0, start: 2, duration: 1 }),
- ];
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const recordEdit = vi.fn(async (_entry) => {});
- vi.stubGlobal(
- "fetch",
- vi.fn(async (input: Parameters[0]): Promise => {
- 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}`);
- }),
- );
- const { groupMove, unmount } = renderTimelineEditingHook({
- timelineElements: clips,
- iframe,
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile,
- recordEdit,
- });
-
- await act(async () => {
- await groupMove([
- { element: clips[0], start: 0.5 },
- { element: clips[1], start: 1.5 },
- { element: clips[2], start: 2.5 },
- ]);
- });
-
- expect(writeProjectFile).toHaveBeenCalledTimes(1);
- const written = writeProjectFile.mock.calls[0]![1] as string;
- expect(written).toContain('id="a" data-start="0.5"');
- expect(written).toContain('id="b" data-start="1.5"');
- expect(written).toContain('id="c" data-start="2.5"');
- expect(recordEdit).toHaveBeenCalledTimes(1);
- expect(Object.keys(recordEdit.mock.calls[0]![0].files)).toEqual(["index.html"]);
-
- unmount();
- });
-
- it("partitions a group move by source file while keeping one undo entry", async () => {
- const files: Record = {
- "index.html": '
',
- "scene.html": '
',
- };
- const iframe = createPreviewIframe([
- { id: "a", track: 0 },
- { id: "b", track: 1 },
- ]);
- const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
- const b = timelineElement({
- id: "b",
- track: 1,
- zIndex: 0,
- start: 1,
- duration: 1,
- sourceFile: "scene.html",
- });
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const recordEdit = vi.fn(async (_entry) => {});
- vi.stubGlobal(
- "fetch",
- vi.fn(async (input: Parameters[0]): Promise => {
- 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}`);
- }),
- );
- const { groupMove, unmount } = renderTimelineEditingHook({
- timelineElements: [a, b],
- iframe,
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile,
- recordEdit,
- });
-
- await act(async () => {
- await groupMove([
- { element: a, start: 0.25 },
- { element: b, start: 1.25 },
- ]);
- });
-
- expect(writeProjectFile.mock.calls.map((call) => call[0])).toEqual([
- "index.html",
- "scene.html",
- ]);
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0.25"');
- expect(writeProjectFile.mock.calls[1]![1]).toContain('data-start="1.25"');
- expect(recordEdit).toHaveBeenCalledTimes(1);
- expect(Object.keys(recordEdit.mock.calls[0]![0].files).sort()).toEqual([
- "index.html",
- "scene.html",
- ]);
-
- unmount();
- });
-
- it("waits for a z-index commit before the group timing write", async () => {
- const source = '
';
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
- let releaseCommit!: () => void;
- const zIndexCommit = new Promise((resolve) => {
- releaseCommit = resolve;
- });
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- vi.stubGlobal(
- "fetch",
- vi.fn(async (input: Parameters[0]): Promise => {
- 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}`);
- }),
- );
- const { groupMove, unmount } = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe,
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile,
- recordEdit: vi.fn(async () => {}),
- });
-
- let movePromise!: Promise;
- await act(async () => {
- movePromise = groupMove([{ element: clip, start: 0.75 }], { beforeTiming: zIndexCommit });
- await flushAsyncWork();
- });
- expect(writeProjectFile).not.toHaveBeenCalled();
-
- await act(async () => {
- releaseCommit();
- await movePromise;
- await flushAsyncWork();
- });
- expect(writeProjectFile).toHaveBeenCalledTimes(1);
-
- unmount();
- });
-
- it("matches the single-clip move output when a group move contains one clip", async () => {
- const source = '
';
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
- const fetchMock = vi.fn(async (input: Parameters[0]): Promise => {
- 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);
-
- const singleWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const single = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile: singleWrite,
- recordEdit: vi.fn(async () => {}),
- });
- await act(async () => {
- await single.move(clip, { start: 0.5, track: clip.track });
- });
- single.unmount();
-
- const groupWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const group = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile: groupWrite,
- recordEdit: vi.fn(async () => {}),
- });
- await act(async () => {
- await group.groupMove([{ element: clip, start: 0.5 }]);
- });
-
- expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]);
- group.unmount();
- });
-});
diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts
index b873f85178..7ebc583312 100644
--- a/packages/studio/src/hooks/useTimelineEditing.ts
+++ b/packages/studio/src/hooks/useTimelineEditing.ts
@@ -1,51 +1,39 @@
+// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale + pbs resolution).
// fallow-ignore-file complexity
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";
+ furthestClipEndFromDocument,
+ furthestClipEndFromSource,
+} from "../player/lib/timelineElementHelpers";
+import { useRazorSplit } from "./useRazorSplit";
+import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
+import { getTimelineElementLabel } from "../utils/studioHelpers";
import {
- getTimelineElementLabel,
- collectHtmlIds,
- resolveDroppedAssetDuration,
-} from "../utils/studioHelpers";
-import {
- applyTimelineStackingReorder,
buildPatchTarget,
patchIframeDomTiming,
+ patchIframeRootDuration,
+ resolveResizePlaybackStart,
persistTimelineEdit,
readFileContent,
- foldedShiftGsapMutation,
- foldedScaleGsapMutation,
+ applyPatchByTarget,
formatTimelineAttributeNumber,
- finishTimelineTimingFallback,
- extendRootDurationIfNeeded,
- buildTimelineMoveTimingPatch,
- buildTimelineResizeTimingPatch,
+ shiftGsapPositions,
+ scaleGsapPositions,
+ syncTimingEditPreview,
} from "./timelineEditingHelpers";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
-import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import {
useTimelineElementVisibilityEditing,
useTimelineTrackVisibilityEditing,
} from "./timelineTrackVisibility";
-import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
import { sdkTimingPersist } from "../utils/sdkCutover";
+import { useTimelineElementsMove } from "./timelineElementsMove";
+import { useTimelineEditingDrops } from "./useTimelineEditingDrops";
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
-type TimelineMoveUpdates = Pick & {
- stackingReorder?: TimelineStackingReorderIntent | null;
-};
-
export function useTimelineEditing({
projectId,
activeCompPath,
@@ -61,7 +49,6 @@ export function useTimelineEditing({
isRecordingRef,
sdkSession,
forceReloadSdkSession,
- handleDomZIndexReorderCommitRef,
}: UseTimelineEditingOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
@@ -98,6 +85,7 @@ export function useTimelineEditing({
}),
)
.then(() => {
+ // Server wrote the file; resync the stale in-memory SDK doc.
forceReloadSdkSession?.();
});
editQueueRef.current = queued.catch((error) => {
@@ -116,99 +104,105 @@ export function useTimelineEditing({
forceReloadSdkSession,
],
);
- const groupEditing = useTimelineGroupEditing({
- activeCompPath,
- domEditSaveTimestampRef,
- editQueueRef,
- forceReloadSdkSession,
- isRecordingRef,
- pendingTimelineEditPathRef,
- previewIframeRef,
- projectIdRef,
- recordEdit,
- reloadPreview,
- sdkSession,
- showToast,
- writeProjectFile,
- });
+ // 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.
+ const syncReadoutDurationFromPreview = useCallback(() => {
+ const end = furthestClipEndFromDocument(previewIframeRef.current?.contentDocument ?? null);
+ if (end > 0) {
+ usePlayerStore.getState().setDuration(end);
+ // Also write the content end into the live root's `data-duration`. Timing
+ // edits now 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.
+ patchIframeRootDuration(previewIframeRef.current, end);
+ }
+ }, [previewIframeRef]);
+
+ // fallow-ignore-next-line complexity
const handleTimelineElementMove = useCallback(
// fallow-ignore-next-line complexity
- (element: TimelineElement, updates: TimelineMoveUpdates) => {
+ (element: TimelineElement, updates: Pick) => {
+ patchIframeDomTiming(previewIframeRef.current, element, [
+ ["data-start", formatTimelineAttributeNumber(updates.start)],
+ ["data-track-index", String(updates.track)],
+ ]);
+ syncReadoutDurationFromPreview();
const targetPath = element.sourceFile || activeCompPath || "index.html";
- const startChanged = updates.start !== element.start;
-
- if (startChanged) {
- patchIframeDomTiming(previewIframeRef.current, element, [
- ["data-start", formatTimelineAttributeNumber(updates.start)],
- ]);
- }
-
- const reorderDone = applyTimelineStackingReorder({
- element,
- stackingReorder: updates.stackingReorder,
- timelineElements,
- iframe: previewIframeRef.current,
- activeCompPath,
- commit: handleDomZIndexReorderCommitRef?.current,
- });
-
- if (!startChanged) return reorderDone;
-
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
- return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
+ let patched = applyPatchByTarget(original, target, {
+ type: "attribute",
+ property: "start",
+ value: formatTimelineAttributeNumber(updates.start),
+ });
+ patched = applyPatchByTarget(patched, target, {
+ type: "attribute",
+ property: "track-index",
+ value: String(updates.track),
+ });
+ // 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 (HANDOFF-3 §6.1 feedback loop).
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
};
+ // Server-path fallback (no SDK session): persist the attr patch, then
+ // shift GSAP tween positions on the server and reload the preview — the
+ // SDK path folds both into setTiming, but the fallback must do them
+ // explicitly or the clip moves while its GSAP tweens stay put + the
+ // preview never refreshes. coalesceKey mirrors the SDK branch so undo
+ // granularity is identical on either path.
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({
- iframe: previewIframeRef.current,
- needsExtension,
- rootDurationSeconds: updates.start + element.duration,
+ if (delta !== 0 && element.domId && pid) {
+ // Soft-reload with the server's rewritten GSAP script instead of a full
+ // iframe reload — a timing-only move already patched the DOM + store, so
+ // swapping the script in place avoids the all-clips flash. Falls back to
+ // reloadPreview() when the soft path can't apply. (See syncTimingEditPreview.)
+ return shiftGsapPositions(pid, targetPath, element.domId, delta)
+ .then((outcome) =>
+ syncTimingEditPreview(
+ previewIframeRef.current,
+ outcome,
+ usePlayerStore.getState().currentTime,
+ reloadPreview,
+ ),
+ )
+ .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
+ }
+ return reloadPreview();
+ });
+ if (sdkSession && element.hfId) {
+ return sdkTimingPersist(
+ element.hfId,
+ targetPath,
+ { start: updates.start, trackIndex: updates.track },
+ sdkSession,
+ {
+ editHistory: { recordEdit },
+ writeProjectFile,
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),
- });
+ 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();
});
- const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
- return reorderDone.then(() => {
- if (sdkSession && element.hfId && !needsExtension) {
- return sdkTimingPersist(
- element.hfId,
- targetPath,
- { start: updates.start },
- sdkSession,
- {
- editHistory: { recordEdit },
- writeProjectFile,
- reloadPreview,
- domEditSaveTimestampRef,
- compositionPath: activeCompPath,
- // Capture on-disk bytes as the undo `before` so undoing a timing move
- // restores the file verbatim, not a normalized full-DOM re-emit.
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
- },
- { label: "Move timeline clip", coalesceKey },
- ).then((handled) => {
- if (!handled) return moveFallback();
- });
- }
- return moveFallback();
- });
+ }
+ return moveFallback();
},
[
previewIframeRef,
@@ -219,11 +213,27 @@ export function useTimelineEditing({
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
- timelineElements,
- handleDomZIndexReorderCommitRef,
+ syncReadoutDurationFromPreview,
],
);
+ // Batched, atomic multi-clip move — one read/patch/write/GSAP-shift/reload for
+ // ALL edits (single undo). Used by the drag commit for main-track ripple and
+ // track-insert; single-clip moves keep the SDK-aware handler above.
+ const handleTimelineElementsMove = useTimelineElementsMove({
+ projectIdRef,
+ activeCompPath,
+ previewIframeRef,
+ writeProjectFile,
+ recordEdit,
+ reloadPreview,
+ forceReloadSdkSession,
+ domEditSaveTimestampRef,
+ isRecordingRef,
+ showToast,
+ });
+
+ // fallow-ignore-next-line complexity
const handleTimelineElementResize = useCallback(
// fallow-ignore-next-line complexity
(
@@ -234,9 +244,8 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-duration", formatTimelineAttributeNumber(updates.duration)],
];
- // Patch the live playback-start/media-start attr too, or a resize that
- // trims the playback start leaves the preview showing the old in-point
- // until the next reload (the persisted patch handles it via pbs below).
+ // Patch the live playback-start/media-start attr too, or a start-trim shows
+ // the old in-point until the next reload (persisted patch handles pbs below).
if (updates.playbackStart != null) {
const liveAttr =
element.playbackStartAttr === "playback-start"
@@ -245,46 +254,75 @@ export function useTimelineEditing({
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
+ syncReadoutDurationFromPreview();
const targetPath = element.sourceFile || activeCompPath || "index.html";
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
- return buildTimelineResizeTimingPatch(original, target, element, updates);
+ const pbs = resolveResizePlaybackStart(original, target, element, updates);
+ let patched = applyPatchByTarget(original, target, {
+ type: "attribute",
+ property: "start",
+ value: formatTimelineAttributeNumber(updates.start),
+ });
+ patched = applyPatchByTarget(patched, target, {
+ type: "attribute",
+ property: "duration",
+ value: formatTimelineAttributeNumber(updates.duration),
+ });
+ if (pbs) {
+ patched = applyPatchByTarget(patched, target, {
+ type: "attribute",
+ property: pbs.attrName,
+ value: formatTimelineAttributeNumber(pbs.value),
+ });
+ }
+ // 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 (HANDOFF-3 §6.1 feedback loop).
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
};
+ // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
+ // The second clause fires because trimming the start of a clip that has a
+ // playback-start attribute implicitly shifts that in-point — which the SDK
+ // setTiming op can't express — so those resizes must take the server path.
const hasPbsAdjustment =
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 and reload the preview. The SDK path
+ // folds both into setTiming; the fallback must do them explicitly or the
+ // clip resizes while its GSAP tweens keep their old timing + the preview
+ // never refreshes. coalesceKey mirrors the SDK branch for undo parity.
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({
- 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 (timingChanged && element.domId && pid) {
+ // Soft-reload with the rewritten script (timing-only resize) — same
+ // no-flash path as move; full reload is the fallback.
+ return scaleGsapPositions(
+ pid,
+ targetPath,
+ element.domId,
+ element.start,
+ element.duration,
+ updates.start,
+ updates.duration,
+ )
+ .then((outcome) =>
+ syncTimingEditPreview(
+ previewIframeRef.current,
+ outcome,
+ usePlayerStore.getState().currentTime,
+ reloadPreview,
+ ),
+ )
+ .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
+ }
+ return reloadPreview();
});
- if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
+ if (sdkSession && element.hfId && !hasPbsAdjustment) {
return sdkTimingPersist(
element.hfId,
targetPath,
@@ -316,9 +354,9 @@ export function useTimelineEditing({
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
+ syncReadoutDurationFromPreview,
],
);
-
const handleToggleTrackHidden = useTimelineTrackVisibilityEditing({
projectIdRef,
activeCompPath,
@@ -332,7 +370,6 @@ export function useTimelineEditing({
isRecordingRef,
forceReloadSdkSession,
});
-
const handleToggleElementHidden = useTimelineElementVisibilityEditing({
projectIdRef,
activeCompPath,
@@ -346,69 +383,100 @@ export function useTimelineEditing({
isRecordingRef,
forceReloadSdkSession,
});
-
+ // Atomic multi-clip delete: ONE pass that removes every element from the
+ // source (grouped per owning file), then ONE content-driven duration sync +
+ // ONE save (single undo entry) + one reload — mirroring the batched pattern
+ // in persistTimelineElementsMove. The single-clip delete is the same path
+ // with a one-element list.
// fallow-ignore-next-line complexity
- const handleTimelineElementDelete = useCallback(
+ const handleTimelineElementsDelete = useCallback(
// fallow-ignore-next-line complexity
- async (element: TimelineElement) => {
+ async (elementsToDelete: TimelineElement[]) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
- const label = getTimelineElementLabel(element);
-
- const targetPath = element.sourceFile || activeCompPath || "index.html";
+ if (elementsToDelete.length === 0) return;
+ // Pin the zoom before a delete shrinks the composition (content-driven
+ // duration), so the reload doesn't re-fit and rescale every clip. Covers the
+ // keyboard-delete path too (the context-menu delete already pins in Timeline;
+ // pinTimelineZoom is idempotent, so a double-pin is harmless).
+ usePlayerStore.getState().pinTimelineZoomToCurrent();
+ const count = elementsToDelete.length;
+ const label = count === 1 ? getTimelineElementLabel(elementsToDelete[0]) : `${count} clips`;
try {
- const originalContent = await readFileContent(pid, targetPath);
-
- const patchTarget = buildPatchTarget(element);
- if (!patchTarget) {
- throw new Error(`Timeline element ${element.id} is missing a patchable target`);
+ // Group by owning source file — one read → remove-all → duration-sync
+ // cycle per file, all folded into a single history entry below.
+ const groups = new Map();
+ for (const element of elementsToDelete) {
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
+ const bucket = groups.get(targetPath);
+ if (bucket) bucket.push(element);
+ else groups.set(targetPath, [element]);
}
-
- const removeResponse = await fetch(
- `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ target: patchTarget }),
- },
- );
- if (!removeResponse.ok) {
- throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
+ const originals = new Map();
+ const patchedFiles: Record = {};
+ for (const [targetPath, groupElements] of groups) {
+ const originalContent = await readFileContent(pid, targetPath);
+ originals.set(targetPath, originalContent);
+ let content = originalContent;
+ for (const element of groupElements) {
+ const patchTarget = buildPatchTarget(element);
+ if (!patchTarget) {
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
+ }
+ const removeResponse = await fetch(
+ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ target: patchTarget }),
+ },
+ );
+ if (!removeResponse.ok) {
+ throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
+ }
+ const removeData = (await removeResponse.json()) as {
+ changed?: boolean;
+ content?: string;
+ };
+ content = typeof removeData.content === "string" ? removeData.content : content;
+ }
+ // 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 (HANDOFF-3 §6.1 feedback loop).
+ const deleteContentEnd = furthestClipEndFromSource(content);
+ patchedFiles[targetPath] = setCompositionDurationToContent(content, deleteContentEnd);
+ // Optimistically reflect the shrunk length in the readout/seek bar.
+ if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
+ usePlayerStore.getState().setDuration(deleteContentEnd);
+ }
}
-
- const removeData = (await removeResponse.json()) as {
- changed?: boolean;
- content?: string;
- };
- const patchedContent =
- typeof removeData.content === "string" ? removeData.content : originalContent;
-
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
- label: "Delete timeline clip",
+ label: count === 1 ? "Delete timeline clip" : `Delete ${count} timeline clips`,
kind: "timeline",
- files: { [targetPath]: patchedContent },
- readFile: async () => originalContent,
+ files: patchedFiles,
+ readFile: async (path) => originals.get(path) ?? "",
writeFile: writeProjectFile,
recordEdit,
});
-
+ const deletedKeys = new Set(elementsToDelete.map((e) => e.key ?? e.id));
usePlayerStore
.getState()
- .setElements(
- timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
- );
+ .setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
usePlayerStore.getState().setSelectedElementId(null);
+ usePlayerStore.getState().clearSelectedElementIds();
forceReloadSdkSession?.();
reloadPreview();
- showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
+ showToast(`Deleted ${label}. Use Undo to restore ${count === 1 ? "it" : "them"}.`, "info");
} catch (error) {
- const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
+ const message = error instanceof Error ? error.message : "Failed to delete timeline clips";
showToast(message);
}
},
@@ -425,142 +493,41 @@ export function useTimelineEditing({
],
);
- // fallow-ignore-next-line complexity
- const handleTimelineAssetDrop = useCallback(
- // fallow-ignore-next-line complexity
- async (
- assetPath: string,
- placement: Pick,
- 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);
+ const handleTimelineElementDelete = useCallback(
+ (element: TimelineElement) => {
+ // Deleting a clip that is part of the marquee multi-selection deletes the
+ // WHOLE selection (standard NLE behavior) — one atomic pass, single undo.
+ // A delete on a clip outside the selection only removes that clip.
+ const { selectedElementIds } = usePlayerStore.getState();
+ const key = element.key ?? element.id;
+ if (selectedElementIds.size > 1 && selectedElementIds.has(key)) {
+ const byKey = new Map(timelineElements.map((te) => [te.key ?? te.id, te]));
+ const targets = [...selectedElementIds]
+ .map((id) => byKey.get(id))
+ .filter((te): te is TimelineElement => te != null);
+ if (!targets.some((te) => (te.key ?? te.id) === key)) targets.push(element);
+ return handleTimelineElementsDelete(targets);
}
+ return handleTimelineElementsDelete([element]);
},
- [
+ [handleTimelineElementsDelete, timelineElements],
+ );
+ // Asset/file drop + add-at-playhead handlers live in a sub-hook, called
+ // unconditionally here so the parent's hook call order is unchanged.
+ const { handleTimelineAssetDrop, handleTimelineFileDrop, handleAddAssetAtPlayhead } =
+ useTimelineEditingDrops({
+ projectIdRef,
activeCompPath,
- recordEdit,
- showToast,
timelineElements,
+ showToast,
writeProjectFile,
+ recordEdit,
domEditSaveTimestampRef,
reloadPreview,
- isRecordingRef,
- forceReloadSdkSession,
- ],
- );
-
- // fallow-ignore-next-line complexity
- const handleTimelineFileDrop = useCallback(
- // fallow-ignore-next-line complexity
- async (files: File[], placement?: Pick) => {
- 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,
- ],
- );
+ forceReloadSdkSession,
+ });
const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
@@ -580,12 +547,15 @@ export function useTimelineEditing({
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
+ forceReloadSdkSession,
isRecordingRef,
});
return {
handleTimelineElementMove,
+ handleTimelineElementsMove,
handleTimelineElementResize,
+ handleTimelineElementsDelete,
handleToggleTrackHidden,
handleToggleElementHidden,
handleTimelineElementDelete,
@@ -594,7 +564,7 @@ export function useTimelineEditing({
handleRazorSplitAll,
handleTimelineAssetDrop,
handleTimelineFileDrop,
+ handleAddAssetAtPlayhead,
handleBlockedTimelineEdit,
- ...groupEditing,
};
}
diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts
index b8cc2c7538..da7927c9fa 100644
--- a/packages/studio/src/hooks/useTimelineEditingTypes.ts
+++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts
@@ -43,3 +43,8 @@ export interface UseTimelineEditingOptions {
forceReloadSdkSession?: () => void;
handleDomZIndexReorderCommitRef?: MutableRefObject;
}
+
+export type TimelineFileDropHandler = (
+ files: File[],
+ placement?: { start: number; track: number },
+) => Promise;
diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts
deleted file mode 100644
index 0ad9d365d7..0000000000
--- a/packages/studio/src/hooks/useTimelineGroupEditing.ts
+++ /dev/null
@@ -1,370 +0,0 @@
-import { useCallback, type MutableRefObject, type RefObject } from "react";
-import type { Composition } from "@hyperframes/sdk";
-import type { TimelineElement } from "../player";
-import { sdkTimingBatchPersist } from "../utils/sdkCutover";
-import {
- buildTimelineMoveTimingPatch,
- buildTimelineResizeTimingPatch,
- extendRootDurationIfNeeded,
- finishTimelineTimingFallback,
- foldGsapMutationIntoHistory,
- formatTimelineAttributeNumber,
- patchIframeDomTiming,
- persistTimelineBatchEdit,
- readFileContent,
- scaleGsapPositions,
- shiftGsapPositions,
- type PersistTimelineBatchChange,
- type RecordEditInput,
-} from "./timelineEditingHelpers";
-
-export interface TimelineGroupMoveChange {
- element: TimelineElement;
- start: number;
-}
-
-export interface TimelineGroupResizeChange {
- element: TimelineElement;
- start: number;
- duration: number;
- playbackStart?: number;
-}
-
-export interface TimelineGroupCommitOptions {
- beforeTiming?: Promise;
- coalesceKey?: string;
-}
-
-interface UseTimelineGroupEditingOptions {
- activeCompPath: string | null;
- domEditSaveTimestampRef: MutableRefObject;
- editQueueRef: MutableRefObject>;
- forceReloadSdkSession?: () => void;
- isRecordingRef?: RefObject;
- pendingTimelineEditPathRef: MutableRefObject>;
- previewIframeRef: RefObject;
- projectIdRef: MutableRefObject;
- recordEdit: (input: RecordEditInput) => Promise;
- reloadPreview: () => void;
- sdkSession?: Composition | null;
- showToast: (message: string, tone?: "error" | "info") => void;
- writeProjectFile: (path: string, content: string) => Promise;
-}
-
-function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
- return element.sourceFile || activeCompPath || "index.html";
-}
-
-function allChangesSharePath(
- changes: readonly { element: TimelineElement }[],
- activeCompPath: string | null,
-): string | null {
- const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null;
- if (!firstPath) return null;
- return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath)
- ? firstPath
- : null;
-}
-
-function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string {
- return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
-}
-
-function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string {
- return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
-}
-
-function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
- return (
- change.playbackStart != null ||
- (change.start !== change.element.start && change.element.playbackStart != null)
- );
-}
-
-export function useTimelineGroupEditing({
- activeCompPath,
- domEditSaveTimestampRef,
- editQueueRef,
- forceReloadSdkSession,
- isRecordingRef,
- pendingTimelineEditPathRef,
- previewIframeRef,
- projectIdRef,
- recordEdit,
- reloadPreview,
- sdkSession,
- showToast,
- writeProjectFile,
-}: UseTimelineGroupEditingOptions) {
- const enqueueGroupOperation = useCallback(
- (label: string, operation: (projectId: string) => Promise): Promise => {
- if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
- return Promise.reject(new Error(`${label}: blocked while recording`));
- }
- const projectId = projectIdRef.current;
- if (!projectId) return Promise.reject(new Error(`${label}: no active project`));
- const run = editQueueRef.current.then(() => operation(projectId));
- // Keep the shared edit queue from wedging on a rejection, but return the raw
- // (rejecting) promise so the gesture owner can roll back on a real failure.
- editQueueRef.current = run.then(
- () => undefined,
- (error) => {
- console.error(`[Timeline] Failed to persist: ${label}`, error);
- },
- );
- return run;
- },
- [editQueueRef, isRecordingRef, projectIdRef, showToast],
- );
-
- const persistServerBatch = useCallback(
- async (
- projectId: string,
- label: string,
- batchChanges: PersistTimelineBatchChange[],
- coalesceKey: string,
- ) => {
- await persistTimelineBatchEdit({
- projectId,
- activeCompPath,
- label,
- changes: batchChanges,
- writeProjectFile,
- recordEdit,
- domEditSaveTimestampRef,
- pendingTimelineEditPathRef,
- coalesceKey,
- });
- forceReloadSdkSession?.();
- },
- [
- activeCompPath,
- domEditSaveTimestampRef,
- forceReloadSdkSession,
- pendingTimelineEditPathRef,
- recordEdit,
- writeProjectFile,
- ],
- );
-
- const handleTimelineGroupMove = useCallback(
- (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
- if (changes.length === 0) return Promise.resolve();
- for (const change of changes) {
- patchIframeDomTiming(previewIframeRef.current, change.element, [
- ["data-start", formatTimelineAttributeNumber(change.start)],
- ]);
- }
-
- const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
- const needsExtension = extendRootDurationIfNeeded(maxEnd);
- 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 && sdkChanges.every((change) => change !== null);
- if (canUseSdk) {
- const handled = await sdkTimingBatchPersist(
- sdkChanges.filter((change): change is NonNullable => 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;
- }
-
- await persistServerBatch(
- projectId,
- "Move timeline clips",
- changes.map((change) => ({
- element: change.element,
- buildPatches: (original, target) =>
- buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration),
- })),
- coalesceKey,
- );
- await finishTimelineTimingFallback({
- 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),
- });
- });
- },
- [
- activeCompPath,
- domEditSaveTimestampRef,
- enqueueGroupOperation,
- persistServerBatch,
- previewIframeRef,
- projectIdRef,
- recordEdit,
- reloadPreview,
- sdkSession,
- writeProjectFile,
- ],
- );
-
- const handleTimelineGroupResize = useCallback(
- (changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => {
- if (changes.length === 0) return Promise.resolve();
- for (const change of changes) {
- const liveAttrs: Array<[string, string]> = [
- ["data-start", formatTimelineAttributeNumber(change.start)],
- ["data-duration", formatTimelineAttributeNumber(change.duration)],
- ];
- if (change.playbackStart != null) {
- const liveAttr =
- change.element.playbackStartAttr === "playback-start"
- ? "data-playback-start"
- : "data-media-start";
- liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
- }
- patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs);
- }
-
- const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
- const needsExtension = extendRootDurationIfNeeded(maxEnd);
- 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 => 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;
- }
-
- await persistServerBatch(
- projectId,
- "Resize timeline clips",
- changes.map((change) => ({
- element: change.element,
- buildPatches: (original, target) =>
- buildTimelineResizeTimingPatch(original, target, change.element, {
- start: change.start,
- duration: change.duration,
- playbackStart: change.playbackStart,
- }),
- })),
- coalesceKey,
- );
- await finishTimelineTimingFallback({
- iframe: previewIframeRef.current,
- needsExtension,
- rootDurationSeconds: maxEnd,
- reloadPreview,
- gsapMutation: () =>
- foldGsapMutationIntoHistory({
- 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),
- });
- });
- },
- [
- activeCompPath,
- domEditSaveTimestampRef,
- enqueueGroupOperation,
- persistServerBatch,
- previewIframeRef,
- projectIdRef,
- recordEdit,
- reloadPreview,
- sdkSession,
- writeProjectFile,
- ],
- );
-
- return { handleTimelineGroupMove, handleTimelineGroupResize };
-}
diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts
index b9a08e6628..1da7407a66 100644
--- a/packages/studio/src/index.ts
+++ b/packages/studio/src/index.ts
@@ -1,5 +1,5 @@
// NLE Layout
-export { NLELayout } from "./components/nle/NLELayout";
+export { EditorShell } from "./components/EditorShell";
export { NLEPreview } from "./components/nle/NLEPreview";
export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx
index 70ca13c7f3..9a2078a48b 100644
--- a/packages/studio/src/player/components/TimelineLanes.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.tsx
@@ -375,6 +375,7 @@ export function TimelineLanes({
pointerOffsetY: e.clientY - rect.top,
previewStart: el.start,
previewTrack: el.track,
+ desiredTrack: el.track,
insertRow: null,
snapTime: null,
snapType: null,
diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts
index 1b68cc88ea..d209486c0b 100644
--- a/packages/studio/src/player/components/timelineCallbacks.ts
+++ b/packages/studio/src/player/components/timelineCallbacks.ts
@@ -1,12 +1,7 @@
// fallow-ignore-file code-duplication
// fallow-ignore-file dead-code
import type { TimelineElement } from "../store/playerStore";
-import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing";
-import type {
- TimelineGroupCommitOptions,
- TimelineGroupMoveChange,
- TimelineGroupResizeChange,
-} from "../../hooks/useTimelineGroupEditing";
+import type { BlockedTimelineEditIntent } from "./timelineEditing";
/**
* Shared callback signatures for timeline editing operations.
@@ -31,31 +26,20 @@ export interface TimelineDropCallbacks {
export interface TimelineEditCallbacks {
onMoveElement?: (
element: TimelineElement,
- updates: Pick & {
- stackingReorder?: TimelineStackingReorderIntent | null;
- },
+ updates: Pick,
+ ) => Promise | void;
+ /** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
+ * `coalesceKey` (drag-commit gesture id) merges the move history entry with a
+ * lane change's follow-up z-reorder entry into one undo step. */
+ onMoveElements?: (
+ edits: Array<{ element: TimelineElement; updates: Pick }>,
+ coalesceKey?: string,
) => Promise | void;
onResizeElement?: (
element: TimelineElement,
updates: Pick,
) => Promise | void;
- /**
- * Batched move. Method syntax (bivariant) + union parameter so both the
- * legacy group-editing shape (TimelineGroupMoveChange) and the NLE edit
- * shape (TimelineGroupMoveEdit) type-check while both engines coexist.
- */
- onMoveElements?(
- changes: Array,
- options?: TimelineGroupCommitOptions,
- ): Promise | void;
- onResizeElements?: (
- changes: TimelineGroupResizeChange[],
- options?: TimelineGroupCommitOptions,
- ) => Promise | void;
- onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
- onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise | void;
- onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise | void;
@@ -71,13 +55,3 @@ export interface TimelineEditCallbacks {
) => void;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
}
-
-/**
- * NLE batched-move edit: the element plus exactly the fields the move changes.
- * The legacy engine's TimelineGroupMoveChange carries resolved absolute values
- * instead; onMoveElements accepts either while the engines coexist.
- */
-export interface TimelineGroupMoveEdit {
- element: TimelineElement;
- updates: Pick;
-}
diff --git a/packages/studio/src/player/components/timelineClipDragCommit.test.ts b/packages/studio/src/player/components/timelineClipDragCommit.test.ts
index 72dbcaa82a..111fff02c2 100644
--- a/packages/studio/src/player/components/timelineClipDragCommit.test.ts
+++ b/packages/studio/src/player/components/timelineClipDragCommit.test.ts
@@ -6,6 +6,13 @@ import {
type DragCommitDeps,
type TimelineMoveEdit,
} from "./timelineClipDragCommit";
+import {
+ buildEditHistoryEntry,
+ createEmptyEditHistory,
+ pushEditHistoryEntry,
+} from "../../utils/editHistory";
+import { normalizeToZones } from "./timelineZones";
+import type { StackingPatch } from "./timelineStackingSync";
function el(
id: string,
@@ -14,12 +21,27 @@ function el(
duration: number,
tag = "video",
): TimelineElement {
- return { id, key: id, tag, start, duration, track };
+ // domId gives the row a patchable target so getTimelineEditCapabilities().canMove
+ // is true (the capabilities gate in commitDraggedClipMove filters on it).
+ return { id, key: id, tag, start, duration, track, domId: id };
+}
+
+/** Flush the microtask chain: the z-sync now fires only after the move persist
+ * promise resolves (serialized), so tests asserting on it must await. */
+async function flushMicrotasks(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
}
function drag(
element: TimelineElement,
- opts: { previewStart: number; previewTrack: number; insertRow?: number | null },
+ opts: {
+ previewStart: number;
+ previewTrack: number;
+ desiredTrack?: number;
+ insertRow?: number | null;
+ },
): DraggedClipState {
return {
element,
@@ -33,6 +55,9 @@ function drag(
pointerOffsetY: 0,
previewStart: opts.previewStart,
previewTrack: opts.previewTrack,
+ // Defaults to previewTrack (a lane change) when a test doesn't distinguish a
+ // horizontal collision bump — the commit's `desiredTrack ?? previewTrack`.
+ desiredTrack: opts.desiredTrack,
insertRow: opts.insertRow ?? null,
snapTime: null,
snapType: null,
@@ -63,17 +88,78 @@ function runClipMove(
return { updateElement, onMoveElement, onMoveElements };
}
+/** A drag onto another lane where the two clips do NOT time-overlap must issue no
+ * stacking patch (the dragged clip is elements[0]). Awaits the serialized z-sync. */
+async function expectNoStackingPatchOnNonOverlapLaneChange(
+ elements: TimelineElement[],
+): Promise {
+ const onStackingPatches = vi.fn();
+ runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
+ elements,
+ trackOrder: [0, 1],
+ readZIndex: () => 0,
+ onStackingPatches,
+ });
+ await flushMicrotasks();
+ expect(onStackingPatches).not.toHaveBeenCalled();
+}
+
+type MoveMap = Record;
+
/** Assert a lane change persisted atomically (single onMoveElements, no single
* onMoveElement) and return the resulting id → {start, track} edit map. */
-function expectAtomicMoveMap(spies: {
- onMoveElement: Mock;
- onMoveElements: Mock;
-}): Record {
+function expectAtomicMoveMap(spies: { onMoveElement: Mock; onMoveElements: Mock }): MoveMap {
expect(spies.onMoveElement).not.toHaveBeenCalled();
expect(spies.onMoveElements).toHaveBeenCalledTimes(1);
return editMap(spies.onMoveElements.mock.calls[0][0]);
}
+// Two time-overlapping clips carrying authored z (a below at z=1, b on top at
+// z=5) — the bed for the z-sync / lane-change tests.
+const overlapping = (): TimelineElement[] => [
+ { id: "a", key: "a", tag: "video", start: 0, duration: 10, track: 1, zIndex: 1, domId: "a" },
+ { id: "b", key: "b", tag: "video", start: 0, duration: 10, track: 0, zIndex: 5, domId: "b" },
+];
+const zOf = (e: TimelineElement) => ({ a: 1, b: 5 })[e.key ?? e.id] ?? 0;
+
+// Commit an "insert a above b" lane change: the drop lifts a's z, so both the
+// move persist and the z-sync fire. Tests drive onMoveElements differently
+// (immediate / pending / rejecting), so the persist deps are supplied per call.
+function commitInsertAbove(
+ elements: TimelineElement[],
+ deps: Partial & Pick,
+): void {
+ commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
+ elements,
+ trackOrder: [0, 1],
+ updateElement: vi.fn(),
+ onMoveElement: vi.fn(),
+ readZIndex: zOf,
+ ...deps,
+ });
+}
+
+// The edited clip `a` is lifted above b(5) → 6, issued as one stacking patch.
+function expectZLiftedToSix(onStackingPatches: Mock): void {
+ expect(onStackingPatches).toHaveBeenCalledTimes(1);
+ expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 6 }]);
+}
+
+// A marquee (a+b selected) time-move of the dragged clip; returns the persist
+// spies plus the resulting id → {start, track} edit map.
+function runMarqueeMove(
+ dragged: TimelineElement,
+ previewStart: number,
+ elements: TimelineElement[],
+): { updateElement: Mock; onMoveElement: Mock; onMoveElements: Mock; map: MoveMap } {
+ const spies = runClipMove(drag(dragged, { previewStart, previewTrack: 0 }), {
+ elements,
+ trackOrder: [0, 1],
+ selectedKeys: new Set(["a", "b"]),
+ });
+ return { ...spies, map: editMap(spies.onMoveElements.mock.calls[0][0]) };
+}
+
describe("commitDraggedClipMove", () => {
it("pure time-move (same lane) persists just the dragged clip (single, SDK-aware)", () => {
const elements = [el("v1", 1, 0, 5)];
@@ -87,17 +173,18 @@ describe("commitDraggedClipMove", () => {
expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 1 });
});
- it("a lane change re-normalizes and persists EVERY clip atomically (fixes raw-vs-normalized collision)", () => {
+ it("a plain lane change persists ONLY the dragged clip (CapCut: never re-lanes others)", () => {
// Move 'a' from lane 0 down onto lane 1 (b's lane) at a non-overlapping time.
+ // The CapCut rule: editing one clip must never rewrite another. Only 'a' is
+ // persisted — with its new lane (previewTrack 1) — and 'b' is left untouched.
const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)];
- const { onMoveElement, onMoveElements } = runClipMove(
- drag(elements[0], { previewStart: 20, previewTrack: 1 }),
+ const { onMoveElements } = runClipMove(
+ drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
{ elements, trackOrder: [0, 1] },
);
- // BOTH clips persist atomically with consistent normalized tracks (both visual → lane 0).
- const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
- expect(map.a).toEqual({ start: 20, track: 0 });
- expect(map.b).toEqual({ start: 10, track: 0 });
+ const map = editMap(onMoveElements.mock.calls[0][0]);
+ expect(map.a).toEqual({ start: 20, track: 1 });
+ expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
});
it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
@@ -116,11 +203,7 @@ describe("commitDraggedClipMove", () => {
it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
// Drag 'a' −5s: b would land at −3 → clamps to 0.
- const { updateElement, onMoveElements } = runClipMove(
- drag(elements[0], { previewStart: 1, previewTrack: 0 }),
- { elements, trackOrder: [0, 1], selectedKeys: new Set(["a", "b"]) },
- );
- const map = editMap(onMoveElements.mock.calls[0][0]);
+ const { updateElement, map } = runMarqueeMove(elements[0], 1, elements);
expect(map.a).toEqual({ start: 1, track: 0 });
expect(map.b).toEqual({ start: 0, track: 1 });
expect(updateElement).toHaveBeenCalledWith("a", { start: 1, track: 0 });
@@ -138,31 +221,26 @@ describe("commitDraggedClipMove", () => {
expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 0 });
});
- it("multi-selection lane change: dragged clip changes track, the rest of the selection shifts in time only", () => {
+ it("multi-selection lane change: dragged clip changes track, selection shifts in time, others untouched", () => {
const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
// Drag 'a' +4s down onto lane 1 (non-overlapping with b) while {a, c} selected.
const { onMoveElements } = runClipMove(
- drag(elements[0], { previewStart: 4, previewTrack: 1 }),
+ drag(elements[0], { previewStart: 4, previewTrack: 1, desiredTrack: 1 }),
{ elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "c"]) },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
- expect(map.a.start).toBe(4); // dragged: new time + new (normalized) lane
- expect(map.c.start).toBe(24); // selected passenger: same +4 delta
- expect(map.c.track).not.toBe(map.a.track); // passenger keeps its own lane
- expect(map.b.start).toBe(10); // unselected: time untouched
- });
-
- it("inserting a new lane re-packs the whole set into contiguous lanes (single atomic persist)", () => {
- // a,b,c all start=0 dur=5 → mutually overlapping, all equal z (absent ⇒ 0).
- // The insert drops c at a fractional lane between a and b. Under the per-clip
- // constrained pack, equal-z overlapping clips lay out by DOM order (later on
- // top): c (last in DOM) → lane 0, b → lane 1, a → lane 2. (Was pinned to
- // a=0/c=1/b=2 by the old whole-track packer, which used the fractional-track
- // value for ordering; the new pack encodes insert INTENT via the z-sync path
- // instead, and lanes reflect true canvas paint order.) Contiguous 0..2, one
- // atomic persist for all three.
+ expect(map.a).toEqual({ start: 4, track: 1 }); // dragged: new time + new lane
+ expect(map.c).toEqual({ start: 24, track: 2 }); // passenger: same +4 delta, own lane
+ expect(map.b).toBeUndefined(); // unselected clip is NOT rewritten
+ });
+
+ it("inserting a new lane places the dragged clip at the aimed row and +1-shifts the clips below", () => {
+ // a,b,c all start=0 dur=5 → mutually overlapping. Insert drops c on a NEW lane
+ // at row 1 (between a and b). The CapCut renumber: c lands at row 1, a keeps
+ // row 0, and b (at/below the insert) shifts down by one to row 2. Lane order
+ // now follows track-index (a, c, b) — the ONLY sanctioned multi-clip write,
+ // index-only. Contiguous 0..2, one atomic persist.
const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
- // insert a new lane at row 1 (between a and b) with c.
const { onMoveElements } = runClipMove(
drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 1 }),
{ elements, trackOrder: [0, 1, 2] },
@@ -171,13 +249,13 @@ 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]));
- expect(map.c.track).toBe(0); // last in DOM → top lane
- expect(map.b.track).toBe(1);
- expect(map.a.track).toBe(2);
+ 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", () => {
- it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", () => {
+ it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => {
// a & b overlap in time. Elements carry their authored z (as real discovery
// populates TimelineElement.zIndex from the DOM), so the per-clip pack lays
// them out by z: b (z=5) tops, a (z=1) below. The user drags a UP onto the
@@ -187,8 +265,26 @@ describe("commitDraggedClipMove", () => {
// key-order tie-break placing a on top, which contradicted canvas paint for
// equal z — the elements now carry z and the drop intent is an insert above.)
const elements: TimelineElement[] = [
- { id: "a", key: "a", tag: "video", start: 0, duration: 10, track: 1, zIndex: 1 },
- { id: "b", key: "b", tag: "video", start: 0, duration: 10, track: 0, zIndex: 5 },
+ {
+ id: "a",
+ key: "a",
+ tag: "video",
+ start: 0,
+ duration: 10,
+ track: 1,
+ zIndex: 1,
+ domId: "a",
+ },
+ {
+ id: "b",
+ key: "b",
+ tag: "video",
+ start: 0,
+ duration: 10,
+ track: 0,
+ zIndex: 5,
+ domId: "b",
+ },
];
const z: Record = { a: 1, b: 5 };
const onStackingPatches = vi.fn();
@@ -199,43 +295,540 @@ describe("commitDraggedClipMove", () => {
readZIndex: (e) => z[e.key ?? e.id] ?? 0,
onStackingPatches,
});
+ // z-sync is serialized after the move persist → deferred to a microtask.
+ await flushMicrotasks();
// Only `a` (the edited clip) is patched, lifted above b(5) → 6.
- expect(onStackingPatches).toHaveBeenCalledTimes(1);
- expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 6 }]);
+ expectZLiftedToSix(onStackingPatches);
});
- it("no z-sync deps → no stacking side-effects (pure time-move path safe)", () => {
+ it("partial z-sync deps (no readZIndex) → move persists but no stacking call", async () => {
const elements = [el("a", 1, 0, 10), el("b", 0, 0, 10)];
- // No readZIndex/onStackingPatches supplied → must not throw, no patches.
- runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
+ // onStackingPatches present but readZIndex absent → syncStackingForEdit needs
+ // BOTH, so it must issue zero patches even though this IS a lane change.
+ const onStackingPatches = vi.fn();
+ const { onMoveElements } = runClipMove(
+ drag(elements[0], { previewStart: 0, previewTrack: 0 }),
+ { elements, trackOrder: [0, 1], onStackingPatches },
+ );
+ await flushMicrotasks();
+ // The move still persists atomically; no z patch is issued, no stacking call.
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
+ expect(onStackingPatches).not.toHaveBeenCalled();
+ });
+
+ it("no time overlap → no stacking patch even on a lane change", async () => {
+ await expectNoStackingPatchOnNonOverlapLaneChange([el("a", 1, 0, 5), el("b", 0, 10, 5)]);
+ });
+
+ it("pure time-move (no lane change) never triggers a stacking patch", () => {
+ const elements = [el("a", 0, 0, 10), el("b", 0, 0, 10)];
+ const onStackingPatches = vi.fn();
+ // same track → not a topology change → z-sync branch not reached.
+ runClipMove(drag(elements[0], { previewStart: 3, previewTrack: 0 }), {
elements,
- trackOrder: [0, 1],
+ trackOrder: [0],
+ readZIndex: () => 0,
+ onStackingPatches,
});
- // (nothing to assert beyond "did not throw")
+ expect(onStackingPatches).not.toHaveBeenCalled();
});
+ });
- it("no time overlap → no stacking patch even on a lane change", () => {
- const elements = [el("a", 1, 0, 5), el("b", 0, 10, 5)];
+ describe("drop-intent lane realization — the dragged clip DISPLAYS on the aimed lane", () => {
+ // CapCut-stable lanes follow the track-index, so a gap INSERT lands the dragged
+ // clip exactly where aimed: it takes the new lane at the insert row and the
+ // clips at/below shift down by one (the sanctioned renumber). z never enters
+ // lane assignment, so a low-z clip aimed at the top gets the top lane regardless
+ // of z. These are full preview→commit→normalize round trips: apply the persisted
+ // track edits (and any z patches), then re-normalize — the lanes come purely
+ // from the track-index renumber, never from z.
+ const vz = (
+ id: string,
+ track: number,
+ start: number,
+ duration: number,
+ zIndex: number,
+ ): TimelineElement => ({
+ id,
+ key: id,
+ tag: "video",
+ start,
+ duration,
+ track,
+ zIndex,
+ domId: id,
+ });
+
+ async function roundTripLane(
+ elements: TimelineElement[],
+ dragState: DraggedClipState,
+ trackOrder: number[],
+ ): Promise> {
+ let edits: TimelineMoveEdit[] = [];
+ let patches: StackingPatch[] = [];
+ const z = new Map(elements.map((e) => [e.key ?? e.id, (e.zIndex as number) ?? 0]));
+ commitDraggedClipMove(dragState, {
+ elements,
+ trackOrder,
+ updateElement: vi.fn(),
+ onMoveElement: vi.fn(),
+ onMoveElements: (e: TimelineMoveEdit[]) => {
+ edits = e;
+ },
+ readZIndex: (el) => z.get(el.key ?? el.id) ?? 0,
+ onStackingPatches: (p: StackingPatch[]) => {
+ patches = p;
+ },
+ });
+ await flushMicrotasks();
+ const te = new Map(edits.map((e) => [e.element.key ?? e.element.id, e.updates]));
+ const ze = new Map(patches.map((p) => [p.key, p.zIndex]));
+ const persisted = elements.map((e) => {
+ const k = e.key ?? e.id;
+ const t = te.get(k);
+ return {
+ ...e,
+ start: t ? t.start : e.start,
+ track: t ? t.track : e.track,
+ zIndex: ze.has(k) ? ze.get(k)! : e.zIndex,
+ };
+ });
+ const lane: Record = {};
+ for (const e of normalizeToZones(persisted)) lane[e.key ?? e.id] = e.track;
+ return lane;
+ }
+
+ it("TOP-insert of a clip that OVERLAPS NOTHING lands it on the top lane (not its z-rank)", async () => {
+ // top(z20)/mid(z15) share the upper lanes; dragged(z2) is sequential (no time
+ // overlap with anything) and low-z, so a global z-sort would sink it. Aim: top.
+ const elements = [vz("top", 0, 0, 3, 20), vz("mid", 5, 3, 1, 15), vz("dragged", 1, 30, 3, 2)];
+ const lane = await roundTripLane(
+ elements,
+ drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
+ [0, 1],
+ );
+ 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 () => {
+ const elements = [vz("a", 0, 0, 3, 9), vz("b", 1, 0, 3, 5), vz("x", 2, 20, 5, 1)];
+ const lane = await roundTripLane(
+ elements,
+ drag(elements[2], { previewStart: 20, previewTrack: 2, insertRow: 1 }),
+ [0, 1, 2],
+ );
+ expect(lane.a).toBe(0);
+ 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 () => {
+ // X overlaps M but NOT T (T tops the timeline, disjoint in time). Aiming X at
+ // the top must lift it past T even though they never overlap.
+ const elements = [vz("T", 0, 0, 3, 9), vz("M", 1, 10, 5, 5), vz("X", 2, 10, 5, 1)];
+ const lane = await roundTripLane(
+ elements,
+ drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
+ [0, 1, 2],
+ );
+ 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 () => {
+ // Four mutually-overlapping clips. Baseline lanes by z: n1,n2,x,n3. Drag x to
+ // the top. Everything above x shifts down by one to make room (unavoidable),
+ // but no TWO non-dragged clips may swap — a lane change of X must not reshuffle
+ // its neighbours among themselves.
+ const elements = [
+ vz("n1", 0, 0, 10, 4),
+ vz("n2", 1, 0, 10, 3),
+ vz("x", 2, 0, 10, 2),
+ vz("n3", 3, 0, 10, 1),
+ ];
+ const baseOrder = normalizeToZones(elements)
+ .slice()
+ .sort((a, b) => a.track - b.track)
+ .map((e) => e.id)
+ .filter((id) => id !== "x");
+ const lane = await roundTripLane(
+ elements,
+ drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 0 }),
+ [0, 1, 2, 3],
+ );
+ expect(lane.x).toBe(0); // X lands where aimed
+ const finalOrder = Object.keys(lane)
+ .sort((a, b) => lane[a] - lane[b])
+ .filter((id) => id !== "x");
+ expect(finalOrder).toEqual(baseOrder); // neighbours keep their relative order
+ });
+
+ it("a drop that merely SHARES a lane with a non-overlapping neighbour issues no z nudge", async () => {
+ // a → b's lane; they don't overlap → they share the lane, aim already met, so
+ // no stacking patch (byte-identical to the pre-fix no-op path).
+ await expectNoStackingPatchOnNonOverlapLaneChange([
+ vz("a", 1, 0, 5, 0),
+ vz("b", 0, 10, 5, 0),
+ ]);
+ });
+ });
+
+ describe("BUG 1 — a plain horizontal move never patches z (fixture repro)", () => {
+ // Mirror the user's index.html: v-moodboard alone on the top display lane (0),
+ // highest z, over lower-lane clips it OVERLAPS in time. A horizontal drag (the
+ // preview yields insertRow=null, previewTrack unchanged) must be a pure time
+ // move: single-clip persist, and NOT a single z patch or history entry — even
+ // with the z-sync deps fully wired and time-overlapping neighbours present.
+ const fixture = (): TimelineElement[] => [
+ {
+ id: "v-moodboard",
+ key: "v-moodboard",
+ tag: "video",
+ start: 19,
+ duration: 5.5,
+ track: 0,
+ zIndex: 37,
+ domId: "v-moodboard",
+ },
+ {
+ id: "v-dashboard",
+ key: "v-dashboard",
+ tag: "video",
+ start: 19,
+ duration: 4,
+ track: 1,
+ zIndex: 16,
+ domId: "v-dashboard",
+ },
+ {
+ id: "v-globe",
+ key: "v-globe",
+ tag: "video",
+ start: 23,
+ duration: 1.5,
+ track: 1,
+ zIndex: 17,
+ domId: "v-globe",
+ },
+ {
+ id: "cap",
+ key: "cap",
+ tag: "text",
+ start: 20.82,
+ duration: 1.78,
+ track: 2,
+ zIndex: 0,
+ domId: "cap",
+ },
+ ];
+
+ it("+2s on its own lane → single onMoveElement, zero stacking patches", async () => {
+ const elements = fixture();
+ const z: Record = { "v-moodboard": 37, "v-dashboard": 16, "v-globe": 17 };
+ const onStackingPatches = vi.fn();
+ // previewTrack === element.track (0) and insertRow null → pure time move.
+ const { onMoveElement, onMoveElements } = runClipMove(
+ drag(elements[0], { previewStart: 21, previewTrack: 0 }),
+ {
+ elements,
+ trackOrder: [0, 1, 2],
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
+ onStackingPatches,
+ },
+ );
+ await flushMicrotasks();
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
+ expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 21, track: 0 });
+ expect(onMoveElements).not.toHaveBeenCalled();
+ expect(onStackingPatches).not.toHaveBeenCalled(); // zero z patches / history entries
+ });
+
+ it("guard: a topology call that AIMS at the clip's own display lane issues no z nudge", async () => {
+ // Belt-and-suspenders: even if a spurious insert whose boundary equals the
+ // clip's own lane slips into the topology branch, aiming at the current lane
+ // must never restack (syncStackingForEdit's aimedLane === currentLane no-op).
+ const elements = fixture();
+ const z: Record = { "v-moodboard": 37, "v-dashboard": 16, "v-globe": 17 };
const onStackingPatches = vi.fn();
- runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
+ // insertRow 0 === v-moodboard's own display lane (0).
+ runClipMove(drag(elements[0], { previewStart: 21, previewTrack: 0, insertRow: 0 }), {
+ elements,
+ trackOrder: [0, 1, 2],
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
+ onStackingPatches,
+ });
+ await flushMicrotasks();
+ expect(onStackingPatches).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("horizontal drag among overlapping neighbours touches exactly ONE clip (live repro)", () => {
+ // The disease: a plain horizontal drag of one caption rewrote its own track,
+ // added a z-index, and rewrote FOUR other clips' track-indexes. The cure: a
+ // horizontal move writes ONLY the dragged clip's start — no other clip's
+ // start/track, no z — even surrounded by time-overlapping clips.
+ it("writes only the dragged clip's start; no other clip, no z", async () => {
+ const elements: TimelineElement[] = [
+ {
+ id: "cap",
+ key: "cap",
+ tag: "text",
+ start: 4.5,
+ duration: 1.2,
+ track: 0,
+ zIndex: 26,
+ domId: "cap",
+ },
+ {
+ id: "n1",
+ key: "n1",
+ tag: "video",
+ start: 4,
+ duration: 3,
+ track: 1,
+ zIndex: 12,
+ domId: "n1",
+ },
+ {
+ id: "n2",
+ key: "n2",
+ tag: "video",
+ start: 4,
+ duration: 3,
+ track: 2,
+ zIndex: 19,
+ domId: "n2",
+ },
+ {
+ id: "n3",
+ key: "n3",
+ tag: "text",
+ start: 5,
+ duration: 2,
+ track: 3,
+ zIndex: 25,
+ domId: "n3",
+ },
+ ];
+ const onStackingPatches = vi.fn();
+ // Pure horizontal: previewTrack === element.track (0), no insert.
+ const { updateElement, onMoveElement, onMoveElements } = runClipMove(
+ drag(elements[0], { previewStart: 5.5, previewTrack: 0, desiredTrack: 0 }),
+ {
+ elements,
+ trackOrder: [0, 1, 2, 3],
+ readZIndex: (e) => (e.zIndex as number) ?? 0,
+ onStackingPatches,
+ },
+ );
+ await flushMicrotasks();
+ // Exactly one clip written, start only, no z entry.
+ expect(updateElement).toHaveBeenCalledTimes(1);
+ expect(updateElement).toHaveBeenCalledWith("cap", { start: 5.5, track: 0 });
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
+ expect(onMoveElements).not.toHaveBeenCalled();
+ expect(onStackingPatches).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("horizontal collision relocation (ITEM 2 — dragged clip only, never z)", () => {
+ it("a horizontal drag bumped to a free lane writes ONLY the dragged clip, and never z", async () => {
+ // The pointer stayed on lane 0 (desiredTrack === element.track), but the
+ // target span was occupied there, so the collision rules relocated the DRAGGED
+ // clip to lane 1 (previewTrack 1). That is not a deliberate vertical move:
+ // persist just the dragged clip (new start + relocated lane), rewrite no other
+ // clip, and issue zero z patches even though it now overlaps a neighbour.
+ const elements: TimelineElement[] = [
+ { id: "a", key: "a", tag: "video", start: 0, duration: 5, track: 0, zIndex: 2, domId: "a" },
+ { id: "d", key: "d", tag: "video", start: 0, duration: 5, track: 2, zIndex: 0, domId: "d" },
+ ];
+ const onStackingPatches = vi.fn();
+ const { onMoveElements } = runClipMove(
+ // desiredTrack 0 (pointer never left lane 0) but previewTrack 1 (bumped).
+ drag(elements[0], { previewStart: 2, previewTrack: 1, desiredTrack: 0 }),
+ {
+ elements,
+ trackOrder: [0, 1, 2],
+ readZIndex: (e) => (e.zIndex as number) ?? 0,
+ onStackingPatches,
+ },
+ );
+ await flushMicrotasks();
+ const map = editMap(onMoveElements.mock.calls[0][0]);
+ expect(map.a).toEqual({ start: 2, track: 1 }); // dragged clip relocated
+ expect(map.d).toBeUndefined(); // untouched
+ expect(onStackingPatches).not.toHaveBeenCalled(); // horizontal → never z
+ });
+ });
+
+ describe("capabilities gate (ITEM 2)", () => {
+ const locked = (
+ id: string,
+ track: number,
+ start: number,
+ duration: number,
+ ): TimelineElement => ({
+ ...el(id, track, start, duration),
+ timelineLocked: true,
+ });
+
+ it("a marquee containing a locked clip never persists an edit for the locked clip", () => {
+ const dragged = el("a", 0, 2, 3);
+ const elements = [dragged, locked("b", 1, 10, 3)];
+ // Pure time-move +5 on the same lane while {a, b} are marquee-selected.
+ const { map } = runMarqueeMove(dragged, 7, elements);
+ expect(map.a).toEqual({ start: 7, track: 0 });
+ expect(map.b).toBeUndefined(); // locked → filtered out of the moving set
+ });
+
+ it("a plain lane change never persists a locked neighbour (only the dragged clip is written)", async () => {
+ const dragged = el("a", 0, 0, 3);
+ const elements = [dragged, locked("b", 1, 0, 3)];
+ // Drag a onto b's lane. A plain lane change writes ONLY the dragged clip, so
+ // the locked neighbour b is inherently untouched (never even a candidate).
+ const { onMoveElements } = runClipMove(drag(dragged, { previewStart: 0, previewTrack: 1 }), {
elements,
trackOrder: [0, 1],
+ });
+ await flushMicrotasks();
+ const map = editMap(onMoveElements.mock.calls[0][0]);
+ expect(map.a).toBeDefined(); // movable dragged clip persists
+ expect(map.b).toBeUndefined(); // locked clip receives no patch
+ });
+
+ it("a lane change that produces ZERO move edits fires no z-sync (no orphaned z entry, ITEM 5)", async () => {
+ // Every clip in the set is locked (including the dragged one), so the moving
+ // set filters down to nothing. persistMoveEdits resolves true for the empty
+ // batch, but the z-sync must NOT fire — otherwise a "Reorder layers" history
+ // entry lands with no corresponding move.
+ const dragged = locked("a", 0, 0, 5);
+ const elements = [dragged, locked("b", 0, 0, 5)];
+ const onMoveElements = vi.fn();
+ const onStackingPatches = vi.fn();
+ // Drag onto lane 1 (a topology change) so the lane-change branch runs.
+ commitDraggedClipMove(drag(dragged, { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
+ elements,
+ trackOrder: [0],
+ updateElement: vi.fn(),
+ onMoveElement: vi.fn(),
+ onMoveElements,
readZIndex: () => 0,
onStackingPatches,
});
+ await flushMicrotasks();
+ expect(onMoveElements).not.toHaveBeenCalled(); // empty batch → no persist call
+ expect(onStackingPatches).not.toHaveBeenCalled(); // and no orphaned z entry
+ });
+ });
+
+ describe("z-sync serialization + rollback (ITEM 3)", () => {
+ it("defers the z-sync until the move persist resolves (no clobbering pre-write)", async () => {
+ const elements = overlapping();
+ let resolveMove!: () => void;
+ const onMoveElements = vi.fn(() => new Promise((r) => (resolveMove = r)));
+ const onStackingPatches = vi.fn();
+ commitInsertAbove(elements, { onMoveElements, onStackingPatches });
+ await flushMicrotasks();
+ // Move persist still pending → the z patch must NOT have been issued yet.
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
expect(onStackingPatches).not.toHaveBeenCalled();
+ resolveMove();
+ await flushMicrotasks();
+ // Only after the write lands does the z patch fire — once, for the edited clip.
+ expectZLiftedToSix(onStackingPatches);
});
- it("pure time-move (no lane change) never triggers a stacking patch", () => {
- const elements = [el("a", 0, 0, 10), el("b", 0, 0, 10)];
+ it("rolls back the move and skips the z-sync when the persist fails", async () => {
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const elements = overlapping();
+ const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
const onStackingPatches = vi.fn();
- // same track → not a topology change → z-sync branch not reached.
- runClipMove(drag(elements[0], { previewStart: 3, previewTrack: 0 }), {
+ const updateElement = vi.fn();
+ commitInsertAbove(elements, { updateElement, onMoveElements, onStackingPatches });
+ await flushMicrotasks();
+ // Failed move → z patch never issued (no orphaned z change left behind)...
+ expect(onStackingPatches).not.toHaveBeenCalled();
+ // ...and the optimistic start/track edit for the dragged clip is rolled back.
+ expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
+ errSpy.mockRestore();
+ });
+ });
+
+ describe("lane-change undo coalescing (ITEM 3c)", () => {
+ const commitLaneChange = (elements: TimelineElement[]) => {
+ const onMoveElements = vi.fn();
+ const onStackingPatches = vi.fn();
+ commitInsertAbove(elements, { onMoveElements, onStackingPatches });
+ return { onMoveElements, onStackingPatches };
+ };
+
+ it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the two records merge into a single undo entry", async () => {
+ const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping());
+ await flushMicrotasks();
+
+ // Both sides receive the SAME non-empty gesture key (second arg).
+ const moveKey = onMoveElements.mock.calls[0][1];
+ const zKey = onStackingPatches.mock.calls[0][1];
+ expect(typeof moveKey).toBe("string");
+ expect(moveKey).not.toBe("");
+ expect(zKey).toBe(moveKey);
+
+ // With that shared key, editHistory folds the two consecutive records (the
+ // "Move timeline clips" write + the "Reorder layers" z patch, same file,
+ // inside the coalesce window) into ONE undo entry spanning before→after.
+ const now = 1_000;
+ const moveEntry = buildEditHistoryEntry({
+ id: "m",
+ projectId: "p",
+ label: "Move timeline clips",
+ kind: "timeline",
+ coalesceKey: moveKey,
+ now,
+ files: { "index.html": { before: "", after: "" } },
+ });
+ const zEntry = buildEditHistoryEntry({
+ id: "z",
+ projectId: "p",
+ label: "Reorder layers",
+ kind: "timeline",
+ coalesceKey: zKey,
+ now: now + 50,
+ files: { "index.html": { before: "", after: "" } },
+ });
+ const state = pushEditHistoryEntry(
+ pushEditHistoryEntry(createEmptyEditHistory(), moveEntry),
+ zEntry,
+ );
+ expect(state.undo).toHaveLength(1);
+ expect(state.undo[0].files["index.html"]).toMatchObject({ before: "", after: "" });
+ });
+
+ it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => {
+ const { onMoveElements: first } = commitLaneChange(overlapping());
+ const { onMoveElements: second } = commitLaneChange(overlapping());
+ await flushMicrotasks();
+ expect(first.mock.calls[0][1]).not.toBe(second.mock.calls[0][1]);
+ });
+
+ it("a plain move that issues no z patch persists once and records no second entry (unchanged)", async () => {
+ // a & b do NOT overlap in time → the z-sync produces zero patches.
+ const elements = [el("a", 1, 0, 5), el("b", 0, 10, 5)];
+ const onMoveElements = vi.fn();
+ const onStackingPatches = vi.fn();
+ commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
elements,
- trackOrder: [0],
+ trackOrder: [0, 1],
+ updateElement: vi.fn(),
+ onMoveElement: vi.fn(),
+ onMoveElements,
readZIndex: () => 0,
onStackingPatches,
});
+ await flushMicrotasks();
+ // Move persists exactly once; no z entry is created → nothing to merge, so
+ // the single "Move timeline clips" entry stands alone as before.
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
expect(onStackingPatches).not.toHaveBeenCalled();
});
});
diff --git a/packages/studio/src/player/components/timelineClipDragCommit.ts b/packages/studio/src/player/components/timelineClipDragCommit.ts
index b02ac1bb03..a36be7d1f4 100644
--- a/packages/studio/src/player/components/timelineClipDragCommit.ts
+++ b/packages/studio/src/player/components/timelineClipDragCommit.ts
@@ -2,6 +2,7 @@ import type { TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./useTimelineClipDrag";
import { classifyZone, normalizeToZones } from "./timelineZones";
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
+import { getTimelineEditCapabilities } from "./timelineEditing";
type StartTrack = Pick;
export interface TimelineMoveEdit {
@@ -15,8 +16,11 @@ export interface DragCommitDeps {
updateElement: (key: string, updates: Partial) => void;
/** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */
onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise | void;
- /** Atomic multi-clip persist (single undo) for lane changes + track inserts. */
- onMoveElements?: (edits: TimelineMoveEdit[]) => Promise | void;
+ /** Atomic multi-clip persist (single undo) for lane changes + track inserts.
+ * `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
+ * history entry so it merges with the lane change's z-reorder entry (see the
+ * lane-change branch below). */
+ onMoveElements?: (edits: TimelineMoveEdit[], coalesceKey?: string) => Promise | void;
/**
* The current multi-selection (store.selectedElementIds). When the dragged
* clip is part of a multi-selection (size > 1), the WHOLE selection moves by
@@ -25,12 +29,13 @@ export interface DragCommitDeps {
*/
selectedKeys?: ReadonlySet | null;
/**
- * Lane ↔ stacking unification. When a lane change happens, the edited clip(s)
- * get z-index patches so their stacking matches lane order (higher lane = on
- * top) relative to time-overlapping clips — see timelineStackingSync. Both
- * deps must be supplied to engage; if either is absent the z-sync is skipped
- * (pure time-moves never restack). `readZIndex` returns the clip's current
- * z-index (from the live DOM inline style / computed; "auto" ⇒ 0).
+ * Lane ↔ stacking unification. When a DELIBERATE vertical lane change happens,
+ * the edited clip(s) get z-index patches so their canvas stacking matches lane
+ * order (higher lane = on top) relative to time-overlapping clips — see
+ * timelineStackingSync. Both deps must be supplied to engage; if either is
+ * absent the z-sync is skipped (pure time-moves and horizontal collision bumps
+ * never restack). `readZIndex` returns the clip's current z-index (from the
+ * live DOM inline style / computed; "auto" ⇒ 0).
*/
readZIndex?: (element: TimelineElement) => number;
/**
@@ -39,22 +44,62 @@ export interface DragCommitDeps {
* the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in
* research/STAGE3-NEEDED-WIRING.md.
*/
- onStackingPatches?: (patches: StackingPatch[]) => void;
+ onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void;
}
const keyOf = (e: TimelineElement) => e.key ?? e.id;
const round3 = (v: number) => Math.round(v * 1000) / 1000;
+// One coalesce key per lane-change gesture, shared by the move-persist history
+// entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder
+// layers") so editHistory (pushEditHistoryEntry) folds the two consecutive
+// records into a single undo step. A monotonic counter — NOT Date.now() /
+// Math.random(), which the determinism rules forbid — suffices: the key only has
+// to be unique per gesture and identical across the gesture's two records.
+let laneChangeGestureSeq = 0;
+
+/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
+function canMoveElement(element: TimelineElement): boolean {
+ return getTimelineEditCapabilities({
+ tag: element.tag,
+ duration: element.duration,
+ domId: element.domId,
+ selector: element.selector,
+ compositionSrc: element.compositionSrc,
+ playbackStart: element.playbackStart,
+ playbackStartAttr: element.playbackStartAttr,
+ sourceDuration: element.sourceDuration,
+ timingSource: element.timingSource,
+ timelineLocked: element.timelineLocked,
+ }).canMove;
+}
+
/**
* Optimistically apply + persist a batch of moves with rollback on failure.
*
- * Fire-and-forget: the persistence promise is intentionally NOT awaited (the caller
- * returns void). The DOM is updated synchronously up front and the `.catch` rolls the
- * optimistic edits back if the async write rejects, so the UI never blocks on I/O.
+ * Returns a promise that resolves `true` once the write lands, or `false` after a
+ * rejected write has been rolled back. The caller uses this to SERIALIZE the
+ * lane→z stacking patch: the z-sync is a separate server style-patch, and firing
+ * it before this full-file write resolves let the move (computed from a pre-z
+ * snapshot) land after — and clobber — the z change. A failed move resolves
+ * `false` so the caller also skips the z-sync (no orphaned z patch).
+ *
+ * The DOM is updated synchronously up front; the returned promise never rejects.
*/
-function persistMoveEdits(edits: TimelineMoveEdit[], deps: DragCommitDeps): void {
- if (edits.length === 0) return;
+function persistMoveEdits(
+ edits: TimelineMoveEdit[],
+ deps: DragCommitDeps,
+ coalesceKey?: string,
+): Promise {
+ if (edits.length === 0) return Promise.resolve(true);
const { updateElement, onMoveElement, onMoveElements } = deps;
+ if (!onMoveElements) {
+ console.warn(
+ onMoveElement
+ ? `[Timeline] persistMoveEdits: only single-clip onMoveElement wired — this ${edits.length}-clip move degrades to a per-clip persist race (no atomic single-undo)`
+ : `[Timeline] persistMoveEdits: no move persist handler wired — ${edits.length} edit(s) applied to the store only, not saved`,
+ );
+ }
const prev = edits.map((e) => ({
key: keyOf(e.element),
start: e.element.start,
@@ -62,18 +107,23 @@ function persistMoveEdits(edits: TimelineMoveEdit[], deps: DragCommitDeps): void
}));
for (const e of edits) updateElement(keyOf(e.element), e.updates);
const persisted = onMoveElements
- ? onMoveElements(edits)
+ ? onMoveElements(edits, coalesceKey)
: Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
- Promise.resolve(persisted).catch((error) => {
- for (const p of prev) updateElement(p.key, { start: p.start, track: p.track });
- console.error("[Timeline] Failed to persist clip edits", error);
- });
+ return Promise.resolve(persisted).then(
+ () => true,
+ (error) => {
+ for (const p of prev) updateElement(p.key, { start: p.start, track: p.track });
+ console.error("[Timeline] Failed to persist clip edits", error);
+ return false;
+ },
+ );
}
/**
* A fractional track value for a NEW lane inserted at boundary `insertRow` in
* `trackOrder` (0 = above the top, `length` = below the bottom). normalizeToZones
- * then compacts it to a distinct integer lane between its neighbours.
+ * 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.
*/
function insertTrackValue(trackOrder: number[], insertRow: number): number {
if (trackOrder.length === 0) return 0;
@@ -82,40 +132,73 @@ function insertTrackValue(trackOrder: number[], insertRow: number): number {
return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2;
}
+/**
+ * Build the time-shift resolver for a multi-selection drag: every member of the
+ * selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
+ * untouched. Returns null when this is not a multi-selection drag. A locked /
+ * implicit member is dropped from the moving set (a marquee can sweep one in).
+ */
+function resolveMultiSelection(
+ drag: DraggedClipState,
+ deps: DragCommitDeps,
+): { keys: ReadonlySet; movedStart: (e: TimelineElement) => number } | null {
+ const { elements, selectedKeys } = deps;
+ const dragKey = keyOf(drag.element);
+ if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null;
+ const keys = new Set(
+ [...selectedKeys].filter((k) => {
+ const el = elements.find((e) => keyOf(e) === k);
+ return el ? canMoveElement(el) : false;
+ }),
+ );
+ const delta = drag.previewStart - drag.element.start;
+ const movedStart = (e: TimelineElement): number =>
+ keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta));
+ return { keys, movedStart };
+}
+
/**
* Commit a finished clip drag.
*
- * - **Pure time-move** (same lane): persist just the dragged clip's start via the
- * SDK-aware single-clip handler.
- * - **Lane change / new track**: apply the move (a fractional track for an insert),
- * RE-NORMALIZE the whole element set (normalizeToZones) so display track indices
- * are contiguous + kind-grouped, and persist EVERY clip atomically (single undo).
- * This is the fix for the raw-vs-normalized collision: persisting only the dragged
- * clip left other clips' unchanged source indices to clash on reload → overlap.
+ * The lane model is CapCut-stable: a clip's display lane is its track, and editing
+ * ONE clip must never re-lane or rewrite OTHER clips. Three outcomes:
+ *
+ * - **Pure time-move** (dragged clip keeps its lane, no insert): persist just the
+ * dragged clip's start (multi-selection shifts every selected clip in time).
+ * - **Lane change / collision relocation** (the dragged clip's OWN lane changes,
+ * no new track): persist ONLY the dragged clip's start + lane. No other clip is
+ * touched. z is synced only when the gesture is a DELIBERATE vertical move
+ * (the pointer aimed at another lane) — a horizontal drag merely bumped to a
+ * free lane never restacks.
+ * - **Track insert** (a new lane at a gap boundary): the dragged clip lands on
+ * the new lane and the clips at/below the insert are renumbered by +1 (the ONLY
+ * permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
*/
// fallow-ignore-next-line complexity
export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
- const { elements, trackOrder, updateElement, onMoveElement, selectedKeys } = deps;
+ const { elements, updateElement, onMoveElement } = deps;
const dragKey = keyOf(drag.element);
- const isTopologyChange = drag.insertRow != null || drag.previewTrack !== drag.element.track;
- // Multi-selection drag: engaged only when the dragged clip is itself part of
- // a multi-selection. Every selected clip shifts by the same time delta
- // (clamped ≥ 0); only the dragged clip changes track.
- const multiKeys =
- selectedKeys && selectedKeys.size > 1 && selectedKeys.has(dragKey) ? selectedKeys : null;
- const delta = drag.previewStart - drag.element.start;
- const movedStart = (e: TimelineElement): number =>
- keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta));
+ const isInsert = drag.insertRow != null;
+ const laneChanged = drag.previewTrack !== drag.element.track;
+ // Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at a
+ // gap boundary (insert). A plain HORIZONTAL drag whose target span is occupied
+ // gets the DRAGGED clip bumped to a free lane (previewTrack differs) while the
+ // pointer never left its lane (desiredTrack === element.track) — that is NOT a
+ // vertical move: it must neither rewrite other clips nor touch z.
+ const aimTrack = drag.desiredTrack ?? drag.previewTrack;
+ const isVertical = isInsert || aimTrack !== drag.element.track;
+ const multi = resolveMultiSelection(drag, deps);
- // ── Pure time-move (same lane) ──────────────────────────────────────────────
- if (!isTopologyChange) {
+ // ── Pure time-move (dragged clip keeps its lane, no insert) ─────────────────
+ if (!isInsert && !laneChanged) {
+ const delta = drag.previewStart - drag.element.start;
if (delta === 0) return;
- if (multiKeys) {
+ if (multi) {
const edits: TimelineMoveEdit[] = elements
- .filter((e) => multiKeys.has(keyOf(e)))
- .map((e) => ({ element: e, updates: { start: movedStart(e), track: e.track } }))
+ .filter((e) => multi.keys.has(keyOf(e)))
+ .map((e) => ({ element: e, updates: { start: multi.movedStart(e), track: e.track } }))
.filter((e) => e.updates.start !== e.element.start);
- persistMoveEdits(edits, deps);
+ void persistMoveEdits(edits, deps);
return;
}
const updates = { start: drag.previewStart, track: drag.element.track };
@@ -128,58 +211,148 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
return;
}
- // ── Lane change / new track: normalize the whole set, persist all atomically ─
- const targetTrack =
- drag.insertRow != null ? insertTrackValue(trackOrder, drag.insertRow) : drag.previewTrack;
+ // ── Track insert: renumber the at/below clips by +1 (the one multi-clip write) ─
+ if (isInsert) {
+ commitTrackInsert(drag, deps, multi);
+ return;
+ }
+
+ // ── Lane change / collision relocation: persist ONLY the dragged clip ────────
+ // CapCut invariant — one edit never re-lanes another clip. The dragged clip
+ // takes its new lane (previewTrack); the rest of any selection shifts in time
+ // only. Nothing else is written.
+ const dragEdit: TimelineMoveEdit = {
+ element: drag.element,
+ updates: { start: drag.previewStart, track: drag.previewTrack },
+ };
+ const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
+
+ const edits: TimelineMoveEdit[] = [dragEdit];
+ if (multi) {
+ for (const e of elements) {
+ if (keyOf(e) === dragKey || !multi.keys.has(keyOf(e))) continue;
+ const start = multi.movedStart(e);
+ if (start !== e.start) edits.push({ element: e, updates: { start, track: e.track } });
+ }
+ }
+ // 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 multiKeys = multi ? multi.keys : null;
+ void persistMoveEdits(edits, deps, coalesceKey).then((moved) => {
+ if (moved && isVertical) {
+ syncStackingForEdit(
+ candidate,
+ dragKey,
+ drag.element.track,
+ drag.previewTrack,
+ multiKeys,
+ deps,
+ coalesceKey,
+ );
+ }
+ });
+}
+
+/**
+ * Insert a new track at the drop's gap boundary. The dragged clip lands on the
+ * fractional insert lane; normalizeToZones then compacts every lane to a contiguous
+ * integer, which shifts the clips at/below the insert down by one. That +1
+ * renumber is the ONLY sanctioned multi-clip write; it is index-only (never z).
+ * The whole affected set is persisted atomically (single undo), and the deliberate
+ * vertical move syncs the dragged clip's stacking afterwards.
+ */
+function commitTrackInsert(
+ drag: DraggedClipState,
+ deps: DragCommitDeps,
+ multi: { keys: ReadonlySet; movedStart: (e: TimelineElement) => number } | null,
+): void {
+ const { elements, trackOrder } = deps;
+ const dragKey = keyOf(drag.element);
+ const targetTrack = insertTrackValue(trackOrder, drag.insertRow!);
+ // Drop-intent set: dragged clip at the fractional insert lane (so it sorts
+ // between its neighbours), selection members time-shifted, others as-is.
const candidate = elements.map((e) => {
if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: targetTrack };
- if (multiKeys?.has(keyOf(e))) return { ...e, start: movedStart(e) };
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
return e;
});
+ // normalizeToZones compacts the fractional lane to a contiguous integer, which
+ // 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]));
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).
+ if (!canMoveElement(src)) continue;
const start =
- keyOf(norm) === dragKey || multiKeys?.has(keyOf(norm)) ? movedStart(src) : src.start;
+ keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
+ ? (multi?.movedStart(src) ?? drag.previewStart)
+ : src.start;
edits.push({ element: src, updates: { start, track: norm.track } });
}
- persistMoveEdits(edits, deps);
-
- // Lane ↔ stacking: patch the edited clip(s)' z-index so their stacking matches
- // the user's DROP-INTENT lane order relative to time-overlapping clips. We must
- // reason on `candidate` (the drop-intent tracks) — NOT `normalized` — because
- // normalizeToZones is purely z/DOM-driven and re-packs a lane-drag that
- // contradicts z straight back down; reading the post-normalize lane would make
- // the sync see the reverted layout and never realise the user's move. The
- // fractional drop track (e.g. −0.5 for "above the top lane") preserves the drop
- // order against the other clips' tracks. Only the edited clip(s) change; the
- // untouched clips' authored z stays sacred (unless a cascade is unavoidable).
- syncStackingForEdit(candidate, dragKey, multiKeys, deps);
+
+ const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
+ void persistMoveEdits(edits, deps, coalesceKey).then((moved) => {
+ // Skip the z-sync when the insert produced NO move edits (e.g. every clip in
+ // the set is locked/implicit and gets filtered out). persistMoveEdits resolves
+ // `true` for an empty batch so the caller's serialization proceeds, but firing
+ // the z-sync here would record an orphaned z-only history entry for a move that
+ // never persisted.
+ if (moved && edits.length > 0) {
+ // Reason the z-sync on the drop-intent `candidate` (dragged clip at its
+ // fractional insert lane) — NOT the re-normalized lanes — so the sync sees
+ // the user's move. The guard lane is the aimed insert row (a boundary in
+ // display-lane space, comparable to the clip's contiguous current lane).
+ syncStackingForEdit(
+ candidate,
+ dragKey,
+ drag.element.track,
+ drag.insertRow!,
+ multi ? multi.keys : null,
+ deps,
+ coalesceKey,
+ );
+ }
+ });
}
/**
- * Compute + apply z-index patches for the edited clip(s) after a lane change.
- * Projects the DROP-INTENT element set (`candidate`: edited clip at its dropped
- * fractional track, others at their current tracks) onto StackingElement using
- * the caller-supplied live z-index reader, then delegates the minimal-z
- * resolution to computeStackingPatches. No-op unless both z-sync deps are present.
+ * Compute + apply z-index patches for the edited clip(s) after a DELIBERATE
+ * vertical lane change. Projects the drop-intent element set (`candidate`: the
+ * dragged clip at its new / fractional-insert lane, others at their current tracks)
+ * onto StackingElement using the caller-supplied live z-index reader, then
+ * delegates the minimal-z resolution to computeStackingPatches — a clip on the
+ * upper lane paints above every clip it time-overlaps. No-op unless both z-sync
+ * deps are present, and never when the gesture aimed at the clip's OWN current
+ * lane (`aimedLane === currentLane` — not a relocation).
*/
function syncStackingForEdit(
candidate: TimelineElement[],
dragKey: string,
+ currentLane: number,
+ aimedLane: number,
multiKeys: ReadonlySet | null,
deps: DragCommitDeps,
+ coalesceKey?: string,
): void {
const { readZIndex, onStackingPatches } = deps;
if (!readZIndex || !onStackingPatches) return;
+ // Aiming at the clip's OWN current display lane is not a relocation — never
+ // touch z (guards the pure-time-move invariant even if a spurious topology call
+ // slips through). Every real lane-realization drop aims at a DIFFERENT lane.
+ if (aimedLane === currentLane) return;
+
// `candidate` is in discovery order, so its array index IS the DOM document
// position. Equal-z clips paint by DOM order, so the sync needs it to decide
- // "is A above B" (see StackingElement.domIndex) — without it a bottom-lane drag
- // over an equal-z neighbour could no-op on canvas.
+ // "is A above B" (see StackingElement.domIndex).
const stackingEls = candidate.map((el, domIndex) => ({
key: keyOf(el),
start: el.start,
@@ -194,5 +367,5 @@ function syncStackingForEdit(
if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
const patches = computeStackingPatches(stackingEls, editedKeys);
- if (patches.length > 0) onStackingPatches(patches);
+ if (patches.length > 0) onStackingPatches(patches, coalesceKey);
}
diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts
new file mode 100644
index 0000000000..fc1a4ca2bf
--- /dev/null
+++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview";
+import type { DraggedClipState } from "./timelineClipDragTypes";
+import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
+// on its own top lane armed a phantom new-track insert (the old 0.32 insert band
+// reached deep into the clip body). That insert flipped the commit into the
+// lane-change branch, which nudged the clip's z-index and re-sorted it off its
+// lane. The invariant: a horizontal drag over a clip BODY → insertRow === null,
+// previewTrack unchanged (a pure time move — zero topology change, zero z sync).
+//
+// Elements mirror the user's index.html shapes: a high-z "v-moodboard" alone on
+// the top display lane, over several lower-lane video clips it overlaps in time,
+// plus a caption. Tracks here are already the normalized DISPLAY lanes (the store
+// runs normalizeToZones on discovery), matching what the drag hook passes in.
+// ─────────────────────────────────────────────────────────────────────────────
+
+const PPS = 40;
+
+function clip(
+ id: string,
+ track: number,
+ start: number,
+ duration: number,
+ zIndex: number,
+ tag = "video",
+): TimelineElement {
+ return { id, key: id, tag, start, duration, track, zIndex, domId: id };
+}
+
+// v-moodboard: own top lane (0). Lower lane (1) carries overlapping video clips;
+// captions sit on lane 2. trackOrder = [0, 1, 2].
+const moodboard = clip("v-moodboard", 0, 19, 5.5, 37);
+const fixtureElements: TimelineElement[] = [
+ moodboard,
+ clip("v-dashboard", 1, 19, 4, 16),
+ clip("v-globe", 1, 23, 1.5, 17),
+ clip("cap", 2, 20.82, 1.78, 0, "text"),
+];
+
+// A scroll container whose content-space y equals clientY (rect top 0, no scroll).
+function fakeScroll(): HTMLDivElement {
+ return {
+ getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }),
+ scrollLeft: 0,
+ scrollTop: 0,
+ scrollWidth: 100000,
+ } as unknown as HTMLDivElement;
+}
+
+function ctx(): DragPreviewContext {
+ return {
+ scroll: fakeScroll(),
+ pps: PPS,
+ duration: 44.5,
+ trackOrder: [0, 1, 2],
+ elements: fixtureElements,
+ selectedKeys: new Set(),
+ buildSnapTargets: () => [],
+ audioTracks: new Set(),
+ };
+}
+
+// content-space y for a fractional row index (inverse of getTimelineRowFromY).
+const yForRow = (rowFloat: number) => RULER_H + TRACKS_TOP_PAD + rowFloat * TRACK_H;
+
+// A drag grabbing `element` at vertical position `grabRowFloat` within its lane.
+function horizontalDrag(
+ element: TimelineElement,
+ grabRowFloat: number,
+ deltaSeconds: number,
+): { drag: DraggedClipState; clientX: number; clientY: number } {
+ const originClientX = 800;
+ const originClientY = yForRow(grabRowFloat);
+ const drag: DraggedClipState = {
+ element,
+ originClientX,
+ originClientY,
+ originScrollLeft: 0,
+ originScrollTop: 0,
+ pointerClientX: originClientX,
+ pointerClientY: originClientY,
+ pointerOffsetX: 0,
+ pointerOffsetY: 0,
+ previewStart: element.start,
+ previewTrack: element.track,
+ insertRow: null,
+ snapTime: null,
+ snapType: null,
+ started: true,
+ };
+ // Horizontal: clientY stays at the grab point; only x advances by the delta.
+ return { drag, clientX: originClientX + deltaSeconds * PPS, clientY: originClientY };
+}
+
+describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => {
+ it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => {
+ const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2);
+ const next = computeDragPreview(drag, clientX, clientY, ctx());
+ expect(next.insertRow).toBeNull(); // no phantom new-track insert
+ expect(next.previewTrack).toBe(0); // stays on its own lane
+ expect(next.desiredTrack).toBe(0); // pointer never left lane 0 → not a vertical aim
+ expect(next.previewStart).toBeCloseTo(21, 5); // +2s moved
+ });
+
+ it("grabbing ANYWHERE across the clip body (not just dead-center) stays a pure time move", () => {
+ // Sweep the whole clip body of lane 0; a horizontal drag must never insert.
+ for (let grab = 0.1; grab <= 0.9 + 1e-9; grab += 0.1) {
+ const { drag, clientX, clientY } = horizontalDrag(moodboard, grab, 2);
+ const next = computeDragPreview(drag, clientX, clientY, ctx());
+ expect(next.insertRow).toBeNull();
+ expect(next.previewTrack).toBe(0);
+ }
+ });
+
+ it("aiming the gutter ABOVE the top lane arms a top insert (UX rule 2)", () => {
+ // Drag v-moodboard up into the top breathing pad → insert a new top track.
+ const originClientX = 800;
+ const originClientY = yForRow(0.5);
+ const drag: DraggedClipState = {
+ element: moodboard,
+ originClientX,
+ originClientY,
+ originScrollLeft: 0,
+ originScrollTop: 0,
+ pointerClientX: originClientX,
+ pointerClientY: originClientY,
+ pointerOffsetX: 0,
+ pointerOffsetY: 0,
+ previewStart: moodboard.start,
+ previewTrack: moodboard.track,
+ insertRow: null,
+ snapTime: null,
+ snapType: null,
+ started: true,
+ };
+ // Pointer well above the first lane (into the top pad → rowFloat < 0).
+ const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx());
+ expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
+ });
+});
diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts
index 4ba27f0164..30b06d3ea6 100644
--- a/packages/studio/src/player/components/timelineClipDragPreview.ts
+++ b/packages/studio/src/player/components/timelineClipDragPreview.ts
@@ -1,6 +1,6 @@
import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
import type { TimelineElement } from "../store/playerStore";
-import { TRACK_H, getTimelineRowFromY } from "./timelineLayout";
+import { TRACK_H, getTimelineRowFromY, INSERT_BOUNDARY_BAND } from "./timelineLayout";
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
import {
TIMELINE_SNAP_PX,
@@ -9,6 +9,10 @@ import {
type TimelineSnapTarget,
} from "./timelineSnapping";
import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision";
+import {
+ applyTimelineGroupResizePreview,
+ type TimelineGroupResizeSession,
+} from "./timelineGroupEditing";
import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
@@ -26,6 +30,13 @@ export interface DragPreviewContext {
elements: TimelineElement[];
selectedKeys: ReadonlySet;
buildSnapTargets: BuildSnapTargets;
+ /**
+ * The set of tracks that hold audio clips (drives zone-aware drop placement).
+ * Frozen for the whole gesture, so the hook builds it ONCE at drag start and
+ * passes it in — see useTimelineClipDrag. Absent (e.g. in unit tests) ⇒ built
+ * on demand from `elements`, so the result is identical either way.
+ */
+ audioTracks?: ReadonlySet;
}
/**
@@ -77,8 +88,15 @@ function resolveDropPlacement(
const rowFloat = scroll
? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop)
: 0;
- const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length);
- const audioTracks = new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
+ // Geometry-exact band (the clip inset) so an insert only arms in the visible
+ // gutter BETWEEN clip bodies — dragging over a clip body is a lane move, never a
+ // phantom insert (the plain-horizontal-drag misfire). See INSERT_BOUNDARY_BAND.
+ const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length, INSERT_BOUNDARY_BAND);
+ // Pointer sub-row half: when a drop must auto-create a track (aimed span
+ // occupied, no free lane), open it on the side the pointer is nearer.
+ const preferInsertAbove = rowFloat - Math.floor(rowFloat) < 0.5;
+ const audioTracks =
+ ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
return resolveZoneDropPlacement({
order: trackOrder,
audioTracks,
@@ -89,6 +107,7 @@ function resolveDropPlacement(
duration: drag.element.duration,
dragKey: drag.element.key ?? drag.element.id,
isAudio: isAudioTimelineElement(drag.element),
+ preferInsertAbove,
});
}
@@ -157,6 +176,9 @@ export function computeDragPreview(
pointerClientY: clientY,
previewStart,
previewTrack,
+ // The lane the POINTER aims at (pre-collision): the commit reads it to tell a
+ // deliberate vertical lane change from a horizontal drag merely bumped sideways.
+ desiredTrack: nextMove.track,
insertRow,
snapTime: snap.snapTime,
snapType: snap.snapType,
@@ -284,3 +306,32 @@ export function computeResizePreview(
previewPlaybackStart: nextResize.playbackStart,
};
}
+
+/**
+ * Apply a rigid group-resize preview: fold the grabbed clip's raw delta into the
+ * session, preview every non-grabbed member through the store (`updateElement`),
+ * and set the grabbed clip's preview state (it renders from resizingClip state, so
+ * its store value stays pristine until commit — like the single-clip path).
+ */
+export function previewGroupResize(
+ session: TimelineGroupResizeSession,
+ next: ResizePreviewResult,
+ grabbedKey: string,
+ updateElement: (
+ key: string,
+ patch: { start: number; duration: number; playbackStart?: number },
+ ) => void,
+ setResizeState: (v: ResizePreviewResult) => void,
+): void {
+ const grabbedChange = applyTimelineGroupResizePreview(session, next);
+ for (const c of session.changes) {
+ if (c.key === grabbedKey) continue;
+ updateElement(c.key, { start: c.start, duration: c.duration, playbackStart: c.playbackStart });
+ }
+ setResizeState({
+ originScrollLeft: next.originScrollLeft,
+ previewStart: grabbedChange?.start ?? next.previewStart,
+ previewDuration: grabbedChange?.duration ?? next.previewDuration,
+ previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
+ });
+}
diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts
index f2c2913ead..44d9d79a52 100644
--- a/packages/studio/src/player/components/timelineEditing.test.ts
+++ b/packages/studio/src/player/components/timelineEditing.test.ts
@@ -4,19 +4,16 @@ import {
buildPromptCopyText,
buildTimelineElementAgentPrompt,
buildTimelineAgentPrompt,
- clampTimelineGroupResizeDelta,
getTimelineEditCapabilities,
hasPatchableTimelineTarget,
resolveBlockedTimelineEditIntent,
resolveTimelineAutoScroll,
+ resolveTimelineDragEscape,
resolveTimelineMove,
resolveTimelineResize,
- resolveTimelineGroupMove,
- resolveTimelineGroupResize,
- snapKeyframePctToBeat,
+ MAGNETIC_TRACK_THRESHOLD,
type TimelinePromptElement,
} from "./timelineEditing";
-import { buildStackingTimelineLayers } from "./timelineTrackOrder";
describe("resolveTimelineMove", () => {
it("moves timing based on horizontal drag and snaps to centiseconds", () => {
@@ -161,217 +158,38 @@ describe("resolveTimelineMove", () => {
).toEqual({ start: 2, track: 2 });
});
- it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => {
- const stackingElements = [
- {
- id: "root-front",
- tag: "div",
- start: 0,
- duration: 2,
- track: 0,
- zIndex: 2,
- hasExplicitZIndex: true,
- stackingContextId: "root",
- parentCompositionId: null,
- compositionAncestors: ["root"],
- },
- {
- id: "root-back",
- tag: "div",
- start: 0,
- duration: 2,
- track: 1,
- zIndex: 1,
- hasExplicitZIndex: true,
- stackingContextId: "root",
- parentCompositionId: null,
- compositionAncestors: ["root"],
- },
- ];
- const layers = buildStackingTimelineLayers(stackingElements).rows;
- const result = resolveTimelineMove(
- {
- start: 0,
- track: 1,
- duration: 2,
- originClientX: 0,
- originClientY: 0,
- pixelsPerSecond: 100,
- trackHeight: 72,
- maxStart: 8,
- trackOrder: [0, 1],
- layerOrder: layers.map((layer) => layer.id),
- timelineLayers: layers,
- stackingElement: stackingElements[1],
- stackingElements,
- },
- 0,
- -72,
- );
-
- expect(result).toEqual({
- start: 0,
+ describe("magnetic vertical threshold", () => {
+ const base = {
+ start: 1,
track: 1,
- previewLayerId: `preview:root-back:above:${layers[0]!.id}`,
- previewLayerIndex: 0,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "above", layerId: layers[0]!.id },
- zIndexChanges: [{ key: "root-back", zIndex: 3 }],
- },
- });
- });
-});
-
-describe("resolveTimelineGroupMove", () => {
- it("applies an unclamped delta uniformly", () => {
- const result = resolveTimelineGroupMove(
- [
- { start: 1, duration: 2 },
- { start: 4, duration: 3 },
- ],
- 1.25,
- );
-
- expect(result).toEqual({
- delta: 1.25,
- members: [
- { start: 2.25, duration: 2 },
- { start: 5.25, duration: 3 },
- ],
+ duration: 2,
+ originClientX: 100,
+ originClientY: 200,
+ pixelsPerSecond: 100,
+ trackHeight: 100,
+ maxStart: 8,
+ trackOrder: [0, 1, 2, 3, 4],
+ };
+
+ it("defaults to 0.5 (midpoint parity with rounding)", () => {
+ expect(MAGNETIC_TRACK_THRESHOLD).toBe(0.5);
});
- });
- it("clamps the whole group when the earliest start reaches zero", () => {
- const result = resolveTimelineGroupMove(
- [
- { start: 1, duration: 2 },
- { start: 5, duration: 3 },
- ],
- -3,
- );
-
- expect(result).toEqual({
- delta: -1,
- members: [
- { start: 0, duration: 2 },
- { start: 4, duration: 3 },
- ],
+ it("stays on the current track below the threshold", () => {
+ // 40px / 100px track = 0.4 < 0.5 → no lane change
+ expect(resolveTimelineMove(base, 100, 240).track).toBe(1);
});
- expect(result.members[1]!.start - result.members[0]!.start).toBe(4);
- });
-});
-
-describe("resolveTimelineGroupResize", () => {
- it("returns the shared clamped delta without applying per-member starts", () => {
- expect(
- clampTimelineGroupResizeDelta(
- 1,
- [
- { start: 1, duration: 0.5 },
- { start: 4, duration: 2 },
- ],
- "start",
- ),
- ).toBe(0.4);
- });
- it("applies an unclamped start-edge delta uniformly", () => {
- const result = resolveTimelineGroupResize(
- [
- { start: 1, duration: 3 },
- { start: 5, duration: 4 },
- ],
- "start",
- 1,
- );
-
- expect(result).toEqual({
- delta: 1,
- members: [
- { start: 2, duration: 2, playbackStart: undefined },
- { start: 6, duration: 3, playbackStart: undefined },
- ],
+ it("commits to the next track past the threshold", () => {
+ // 60px / 100px track = 0.6 > 0.5 → one lane down
+ expect(resolveTimelineMove(base, 100, 260).track).toBe(2);
});
- expect(result.members[1]!.start - result.members[0]!.start).toBe(4);
- });
-
- it("clamps a start-edge delta when the earliest member reaches zero", () => {
- const result = resolveTimelineGroupResize(
- [
- { start: 0.5, duration: 3 },
- { start: 4, duration: 4 },
- ],
- "start",
- -2,
- );
- expect(result).toEqual({
- delta: -0.5,
- members: [
- { start: 0, duration: 3.5, playbackStart: undefined },
- { start: 3.5, duration: 4.5, playbackStart: undefined },
- ],
- });
- expect(result.members[1]!.start - result.members[0]!.start).toBe(3.5);
- });
-
- it("clamps a start-edge delta when any member reaches minimum duration", () => {
- const result = resolveTimelineGroupResize(
- [
- { start: 1, duration: 0.5 },
- { start: 4, duration: 2 },
- ],
- "start",
- 1,
- );
-
- expect(result).toEqual({
- delta: 0.4,
- members: [
- { start: 1.4, duration: 0.1, playbackStart: undefined },
- { start: 4.4, duration: 1.6, playbackStart: undefined },
- ],
- });
- expect(result.members[1]!.start - result.members[0]!.start).toBeCloseTo(3);
- });
-
- it("clamps an end-edge delta when any member reaches minimum duration", () => {
- const result = resolveTimelineGroupResize(
- [
- { start: 1, duration: 0.5 },
- { start: 4, duration: 2 },
- ],
- "end",
- -1,
- );
-
- expect(result).toEqual({
- delta: -0.4,
- members: [
- { start: 1, duration: 0.1, playbackStart: undefined },
- { start: 4, duration: 1.6, playbackStart: undefined },
- ],
- });
- expect(result.members[1]!.start - result.members[0]!.start).toBe(3);
- });
-
- it("adjusts each start-edge playback start using the shared delta", () => {
- const result = resolveTimelineGroupResize(
- [
- { start: 2, duration: 3, playbackStart: 1, playbackRate: 1 },
- { start: 5, duration: 4, playbackStart: 2, playbackRate: 2 },
- ],
- "start",
- 0.5,
- );
-
- expect(result).toEqual({
- delta: 0.5,
- members: [
- { start: 2.5, duration: 2.5, playbackStart: 1.5 },
- { start: 5.5, duration: 3.5, playbackStart: 3 },
- ],
+ it("commits one lane at a time for larger deltas", () => {
+ // 160px / 100px = 1.6 → two lanes down
+ expect(resolveTimelineMove(base, 100, 360).track).toBe(3);
+ // upward: -160px → two lanes up (clamped by trackOrder)
+ expect(resolveTimelineMove({ ...base, track: 3 }, 100, 40).track).toBe(1);
});
});
});
@@ -693,7 +511,7 @@ describe("buildTimelineAgentPrompt", () => {
prompt: "Move the title later and lower the music",
});
- expect(text).toContain("Time range: 00:01 - 00:04");
+ expect(text).toContain("Time range: 00:01 — 00:04");
expect(text).toContain("#title (div)");
expect(text).toContain("#music (audio)");
expect(text).toContain("Move the title later and lower the music");
@@ -716,6 +534,26 @@ describe("buildTimelineElementAgentPrompt", () => {
});
});
describe("resolveTimelineResize", () => {
+ it("extends past the composition end when maxEnd is the source limit only (Infinity)", () => {
+ // Content-driven duration: trimming the LAST clip rightward must be able to
+ // grow past the current composition end — the comp grows to fit on commit.
+ // The caller passes maxEnd = start + sourceRemaining (Infinity for images).
+ expect(
+ resolveTimelineResize(
+ {
+ start: 1,
+ duration: 3,
+ originClientX: 100,
+ pixelsPerSecond: 100,
+ minStart: 0,
+ maxEnd: Number.POSITIVE_INFINITY,
+ },
+ "end",
+ 700, // +6s drag → duration 9, ends at 10 — far past any prior comp end
+ ),
+ ).toEqual({ start: 1, duration: 9, playbackStart: undefined });
+ });
+
it("shrinks clip duration from the right edge", () => {
expect(
resolveTimelineResize(
@@ -833,33 +671,44 @@ describe("buildPromptCopyText", () => {
});
});
-describe("snapKeyframePctToBeat", () => {
- // el spans 0–10s, so clip-% maps to composition time as pct * 0.1s.
- // At pps=100 the snap window is 8 / 100 = 0.08s.
- const el = { start: 0, duration: 10 };
- const beats = [2, 5, 8];
+describe("resolveTimelineDragEscape", () => {
+ const started = { started: true };
+ const pending = { started: false };
+ const none = { drag: null, resize: null, blocked: null };
- it("snaps a keyframe within ~8px of a beat exactly onto it", () => {
- // pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%.
- expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50);
- });
-
- it("leaves a keyframe unchanged when no beat is within the window", () => {
- // pct 55 → 5.5s, 0.5s from the nearest beat → free.
- expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55);
+ it("cancels only on Escape", () => {
+ expect(resolveTimelineDragEscape({ key: "Enter", ...none, drag: started })).toEqual({
+ cancel: false,
+ suppressClick: false,
+ });
+ expect(resolveTimelineDragEscape({ key: "Escape", ...none, drag: started })).toEqual({
+ cancel: true,
+ suppressClick: true,
+ });
});
- it("is a no-op when there are no beats", () => {
- expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5);
- expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5);
+ it("does nothing when no gesture is in progress", () => {
+ expect(resolveTimelineDragEscape({ key: "Escape", ...none })).toEqual({
+ cancel: false,
+ suppressClick: false,
+ });
});
- it("is a no-op for a zero-duration clip", () => {
- expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5);
+ it("cancels a resize or a pending blocked-drag", () => {
+ expect(resolveTimelineDragEscape({ key: "Escape", ...none, resize: started })).toEqual({
+ cancel: true,
+ suppressClick: true,
+ });
+ expect(resolveTimelineDragEscape({ key: "Escape", ...none, blocked: pending })).toEqual({
+ cancel: true,
+ suppressClick: false,
+ });
});
- it("widens the snap window as zoom (pps) decreases", () => {
- // pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%.
- expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50);
+ it("suppresses the pointerup click only for gestures past the drag threshold", () => {
+ expect(resolveTimelineDragEscape({ key: "Escape", ...none, drag: pending })).toEqual({
+ cancel: true,
+ suppressClick: false,
+ });
});
});
diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts
index 685f96e7c4..23a6275868 100644
--- a/packages/studio/src/player/components/timelineEditing.ts
+++ b/packages/studio/src/player/components/timelineEditing.ts
@@ -1,28 +1,5 @@
import { formatTime } from "../lib/time";
import { roundToCenti } from "../../utils/rounding";
-import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
-import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
-import { shouldShowTimelineLayerGroupHeader } from "./TimelineLayerGroupHeader";
-import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
-
-import {
- applyClipStartTrimDelta,
- clipStartTrimDeltaBounds,
- resolveTimelineMinDuration,
-} from "./timelineGroupEditing";
-
-export {
- clampTimelineGroupResizeDelta,
- resolveTimelineGroupMove,
- resolveTimelineGroupResize,
- type TimelineGroupResizeEdge,
- type TimelineGroupTimingMember,
-} from "./timelineGroupEditing";
-
-export {
- type TimelineStackingElement,
- type TimelineStackingReorderIntent,
-} from "./timelineStacking";
const roundToCentiseconds = roundToCenti;
@@ -31,6 +8,11 @@ function clamp(value: number, min: number, max: number): number {
}
const EDGE_TRACK_CREATE_THRESHOLD = 0.55;
+// Magnetic vertical drag: the clip stays on its current track until the pointer
+// is dragged past this fraction of a track height into a neighbor, then commits
+// one lane at a time. 0.5 reproduces plain midpoint rounding (regression-safe
+// default); lower = more eager lane changes, higher = stickier. Tuned by feel.
+export const MAGNETIC_TRACK_THRESHOLD = 0.5;
const AUTO_SCROLL_EDGE_ZONE = 40;
const AUTO_SCROLL_MAX_SPEED = 12;
@@ -48,12 +30,6 @@ export interface TimelineMoveInput {
trackHeight: number;
maxStart: number;
trackOrder: number[];
- layerOrder?: TimelineLayerId[];
- timelineLayers?: StackingTimelineLayer[];
- /** When provided, vertical movement is resolved as a z-index stacking reorder
- * within `stackingElement`'s context instead of a raw track change. */
- stackingElement?: TimelineStackingElement;
- stackingElements?: TimelineStackingElement[];
}
export interface TimelineResizeInput {
@@ -98,54 +74,90 @@ export function resolveTimelineAutoScroll(
};
}
+/**
+ * Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the
+ * pointer is in, clamped to the scrollable range. Returns true when the
+ * container actually moved (the caller keeps its RAF running and re-runs its
+ * live preview), false when the pointer is outside the edge zones or the scroll
+ * is already clamped (the caller stops).
+ */
+export function applyTimelineAutoScrollStep(
+ scroll: HTMLElement,
+ clientX: number,
+ clientY: number,
+): boolean {
+ const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
+ if (delta.x === 0 && delta.y === 0) return false;
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
+ const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
+ const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
+ if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return false;
+ scroll.scrollLeft = nextScrollLeft;
+ scroll.scrollTop = nextScrollTop;
+ return true;
+}
+
+/**
+ * Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is
+ * for the current pointer: "start" when the pointer is in an edge zone and no
+ * loop is running, "stop" when it left the zones while a loop is running,
+ * "none" otherwise.
+ */
+export function resolveTimelineAutoScrollLoopAction(
+ scroll: HTMLElement | null,
+ clientX: number,
+ clientY: number,
+ rafActive: boolean,
+): "start" | "stop" | "none" {
+ if (!scroll) return "none";
+ const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
+ if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
+ return rafActive ? "none" : "start";
+}
+
+export interface TimelineDragEscapeInput {
+ key: string;
+ drag: { started: boolean } | null;
+ resize: { started: boolean } | null;
+ blocked: { started: boolean } | null;
+}
+
+/**
+ * Escape cancels an in-progress clip drag / resize / blocked-drag: no commit,
+ * no undo entry — the previews live only in the gesture state, so clearing it
+ * restores the pre-drag timeline. `suppressClick` arms the click suppressor
+ * only when the gesture actually started, so the click generated by the
+ * eventual pointerup can't reselect or split the clip.
+ */
+export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
+ cancel: boolean;
+ suppressClick: boolean;
+} {
+ if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) {
+ return { cancel: false, suppressClick: false };
+ }
+ return {
+ cancel: true,
+ suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started),
+ };
+}
+
export function resolveTimelineMove(
input: TimelineMoveInput,
clientX: number,
clientY: number,
-): {
- start: number;
- track: number;
- previewLayerId?: TimelineLayerId;
- previewLayerIndex?: number;
- stackingReorder?: TimelineStackingReorderIntent | null;
-} {
+): { start: number; track: number } {
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
const deltaTime =
(clientX - input.originClientX + scrollDeltaX) / Math.max(input.pixelsPerSecond, 1);
const trackDeltaRaw =
(clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
- const deltaTrack = Math.round(trackDeltaRaw);
- const nextStart = clamp(
- roundToCentiseconds(input.start + deltaTime),
- 0,
- Math.max(0, input.maxStart),
- );
-
- // Stacking mode: the two axes never fight. Horizontal movement writes time
- // (nextStart); vertical movement writes z-index. Lane/overlap resolution
- // uses the clip's authored time span, NOT the dragged start, otherwise a
- // diagonal drag that drifts the clip out of overlap silently flips the
- // placement from "restack" to "join lane" and cancels the reorder.
- if (input.stackingElement) {
- const layerMove =
- input.timelineLayers && input.layerOrder
- ? resolveTimelineLayerStackingMove({
- element: { ...input.stackingElement, duration: input.duration },
- layers: input.timelineLayers,
- layerOrder: input.layerOrder,
- trackDeltaRaw,
- })
- : null;
- return {
- start: nextStart,
- track: input.track,
- previewLayerId: layerMove?.previewLayerId,
- previewLayerIndex: layerMove?.previewLayerIndex,
- stackingReorder: layerMove?.stackingReorder ?? null,
- };
- }
-
+ // Magnetic commit: cross MAGNETIC_TRACK_THRESHOLD into a neighbor to move one
+ // lane. At 0.5 this equals Math.round(trackDeltaRaw).
+ const magnitude = Math.max(0, Math.floor(Math.abs(trackDeltaRaw) - MAGNETIC_TRACK_THRESHOLD) + 1);
+ const deltaTrack = trackDeltaRaw === 0 ? 0 : Math.sign(trackDeltaRaw) * magnitude;
const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
const desiredTrackIndex = currentTrackIndex + deltaTrack;
const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
@@ -171,45 +183,17 @@ export function resolveTimelineMove(
}
return {
- start: nextStart,
+ start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)),
track: nextTrack,
};
}
-/**
- * Snap a keyframe's clip-relative percentage to the nearest beat within ~8px,
- * mapping through composition time (pct → time → nearest beat → pct). Returns
- * the percentage unchanged when no beat is in range, so dragging stays free
- * between beats.
- */
-export function snapKeyframePctToBeat(
- el: { start: number; duration: number },
- pct: number,
- beatTimes: number[] | undefined,
- pixelsPerSecond: number,
-): number {
- if (!beatTimes || beatTimes.length === 0 || el.duration <= 0) return pct;
- const t = el.start + (pct / 100) * el.duration;
- const snapSecs = 8 / Math.max(pixelsPerSecond, 1);
- let best = t;
- let bestDist = snapSecs;
- for (const bt of beatTimes) {
- const d = Math.abs(bt - t);
- if (d < bestDist) {
- bestDist = d;
- best = bt;
- }
- }
- if (best === t) return pct;
- return Math.max(0, Math.min(100, ((best - el.start) / el.duration) * 100));
-}
-
export function resolveTimelineResize(
input: TimelineResizeInput,
edge: "start" | "end",
clientX: number,
): { start: number; duration: number; playbackStart?: number } {
- const minDuration = resolveTimelineMinDuration(input.minDuration);
+ const minDuration = Math.max(0.05, input.minDuration ?? 0.1);
const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1);
if (edge === "end") {
@@ -225,15 +209,23 @@ export function resolveTimelineResize(
};
}
- const { minDelta, maxDelta } = clipStartTrimDeltaBounds(input, input.minStart, minDuration);
+ const playbackRate = Math.max(0.1, input.playbackRate ?? 1);
+ const maxLeftExtensionFromMedia =
+ input.playbackStart != null ? input.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
+ const minDelta = -Math.min(input.start - input.minStart, maxLeftExtensionFromMedia);
+ const maxDelta = input.duration - minDuration;
const clampedDelta = clamp(deltaTime, minDelta, maxDelta);
- const trimmed = applyClipStartTrimDelta(input, clampedDelta);
+ const nextStart = roundToCentiseconds(input.start + clampedDelta);
+ const nextDuration = roundToCentiseconds(input.duration - clampedDelta);
+ const nextPlaybackStart =
+ input.playbackStart != null
+ ? roundToCentiseconds(Math.max(0, input.playbackStart + clampedDelta * playbackRate))
+ : undefined;
return {
- start: roundToCentiseconds(trimmed.start),
- duration: roundToCentiseconds(trimmed.duration),
- playbackStart:
- trimmed.playbackStart != null ? roundToCentiseconds(trimmed.playbackStart) : undefined,
+ start: nextStart,
+ duration: nextDuration,
+ playbackStart: nextPlaybackStart,
};
}
@@ -260,108 +252,6 @@ export interface TimelineRangeSelection {
anchorY: number;
}
-export interface TimelineMarqueeSelectionRect {
- startTime: number;
- endTime: number;
- top: number;
- bottom: number;
-}
-
-export interface TimelineMarqueeSelectionInput {
- rect: TimelineMarqueeSelectionRect;
- layers: readonly StackingTimelineLayer[];
- layerOrder: readonly TimelineLayerId[];
- rulerHeight: number;
- trackHeight: number;
- groupHeaderHeight?: number;
-}
-
-interface NormalizedTimelineMarqueeRect {
- startTime: number;
- endTime: number;
- top: number;
- bottom: number;
-}
-
-function timelineIntervalsOverlap(
- startA: number,
- endA: number,
- startB: number,
- endB: number,
-): boolean {
- return startA < endB && startB < endA;
-}
-
-function normalizeTimelineMarqueeRect(
- rect: TimelineMarqueeSelectionRect,
-): NormalizedTimelineMarqueeRect | null {
- const normalized = {
- startTime: Math.max(0, Math.min(rect.startTime, rect.endTime)),
- endTime: Math.max(0, Math.max(rect.startTime, rect.endTime)),
- top: Math.min(rect.top, rect.bottom),
- bottom: Math.max(rect.top, rect.bottom),
- };
- if (normalized.endTime <= normalized.startTime || normalized.bottom <= normalized.top) {
- return null;
- }
- return normalized;
-}
-
-function buildTimelineLayerMap(layers: readonly StackingTimelineLayer[]) {
- const layerById = new Map();
- for (const layer of layers) layerById.set(layer.id, layer);
- return layerById;
-}
-
-function appendMarqueeLayerSelection(
- selected: string[],
- layer: StackingTimelineLayer,
- rect: NormalizedTimelineMarqueeRect,
-) {
- for (const element of layer.elements) {
- if (
- timelineIntervalsOverlap(
- rect.startTime,
- rect.endTime,
- element.start,
- element.start + element.duration,
- )
- ) {
- selected.push(element.key ?? element.id);
- }
- }
-}
-
-export function selectTimelineElementsInMarquee({
- rect,
- layers,
- layerOrder,
- rulerHeight,
- trackHeight,
- groupHeaderHeight = 0,
-}: TimelineMarqueeSelectionInput): string[] {
- const normalized = normalizeTimelineMarqueeRect(rect);
- if (!normalized) return [];
- const layerById = buildTimelineLayerMap(layers);
- const selected: string[] = [];
- let previousContextKey = "";
- let rowTop = rulerHeight;
- for (const layerId of layerOrder) {
- const layer = layerById.get(layerId);
- if (!layer) continue;
- if (shouldShowTimelineLayerGroupHeader(layer.contextKey, previousContextKey)) {
- rowTop += groupHeaderHeight;
- }
- const rowBottom = rowTop + trackHeight;
- if (timelineIntervalsOverlap(normalized.top, normalized.bottom, rowTop, rowBottom)) {
- appendMarqueeLayerSelection(selected, layer, normalized);
- }
- previousContextKey = layer.contextKey;
- rowTop = rowBottom;
- }
- return selected;
-}
-
function isDeterministicTimelineWindow(input: {
tag: string;
compositionSrc?: string;
@@ -465,13 +355,13 @@ export function buildTimelineAgentPrompt({
const elementLines = elements
.map(
(el) =>
- `- #${el.id} (${el.tag}) - ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
+ `- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
)
.join("\n");
return `Edit the following HyperFrames composition:
-Time range: ${formatTime(start)} - ${formatTime(end)}
+Time range: ${formatTime(start)} — ${formatTime(end)}
Elements in range:
${elementLines || "(none)"}
@@ -525,72 +415,3 @@ export function buildTimelineElementAgentPrompt(element: {
export function formatTimelineAttributeNumber(value: number): string {
return Number(roundToCentiseconds(value).toFixed(2)).toString();
}
-
-/**
- * Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the
- * pointer is in, clamped to the scrollable range. Returns true when the
- * container actually moved (the caller keeps its RAF running and re-runs its
- * live preview), false when the pointer is outside the edge zones or the scroll
- * is already clamped (the caller stops).
- */
-export function applyTimelineAutoScrollStep(
- scroll: HTMLElement,
- clientX: number,
- clientY: number,
-): boolean {
- const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
- if (delta.x === 0 && delta.y === 0) return false;
- const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
- const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
- const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
- const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
- if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return false;
- scroll.scrollLeft = nextScrollLeft;
- scroll.scrollTop = nextScrollTop;
- return true;
-}
-
-/**
- * Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is
- * for the current pointer: "start" when the pointer is in an edge zone and no
- * loop is running, "stop" when it left the zones while a loop is running,
- * "none" otherwise.
- */
-export function resolveTimelineAutoScrollLoopAction(
- scroll: HTMLElement | null,
- clientX: number,
- clientY: number,
- rafActive: boolean,
-): "start" | "stop" | "none" {
- if (!scroll) return "none";
- const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
- if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
- return rafActive ? "none" : "start";
-}
-
-/**
- * Escape cancels an in-progress clip drag / resize / blocked-drag: no commit,
- * no undo entry — the previews live only in the gesture state, so clearing it
- * restores the pre-drag timeline. `suppressClick` arms the click suppressor
- * only when the gesture actually started, so the click generated by the
- * eventual pointerup can't reselect or split the clip.
- */
-export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
- cancel: boolean;
- suppressClick: boolean;
-} {
- if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) {
- return { cancel: false, suppressClick: false };
- }
- return {
- cancel: true,
- suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started),
- };
-}
-
-export interface TimelineDragEscapeInput {
- key: string;
- drag: { started: boolean } | null;
- resize: { started: boolean } | null;
- blocked: { started: boolean } | null;
-}
diff --git a/packages/studio/src/player/components/timelineGroupEditing.test.ts b/packages/studio/src/player/components/timelineGroupEditing.test.ts
new file mode 100644
index 0000000000..31c7ec2e51
--- /dev/null
+++ b/packages/studio/src/player/components/timelineGroupEditing.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import {
+ buildTimelineGroupResizeMembers,
+ resolveTimelineGroupResize,
+ resolveTimelineGroupResizeChanges,
+} from "./timelineGroupEditing";
+
+function el(id: string, over: Partial = {}): TimelineElement {
+ // domId gives a patchable target so getTimelineEditCapabilities().canTrim* is true.
+ return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over };
+}
+
+function keys(...ids: string[]): ReadonlySet {
+ return new Set(ids);
+}
+
+describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
+ it("returns null for a single-clip selection (no group forms)", () => {
+ const a = el("a");
+ expect(buildTimelineGroupResizeMembers([a], keys("a"), "a", "end")).toBeNull();
+ expect(buildTimelineGroupResizeMembers([a], new Set(), "a", "end")).toBeNull();
+ });
+
+ it("returns null when the grabbed clip is not part of the selection", () => {
+ const a = el("a");
+ const b = el("b");
+ expect(buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "c", "end")).toBeNull();
+ });
+
+ it("degrades to single-clip (null) when ANY member is locked — locked clip never patched", () => {
+ const a = el("a");
+ const locked = el("b", { timelineLocked: true });
+ expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
+ });
+
+ it("degrades when a member is implicitly timed or has no patch target", () => {
+ const a = el("a");
+ const implicit = el("b", { timingSource: "implicit" });
+ const noTarget = el("c", { domId: undefined, selector: undefined });
+ expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
+ expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
+ });
+
+ it("snapshots members and seeds start-edge media playbackStart to 0", () => {
+ const grabbed = el("a", { start: 2, duration: 2 });
+ const audio = el("b", { tag: "audio", start: 5, duration: 3 }); // playbackStart undefined
+ const members = buildTimelineGroupResizeMembers([grabbed, audio], keys("a", "b"), "a", "start");
+ expect(members).not.toBeNull();
+ expect(members!.map((m) => [m.key, m.start, m.duration, m.playbackStart])).toEqual([
+ ["a", 2, 2, 0], // start-edge media seeds playbackStart to 0
+ ["b", 5, 3, 0],
+ ]);
+ });
+
+ it("does not seed playbackStart on the END edge", () => {
+ const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
+ const b = el("b", { tag: "audio", start: 3, duration: 2 });
+ const members = buildTimelineGroupResizeMembers([grabbed, b], keys("a", "b"), "a", "end");
+ expect(members!.every((m) => m.playbackStart === undefined)).toBe(true);
+ });
+});
+
+describe("resolveTimelineGroupResizeChanges (rigid group patch set)", () => {
+ it("END edge: extends every member's duration by the shared delta", () => {
+ const members = buildTimelineGroupResizeMembers(
+ [el("a", { duration: 2 }), el("b", { duration: 3 })],
+ keys("a", "b"),
+ "a",
+ "end",
+ )!;
+ const changes = resolveTimelineGroupResizeChanges(members, "end", 0.5);
+ expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
+ ["a", 0, 2.5],
+ ["b", 0, 3.5],
+ ]);
+ });
+
+ it("END edge: rigid — the most-constrained member clamps the whole group", () => {
+ // b (0.2s) can only shrink 0.1 before hitting min duration, so the group as a
+ // whole shrinks 0.1 even though the grabbed clip asked for -0.5.
+ const members = buildTimelineGroupResizeMembers(
+ [el("a", { duration: 2 }), el("b", { duration: 0.2 })],
+ keys("a", "b"),
+ "a",
+ "end",
+ )!;
+ const changes = resolveTimelineGroupResizeChanges(members, "end", -0.5);
+ expect(changes.map((c) => c.duration)).toEqual([1.9, 0.1]);
+ });
+
+ it("START edge: shifts start + duration together across the group", () => {
+ const members = buildTimelineGroupResizeMembers(
+ [
+ el("a", { tag: "text", start: 2, duration: 2 }),
+ el("b", { tag: "text", start: 5, duration: 3 }),
+ ],
+ keys("a", "b"),
+ "a",
+ "start",
+ )!;
+ const changes = resolveTimelineGroupResizeChanges(members, "start", -0.5);
+ expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
+ ["a", 1.5, 2.5],
+ ["b", 4.5, 3.5],
+ ]);
+ });
+
+ it("produces exactly the resolveTimelineGroupResize output, keyed per member", () => {
+ const members = buildTimelineGroupResizeMembers(
+ [el("a", { duration: 2 }), el("b", { duration: 4 })],
+ keys("a", "b"),
+ "a",
+ "end",
+ )!;
+ const raw = resolveTimelineGroupResize(members, "end", 0.75);
+ const changes = resolveTimelineGroupResizeChanges(members, "end", 0.75);
+ expect(
+ changes.map((c) => ({
+ start: c.start,
+ duration: c.duration,
+ playbackStart: c.playbackStart,
+ })),
+ ).toEqual(raw.members);
+ });
+});
diff --git a/packages/studio/src/player/components/timelineGroupEditing.ts b/packages/studio/src/player/components/timelineGroupEditing.ts
index 65533df135..13e5464ad8 100644
--- a/packages/studio/src/player/components/timelineGroupEditing.ts
+++ b/packages/studio/src/player/components/timelineGroupEditing.ts
@@ -1,4 +1,6 @@
import { roundToCenti } from "../../utils/rounding";
+import type { TimelineElement } from "../store/playerStore";
+import { getTimelineEditCapabilities } from "./timelineEditing";
const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05;
@@ -11,7 +13,7 @@ function roundTimelineTime(value: number): number {
return roundToCenti(value);
}
-export function resolveTimelineMinDuration(minDuration?: number): number {
+function resolveTimelineMinDuration(minDuration?: number): number {
return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION);
}
@@ -33,7 +35,7 @@ interface TimelineStartTrimClip {
* media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`.
* Returned deltas are unrounded — callers round with their own centisecond helper.
*/
-export function clipStartTrimDeltaBounds(
+function clipStartTrimDeltaBounds(
clip: TimelineStartTrimClip,
minStart: number,
minDuration: number,
@@ -52,7 +54,7 @@ export function clipStartTrimDeltaBounds(
* duration by the same amount, and shifts the media in-point by the delta scaled to
* the playback rate (clamped at 0).
*/
-export function applyClipStartTrimDelta(
+function applyClipStartTrimDelta(
clip: TimelineStartTrimClip,
delta: number,
): { start: number; duration: number; playbackStart?: number } {
@@ -76,40 +78,12 @@ export interface TimelineGroupTimingMember {
export type TimelineGroupResizeEdge = "start" | "end";
-export interface TimelineGroupMoveResult {
- delta: number;
- members: Array>;
-}
-
export interface TimelineGroupResizeResult {
delta: number;
members: Array>;
}
-function clampTimelineGroupMoveDelta(
- rawDelta: number,
- members: readonly TimelineGroupTimingMember[],
-): number {
- if (members.length === 0) return 0;
- const minDelta = Math.max(...members.map((member) => -member.start));
- return roundTimelineTime(Math.max(rawDelta, minDelta));
-}
-
-export function resolveTimelineGroupMove(
- members: readonly TimelineGroupTimingMember[],
- rawDelta: number,
-): TimelineGroupMoveResult {
- const delta = clampTimelineGroupMoveDelta(rawDelta, members);
- return {
- delta,
- members: members.map((member) => ({
- start: roundTimelineTime(member.start + delta),
- duration: member.duration,
- })),
- };
-}
-
-export function clampTimelineGroupResizeDelta(
+function clampTimelineGroupResizeDelta(
rawDelta: number,
members: readonly TimelineGroupTimingMember[],
edge: TimelineGroupResizeEdge,
@@ -157,3 +131,134 @@ export function resolveTimelineGroupResize(
}),
};
}
+
+/* ── Multi-select group resize wiring (restored from main 36413da7f) ───────── */
+
+/** A selected clip snapshot captured at group-resize gesture start. */
+export interface TimelineGroupResizeMember extends TimelineGroupTimingMember {
+ element: TimelineElement;
+ key: string;
+}
+
+/** Per-member timing patch produced by a group resize (grabbed clip included). */
+export interface TimelineGroupResizeChange {
+ element: TimelineElement;
+ key: string;
+ start: number;
+ duration: number;
+ playbackStart?: number;
+}
+
+function elementKey(element: TimelineElement): string {
+ return element.key ?? element.id;
+}
+
+function isMediaTimelineElement(element: TimelineElement): boolean {
+ const tag = element.tag.toLowerCase();
+ return tag === "audio" || tag === "video";
+}
+
+function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
+ const caps = getTimelineEditCapabilities({
+ tag: element.tag,
+ duration: element.duration,
+ domId: element.domId,
+ selector: element.selector,
+ compositionSrc: element.compositionSrc,
+ playbackStart: element.playbackStart,
+ playbackStartAttr: element.playbackStartAttr,
+ sourceDuration: element.sourceDuration,
+ timingSource: element.timingSource,
+ timelineLocked: element.timelineLocked,
+ });
+ return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
+}
+
+/**
+ * Snapshot the members for a group resize, or `null` when the gesture must stay a
+ * single-clip resize. Legacy semantics (main 36413da7f): a group forms only when
+ * the grabbed clip is part of a multi-selection (> 1) AND EVERY selected member
+ * can take THIS edge's trim. It is all-or-nothing — if any member is locked or
+ * implicitly-timed the group does not form, so a locked member is never patched
+ * (the grabbed clip then resizes on its own). Start-edge media members seed
+ * `playbackStart` to 0 so the in-point trim math has a base, mirroring the legacy
+ * `resizeMember`.
+ */
+export function buildTimelineGroupResizeMembers(
+ elements: readonly TimelineElement[],
+ selectedKeys: ReadonlySet,
+ grabbedKey: string,
+ edge: TimelineGroupResizeEdge,
+): TimelineGroupResizeMember[] | null {
+ if (selectedKeys.size <= 1 || !selectedKeys.has(grabbedKey)) return null;
+ const selected = elements.filter((element) => selectedKeys.has(elementKey(element)));
+ if (selected.length <= 1) return null;
+ if (!selected.every((element) => canTrimEdge(element, edge))) return null;
+ return selected.map((element) => ({
+ element,
+ key: elementKey(element),
+ start: element.start,
+ duration: element.duration,
+ playbackStart:
+ edge === "start" && isMediaTimelineElement(element)
+ ? (element.playbackStart ?? 0)
+ : element.playbackStart,
+ playbackRate: element.playbackRate,
+ }));
+}
+
+/**
+ * Resolve the per-member timing patches for a group resize from the grabbed
+ * clip's raw edge delta. Rigid group: one shared delta, clamped by the most
+ * constrained member (see resolveTimelineGroupResize), applied to every member.
+ */
+export function resolveTimelineGroupResizeChanges(
+ members: readonly TimelineGroupResizeMember[],
+ edge: TimelineGroupResizeEdge,
+ rawDelta: number,
+ minDuration = resolveTimelineMinDuration(),
+): TimelineGroupResizeChange[] {
+ const result = resolveTimelineGroupResize(members, edge, rawDelta, minDuration);
+ return result.members.map((member, index) => ({
+ element: members[index]!.element,
+ key: members[index]!.key,
+ start: member.start,
+ duration: member.duration,
+ playbackStart: member.playbackStart,
+ }));
+}
+
+/** In-flight multi-select group-resize gesture (owned by the drag hook). */
+export interface TimelineGroupResizeSession {
+ grabbedKey: string;
+ edge: TimelineGroupResizeEdge;
+ members: TimelineGroupResizeMember[];
+ changes: TimelineGroupResizeChange[];
+ hasChanged: boolean;
+}
+
+/**
+ * Fold the grabbed clip's single-clip preview into the group: derive the raw
+ * edge delta from it, resolve the rigid per-member changes onto the session
+ * (updating `changes` + `hasChanged`), and return the grabbed clip's own change
+ * so the caller can render it from the resize state.
+ */
+export function applyTimelineGroupResizePreview(
+ session: TimelineGroupResizeSession,
+ grabbedPreview: { previewStart: number; previewDuration: number },
+): TimelineGroupResizeChange | undefined {
+ const grabbed = session.members.find((m) => m.key === session.grabbedKey);
+ const rawDelta =
+ session.edge === "start"
+ ? grabbedPreview.previewStart - (grabbed?.start ?? grabbedPreview.previewStart)
+ : grabbedPreview.previewDuration - (grabbed?.duration ?? grabbedPreview.previewDuration);
+ const changes = resolveTimelineGroupResizeChanges(session.members, session.edge, rawDelta);
+ session.changes = changes;
+ session.hasChanged = changes.some(
+ (c, i) =>
+ c.start !== session.members[i]!.start ||
+ c.duration !== session.members[i]!.duration ||
+ c.playbackStart !== session.members[i]!.playbackStart,
+ );
+ return changes.find((c) => c.key === session.grabbedKey);
+}
diff --git a/packages/studio/src/player/components/timelineLayerDrag.test.ts b/packages/studio/src/player/components/timelineLayerDrag.test.ts
deleted file mode 100644
index b325095a21..0000000000
--- a/packages/studio/src/player/components/timelineLayerDrag.test.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { TimelineElement } from "../store/playerStore";
-import type { StackingTimelineLayer } from "./timelineTrackOrder";
-import {
- resolveTimelineLayerStackingMove,
- resolveTimelineLayerZIndexChanges,
-} from "./timelineLayerDrag";
-
-function element(input: {
- id: string;
- zIndex: number;
- tag?: string;
- start?: number;
- duration?: number;
-}): TimelineElement {
- return {
- id: input.id,
- tag: input.tag ?? "div",
- start: input.start ?? 0,
- duration: input.duration ?? 1,
- track: 0,
- zIndex: input.zIndex,
- hasExplicitZIndex: true,
- stackingContextId: "root",
- parentCompositionId: null,
- compositionAncestors: ["root"],
- };
-}
-
-function layer(
- id: string,
- zIndex: number,
- elements: TimelineElement[] = [element({ id, zIndex })],
-): StackingTimelineLayer {
- return {
- id,
- kind: "visual",
- contextKey: "root",
- zIndex,
- placementTrack: 0,
- elements,
- };
-}
-
-describe("resolveTimelineLayerZIndexChanges", () => {
- it("joins an existing lane by assigning the dragged clip that lane's z-index", () => {
- const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
- const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
-
- expect(
- resolveTimelineLayerZIndexChanges({
- element: dragged,
- layers: [layer("front", 10, [front]), layer("back", 1)],
- placement: { type: "onto", layerId: "front" },
- })?.zIndexChanges,
- ).toEqual([{ key: "dragged", zIndex: 10 }]);
- });
-
- it("rejects an onto-lane join when the dragged clip would overlap that lane", () => {
- const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
- const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
-
- expect(
- resolveTimelineLayerZIndexChanges({
- element: dragged,
- layers: [layer("front", 10, [front]), layer("back", 1)],
- placement: { type: "onto", layerId: "front" },
- }),
- ).toBeNull();
- });
-
- it("interpolates a new integer z-index strictly between neighboring layers", () => {
- const dragged = element({ id: "dragged", zIndex: 1 });
-
- expect(
- resolveTimelineLayerZIndexChanges({
- element: dragged,
- layers: [layer("front", 10), layer("back", 4)],
- placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
- })?.zIndexChanges,
- ).toEqual([{ key: "dragged", zIndex: 7 }]);
- });
-
- it("renumbers the minimum sibling set when adjacent layers leave no integer gap", () => {
- const dragged = element({ id: "dragged", zIndex: 0 });
-
- expect(
- resolveTimelineLayerZIndexChanges({
- element: dragged,
- layers: [layer("front", 2), layer("back", 1), layer("lower", 0)],
- placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
- })?.zIndexChanges,
- ).toEqual([
- { key: "dragged", zIndex: 2 },
- { key: "front", zIndex: 3 },
- ]);
- });
-
- it("assigns new extreme z-index values above the top and below the bottom layer", () => {
- const dragged = element({ id: "dragged", zIndex: 0 });
- const layers = [layer("front", 10), layer("back", -2)];
-
- expect(
- resolveTimelineLayerZIndexChanges({
- element: dragged,
- layers,
- placement: { type: "above", layerId: "front" },
- })?.zIndexChanges,
- ).toEqual([{ key: "dragged", zIndex: 11 }]);
-
- expect(
- resolveTimelineLayerZIndexChanges({
- element: dragged,
- layers,
- placement: { type: "below", layerId: "back" },
- })?.zIndexChanges,
- ).toEqual([{ key: "dragged", zIndex: -3 }]);
- });
-
- it("does not resolve stacking z-index changes for audio clips", () => {
- expect(
- resolveTimelineLayerZIndexChanges({
- element: element({ id: "music", zIndex: 0, tag: "audio" }),
- layers: [layer("front", 10)],
- placement: { type: "onto", layerId: "front" },
- }),
- ).toBeNull();
- });
-});
-
-describe("resolveTimelineLayerStackingMove", () => {
- it("places an upward onto-lane drop above the overlapping target lane", () => {
- const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 });
- const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
- const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
-
- expect(
- resolveTimelineLayerStackingMove({
- element: dragged,
- layers,
- layerOrder: layers.map((item) => item.id),
- trackDeltaRaw: -0.8,
- }),
- ).toEqual({
- previewLayerId: "preview:dragged:above:front",
- previewLayerIndex: 0,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "above", layerId: "front" },
- zIndexChanges: [{ key: "dragged", zIndex: 11 }],
- },
- });
- });
-
- it("places a downward onto-lane drop below the overlapping target lane", () => {
- const dragged = element({ id: "dragged", zIndex: 10, start: 0.5, duration: 1 });
- const back = element({ id: "back", zIndex: 1, start: 0, duration: 2 });
- const layers = [layer("dragged", 10, [dragged]), layer("back", 1, [back])];
-
- expect(
- resolveTimelineLayerStackingMove({
- element: dragged,
- layers,
- layerOrder: layers.map((item) => item.id),
- trackDeltaRaw: 0.8,
- }),
- ).toEqual({
- previewLayerId: "preview:dragged:below:back",
- previewLayerIndex: 2,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "below", layerId: "back" },
- zIndexChanges: [{ key: "dragged", zIndex: 0 }],
- },
- });
- });
-
- it("keeps a non-overlapping onto-lane drop joined to the target lane", () => {
- const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
- const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
- const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
-
- expect(
- resolveTimelineLayerStackingMove({
- element: dragged,
- layers,
- layerOrder: layers.map((item) => item.id),
- trackDeltaRaw: -0.8,
- }),
- ).toEqual({
- previewLayerId: "front",
- previewLayerIndex: 0,
- stackingReorder: {
- contextKey: "root",
- placement: { type: "onto", layerId: "front" },
- zIndexChanges: [{ key: "dragged", zIndex: 10 }],
- },
- });
- });
-});
diff --git a/packages/studio/src/player/components/timelineLayerDrag.ts b/packages/studio/src/player/components/timelineLayerDrag.ts
deleted file mode 100644
index 75e4d4c1bc..0000000000
--- a/packages/studio/src/player/components/timelineLayerDrag.ts
+++ /dev/null
@@ -1,383 +0,0 @@
-import { resolveStackingContextKey } from "../lib/layerOrdering";
-import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
-import {
- timelineElementsOverlap,
- type StackingTimelineLayer,
- type TimelineLayerId,
-} from "./timelineTrackOrder";
-import {
- toStackingOrderItem,
- type TimelineLayerDropPlacement,
- type TimelineStackingElement,
- type TimelineStackingReorderIntent,
- type TimelineStackingZIndexChange,
-} from "./timelineStacking";
-
-const ONTO_ROW_THRESHOLD = 0.35;
-
-export interface TimelineLayerStackingMoveResolution {
- previewLayerId: TimelineLayerId;
- previewLayerIndex: number;
- stackingReorder: TimelineStackingReorderIntent | null;
-}
-
-function isAudioElement(element: TimelineStackingElement): boolean {
- return element.tag?.toLowerCase() === "audio";
-}
-
-function layerContainsElement(layer: StackingTimelineLayer, key: string): boolean {
- return layer.elements.some((element) => getTimelineElementIdentity(element) === key);
-}
-
-function layerConflictsWithElement(
- layer: StackingTimelineLayer,
- element: TimelineStackingElement,
- draggedKey: string,
-): boolean {
- return layer.elements.some(
- (candidate) =>
- getTimelineElementIdentity(candidate) !== draggedKey &&
- timelineElementsOverlap(candidate, element),
- );
-}
-
-function addElementChange(
- changes: TimelineStackingZIndexChange[],
- element: TimelineStackingElement,
- zIndex: number,
-): void {
- if ((element.zIndex ?? 0) === zIndex) return;
- changes.push({
- key: getTimelineElementIdentity(element),
- zIndex,
- domId: element.domId,
- selector: element.selector,
- selectorIndex: element.selectorIndex,
- sourceFile: element.sourceFile,
- });
-}
-
-function addLayerChanges(
- changes: TimelineStackingZIndexChange[],
- layer: StackingTimelineLayer,
- zIndex: number,
- excludedKey: string,
-): number {
- let count = 0;
- for (const element of layer.elements) {
- const key = getTimelineElementIdentity(element);
- if (key === excludedKey) continue;
- const before = changes.length;
- addElementChange(changes, element, zIndex);
- if (changes.length > before) count += 1;
- }
- return count;
-}
-
-function getContextLayers(
- layers: readonly StackingTimelineLayer[],
- contextKey: string,
-): StackingTimelineLayer[] {
- return layers.filter((layer) => layer.kind === "visual" && layer.contextKey === contextKey);
-}
-
-function findLayer(
- layers: readonly StackingTimelineLayer[],
- id: string,
-): StackingTimelineLayer | null {
- return layers.find((layer) => layer.id === id) ?? null;
-}
-
-function resolvePlacementZIndexChanges(input: {
- element: TimelineStackingElement;
- targetZIndex: number;
-}): TimelineStackingZIndexChange[] {
- const changes: TimelineStackingZIndexChange[] = [];
- addElementChange(changes, input.element, input.targetZIndex);
- return changes;
-}
-
-function buildPushUpCandidate(input: {
- element: TimelineStackingElement;
- layers: readonly StackingTimelineLayer[];
- beforeIndex: number;
- bottomZIndex: number;
-}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
- const draggedKey = getTimelineElementIdentity(input.element);
- const draggedZIndex = input.bottomZIndex + 1;
- const changes = resolvePlacementZIndexChanges({
- element: input.element,
- targetZIndex: draggedZIndex,
- });
- let siblingChanges = 0;
- let requiredZIndex = draggedZIndex + 1;
- for (let index = input.beforeIndex; index >= 0; index -= 1) {
- const layer = input.layers[index];
- if (!layer) continue;
- const nextZIndex = layer.zIndex >= requiredZIndex ? layer.zIndex : requiredZIndex;
- siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
- requiredZIndex = nextZIndex + 1;
- }
- return { changes, siblingChanges };
-}
-
-function buildPushDownCandidate(input: {
- element: TimelineStackingElement;
- layers: readonly StackingTimelineLayer[];
- afterIndex: number;
- topZIndex: number;
-}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
- const draggedKey = getTimelineElementIdentity(input.element);
- const draggedZIndex = input.topZIndex - 1;
- const changes = resolvePlacementZIndexChanges({
- element: input.element,
- targetZIndex: draggedZIndex,
- });
- let siblingChanges = 0;
- let requiredZIndex = draggedZIndex - 1;
- for (let index = input.afterIndex; index < input.layers.length; index += 1) {
- const layer = input.layers[index];
- if (!layer) continue;
- const nextZIndex = layer.zIndex <= requiredZIndex ? layer.zIndex : requiredZIndex;
- siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
- requiredZIndex = nextZIndex - 1;
- }
- return { changes, siblingChanges };
-}
-
-function resolveBetweenChanges(input: {
- element: TimelineStackingElement;
- layers: readonly StackingTimelineLayer[];
- beforeLayer: StackingTimelineLayer;
- afterLayer: StackingTimelineLayer;
-}): TimelineStackingZIndexChange[] {
- const topZIndex = input.beforeLayer.zIndex;
- const bottomZIndex = input.afterLayer.zIndex;
- if (topZIndex - bottomZIndex > 1) {
- return resolvePlacementZIndexChanges({
- element: input.element,
- targetZIndex: Math.floor((topZIndex + bottomZIndex) / 2),
- });
- }
-
- const beforeIndex = input.layers.findIndex((layer) => layer.id === input.beforeLayer.id);
- const afterIndex = input.layers.findIndex((layer) => layer.id === input.afterLayer.id);
- const pushUp = buildPushUpCandidate({
- element: input.element,
- layers: input.layers,
- beforeIndex,
- bottomZIndex,
- });
- const pushDown = buildPushDownCandidate({
- element: input.element,
- layers: input.layers,
- afterIndex,
- topZIndex,
- });
- return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes;
-}
-
-function resolveOntoChanges(input: {
- element: TimelineStackingElement;
- layers: readonly StackingTimelineLayer[];
- layerId: string;
-}): TimelineStackingZIndexChange[] | null {
- const target = findLayer(input.layers, input.layerId);
- if (!target) return null;
- if (layerConflictsWithElement(target, input.element, getTimelineElementIdentity(input.element))) {
- return null;
- }
- return resolvePlacementZIndexChanges({
- element: input.element,
- targetZIndex: target.zIndex,
- });
-}
-
-function resolveEdgeChanges(input: {
- element: TimelineStackingElement;
- layers: readonly StackingTimelineLayer[];
- layerId: string;
- offset: number;
-}): TimelineStackingZIndexChange[] | null {
- const target = findLayer(input.layers, input.layerId);
- if (!target) return null;
- return resolvePlacementZIndexChanges({
- element: input.element,
- targetZIndex: target.zIndex + input.offset,
- });
-}
-
-function resolvePlacementChanges(input: {
- element: TimelineStackingElement;
- layers: readonly StackingTimelineLayer[];
- placement: TimelineLayerDropPlacement;
-}): TimelineStackingZIndexChange[] | null {
- switch (input.placement.type) {
- case "onto":
- return resolveOntoChanges({
- element: input.element,
- layers: input.layers,
- layerId: input.placement.layerId,
- });
- case "above":
- return resolveEdgeChanges({
- element: input.element,
- layers: input.layers,
- layerId: input.placement.layerId,
- offset: 1,
- });
- case "below":
- return resolveEdgeChanges({
- element: input.element,
- layers: input.layers,
- layerId: input.placement.layerId,
- offset: -1,
- });
- case "between": {
- const beforeLayer = findLayer(input.layers, input.placement.beforeLayerId);
- const afterLayer = findLayer(input.layers, input.placement.afterLayerId);
- return beforeLayer && afterLayer
- ? resolveBetweenChanges({
- element: input.element,
- layers: input.layers,
- beforeLayer,
- afterLayer,
- })
- : null;
- }
- }
-}
-
-export function resolveTimelineLayerZIndexChanges(input: {
- element: TimelineStackingElement;
- layers: readonly StackingTimelineLayer[];
- placement: TimelineLayerDropPlacement;
-}): TimelineStackingReorderIntent | null {
- if (isAudioElement(input.element)) return null;
- const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
- const layers = getContextLayers(input.layers, contextKey);
- const changes = resolvePlacementChanges({
- element: input.element,
- layers,
- placement: input.placement,
- });
-
- return changes && changes.length > 0
- ? { contextKey, placement: input.placement, zIndexChanges: changes }
- : null;
-}
-
-// fallow-ignore-next-line complexity
-function resolveDragPlacement(
- layers: readonly StackingTimelineLayer[],
- targetPosition: number,
-): TimelineLayerDropPlacement | null {
- const first = layers[0];
- const last = layers[layers.length - 1];
- if (!first || !last) return null;
- if (targetPosition < -ONTO_ROW_THRESHOLD) return { type: "above", layerId: first.id };
- if (targetPosition > layers.length - 1 + ONTO_ROW_THRESHOLD) {
- return { type: "below", layerId: last.id };
- }
-
- const nearestIndex = Math.round(targetPosition);
- const nearest = layers[nearestIndex];
- if (nearest && Math.abs(targetPosition - nearestIndex) <= ONTO_ROW_THRESHOLD) {
- return { type: "onto", layerId: nearest.id };
- }
-
- const insertionIndex = Math.max(0, Math.min(layers.length, Math.ceil(targetPosition)));
- if (insertionIndex <= 0) return { type: "above", layerId: first.id };
- if (insertionIndex >= layers.length) return { type: "below", layerId: last.id };
- const before = layers[insertionIndex - 1];
- const after = layers[insertionIndex];
- return before && after
- ? { type: "between", beforeLayerId: before.id, afterLayerId: after.id }
- : null;
-}
-
-function resolveLaneAwareDragPlacement(input: {
- layers: readonly StackingTimelineLayer[];
- element: TimelineStackingElement;
- draggedKey: string;
- placement: TimelineLayerDropPlacement;
- targetPosition: number;
- currentIndex: number;
-}): TimelineLayerDropPlacement | null {
- if (input.placement.type !== "onto") return input.placement;
- const target = findLayer(input.layers, input.placement.layerId);
- if (!target) return null;
- if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement;
- if (input.targetPosition < input.currentIndex) {
- return { type: "above", layerId: target.id };
- }
- if (input.targetPosition > input.currentIndex) {
- return { type: "below", layerId: target.id };
- }
- return input.placement;
-}
-
-function getPreviewLayerId(
- draggedKey: string,
- placement: TimelineLayerDropPlacement,
-): TimelineLayerId {
- if (placement.type === "onto") return placement.layerId;
- if (placement.type === "between") {
- return `preview:${draggedKey}:between:${placement.beforeLayerId}:${placement.afterLayerId}`;
- }
- return `preview:${draggedKey}:${placement.type}:${placement.layerId}`;
-}
-
-function getPreviewLayerIndex(
- layerOrder: readonly TimelineLayerId[],
- placement: TimelineLayerDropPlacement,
-): number {
- if (placement.type === "onto") return Math.max(0, layerOrder.indexOf(placement.layerId));
- if (placement.type === "between") {
- return Math.max(0, layerOrder.indexOf(placement.afterLayerId));
- }
- const targetIndex = Math.max(0, layerOrder.indexOf(placement.layerId));
- return placement.type === "above" ? targetIndex : targetIndex + 1;
-}
-
-export function resolveTimelineLayerStackingMove(input: {
- element: TimelineStackingElement;
- layers: readonly StackingTimelineLayer[];
- layerOrder: readonly TimelineLayerId[];
- trackDeltaRaw: number;
-}): TimelineLayerStackingMoveResolution | null {
- if (isAudioElement(input.element)) return null;
- const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
- const layerById = new Map(input.layers.map((layer) => [layer.id, layer]));
- const contextLayers = input.layerOrder
- .map((id) => layerById.get(id) ?? null)
- .filter(
- (layer): layer is StackingTimelineLayer =>
- layer != null && layer.kind === "visual" && layer.contextKey === contextKey,
- );
- const draggedKey = getTimelineElementIdentity(input.element);
- const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey));
- if (currentIndex < 0) return null;
-
- const targetPosition = currentIndex + input.trackDeltaRaw;
- const rawPlacement = resolveDragPlacement(contextLayers, targetPosition);
- if (!rawPlacement) return null;
- const placement = resolveLaneAwareDragPlacement({
- layers: contextLayers,
- element: input.element,
- draggedKey,
- placement: rawPlacement,
- targetPosition,
- currentIndex,
- });
- if (!placement) return null;
- return {
- previewLayerId: getPreviewLayerId(draggedKey, placement),
- previewLayerIndex: getPreviewLayerIndex(input.layerOrder, placement),
- stackingReorder: resolveTimelineLayerZIndexChanges({
- element: input.element,
- layers: contextLayers,
- placement,
- }),
- };
-}
diff --git a/packages/studio/src/player/components/timelineZones.test.ts b/packages/studio/src/player/components/timelineZones.test.ts
index 4e446b0568..02e721379e 100644
--- a/packages/studio/src/player/components/timelineZones.test.ts
+++ b/packages/studio/src/player/components/timelineZones.test.ts
@@ -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,301 +139,99 @@ describe("normalizeToZones", () => {
expectZoningIdempotent(input);
});
- it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => {
+ 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, 1, 1),
+ zClip("mid", 3, 5, 2, 5),
+ ];
+ const first = normalizeToZones(build());
+ const second = normalizeToZones(build());
+ for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
+ });
+});
+
+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, // all authored on the SAME track, some overlapping in time
+ track: 1,
});
- // 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")).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"));
- 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.
+ 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);
});
-
- 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.
- const build = (): TimelineElement[] => [
- zClip("hi", 0, 10, 0, 9),
- zClip("lo", 0, 10, 0, 1),
- zClip("mid", 3, 5, 0, 5),
- ];
- const first = normalizeToZones(build());
- const second = normalizeToZones(build());
- for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
- });
});
-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 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("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("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("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("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("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());
});
});
diff --git a/packages/studio/src/player/components/timelineZones.ts b/packages/studio/src/player/components/timelineZones.ts
index aed2a0a921..515555b3e1 100644
--- a/packages/studio/src/player/components/timelineZones.ts
+++ b/packages/studio/src/player/components/timelineZones.ts
@@ -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,166 +23,105 @@ 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;
-}
-
-/**
- * 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;
+/** 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;
}
/**
- * 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.
- *
- * Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
- * returns the number of lanes used (≥ 1 when non-empty).
+ * 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.
*/
-function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map): 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 });
+function packTrackLanes(
+ clips: TimelineElement[],
+ base: number,
+ laneOf: Map,
+): number {
+ 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[placed].occupants.push(item);
- laneOf.set(keyOf(item.el), base + placed);
+ lanes[sub].push(el);
+ laneOf.set(keyOf(el), base + sub);
}
- return lanes.length;
+ return Math.max(1, 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).
+ * 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 packAudioTrackLanes(
- clips: IndexedClip[],
+function packZoneLanes(
+ clips: TimelineElement[],
base: number,
laneOf: Map,
): 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)));
- if (sub === -1) {
- sub = lanes.length;
- lanes.push([]);
- }
- lanes[sub].push(item);
- laneOf.set(keyOf(item.el), base + sub);
+ const byTrack = new Map();
+ for (const el of clips) {
+ const list = byTrack.get(el.track);
+ if (list) list.push(el);
+ else byTrack.set(el.track, [el]);
}
- return Math.max(1, lanes.length);
+ 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();
- let nextLane = 0;
-
- const visual: IndexedClip[] = [];
- const audio: IndexedClip[] = [];
- elements.forEach((el, domIndex) => {
- (classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
- });
+ const visual: TimelineElement[] = [];
+ const audio: TimelineElement[] = [];
+ for (const el of elements) {
+ (classifyZone(el) === "audio" ? audio : visual).push(el);
+ }
+ let nextLane = 0;
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();
- 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) => {
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx
new file mode 100644
index 0000000000..8f426ebc05
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx
@@ -0,0 +1,164 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import { usePlayerStore } from "../store/playerStore";
+import type { ResizingClipState } from "./useTimelineClipDrag";
+import { useTimelineClipDrag } from "./useTimelineClipDrag";
+import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+function el(id: string, over: Partial = {}): TimelineElement {
+ return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over };
+}
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ usePlayerStore.getState().reset();
+});
+
+function renderResizeHarness(elements: TimelineElement[], selected: string[]) {
+ usePlayerStore.getState().setElements(elements);
+ usePlayerStore.setState({ timelineSnapEnabled: false });
+ usePlayerStore.getState().setSelectedElementIds(new Set(selected));
+
+ const scroll = document.createElement("div");
+ document.body.append(scroll);
+ const onResizeElement = vi.fn();
+ let setResizingClip: ((s: ResizingClipState | null) => void) | null = null;
+
+ function Harness() {
+ const hook = useTimelineClipDrag({
+ scrollRef: { current: scroll },
+ ppsRef: { current: 100 },
+ durationRef: { current: 100 },
+ trackOrderRef: { current: [0, 1] },
+ onResizeElement,
+ setShowPopover: vi.fn(),
+ setRangeSelectionRef: { current: vi.fn() },
+ });
+ setResizingClip = hook.setResizingClip;
+ return null;
+ }
+
+ const root = mountReactHarness( );
+ const apply = setResizingClip!;
+
+ return {
+ onResizeElement,
+ storeById(id: string) {
+ return usePlayerStore.getState().elements.find((e) => e.id === id)!;
+ },
+ startResize(element: TimelineElement, edge: "start" | "end") {
+ act(() => {
+ apply({
+ element,
+ edge,
+ originClientX: 0,
+ previewStart: element.start,
+ previewDuration: element.duration,
+ previewPlaybackStart: element.playbackStart,
+ started: false,
+ });
+ });
+ },
+ movePointer(clientX: number) {
+ act(() => {
+ window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 }));
+ });
+ },
+ async dropPointer() {
+ await act(async () => {
+ window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
+ });
+ },
+ pressEscape() {
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+ },
+ unmount() {
+ act(() => root.unmount());
+ },
+ };
+}
+
+// Two clips a(0,2) + b(5,3) selected as a group, with a's END edge grabbed and
+// dragged +0.5s (50px @ 100pps). `bOver` customizes b (e.g. locking it).
+function startGroupResize(bOver: Partial = {}) {
+ const a = el("a", { start: 0, duration: 2 });
+ const b = el("b", { start: 5, duration: 3, ...bOver });
+ const h = renderResizeHarness([a, b], ["a", "b"]);
+ h.startResize(a, "end");
+ h.movePointer(50);
+ return { a, b, h };
+}
+
+// Drop the pointer and assert exactly one resize persisted: the grabbed clip `a`
+// grown to 2.5s (the +0.5 gesture), with no other member patched.
+async function expectSingleResizeToA(
+ h: ReturnType,
+ a: TimelineElement,
+): Promise {
+ await h.dropPointer();
+ expect(h.onResizeElement).toHaveBeenCalledTimes(1);
+ expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.5 }));
+}
+
+describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => {
+ it("resizes only the grabbed clip and persists once", async () => {
+ const a = el("a", { start: 0, duration: 2 });
+ const b = el("b", { start: 5, duration: 2 });
+ const h = renderResizeHarness([a, b], []); // no multi-selection
+
+ h.startResize(a, "end");
+ h.movePointer(50); // +0.5s at 100 pps
+ await expectSingleResizeToA(h, a);
+ expect(h.storeById("a").duration).toBe(2.5);
+ expect(h.storeById("b").duration).toBe(2); // untouched
+ h.unmount();
+ });
+});
+
+describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
+ it("previews the non-grabbed member through the store, grabbed stays out until commit", () => {
+ const { h } = startGroupResize(); // grabbed asks +0.5
+
+ // Non-grabbed member is previewed in the store; the grabbed clip renders from
+ // resizingClip state, so its store value is still the original until commit.
+ expect(h.storeById("b").duration).toBe(3.5);
+ expect(h.storeById("a").duration).toBe(2);
+ h.unmount();
+ });
+
+ it("commits the whole group — persists every member by the shared delta", async () => {
+ const { a, b, h } = startGroupResize();
+ await h.dropPointer();
+
+ expect(h.onResizeElement).toHaveBeenCalledTimes(2);
+ expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.5 }));
+ expect(h.onResizeElement).toHaveBeenCalledWith(b, expect.objectContaining({ duration: 3.5 }));
+ expect(h.storeById("a").duration).toBe(2.5);
+ expect(h.storeById("b").duration).toBe(3.5);
+ h.unmount();
+ });
+
+ it("degrades to single-clip when a selected member is locked — locked clip untouched", async () => {
+ const { a, h } = startGroupResize({ timelineLocked: true });
+ await expectSingleResizeToA(h, a);
+ expect(h.storeById("b").duration).toBe(3); // locked member never patched
+ h.unmount();
+ });
+
+ it("Escape rolls back the previewed non-grabbed member and persists nothing", () => {
+ const { h } = startGroupResize();
+ expect(h.storeById("b").duration).toBe(3.5); // previewed
+
+ h.pressEscape();
+ expect(h.storeById("b").duration).toBe(3); // restored
+ expect(h.onResizeElement).not.toHaveBeenCalled();
+ h.unmount();
+ });
+});
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts
index fd6c1a8b4c..4e9fa2d831 100644
--- a/packages/studio/src/player/components/useTimelineClipDrag.ts
+++ b/packages/studio/src/player/components/useTimelineClipDrag.ts
@@ -7,12 +7,21 @@ import {
} from "./timelineEditing";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
-import { isMusicTrack } from "../../utils/timelineInspector";
+import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
import { mergeUserBeats } from "../../utils/beatEditing";
+import {
+ buildTimelineGroupResizeMembers,
+ type TimelineGroupResizeSession,
+} from "./timelineGroupEditing";
import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping";
import { commitDraggedClipMove } from "./timelineClipDragCommit";
import type { StackingPatch } from "./timelineStackingSync";
-import { computeDragPreview, computeResizePreview } from "./timelineClipDragPreview";
+import {
+ computeDragPreview,
+ computeResizePreview,
+ previewGroupResize,
+ type ResizePreviewResult,
+} from "./timelineClipDragPreview";
import type {
DraggedClipState,
ResizingClipState,
@@ -113,15 +122,30 @@ export function useTimelineClipDrag({
const elementsRef = useRef(elements);
elementsRef.current = elements;
+ // Perf (frozen-per-gesture): the snap-target set and the audio-track set are
+ // fixed for the duration of one drag/resize (the store is not re-authored mid
+ // gesture), so build each ONCE and reuse it across every pointermove and every
+ // auto-scroll frame. Both caches are cleared at gesture teardown
+ // (stopClipDragAutoScroll), so the next gesture rebuilds against fresh state.
+ const snapTargetsCacheRef = useRef>(new Map());
+ const dragAudioTracksRef = useRef | null>(null);
+
const buildSnapTargets = useCallback(
(excludeElementKey: string | null, includeBeats: boolean): TimelineSnapTarget[] => {
+ // Magnet off ⇒ no targets and no scan; do NOT cache so a mid-gesture toggle
+ // back on starts scanning immediately (preserves the existing skip).
if (!snapContextRef.current.enabled) return [];
- return collectTimelineSnapTargets({
+ const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}`;
+ const cached = snapTargetsCacheRef.current.get(cacheKey);
+ if (cached) return cached;
+ const targets = collectTimelineSnapTargets({
elements: elementsRef.current,
playheadTime: usePlayerStore.getState().currentTime,
beatTimes: includeBeats ? snapContextRef.current.beatTimes : [],
excludeElementKey,
});
+ snapTargetsCacheRef.current.set(cacheKey, targets);
+ return targets;
},
[],
);
@@ -137,6 +161,30 @@ export function useTimelineClipDrag({
const blockedClipRef = useRef(null);
const suppressClickRef = useRef(false);
+ // Active multi-select group-resize session (restored from main 36413da7f): set
+ // lazily on the first resize pointermove when the grabbed clip is part of a
+ // capability-clean multi-selection (null ⇒ single-clip resize). Holds the
+ // pre-gesture snapshot so the non-grabbed members (previewed through the store)
+ // roll back on escape / cancel / failed persist.
+ const groupResizeRef = useRef(null);
+
+ // Restore the non-grabbed group members to their pre-gesture timing (the
+ // grabbed clip renders from resizingClip state, so it is never written during
+ // preview). `all` also restores the grabbed clip after a committed persist fails.
+ const restoreGroupResizeMembers = useCallback(
+ (session: TimelineGroupResizeSession, all = false) => {
+ for (const m of session.members) {
+ if (!all && m.key === session.grabbedKey) continue;
+ updateElement(m.key, {
+ start: m.start,
+ duration: m.duration,
+ playbackStart: m.playbackStart,
+ });
+ }
+ },
+ [updateElement],
+ );
+
const onMoveElementRef = useRef(onMoveElement);
onMoveElementRef.current = onMoveElement;
const onMoveElementsRef = useRef(onMoveElements);
@@ -157,8 +205,15 @@ export function useTimelineClipDrag({
// (move + snap + group clamp + drop placement) is a tested pure function so
// what runs here is what's verified — see timelineClipDragPreview.
const updateDraggedClipPreview = useCallback(
- (drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState =>
- computeDragPreview(drag, clientX, clientY, {
+ (drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => {
+ // Build the audio-track set once per gesture (see snapTargetsCacheRef): it
+ // only feeds zone-aware drop placement and is frozen while dragging.
+ if (!dragAudioTracksRef.current) {
+ dragAudioTracksRef.current = new Set(
+ elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track),
+ );
+ }
+ return computeDragPreview(drag, clientX, clientY, {
scroll: scrollRef.current,
pps: ppsRef.current,
duration: durationRef.current,
@@ -166,14 +221,15 @@ export function useTimelineClipDrag({
elements: elementsRef.current,
selectedKeys: usePlayerStore.getState().selectedElementIds,
buildSnapTargets,
- }),
+ audioTracks: dragAudioTracksRef.current,
+ });
+ },
[scrollRef, ppsRef, durationRef, trackOrderRef, buildSnapTargets],
);
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
- // branch and the edge auto-scroll stepper (which re-runs it as the content
- // scrolls under a stationary pointer, so the trim keeps tracking). The compute
- // is a pure function (timelineClipDragPreview); here we only apply the state.
+ // branch and the edge auto-scroll stepper (re-runs as content scrolls under a
+ // stationary pointer). computeResizePreview is pure; here we only apply state.
const applyResizePointer = useCallback(
(resize: ResizingClipState, clientX: number) => {
const next = computeResizePreview(resize, clientX, {
@@ -181,20 +237,35 @@ export function useTimelineClipDrag({
pps: ppsRef.current,
buildSnapTargets,
});
- setResizingClip((prev) =>
- prev
- ? {
- ...prev,
- started: true,
- originScrollLeft: next.originScrollLeft,
- previewStart: next.previewStart,
- previewDuration: next.previewDuration,
- previewPlaybackStart: next.previewPlaybackStart,
- }
- : prev,
- );
+ const setResizeState = (v: ResizePreviewResult) =>
+ setResizingClip((prev) => (prev ? { ...prev, started: true, ...v } : prev));
+
+ // Group resize: a capability-clean multi-selection resizes rigidly by one
+ // shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
+ // drives the raw delta and renders from resizingClip state; non-grabbed
+ // members preview through the store (their store value stays pristine).
+ const grabbedKey = resize.element.key ?? resize.element.id;
+ let session = groupResizeRef.current;
+ if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) {
+ const members = buildTimelineGroupResizeMembers(
+ elementsRef.current,
+ usePlayerStore.getState().selectedElementIds,
+ grabbedKey,
+ resize.edge,
+ );
+ session = members
+ ? { grabbedKey, edge: resize.edge, members, changes: [], hasChanged: false }
+ : null;
+ groupResizeRef.current = session;
+ }
+
+ if (!session) {
+ setResizeState(next);
+ return;
+ }
+ previewGroupResize(session, next, grabbedKey, updateElement, setResizeState);
},
- [scrollRef, ppsRef, buildSnapTargets],
+ [scrollRef, ppsRef, buildSnapTargets, updateElement],
);
const applyResizePointerRef = useRef(applyResizePointer);
applyResizePointerRef.current = applyResizePointer;
@@ -205,6 +276,11 @@ export function useTimelineClipDrag({
cancelAnimationFrame(clipDragScrollRaf.current);
clipDragScrollRaf.current = 0;
}
+ // Gesture teardown: drop the frozen-per-gesture perf caches so the next drag
+ // rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT
+ // touch groupResizeRef — commit reads it after this runs.
+ snapTargetsCacheRef.current.clear();
+ dragAudioTracksRef.current = null;
}, []);
const stepClipDragAutoScroll = useCallback(() => {
@@ -316,14 +392,59 @@ export function useTimelineClipDrag({
};
/* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */
+ const commitGroupResize = (session: TimelineGroupResizeSession) => {
+ if (!session.hasChanged) return;
+ const changes = session.changes;
+ // The grabbed clip's timing lived only in resizingClip state during preview,
+ // so write EVERY member (grabbed included) to the store now, then persist.
+ for (const c of changes) {
+ updateElement(c.key, {
+ start: c.start,
+ duration: c.duration,
+ playbackStart: c.playbackStart,
+ });
+ }
+ const persist = onResizeElementRef.current;
+ if (!persist) return;
+ // No batch resize handler is wired to this hook (only single-clip
+ // onResizeElement), so each member persists on its own request — a group
+ // resize without atomic single-undo. Roll the WHOLE group back if any write
+ // rejects.
+ Promise.all(
+ changes.map((c) =>
+ Promise.resolve(
+ persist(c.element, {
+ start: c.start,
+ duration: c.duration,
+ playbackStart: c.playbackStart,
+ }),
+ ),
+ ),
+ ).catch((error) => {
+ restoreGroupResizeMembers(session, true);
+ console.error("[Timeline] Failed to persist group clip resize", error);
+ });
+ };
+
const commitResizePointerUp = (resize: ResizingClipState) => {
resizingClipRef.current = null;
setResizingClip(null);
- if (!resize.started) return;
+ const groupSession = groupResizeRef.current;
+ groupResizeRef.current = null;
+ if (!resize.started) {
+ // No preview ran, so no group store-mutation to undo; guard is defensive.
+ if (groupSession) restoreGroupResizeMembers(groupSession);
+ return;
+ }
suppressClickRef.current = true;
clearSuppressedClick();
+ if (groupSession) {
+ commitGroupResize(groupSession);
+ return;
+ }
+
const hasChanged =
resize.previewStart !== resize.element.start ||
resize.previewDuration !== resize.element.duration ||
@@ -423,6 +544,11 @@ export function useTimelineClipDrag({
setDraggedClip(null);
resizingClipRef.current = null;
setResizingClip(null);
+ // Undo any group-resize preview store-mutation (non-grabbed members) so the
+ // cancelled gesture restores the pre-drag timeline, like the single-clip path.
+ const groupSession = groupResizeRef.current;
+ groupResizeRef.current = null;
+ if (groupSession) restoreGroupResizeMembers(groupSession);
blockedClipRef.current = null;
// The pointer is usually still down; keep the suppressor armed until the
// eventual pointerup (which disarms it) so its click can't reselect.
diff --git a/packages/studio/src/player/components/useTimelineStackingSync.ts b/packages/studio/src/player/components/useTimelineStackingSync.ts
index 7f2ec35dad..84e12a0abc 100644
--- a/packages/studio/src/player/components/useTimelineStackingSync.ts
+++ b/packages/studio/src/player/components/useTimelineStackingSync.ts
@@ -45,16 +45,22 @@ 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],
);
const applyStackingPatches = useCallback(
- (patches: StackingPatch[]) => {
+ (patches: StackingPatch[], coalesceKey?: string) => {
if (!handleDomZIndexReorderCommit) return;
const entries = patches.flatMap((p) => {
const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key);
@@ -71,7 +77,9 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
},
];
});
- if (entries.length) handleDomZIndexReorderCommit(entries);
+ // 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],
);
diff --git a/packages/studio/src/player/index.ts b/packages/studio/src/player/index.ts
index 592e83f309..72e50a2bcd 100644
--- a/packages/studio/src/player/index.ts
+++ b/packages/studio/src/player/index.ts
@@ -11,7 +11,7 @@ export { resolveIframe } from "./lib/timelineDOM";
// Store
export { usePlayerStore, liveTime } from "./store/playerStore";
-export type { TimelineElement } from "./store/playerStore";
+export type { SelectElementOptions, TimelineElement } from "./store/playerStore";
// Utils
export { formatTime } from "./lib/time";
diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts
index 8d70cc81ba..e6d9669b60 100644
--- a/packages/studio/src/player/store/playerStore.test.ts
+++ b/packages/studio/src/player/store/playerStore.test.ts
@@ -220,97 +220,58 @@ describe("usePlayerStore", () => {
usePlayerStore.getState().setSelectedElementId(null);
expect(usePlayerStore.getState().selectedElementId).toBeNull();
});
- });
-
- describe("selectedElementIds", () => {
- it("sets a multi-id selection with a coherent anchor", () => {
- usePlayerStore.getState().setSelection(["el-1", "el-2", "el-3"], "el-2");
-
- const state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]);
- expect(state.selectedElementId).toBe("el-2");
- });
-
- it("falls back to the first selected id when the anchor is outside the set", () => {
- usePlayerStore.getState().setSelection(["el-1", "el-2"], "missing");
-
- const state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]);
- expect(state.selectedElementId).toBe("el-1");
- });
- it("single-click selection replaces the set with the selected id", () => {
+ it("collapses a stale multi-select set when single-selecting", () => {
const store = usePlayerStore.getState();
- store.setSelection(["el-1", "el-2"], "el-2");
- store.setSelectedElementId("el-3");
-
- const state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["el-3"]);
- expect(state.selectedElementId).toBe("el-3");
+ store.setSelectedElementIds(new Set(["a", "b", "c"]));
+ store.setSelectedElementId("a");
+ expect(usePlayerStore.getState().selectedElementId).toBe("a");
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
});
- it("setSelectedElementId collapses to a single element even for a current member", () => {
+ it("collapses the multi-select set even when re-selecting the same primary", () => {
const store = usePlayerStore.getState();
- store.setSelection(["el-1", "el-2", "el-3"], "el-1");
- // A genuine single selection (click) collapses the set, even if the id was a member.
- store.setSelectedElementId("el-2");
-
- const state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["el-2"]);
- expect(state.selectedElementId).toBe("el-2");
+ store.setSelectedElementId("a");
+ store.setSelectedElementIds(new Set(["a", "b"]));
+ store.setSelectedElementId("a");
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
});
- it("setSelectionAnchor moves the anchor within a group without collapsing it", () => {
+ it("clears the multi-select set on deselect (canvas delete nulls the primary)", () => {
const store = usePlayerStore.getState();
- store.setSelection(["el-1", "el-2", "el-3"], "el-1");
- // A DOM->store echo during a group gesture only moves the anchor.
- store.setSelectionAnchor("el-2");
-
- let state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]);
- expect(state.selectedElementId).toBe("el-2");
-
- // A non-member anchor is a genuine new single selection.
- store.setSelectionAnchor("outside");
- state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["outside"]);
- expect(state.selectedElementId).toBe("outside");
+ store.setSelectedElementId("a");
+ store.setSelectedElementIds(new Set(["a", "b"]));
+ store.setSelectedElementId(null);
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
});
- it("clearing single selection empties the set", () => {
+ it("keeps an additive set written AFTER the primary (marquee ordering)", () => {
const store = usePlayerStore.getState();
- store.setSelection(["el-1", "el-2"], "el-2");
- store.setSelectedElementId(null);
-
- const state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual([]);
- expect(state.selectedElementId).toBeNull();
+ // Marquee commits primary first (collapses), then repopulates the set.
+ store.setSelectedElementId("a");
+ store.setSelectedElementIds(new Set(["a", "b", "c"]));
+ expect(usePlayerStore.getState().selectedElementId).toBe("a");
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(3);
});
- it("toggle adds and removes members while keeping the anchor in the set", () => {
+ it("preserves the multi-select set when preserveSet is passed (late marquee primary)", () => {
const store = usePlayerStore.getState();
- store.setSelectedElementId("el-1");
- store.toggleSelectedElementId("el-2");
-
- let state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]);
- expect(state.selectedElementId).toBe("el-1");
-
- store.toggleSelectedElementId("el-1");
-
- state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["el-2"]);
- expect(state.selectedElementId).toBe("el-2");
+ store.setSelectedElementId("a");
+ store.setSelectedElementIds(new Set(["a", "b", "c"]));
+ // A late async primary resolution re-selects a MEMBER of the live set.
+ store.setSelectedElementId("a", { preserveSet: true });
+ expect(usePlayerStore.getState().selectedElementId).toBe("a");
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(3);
});
- it("clearSelection empties the set and the anchor", () => {
+ it("still collapses without preserveSet even when the primary is a member", () => {
const store = usePlayerStore.getState();
- store.setSelection(["el-1", "el-2"], "el-2");
- store.clearSelection();
-
- const state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual([]);
- expect(state.selectedElementId).toBeNull();
+ store.setSelectedElementId("a");
+ store.setSelectedElementIds(new Set(["a", "b", "c"]));
+ // Default (no opt-out) must keep the data-loss guard intact.
+ store.setSelectedElementId("a");
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
});
});
@@ -335,6 +296,17 @@ describe("usePlayerStore", () => {
expect(usePlayerStore.getState().elements[0].start).toBe(0);
});
+ it("syncs zIndex so the reverse z→lane mapping reads fresh store z", () => {
+ usePlayerStore.getState().setElements([
+ { id: "el-1", tag: "div", start: 0, duration: 5, track: 0, zIndex: 1 },
+ { id: "el-2", tag: "div", start: 0, duration: 5, track: 1, zIndex: 2 },
+ ]);
+ usePlayerStore.getState().updateElement("el-1", { zIndex: 9 });
+ const elements = usePlayerStore.getState().elements;
+ expect(elements[0].zIndex).toBe(9);
+ expect(elements[1].zIndex).toBe(2); // unchanged
+ });
+
it("prefers the stable element key when duplicate ids exist", () => {
usePlayerStore.getState().setElements([
{ id: "headline", key: "a", tag: "div", start: 0, duration: 5, track: 0 },
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 73553e45c3..9727006e52 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -3,7 +3,7 @@ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
import type { BeatEditState } from "../../utils/beatEditing";
import type { ClipManifestClip } from "../lib/playbackTypes";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
-import { computePinnedZoomPercent } from "../components/timelineZoom";
+import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
export interface KeyframeCacheEntry {
@@ -29,16 +29,6 @@ export interface TimelineElement {
start: number;
duration: number;
track: 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. */
- hasExplicitZIndex?: boolean;
- /** Stacking context this element belongs to; root clips use the root composition id. */
- stackingContextId?: string | null;
- /** Nearest parent composition context, matching RuntimeTimelineClip. */
- parentCompositionId?: string | null;
- /** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */
- compositionAncestors?: string[];
domId?: string;
/** Stable `data-hf-id` attribute value — used as primary patch target when present */
hfId?: string;
@@ -64,6 +54,14 @@ export interface TimelineElement {
hidden?: boolean;
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
timelineRole?: string;
+ /**
+ * Live CSS stacking order read from the host element during discovery
+ * (inline/computed z-index; "auto" ⇒ 0). Canvas paint order only — display
+ * lanes are track-derived (stable-track model); the explicit vertical
+ * stacking sync in timelineStackingSync updates this on deliberate lane
+ * moves (lower lane ⇒ higher z). Absent ⇒ treated as 0.
+ */
+ zIndex?: number;
/**
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
* child: the absolute master-timeline start of the sub-comp host the child
@@ -76,21 +74,19 @@ export interface TimelineElement {
export type ZoomMode = "fit" | "manual";
type TimelineTool = "select" | "razor";
-function resolveElementSelection(
- ids: Iterable,
- anchor?: string | null,
-): { selectedElementIds: Set; selectedElementId: string | null } {
- const selectedElementIds = new Set(ids);
- if (selectedElementIds.size === 0) {
- return { selectedElementIds, selectedElementId: null };
- }
- if (anchor && selectedElementIds.has(anchor)) {
- return { selectedElementIds, selectedElementId: anchor };
- }
- return {
- selectedElementIds,
- selectedElementId: selectedElementIds.values().next().value ?? null,
- };
+/** Options for {@link PlayerState.setSelectedElementId}. */
+export interface SelectElementOptions {
+ /**
+ * Keep the current multi-selection set instead of collapsing it. Multi-select
+ * flows (marquee, additive click) build the set AFTER writing the primary, and a
+ * LATE async primary-selection resolution (e.g. the inspector-opening
+ * handleTimelineElementSelect → applyDomSelection path) can re-fire
+ * setSelectedElementId with a primary that is ALREADY a member of the live set.
+ * Passing preserveSet keeps the set intact in that case. The default (collapse)
+ * still guards the original data-loss bug: a fresh single click on a clip that is
+ * NOT in the set clears a stale set left over from a previous gesture.
+ */
+ preserveSet?: boolean;
}
interface PlayerState {
@@ -109,6 +105,12 @@ interface PlayerState {
zoomMode: ZoomMode;
/** Timeline zoom percent relative to the fit width when in manual mode */
manualZoomPercent: number;
+ /** 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;
/** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
inPoint: number | null;
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
@@ -143,29 +145,10 @@ interface PlayerState {
/** Multi-select: additional selected elements beyond selectedElementId. */
selectedElementIds: Set;
- clearSelectedElementIds: () => void;
+ toggleSelectedElementId: (id: string) => void;
/** Replace the whole multi-selection at once (marquee live updates). */
setSelectedElementIds: (ids: Set) => void;
- /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
- timelineSnapEnabled: boolean;
- setTimelineSnapEnabled: (enabled: boolean) => 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
- * rescaling every clip. No-op once already pinned (mode is "manual").
- */
- pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
- /**
- * The timeline's live pixels-per-second + fit basis, published by on
- * every render. Non-reactive scratch state (never read as a render input).
- */
- timelinePps: number;
- timelineFitPps: number;
- setTimelineScale: (pps: number, fitPps: number) => void;
- setSelection: (ids: Iterable, anchor?: string | null) => void;
- addSelectedElementId: (id: string) => void;
- toggleSelectedElementId: (id: string) => void;
- clearSelection: () => void;
+ clearSelectedElementIds: () => void;
/** Keyframe data per element id, populated from parsed GSAP animations. */
keyframeCache: Map;
@@ -180,20 +163,34 @@ interface PlayerState {
setTimelineReady: (ready: boolean) => void;
setBeatDragging: (dragging: boolean) => void;
setElements: (elements: TimelineElement[]) => void;
- setSelectedElementId: (id: string | null) => void;
- /** Move the selection anchor within an active multi-selection without collapsing it. */
- setSelectionAnchor: (id: string | null) => void;
+ setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
updateElement: (
elementId: string,
updates: Partial<
- Pick<
- TimelineElement,
- "start" | "duration" | "track" | "zIndex" | "hasExplicitZIndex" | "playbackStart" | "hidden"
- >
+ Pick
>,
) => void;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
+ /**
+ * Pin the timeline zoom to the CURRENT on-screen pixels-per-second on the first
+ * edit, so a subsequent duration change (which recomputes fit-pps) stops
+ * rescaling every clip. No-op once already pinned (mode is "manual") so the
+ * user's own manual zoom is never overwritten by a later edit.
+ */
+ pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
+ /**
+ * The timeline's live pixels-per-second + fit basis, published by on
+ * every render. Non-reactive scratch state (never read as a render input) so
+ * edit handlers OUTSIDE — e.g. the keyboard-delete path — can still
+ * pin the zoom via `pinTimelineZoomToCurrent` without threading viewport
+ * geometry down to them.
+ */
+ timelinePps: number;
+ timelineFitPps: number;
+ setTimelineScale: (pps: number, fitPps: number) => void;
+ /** Pin using the last-published live scale (see `pinTimelineZoom`). */
+ pinTimelineZoomToCurrent: () => void;
setInPoint: (time: number | null) => void;
setOutPoint: (time: number | null) => void;
reset: () => void;
@@ -278,10 +275,23 @@ export const usePlayerStore = create((set, get) => ({
playbackRate: readStudioUiPreferences().playbackRate ?? 1,
audioMuted: readStudioUiPreferences().audioMuted ?? false,
loopEnabled: false,
- zoomMode: "fit",
- manualZoomPercent: 100,
+ // Hydrate the pinned zoom from prefs so a zoom pinned on a prior edit survives
+ // the iframe reload that the edit triggers — the reload/store-init used to snap
+ // back to "fit" and rescale every clip when the duration changed.
+ zoomMode: readStudioUiPreferences().timelineZoomMode ?? "fit",
+ manualZoomPercent: readStudioUiPreferences().timelineManualZoomPercent ?? 100,
timelinePps: 100,
timelineFitPps: 100,
+ timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
+ setTimeDisplayMode: (mode) => {
+ writeStudioUiPreferences({ timeDisplayMode: mode });
+ set({ timeDisplayMode: mode });
+ },
+ timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
+ setTimelineSnapEnabled: (enabled) => {
+ writeStudioUiPreferences({ timelineSnapEnabled: enabled });
+ set({ timelineSnapEnabled: enabled });
+ },
inPoint: null,
outPoint: null,
@@ -308,21 +318,15 @@ export const usePlayerStore = create((set, get) => ({
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
selectedElementIds: new Set(),
- setSelection: (ids, anchor) => set(resolveElementSelection(ids, anchor)),
- addSelectedElementId: (id: string) =>
- set((s) => {
- const next = new Set(s.selectedElementIds);
- next.add(id);
- return resolveElementSelection(next, s.selectedElementId);
- }),
toggleSelectedElementId: (id: string) =>
set((s) => {
const next = new Set(s.selectedElementIds);
if (next.has(id)) next.delete(id);
else next.add(id);
- return resolveElementSelection(next, s.selectedElementId);
+ return { selectedElementIds: next };
}),
- clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }),
+ setSelectedElementIds: (ids: Set) => set({ selectedElementIds: new Set(ids) }),
+ clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
keyframeCache: new Map(),
setKeyframeCache: (elementId, data) =>
@@ -404,13 +408,9 @@ export const usePlayerStore = create((set, get) => ({
set({ audioMuted: muted });
},
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
- setZoomMode: (mode) => set({ zoomMode: mode }),
- clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
- setSelectedElementIds: (ids: Set) => set({ selectedElementIds: new Set(ids) }),
- timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
- setTimelineSnapEnabled: (enabled) => {
- writeStudioUiPreferences({ timelineSnapEnabled: enabled });
- set({ timelineSnapEnabled: enabled });
+ setZoomMode: (mode) => {
+ writeStudioUiPreferences({ timelineZoomMode: mode });
+ set({ zoomMode: mode });
},
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
set((s) => {
@@ -426,11 +426,15 @@ export const usePlayerStore = create((set, get) => ({
setTimelineScale: (pps, fitPps) => {
// Non-reactive publish: mutate in place + reuse the same object identity so no
// subscriber re-renders (these fields are never a render input, only read
- // imperatively before pinning).
+ // imperatively by pinTimelineZoomToCurrent).
const state = get();
state.timelinePps = pps;
state.timelineFitPps = fitPps;
},
+ pinTimelineZoomToCurrent: () => {
+ const { timelinePps, timelineFitPps, pinTimelineZoom } = get();
+ pinTimelineZoom(timelinePps, timelineFitPps);
+ },
setInPoint: (time) =>
set((state) => {
const t = time !== null && Number.isFinite(time) ? time : null;
@@ -452,41 +456,37 @@ export const usePlayerStore = create((set, get) => ({
loopEnabled: t !== null ? true : state.loopEnabled,
};
}),
- setManualZoomPercent: (percent) =>
- set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
+ setManualZoomPercent: (percent) => {
+ const clamped = clampTimelineZoomPercent(percent);
+ writeStudioUiPreferences({ timelineManualZoomPercent: clamped });
+ set({ manualZoomPercent: clamped });
+ },
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
setTimelineReady: (ready) => set({ timelineReady: ready }),
setBeatDragging: (dragging) => set({ beatDragging: dragging }),
setElements: (elements) => set({ elements }),
- // A genuine single selection: always collapse the set to just this element. User
- // intent (timeline click, preview click via applyDomSelection) flows here; DOM sync
- // echoes that must preserve a group go through setSelectionAnchor instead.
- setSelectedElementId: (id) =>
+ setSelectedElementId: (id, options) =>
set((s) => {
- const selectedElementIds = id ? new Set([id]) : new Set();
// Selecting a different element drops any active keyframe selection — otherwise
// a stale activeKeyframePct from a prior diamond click would force the next drag
// to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
// calling setSelectedElementId, so this never clobbers a genuine keyframe select.
- return id !== s.selectedElementId
- ? {
- selectedElementId: id,
- selectedElementIds,
- activeKeyframePct: null,
- motionPathArmed: false,
- }
- : { selectedElementId: id, selectedElementIds };
- }),
- // Move the anchor within an active multi-selection WITHOUT collapsing it — used by
- // DOM->store sync echoes while a group gesture re-patches the preview. A non-member
- // id is treated as a genuine new single selection.
- setSelectionAnchor: (id) =>
- set((s) => {
- if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
- return { selectedElementId: id };
- }
- return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set() };
+ const base =
+ id !== s.selectedElementId
+ ? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false }
+ : { selectedElementId: id };
+ // A single-select must collapse any multi-selection: otherwise a stale
+ // selectedElementIds set survives and Delete/drag silently act on it (phantom
+ // group). Additive flows (marquee, Escape-restore) set the primary FIRST, then
+ // repopulate the set, so that ordering leaves their multi-selection intact.
+ // A LATE async primary resolution (inspector-open path) lands AFTER the set was
+ // written, though, so those flows pass preserveSet to opt out of the collapse
+ // when the incoming primary is already a live member of the set.
+ if (options?.preserveSet) return base;
+ return s.selectedElementIds.size > 0
+ ? { ...base, selectedElementIds: new Set() }
+ : base;
}),
updateElement: (elementId, updates) =>
set((state) => ({
diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts
index daf636a7f1..89cd45b2eb 100644
--- a/packages/studio/src/utils/blockInstaller.ts
+++ b/packages/studio/src/utils/blockInstaller.ts
@@ -2,9 +2,10 @@ import type { RegistryItem } from "@hyperframes/core/registry";
import type { TimelineElement } from "../player";
import {
insertTimelineAssetIntoSource,
- resolveTimelineAssetInitialGeometry,
+ resolveTimelineAssetCompositionSize,
} from "./timelineAssetDrop";
import { collectHtmlIds } from "./studioHelpers";
+import { generateId } from "./generateId";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import { saveProjectFilesWithHistory } from "./studioFileHistory";
import type { EditHistoryKind } from "./editHistory";
@@ -120,7 +121,9 @@ export async function addBlockToProject(
);
const isBlock = block.type === "hyperframes:block";
- const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
+ const { width: hostWidth, height: hostHeight } =
+ resolveTimelineAssetCompositionSize(originalContent);
+ const hostDims = { left: 0, top: 0, width: hostWidth, height: hostHeight };
const currentTime = opts.currentTime ?? 0;
const start = placement
@@ -152,6 +155,11 @@ export async function addBlockToProject(
const subCompHtml = [
` {
expect(state.undo[0].files["index.html"].after).toBe("c");
});
+ it("merges a lane-change move with its z-reorder past the default window via entry coalesceMs", () => {
+ // The z entry records only after the move persist's round-trip — often >300ms.
+ // Both sides pass coalesceMs: 5000 with the shared gesture key so the pair
+ // still folds into ONE undo step.
+ const move = buildEditHistoryEntry({
+ projectId: "project-1",
+ label: "Move timeline clips",
+ kind: "timeline",
+ coalesceKey: "clip-lane-move:1",
+ coalesceMs: 5000,
+ files: { "index.html": { before: "a", after: "b" } },
+ now: 100,
+ id: "move-entry",
+ });
+ const zReorder = buildEditHistoryEntry({
+ projectId: "project-1",
+ label: "Reorder layers",
+ kind: "manual",
+ coalesceKey: "clip-lane-move:1",
+ coalesceMs: 5000,
+ files: { "index.html": { before: "b", after: "c" } },
+ now: 500,
+ id: "z-entry",
+ });
+
+ const state = pushEditHistoryEntry(
+ pushEditHistoryEntry(createEmptyEditHistory(), move),
+ zReorder,
+ );
+
+ expect(state.undo).toHaveLength(1);
+ expect(state.undo[0].files["index.html"].before).toBe("a");
+ expect(state.undo[0].files["index.html"].after).toBe("c");
+ });
+
it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
const timing = buildEditHistoryEntry({
projectId: "project-1",
diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts
index 25e07edd97..6dae557369 100644
--- a/packages/studio/src/utils/gsapSoftReload.test.ts
+++ b/packages/studio/src/utils/gsapSoftReload.test.ts
@@ -1,7 +1,12 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi } from "vitest";
-import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload";
+import {
+ applySoftReload,
+ ensureMotionPathPluginLoaded,
+ diffSoftReloadableRestore,
+ applyUndoRestoreToPreview,
+} from "./gsapSoftReload";
const SCRIPT_TEXT = `
window.__timelines = window.__timelines || {};
@@ -447,3 +452,116 @@ describe("applySoftReload authored-opacity restore", () => {
expect(restoreOpacity(el)).toBe("");
});
});
+
+// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
+
+const wrap = (body: string) => `${body}`;
+
+describe("diffSoftReloadableRestore", () => {
+ it("reports the changed id for an attribute/inline-style-only diff", () => {
+ const prev = wrap(`
t
`);
+ const next = wrap(`
t
`);
+ expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
+ });
+
+ it("treats a structural change (added element) as NOT soft-reloadable", () => {
+ const prev = wrap(`
t
`);
+ const next = wrap(`
t
t
`);
+ expect(diffSoftReloadableRestore(prev, next)).toBeNull();
+ });
+
+ it("treats an element text/child change as NOT soft-reloadable", () => {
+ const prev = wrap(`
one
`);
+ const next = wrap(`
two
`);
+ expect(diffSoftReloadableRestore(prev, next)).toBeNull();
+ });
+
+ it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
+ const prev = wrap(
+ `
t
`,
+ );
+ const next = wrap(
+ `
t
`,
+ );
+ expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
+ });
+});
+
+function buildLiveIframe(bodyHtml: string) {
+ const doc = document.implementation.createHTMLDocument("");
+ doc.body.innerHTML = bodyHtml;
+ const contentWindow = {
+ gsap: { timeline: () => {} },
+ __hfForceTimelineRebind: () => {},
+ __timelines: {} as Record
,
+ __player: { getTime: () => 3, seek: vi.fn() },
+ __hfStudioManualEditsApply: vi.fn(),
+ };
+ return {
+ iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
+ contentWindow,
+ doc,
+ };
+}
+
+describe("applyUndoRestoreToPreview", () => {
+ const ROOT = "index.html";
+
+ it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
+ const { iframe, contentWindow, doc } = buildLiveIframe(
+ `t
`,
+ );
+ const reloadPreview = vi.fn();
+ const files = {
+ [ROOT]: {
+ previous: wrap(
+ `t
`,
+ ),
+ restored: wrap(`t
`),
+ },
+ };
+ const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
+ expect(outcome).toBe("soft");
+ expect(reloadPreview).not.toHaveBeenCalled();
+ // Live element reverted to the restored inline style.
+ expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
+ // No GSAP script in the restore → the manual-edit reapply runs, playhead held.
+ expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
+ expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
+ });
+
+ it("full-reloads a multi-file restore", () => {
+ const { iframe } = buildLiveIframe(`t
`);
+ const reloadPreview = vi.fn();
+ const files = {
+ [ROOT]: {
+ previous: wrap(`t
`),
+ restored: wrap(`t
`),
+ },
+ "scenes/intro.html": { previous: "a", restored: "b" },
+ };
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
+ });
+
+ it("full-reloads a structural restore (split/delete undo)", () => {
+ const { iframe } = buildLiveIframe(`t
t
`);
+ const reloadPreview = vi.fn();
+ const files = {
+ [ROOT]: {
+ previous: wrap(`t
t
`),
+ restored: wrap(`t
`),
+ },
+ };
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
+ });
+
+ it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
+ const { iframe } = buildLiveIframe(`t
`);
+ const reloadPreview = vi.fn();
+ const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts
index 63012108ca..cf265b891a 100644
--- a/packages/studio/src/utils/gsapSoftReload.ts
+++ b/packages/studio/src/utils/gsapSoftReload.ts
@@ -183,6 +183,159 @@ export interface SoftReloadOptions {
authoredHtml?: string;
}
+/** One file's restore from the edit-history store: before (live) / after (target) bytes. */
+export interface UndoRestoreFile {
+ previous: string;
+ restored: string;
+}
+function idElementMap(doc: Document): Map {
+ const map = new Map();
+ for (const el of doc.querySelectorAll("[id]")) {
+ const id = el.getAttribute("id");
+ if (id) map.set(id, el);
+ }
+ return map;
+}
+
+// Strip id'd elements to bare `id` and blank GSAP scripts, in place: docs that
+// differ only in id'd attributes/inline-style/script text normalize equal; any
+// residual difference is beyond soft-reload's reach → caller full-reloads.
+function normalizeSoftResidual(doc: Document): void {
+ for (const el of doc.querySelectorAll("[id]")) {
+ const id = el.getAttribute("id");
+ for (const name of [...el.getAttributeNames()]) {
+ if (name !== "id") el.removeAttribute(name);
+ }
+ if (id) el.setAttribute("id", id);
+ }
+ for (const script of findGsapScriptElements(doc)) script.textContent = "";
+}
+
+// Soft-reloadable iff the docs differ SOLELY in id'd-element attributes/inline
+// style and/or the GSAP script; returns the changed ids to sync onto the live
+// DOM. Structural/text diffs → null → the caller full-reloads. Pure.
+export function diffSoftReloadableRestore(
+ previous: string,
+ restored: string,
+): { changedElementIds: string[] } | null {
+ let prevDoc: Document;
+ let nextDoc: Document;
+ try {
+ prevDoc = new DOMParser().parseFromString(previous, "text/html");
+ nextDoc = new DOMParser().parseFromString(restored, "text/html");
+ } catch {
+ return null;
+ }
+ const prevById = idElementMap(prevDoc);
+ const nextById = idElementMap(nextDoc);
+ // A different id set means an element was added or removed (e.g. a split, a
+ // delete) — structural, so soft-reload can't express it.
+ if (prevById.size !== nextById.size) return null;
+ const changedElementIds: string[] = [];
+ for (const [id, nextEl] of nextById) {
+ const prevEl = prevById.get(id);
+ if (!prevEl || prevEl.tagName !== nextEl.tagName) return null;
+ // A change inside the element (text / children) is out of soft scope; only
+ // its own attributes may differ. (GSAP scripts are handled via re-run.)
+ if (prevEl.innerHTML !== nextEl.innerHTML) return null;
+ if (prevEl.outerHTML !== nextEl.outerHTML) changedElementIds.push(id);
+ }
+ // Confirm nothing OUTSIDE id'd-element attributes and GSAP scripts changed.
+ normalizeSoftResidual(prevDoc);
+ normalizeSoftResidual(nextDoc);
+ if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
+ return { changedElementIds };
+}
+
+/** Copy every attribute from `source` onto the live `target`, dropping extras. */
+function syncElementAttributes(target: Element, source: Element): void {
+ for (const name of [...target.getAttributeNames()]) {
+ if (!source.hasAttribute(name)) target.removeAttribute(name);
+ }
+ for (const name of source.getAttributeNames()) {
+ target.setAttribute(name, source.getAttribute(name) ?? "");
+ }
+}
+
+/**
+ * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
+ * remount (which blanks the frame black and re-flashes the WebGL context). Only
+ * the active composition — the document living in the root iframe — is eligible;
+ * a sub-comp or multi-file restore falls back to `reloadPreview`.
+ *
+ * The restore is soft-applied when its only differences are id'd-element
+ * attributes / inline-style and/or the GSAP script (see diffSoftReloadableRestore):
+ * 1. Each changed element's attribute surface (inline style, data-start /
+ * -duration, the studio manual-offset props + flags) is synced onto the live
+ * element — so a canvas-position revert lands on the live DOM the runtime's
+ * seek-reapply reads from, not just on disk.
+ * 2. The restored GSAP script is re-run in place via applySoftReload, which
+ * re-seeks to `currentTime` (playhead-invariant) and re-folds manual edits.
+ * With no single script, the manual-edit reapply is invoked directly.
+ *
+ * Returns "soft" when applied in place, "full" when it escalated to reloadPreview
+ * (ineligible restore, missing target, or a permanent soft-reload failure).
+ */
+export function applyUndoRestoreToPreview(
+ iframe: HTMLIFrameElement | null,
+ activeCompPath: string | null,
+ files: Record | undefined,
+ currentTime: number,
+ reloadPreview: () => void,
+): "soft" | "full" {
+ const paths = files ? Object.keys(files) : [];
+ // Soft path only covers the single active-comp document in the root iframe.
+ if (!iframe || !activeCompPath || !files || paths.length !== 1 || paths[0] !== activeCompPath) {
+ reloadPreview();
+ return "full";
+ }
+ const doc = iframe.contentDocument;
+ const win = iframe.contentWindow as IframeWindow | null;
+ if (!doc || !win) {
+ reloadPreview();
+ return "full";
+ }
+ const { previous, restored } = files[activeCompPath]!;
+ const diff = diffSoftReloadableRestore(previous, restored);
+ if (!diff) {
+ reloadPreview();
+ return "full";
+ }
+
+ // Sync each changed element's attributes onto the live DOM from the restored
+ // markup, so the runtime's seek-reapply (which reads inline offset props off
+ // the live element) folds the REVERTED values, not the stale current ones.
+ const restoredById = idElementMap(new DOMParser().parseFromString(restored, "text/html"));
+ for (const id of diff.changedElementIds) {
+ const liveEl = doc.getElementById(id);
+ const restoredEl = restoredById.get(id);
+ if (liveEl && restoredEl) syncElementAttributes(liveEl, restoredEl);
+ }
+
+ const script = extractGsapScriptText(restored);
+ if (script) {
+ const result = applySoftReload(iframe, script, {
+ onAsyncFailure: reloadPreview,
+ currentTimeOverride: currentTime,
+ });
+ if (result === "cannot-soft-reload") {
+ reloadPreview();
+ return "full";
+ }
+ return "soft";
+ }
+ // No single GSAP script to re-run — the change was pure attribute/style. Re-fold
+ // manual edits and hold the playhead so the synced attributes take visible effect.
+ try {
+ win.__player?.seek?.(currentTime);
+ win.__hfStudioManualEditsApply?.();
+ } catch {
+ reloadPreview();
+ return "full";
+ }
+ return "soft";
+}
+
export function applySoftReload(
iframe: HTMLIFrameElement | null,
scriptText: string,
diff --git a/packages/studio/src/utils/mediaTypes.ts b/packages/studio/src/utils/mediaTypes.ts
index 2bd97cf869..c6030bc41f 100644
--- a/packages/studio/src/utils/mediaTypes.ts
+++ b/packages/studio/src/utils/mediaTypes.ts
@@ -1,9 +1,10 @@
-export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
+export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
export const LUT_EXT = /\.cube$/i;
-export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
+export const MEDIA_EXT =
+ /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
export function isMediaFile(path: string): boolean {
return MEDIA_EXT.test(path);
diff --git a/packages/studio/src/utils/timelineAssetDrop.test.ts b/packages/studio/src/utils/timelineAssetDrop.test.ts
index 046c5eba9f..6d2aa49d11 100644
--- a/packages/studio/src/utils/timelineAssetDrop.test.ts
+++ b/packages/studio/src/utils/timelineAssetDrop.test.ts
@@ -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) =>
+ `x
`;
+
+ 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 = `x
`;
+ expect(setCompositionDurationToContent(source, 8)).toBe(
+ `x
`,
+ );
+ });
+
+ it("patches single-quoted attributes and keeps the quote style", () => {
+ const source = `x
`;
+ expect(setCompositionDurationToContent(source, 8)).toBe(
+ `x
`,
+ );
+ });
+});
+
+describe("extendCompositionDurationIfNeeded", () => {
+ it("grows the root duration when a clip lands past the end", () => {
+ const source = `x
`;
+ expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
+ `x
`,
+ );
+ });
+
+ it("is a no-op when the required end fits within the current duration", () => {
+ const source = `x
`;
+ expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
+ });
+
+ it("grows even when the attribute order is swapped and quotes are single", () => {
+ const source = `x
`;
+ expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
+ `x
`,
+ );
+ });
+
+ it("is a no-op when there is no composition root", () => {
+ const source = `x
`;
+ 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(
`
`,
),
).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(' ');
});
});
+
+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,
+ });
+ });
+});
diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts
index 5194f5899b..7015169b26 100644
--- a/packages/studio/src/utils/timelineAssetDrop.ts
+++ b/packages/studio/src/utils/timelineAssetDrop.ts
@@ -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,58 +49,66 @@ 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,
};
}
+/**
+ * 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,
+ comp: { width: number; height: number },
+): { left: number; top: number; width: number; height: number } {
+ if (!natural || natural.width <= 0 || natural.height <= 0) {
+ return { left: 0, top: 0, width: comp.width, height: comp.height };
+ }
+ const scale = Math.min(1, comp.width / natural.width, comp.height / natural.height);
+ const width = Math.round(natural.width * scale);
+ const height = Math.round(natural.height * scale);
+ return {
+ left: Math.round((comp.width - width) / 2),
+ top: Math.round((comp.height - height) / 2),
+ width,
+ height,
+ };
+}
+
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;
+ hfId: string;
assetPath: string;
kind: TimelineAssetKind;
start: number;
@@ -109,7 +117,7 @@ export function buildTimelineAssetInsertHtml(input: {
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 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}`;
@@ -121,23 +129,18 @@ export function buildTimelineAssetInsertHtml(input: {
return ` `;
}
- return ` `;
+ return ` `;
}
-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)}`;
+/**
+ * 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)));
}
/**
@@ -156,45 +159,18 @@ export function setCompositionDurationToContent(source: string, contentEnd: numb
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.
- */
-export function fitTimelineAssetGeometry(
- natural: { width: number; height: number } | null,
- comp: { width: number; height: number },
-): { left: number; top: number; width: number; height: number } {
- if (!natural || natural.width <= 0 || natural.height <= 0) {
- return { left: 0, top: 0, width: comp.width, height: comp.height };
+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 scale = Math.min(1, comp.width / natural.width, comp.height / natural.height);
- const width = Math.round(natural.width * scale);
- const height = Math.round(natural.height * scale);
- return {
- left: Math.round((comp.width - width) / 2),
- top: Math.round((comp.height - height) / 2),
- width,
- height,
- };
-}
-
-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,
- };
+ 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)}`;
}
diff --git a/packages/studio/src/utils/timelineDiscovery.test.ts b/packages/studio/src/utils/timelineDiscovery.test.ts
deleted file mode 100644
index 5174cb1d04..0000000000
--- a/packages/studio/src/utils/timelineDiscovery.test.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- TIMELINE_TOGGLE_SHORTCUT_LABEL,
- getTimelineToggleTitle,
- shouldHandleTimelineToggleHotkey,
-} from "./timelineDiscovery";
-
-describe("shouldHandleTimelineToggleHotkey", () => {
- it("accepts Shift+T when focus is not inside an editor", () => {
- expect(
- shouldHandleTimelineToggleHotkey({
- key: "T",
- shiftKey: true,
- metaKey: false,
- ctrlKey: false,
- altKey: false,
- target: {
- tagName: "DIV",
- isContentEditable: false,
- closest: () => null,
- },
- } as KeyboardEvent),
- ).toBe(true);
- });
-
- it("ignores the shortcut inside text inputs", () => {
- expect(
- shouldHandleTimelineToggleHotkey({
- key: "t",
- shiftKey: true,
- metaKey: false,
- ctrlKey: false,
- altKey: false,
- target: {
- tagName: "TEXTAREA",
- isContentEditable: false,
- closest: () => null,
- },
- } as KeyboardEvent),
- ).toBe(false);
- });
-
- it("ignores the shortcut inside contenteditable editors", () => {
- expect(
- shouldHandleTimelineToggleHotkey({
- key: "t",
- shiftKey: true,
- metaKey: false,
- ctrlKey: false,
- altKey: false,
- target: {
- tagName: "DIV",
- isContentEditable: true,
- closest: () => null,
- },
- } as KeyboardEvent),
- ).toBe(false);
- });
-
- it("requires Shift without other modifiers", () => {
- expect(
- shouldHandleTimelineToggleHotkey({
- key: "t",
- shiftKey: false,
- metaKey: false,
- ctrlKey: false,
- altKey: false,
- target: null,
- } as KeyboardEvent),
- ).toBe(false);
-
- expect(
- shouldHandleTimelineToggleHotkey({
- key: "t",
- shiftKey: true,
- metaKey: true,
- ctrlKey: false,
- altKey: false,
- target: null,
- } as KeyboardEvent),
- ).toBe(false);
- });
-});
-
-describe("getTimelineToggleTitle", () => {
- it("includes the shortcut in both show and hide titles", () => {
- expect(getTimelineToggleTitle(true)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
- expect(getTimelineToggleTitle(false)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
- });
-});
diff --git a/packages/studio/src/utils/timelineDiscovery.ts b/packages/studio/src/utils/timelineDiscovery.ts
index f4027953e1..a6428ba0ce 100644
--- a/packages/studio/src/utils/timelineDiscovery.ts
+++ b/packages/studio/src/utils/timelineDiscovery.ts
@@ -1,9 +1,3 @@
-export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T";
-type TimelineToggleHotkeyEvent = Pick<
- KeyboardEvent,
- "key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target"
->;
-
interface EditableTargetLike {
tagName?: string;
isContentEditable?: boolean;
@@ -28,14 +22,3 @@ export function isEditableTarget(target: EventTarget | null): boolean {
),
);
}
-
-export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEvent): boolean {
- if (event.metaKey || event.ctrlKey || event.altKey) return false;
- if (!event.shiftKey) return false;
- if (event.key.toLowerCase() !== "t") return false;
- return !isEditableTarget(event.target);
-}
-
-export function getTimelineToggleTitle(timelineVisible: boolean): string {
- return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`;
-}
diff --git a/skills/website-to-video/references/capabilities.md b/skills/website-to-video/references/capabilities.md
index f70e0e738e..f0de368536 100644
--- a/skills/website-to-video/references/capabilities.md
+++ b/skills/website-to-video/references/capabilities.md
@@ -554,7 +554,7 @@ mp4, webm, mov, png-sequence — with HDR (PQ / HLG / SDR / auto-detect), transp
Full editor in packages/studio/:
-- **NLELayout**: NLE preview + timeline + controls
+- **EditorShell**: NLE preview + timeline + controls
- **Timeline**: clip rendering, drag to move (`data-start`), resize (`data-duration`), `data-track-index` reassignment, asset drop, file drop
- **PlayerControls**: scrub, play, pause, frame step (`stepFrameTime`), `STUDIO_PREVIEW_FPS`
- **useTimelinePlayer**: resolves `__player` / `__timeline` / `__timelines`