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/AssetCard.tsx b/packages/studio/src/components/sidebar/AssetCard.tsx
new file mode 100644
index 0000000000..e2ef1bf4f6
--- /dev/null
+++ b/packages/studio/src/components/sidebar/AssetCard.tsx
@@ -0,0 +1,316 @@
+/**
+ * AssetCard and FontRow — visual asset tile / row components for the Assets panel.
+ * Extracted from AssetsTab.tsx to keep that file under the 600-line CI gate.
+ */
+import { useState, useEffect, useRef, useCallback } from "react";
+import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
+import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
+import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
+import { ContextMenu } from "./AssetContextMenu";
+import { usePlayerStore } from "../../player/store/playerStore";
+import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
+import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
+import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
+
+/** Drag payload writer shared by the asset tile and the font row: copy effect
+ * plus the timeline-asset MIME and a plain-text path fallback. */
+function writeAssetDragData(e: React.DragEvent, asset: string): void {
+ e.dataTransfer.effectAllowed = "copy";
+ e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
+ e.dataTransfer.setData("text/plain", asset);
+}
+
+/** Open the row/tile context menu at the pointer, shared by asset tile + font row. */
+function openAssetContextMenu(
+ e: React.MouseEvent,
+ setContextMenu: (menu: { x: number; y: number }) => void,
+): void {
+ e.preventDefault();
+ setContextMenu({ x: e.clientX, y: e.clientY });
+}
+
+/**
+ * Lazily probe a video/audio URL for its duration via a hidden HTMLVideoElement
+ * (`preload="metadata"`). The manifest only covers ~/.media assets, so project
+ * assets in assets/ have no manifest entry — this fills the gap.
+ * Returns `undefined` until the probe completes; `null` if it failed.
+ */
+function useProbedDuration(src: string, skip: boolean): number | null | undefined {
+ const [duration, setDuration] = useState(undefined);
+ useEffect(() => {
+ if (skip) return;
+ let cancelled = false;
+
+ function probe(attempt: number) {
+ if (cancelled) return;
+ const vid = document.createElement("video");
+ vid.preload = "metadata";
+ vid.muted = true;
+ vid.onloadedmetadata = () => {
+ const d = Number.isFinite(vid.duration) && vid.duration > 0 ? vid.duration : null;
+ if (!cancelled) setDuration(d);
+ vid.onloadedmetadata = null;
+ vid.onerror = null;
+ vid.src = "";
+ };
+ vid.onerror = () => {
+ vid.onloadedmetadata = null;
+ vid.onerror = null;
+ vid.src = "";
+ if (!cancelled) {
+ if (attempt < 1) setTimeout(() => probe(attempt + 1), 50);
+ else setDuration(null);
+ }
+ };
+ vid.src = src;
+ }
+
+ probe(0);
+ return () => {
+ cancelled = true;
+ };
+ }, [src, skip]);
+ return duration;
+}
+
+export interface AssetCardProps {
+ projectId: string;
+ asset: string;
+ used: boolean;
+ duration?: number;
+ onCopy: (path: string) => void;
+ isCopied: boolean;
+ onDelete?: (path: string) => void;
+ onRename?: (oldPath: string, newPath: string) => void;
+ onAddAssetToTimeline?: (path: string) => void;
+}
+
+/**
+ * Thumbnail card for images and video assets. Renders in a 2-col grid.
+ *
+ * Click behaviour (CapCut-style):
+ * - Already added → selects the clip on the timeline (setSelectedElementId).
+ * - Not yet added → opens the asset preview overlay over the canvas.
+ * Drag behaviour is preserved: a pointer movement exceeding DRAG_THRESHOLD_PX
+ * before pointerup is treated as drag-start, not a click.
+ */
+// fallow-ignore-next-line complexity
+export function AssetCard({
+ projectId,
+ asset,
+ used,
+ duration,
+ onCopy,
+ isCopied,
+ onDelete,
+ onRename,
+ onAddAssetToTimeline,
+}: AssetCardProps) {
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+ const [hovered, setHovered] = useState(false);
+ const fullName = asset.split("/").pop() ?? asset;
+ 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 probedDuration = useProbedDuration(serveUrl, !isVideo || duration != null);
+ const resolvedDuration = duration ?? probedDuration ?? undefined;
+ const durationLabel = formatDuration(resolvedDuration ?? 0);
+
+ // Drag-threshold click gate: track pointer-down position so we can ignore
+ // pointer-up events that followed a real drag gesture.
+ 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;
+ // Treat as click
+ if (used) {
+ const clip = findClipForAsset(elements, asset);
+ if (clip) {
+ setSelectedElementId(clip.key ?? clip.id);
+ return;
+ }
+ }
+ // Not added (or no matching clip found) → preview overlay
+ setPreviewAsset(asset, projectId);
+ },
+ [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
+ );
+
+ return (
+ <>
+ writeAssetDragData(e, asset)}
+ onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)}
+ onPointerEnter={() => setHovered(true)}
+ onPointerLeave={() => setHovered(false)}
+ className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors ${
+ isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/40"
+ }`}
+ >
+ {/* Thumbnail */}
+
+ {isImage && (
+
{
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
+ />
+ )}
+ {isVideo && (
+ <>
+
+ {hovered && (
+
+ )}
+ >
+ )}
+ {!isImage && !isVideo && (
+
+ {extension}
+
+ )}
+
+ {/* "Added" badge — top-left */}
+ {used && (
+
+ Added
+
+ )}
+
+ {/* Duration badge — top-right, media only */}
+ {durationLabel && (
+
+ {durationLabel}
+
+ )}
+
+
+ {/* Filename caption */}
+
+ {truncateMiddle(fullName, 22)}
+
+
+
+ {contextMenu && (
+ setContextMenu(null)}
+ onCopy={onCopy}
+ onDelete={onDelete}
+ onRename={onRename}
+ onAddAtPlayhead={onAddAssetToTimeline}
+ />
+ )}
+ >
+ );
+}
+
+export interface FontRowProps {
+ asset: string;
+ used: boolean;
+ onCopy: (path: string) => void;
+ isCopied: boolean;
+ onDelete?: (path: string) => void;
+ onRename?: (oldPath: string, newPath: string) => void;
+ onAddAssetToTimeline?: (path: string) => void;
+}
+
+/**
+ * Compact row for font assets (no meaningful thumbnail; show ext badge + name).
+ */
+export function FontRow({
+ asset,
+ used,
+ onCopy,
+ isCopied,
+ onDelete,
+ onRename,
+ onAddAssetToTimeline,
+}: FontRowProps) {
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+ const name = basename(asset);
+ const extension = ext(asset);
+
+ return (
+ <>
+ onCopy(asset)}
+ onDragStart={(e) => writeAssetDragData(e, asset)}
+ onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)}
+ className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors ${
+ isCopied
+ ? "bg-studio-accent/10 border-l-2 border-studio-accent"
+ : "border-l-2 border-transparent hover:bg-neutral-800/50"
+ }`}
+ >
+
+ {extension}
+
+
+
+ {name}
+
+
+ {extension}
+ {used && (
+
+ in use
+
+ )}
+
+
+
+
+ {contextMenu && (
+ setContextMenu(null)}
+ onCopy={onCopy}
+ onDelete={onDelete}
+ onRename={onRename}
+ onAddAtPlayhead={onAddAssetToTimeline}
+ />
+ )}
+ >
+ );
+}
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/domSelectionTestHarness.ts b/packages/studio/src/hooks/domSelectionTestHarness.ts
index 78810ca5c8..affd703311 100644
--- a/packages/studio/src/hooks/domSelectionTestHarness.ts
+++ b/packages/studio/src/hooks/domSelectionTestHarness.ts
@@ -1,5 +1,8 @@
// Shared harness helpers for selection hook tests (useDomSelection,
// usePreviewInteraction). Test-only module.
+import type React from "react";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
import type { DomEditSelection } from "../components/editor/domEditing";
export function installReactActEnvironment(): void {
@@ -9,6 +12,17 @@ export function installReactActEnvironment(): void {
});
}
+/** Mount a React element into a fresh detached host and return its root. */
+export function mountReactHarness(node: React.ReactElement): Root {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(node);
+ });
+ return root;
+}
+
export function makeSelection(label: string, element: HTMLElement): DomEditSelection {
return {
element,
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/useAnimatedPropertyCommit.test.tsx b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx
index f5eeed3d35..7e75c84320 100644
--- a/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx
+++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx
@@ -1,12 +1,12 @@
// @vitest-environment happy-dom
import React, { act } from "react";
-import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
+import { mountReactHarness } from "./domSelectionTestHarness";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -58,13 +58,7 @@ function renderHookWith(
onReady(commitAnimatedProperties);
return null;
}
- const host = document.createElement("div");
- document.body.append(host);
- const root = createRoot(host);
- act(() => {
- root.render( );
- });
- return root;
+ return mountReactHarness( );
}
function renderCommitHook(
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/useContextMenuDismiss.ts b/packages/studio/src/hooks/useContextMenuDismiss.ts
index c6d04579a6..796a84a495 100644
--- a/packages/studio/src/hooks/useContextMenuDismiss.ts
+++ b/packages/studio/src/hooks/useContextMenuDismiss.ts
@@ -1,26 +1,50 @@
import { useCallback, useEffect, useRef, type RefObject } from "react";
/**
- * Shared dismiss logic for context menus: closes on outside click or Escape.
- * Returns a ref to attach to the menu container element.
+ * Shared dismiss logic for context menus: closes on ANY pointerdown outside the
+ * menu (mouse, pen, or touch), or Escape.
+ *
+ * Two failure modes this guards against, both seen in the canvas editor:
+ *
+ * 1. The menu lives inside DomEditOverlay, whose own pointer handlers call
+ * `event.stopPropagation()` on several branches (marquee start, shift-select).
+ * A bubble-phase `mousedown`/`pointerdown` listener on `document` never fires
+ * for those events — the overlay eats them first — so the menu stayed open.
+ * Listening in the CAPTURE phase runs this dismiss BEFORE any bubble-phase
+ * stopPropagation, so an outside press always closes the menu.
+ *
+ * 2. `mousedown` alone misses pointer/touch-only gestures. `pointerdown` is the
+ * superset (fires for mouse, pen, and touch) and is exactly the event the
+ * overlay itself acts on, so hooking it here dismisses on the same press that
+ * starts a canvas gesture — no second click required.
*/
export function useContextMenuDismiss(onClose: () => void): RefObject {
const menuRef = useRef(null);
const dismiss = useCallback(
- (e: MouseEvent | KeyboardEvent) => {
- if (e instanceof KeyboardEvent && e.key !== "Escape") return;
- if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
+ (e: PointerEvent | MouseEvent | KeyboardEvent) => {
+ if (e instanceof KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ return;
+ }
+ // Any press inside the menu is a menu interaction — leave it open (the
+ // item's own handler will close it after acting).
+ if (menuRef.current?.contains(e.target as Node)) return;
onClose();
},
[onClose],
);
useEffect(() => {
- document.addEventListener("mousedown", dismiss);
+ // Capture phase so overlay/iframe-side handlers that stopPropagation on the
+ // bubble phase can't swallow the dismiss. `pointerdown` covers mouse + touch
+ // + pen; keep `mousedown` too for any synthetic-mouse path that skips it.
+ document.addEventListener("pointerdown", dismiss, true);
+ document.addEventListener("mousedown", dismiss, true);
document.addEventListener("keydown", dismiss);
return () => {
- document.removeEventListener("mousedown", dismiss);
+ document.removeEventListener("pointerdown", dismiss, true);
+ document.removeEventListener("mousedown", dismiss, true);
document.removeEventListener("keydown", dismiss);
};
}, [dismiss]);
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/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts
index 36061f1a19..e87963dd17 100644
--- a/packages/studio/src/hooks/useDomEditTextCommits.ts
+++ b/packages/studio/src/hooks/useDomEditTextCommits.ts
@@ -174,7 +174,11 @@ export function useDomEditTextCommits({
let editedElement: HTMLElement | null = null;
let previousInlineValue: string | null = null;
const operations = buildDomStyleCommitOperations(property, value, isImageBackgroundCommit);
- const skipRefresh = property !== "z-index";
+ // Inline-style commits never full-reload the preview (that blanks the iframe
+ // until it re-renders): the live element was already mutated optimistically in
+ // apply(). z-index is no exception — setting `element.style.zIndex` restacks the
+ // element in-browser immediately, so a reload would only cost a black blink.
+ const skipRefresh = true;
await runDomEditCommit({
capture: () => {
diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts
index c4c3706c1e..f598cb54e3 100644
--- a/packages/studio/src/hooks/useDomEditWiring.ts
+++ b/packages/studio/src/hooks/useDomEditWiring.ts
@@ -11,7 +11,6 @@ import { useCallback, useEffect, useRef } from "react";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
import { usePlayerStore } from "../player";
-import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
import { useGsapAnimationsForElement, usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
@@ -171,14 +170,13 @@ export function useDomEditWiring({
useEffect(() => {
if (!domEditSelection?.id) return;
- const { selectedElementId, elements, setSelectionAnchor } = usePlayerStore.getState();
- // Resolve through the canonical resolver (source-file + ancestor + active-comp
- // fallback) rather than a narrow domId/id match, so a sub-composition selection
- // maps to the same clip the rest of the selection pipeline picks. Use the
- // anchor-only setter: this is a DOM->store echo and must not collapse a group.
- const key = resolveTimelineIdForSelection(domEditSelection, elements, activeCompPath);
- if (key && key !== selectedElementId) setSelectionAnchor(key);
- }, [domEditSelection, activeCompPath]);
+ const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
+ const matchKey = elements.find(
+ (el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
+ );
+ const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
+ if (key && key !== selectedElementId) setSelectedElementId(key);
+ }, [domEditSelection?.id]);
// ── GSAP cache sync ──
diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts
index ab81e4adfc..667f3caea9 100644
--- a/packages/studio/src/hooks/useDomGeometryCommits.ts
+++ b/packages/studio/src/hooks/useDomGeometryCommits.ts
@@ -61,14 +61,29 @@ export function useDomGeometryCommits({
);
const handleDomBoxSizeCommit = useCallback(
- (selection: DomEditSelection, next: { width: number; height: number }) => {
+ (
+ selection: DomEditSelection,
+ next: { width: number; height: number },
+ offset?: { x: number; y: number },
+ ) => {
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
showToast(error.message, "error");
return Promise.reject(error);
}
applyStudioBoxSize(selection.element, next);
- return commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
+ // Anchored-corner resize (NW/NE/SW) also moves the element to keep the
+ // opposite corner fixed. Apply the offset and emit BOTH patch sets in a
+ // SINGLE commit: one persist = one undo entry, and there is no
+ // intermediate re-stamp where the new size is in source but the anchor
+ // offset is not (that frame was the release "jump"). Both builders read
+ // the already-mutated live element, so concatenation is safe.
+ const patches = buildBoxSizePatches(selection.element);
+ if (offset) {
+ applyStudioPathOffset(selection.element, offset);
+ patches.push(...buildPathOffsetPatches(selection.element));
+ }
+ return commitPositionPatchToHtml(selection, patches, {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
});
diff --git a/packages/studio/src/hooks/useDomSelection.test.ts b/packages/studio/src/hooks/useDomSelection.test.ts
index b7628c4379..53f4524644 100644
--- a/packages/studio/src/hooks/useDomSelection.test.ts
+++ b/packages/studio/src/hooks/useDomSelection.test.ts
@@ -2,9 +2,7 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import type { TimelineElement } from "../player";
-import { usePlayerStore } from "../player/store/playerStore";
+import { describe, expect, it, vi } from "vitest";
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
import { useDomSelection } from "./useDomSelection";
@@ -14,7 +12,6 @@ interface HarnessProps {
activeCompPath: string | null;
projectId: string | null;
refreshKey: number;
- timelineElements?: TimelineElement[];
}
function renderHarness(initialProps: HarnessProps): {
@@ -35,7 +32,7 @@ function renderHarness(initialProps: HarnessProps): {
compIdToSrc: new Map(),
captionEditMode: false,
previewIframeRef: { current: null },
- timelineElements: props.timelineElements ?? [],
+ timelineElements: [],
setSelectedTimelineElementId: vi.fn(),
setRightCollapsed: vi.fn(),
setRightPanelTab: vi.fn(),
@@ -67,10 +64,6 @@ function renderHarness(initialProps: HarnessProps): {
};
}
-afterEach(() => {
- usePlayerStore.getState().reset();
-});
-
function setupSelectedHarness() {
const element = document.createElement("div");
element.id = "headline";
@@ -138,31 +131,4 @@ describe("useDomSelection", () => {
expect(harness.current().domEditSelection).toBe(selection);
harness.cleanup();
});
-
- it("keeps preview marquee selections mirrored to the full timeline selection set", () => {
- const first = document.createElement("div");
- first.id = "clip-1";
- const second = document.createElement("div");
- second.id = "clip-2";
- const firstSelection = makeSelection("First", first);
- const secondSelection = makeSelection("Second", second);
- const harness = renderHarness({
- activeCompPath: "intro.html",
- projectId: "project-1",
- refreshKey: 0,
- timelineElements: [
- { id: "clip-1", domId: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
- { id: "clip-2", domId: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
- ],
- });
-
- act(() => harness.current().applyMarqueeSelection([secondSelection, firstSelection], false));
-
- const state = usePlayerStore.getState();
- expect([...state.selectedElementIds]).toEqual(["clip-2", "clip-1"]);
- expect(state.selectedElementId).toBe("clip-2");
- expect(harness.current().domEditGroupSelections).toHaveLength(2);
- expect(harness.current().domEditSelection).toBe(secondSelection);
- harness.cleanup();
- });
});
diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts
index 3fd25c9158..83eced6cbb 100644
--- a/packages/studio/src/hooks/useDomSelection.ts
+++ b/packages/studio/src/hooks/useDomSelection.ts
@@ -1,10 +1,15 @@
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,
} from "../utils/studioPreviewHelpers";
-import { resolveTimelineIdForSelection, type RightPanelTab } from "../utils/studioHelpers";
+import {
+ findMatchingTimelineElementId,
+ findTimelineIdByAncestor,
+ type RightPanelTab,
+} from "../utils/studioHelpers";
import {
domEditSelectionsTargetSame,
domEditSelectionInGroup,
@@ -20,7 +25,6 @@ import {
type DomEditSelection,
} from "../components/editor/domEditing";
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
-import { usePlayerStore } from "../player/store/playerStore";
// ── Types ──
@@ -44,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;
@@ -92,6 +96,7 @@ export interface UseDomSelectionReturn {
) => Promise;
handleTimelineElementSelect: (element: TimelineElement | null) => Promise;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise;
+ refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
@@ -206,42 +211,33 @@ export function useDomSelection({
if (nextSelection) {
if (options?.revealPanel !== false) {
setRightCollapsed(false);
- // Keep the Variables tab in place — selecting elements is part of
- // the bind flow there; yanking to Design would lose the context.
+ // 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");
}
}
- // Mirror the whole DOM group to the store so it stays the single source of
- // truth: a single selection collapses to one id; a preserved group (echo
- // during a gesture) keeps every member instead of shrinking to the anchor.
- const anchorId = resolveTimelineIdForSelection(
- nextSelection,
- timelineElements,
- activeCompPath,
+ const nextSelectedTimelineId =
+ findMatchingTimelineElementId(nextSelection, timelineElements) ??
+ findTimelineIdByAncestor(
+ nextSelection.element,
+ timelineElements,
+ nextSelection.sourceFile || "index.html",
+ );
+ // 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,
);
- const groupIds = nextGroup
- .map((selection) =>
- resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
- )
- .filter((id): id is string => Boolean(id));
- if (groupIds.length > 0) {
- usePlayerStore.getState().setSelection(groupIds, anchorId);
- } else {
- setSelectedTimelineElementId(anchorId);
- }
return;
}
setSelectedTimelineElementId(null);
},
- [
- setSelectedTimelineElementId,
- timelineElements,
- setRightCollapsed,
- setRightPanelTab,
- activeCompPath,
- ],
+ [setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
);
const clearDomSelection = useCallback(() => {
@@ -424,6 +420,55 @@ export function useDomSelection({
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
);
+ const refreshDomEditGroupSelectionsFromPreview = useCallback(
+ // fallow-ignore-next-line complexity
+ async (selections: DomEditSelection[]) => {
+ const iframe = previewIframeRef.current;
+ let doc: Document | null = null;
+ try {
+ doc = iframe?.contentDocument ?? null;
+ } catch {
+ return;
+ }
+ if (!doc) return;
+
+ const nextGroup: DomEditSelection[] = [];
+ for (const selection of selections) {
+ const element = findElementForSelection(doc, selection, activeCompPath);
+ if (!element) continue;
+ const nextSelection = await buildDomSelectionFromTarget(element);
+ if (nextSelection) nextGroup.push(nextSelection);
+ }
+ if (nextGroup.length === 0) return;
+
+ const currentSelection = domEditSelectionRef.current;
+ const nextSelection =
+ nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
+ nextGroup[0] ??
+ null;
+
+ domEditSelectionRef.current = nextSelection;
+ domEditGroupSelectionsRef.current = nextGroup;
+ setDomEditSelection(nextSelection);
+ setDomEditGroupSelections(nextGroup);
+
+ if (nextSelection) {
+ setSelectedTimelineElementId(
+ findMatchingTimelineElementId(nextSelection, timelineElements),
+ );
+ } else {
+ setSelectedTimelineElementId(null);
+ }
+ },
+ [
+ activeCompPath,
+ buildDomSelectionFromTarget,
+ setSelectedTimelineElementId,
+ timelineElements,
+ previewIframeRef,
+ ],
+ );
+
// ── Effects ──
// Clear hover unconditionally on composition/project/preview change
@@ -471,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 = [];
@@ -493,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);
@@ -505,23 +548,16 @@ export function useDomSelection({
domEditGroupSelectionsRef.current = nextGroup;
setDomEditSelection(nextSelection);
setDomEditGroupSelections(nextGroup);
- const nextTimelineId = resolveTimelineIdForSelection(
- nextSelection,
- timelineElements,
- activeCompPath,
- );
- const nextTimelineIds = nextGroup
- .map((selection) =>
- resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
- )
- .filter((id): id is string => Boolean(id));
- if (nextTimelineIds.length > 0) {
- usePlayerStore.getState().setSelection(nextTimelineIds, nextTimelineId);
- } else {
- setSelectedTimelineElementId(null);
- }
+ const nextTimelineId =
+ findMatchingTimelineElementId(nextSelection, timelineElements) ??
+ findTimelineIdByAncestor(
+ nextSelection.element,
+ timelineElements,
+ nextSelection.sourceFile || "index.html",
+ );
+ setSelectedTimelineElementId(nextTimelineId);
},
- [applyDomSelection, timelineElements, setSelectedTimelineElementId, activeCompPath],
+ [applyDomSelection, timelineElements, setSelectedTimelineElementId],
);
// Disabled inspector effect
@@ -558,6 +594,7 @@ export function useDomSelection({
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
+ refreshDomEditGroupSelectionsFromPreview,
applyMarqueeSelection,
};
}
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