Merge pull request #2111 from heygen-com/feat/timeline-multiselect

feat(studio): timeline multi-select (marquee) + relative group time editing
This commit is contained in:
Miguel Ángel
2026-07-09 17:38:37 -04:00
committed by GitHub
39 changed files with 3686 additions and 240 deletions
@@ -123,6 +123,8 @@ 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<string, { before: string; after: string }>;
}
@@ -312,6 +314,66 @@ 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<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
coalesceKey?: string;
}
export async function persistTimelineBatchEdit(
input: PersistTimelineBatchEditInput,
): Promise<void> {
const originals = new Map<string, string>();
const patchedByPath = new Map<string, string>();
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<string> {
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
@@ -370,6 +432,53 @@ export async function finishTimelineTimingFallback(input: {
input.reloadPreview();
}
// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
// outlast one GSAP server round-trip, never a real second edit.
const GSAP_HISTORY_COALESCE_MS = 10_000;
/**
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
* folding both writes into one undo step. Returns the mutation status for caller reloads.
*/
export async function foldGsapMutationIntoHistory(input: {
projectId: string;
paths: string[];
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
gsapMutation: () => Promise<GsapMutationStatus>;
}): Promise<GsapMutationStatus> {
const uniquePaths = [...new Set(input.paths)];
const before = new Map<string, string>();
for (const path of uniquePaths) {
before.set(path, await readFileContent(input.projectId, path));
}
const status = await input.gsapMutation();
if (status.mutated) {
const files: Record<string, { before: string; after: string }> = {};
for (const path of uniquePaths) {
const priorContent = before.get(path);
const finalContent = await readFileContent(input.projectId, path);
if (priorContent !== undefined && finalContent !== priorContent) {
files[path] = { before: priorContent, after: finalContent };
}
}
if (Object.keys(files).length > 0) {
await input.recordEdit({
label: input.label,
kind: "timeline",
coalesceKey: input.coalesceKey,
coalesceMs: GSAP_HISTORY_COALESCE_MS,
files,
});
}
}
return status;
}
/**
* Shift all GSAP animation positions targeting a given element by a time delta.
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
@@ -433,5 +542,58 @@ export async function scaleGsapPositions(
return readMutationStatus(await res.json().catch(() => null));
}
/** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
export function foldedShiftGsapMutation(input: {
projectId: string;
targetPath: string;
domId: string;
delta: number;
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
}): () => Promise<GsapMutationStatus> {
return () =>
foldGsapMutationIntoHistory({
projectId: input.projectId,
paths: [input.targetPath],
label: input.label,
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: () =>
shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
});
}
/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
export function foldedScaleGsapMutation(input: {
projectId: string;
targetPath: string;
domId: string;
from: { start: number; duration: number };
to: { start: number; duration: number };
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
}): () => Promise<GsapMutationStatus> {
return () =>
foldGsapMutationIntoHistory({
projectId: input.projectId,
paths: [input.targetPath],
label: input.label,
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: () =>
scaleGsapPositions(
input.projectId,
input.targetPath,
input.domId,
input.from.start,
input.from.duration,
input.to.start,
input.to.duration,
),
});
}
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
export { applyPatchByTarget, formatTimelineAttributeNumber };
@@ -11,6 +11,7 @@ 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";
@@ -170,13 +171,14 @@ export function useDomEditWiring({
useEffect(() => {
if (!domEditSelection?.id) return;
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]);
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]);
// ── GSAP cache sync ──
@@ -2,7 +2,9 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player/store/playerStore";
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
import { useDomSelection } from "./useDomSelection";
@@ -12,6 +14,7 @@ interface HarnessProps {
activeCompPath: string | null;
projectId: string | null;
refreshKey: number;
timelineElements?: TimelineElement[];
}
function renderHarness(initialProps: HarnessProps): {
@@ -32,7 +35,7 @@ function renderHarness(initialProps: HarnessProps): {
compIdToSrc: new Map(),
captionEditMode: false,
previewIframeRef: { current: null },
timelineElements: [],
timelineElements: props.timelineElements ?? [],
setSelectedTimelineElementId: vi.fn(),
setRightCollapsed: vi.fn(),
setRightPanelTab: vi.fn(),
@@ -64,6 +67,10 @@ function renderHarness(initialProps: HarnessProps): {
};
}
afterEach(() => {
usePlayerStore.getState().reset();
});
function setupSelectedHarness() {
const element = document.createElement("div");
element.id = "headline";
@@ -131,4 +138,31 @@ 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();
});
});
+49 -74
View File
@@ -4,11 +4,7 @@ import {
getAllPreviewTargetsFromPointer,
getPreviewTargetFromPointer,
} from "../utils/studioPreviewHelpers";
import {
findMatchingTimelineElementId,
findTimelineIdByAncestor,
type RightPanelTab,
} from "../utils/studioHelpers";
import { resolveTimelineIdForSelection, type RightPanelTab } from "../utils/studioHelpers";
import {
domEditSelectionsTargetSame,
domEditSelectionInGroup,
@@ -24,6 +20,7 @@ import {
type DomEditSelection,
} from "../components/editor/domEditing";
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
import { usePlayerStore } from "../player/store/playerStore";
// ── Types ──
@@ -95,7 +92,6 @@ export interface UseDomSelectionReturn {
) => Promise<DomEditSelection | null>;
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
@@ -133,6 +129,9 @@ export function useDomSelection({
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
const compositionIdentityRef = useRef({ activeCompPath, projectId });
// Monotonic token so a rapid A->B timeline-clip select can't let A's slower async
// resolution land after B and restore the wrong selection.
const timelineSelectSeqRef = useRef(0);
// Keep refs in sync with state
domEditSelectionRef.current = domEditSelection;
@@ -213,20 +212,36 @@ export function useDomSelection({
setRightPanelTab("design");
}
}
const nextSelectedTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
findTimelineIdByAncestor(
nextSelection.element,
timelineElements,
nextSelection.sourceFile || "index.html",
);
setSelectedTimelineElementId(nextSelectedTimelineId);
// 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 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],
[
setSelectedTimelineElementId,
timelineElements,
setRightCollapsed,
setRightPanelTab,
activeCompPath,
],
);
const clearDomSelection = useCallback(() => {
@@ -366,12 +381,15 @@ export function useDomSelection({
const handleTimelineElementSelect = useCallback(
async (element: TimelineElement | null) => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
const seq = ++timelineSelectSeqRef.current;
if (!element) {
applyDomSelection(null, { revealPanel: false });
return;
}
const selection = await buildDomSelectionForTimelineElement(element);
// A newer selection superseded this one while we were resolving — drop the stale result.
if (seq !== timelineSelectSeqRef.current) return;
if (selection) applyDomSelection(selection);
},
[applyDomSelection, buildDomSelectionForTimelineElement],
@@ -406,55 +424,6 @@ 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
@@ -536,16 +505,23 @@ export function useDomSelection({
domEditGroupSelectionsRef.current = nextGroup;
setDomEditSelection(nextSelection);
setDomEditGroupSelections(nextGroup);
const nextTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
findTimelineIdByAncestor(
nextSelection.element,
timelineElements,
nextSelection.sourceFile || "index.html",
);
setSelectedTimelineElementId(nextTimelineId);
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);
}
},
[applyDomSelection, timelineElements, setSelectedTimelineElementId],
[applyDomSelection, timelineElements, setSelectedTimelineElementId, activeCompPath],
);
// Disabled inspector effect
@@ -582,7 +558,6 @@ export function useDomSelection({
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
applyMarqueeSelection,
};
}
@@ -56,20 +56,23 @@ function timelineElement(input: {
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: 0,
duration: 2,
start: input.start ?? 0,
duration: input.duration ?? 2,
track: input.track,
zIndex: input.zIndex,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
sourceFile: "index.html",
sourceFile: input.sourceFile ?? "index.html",
timingSource: "authored",
};
}
@@ -92,10 +95,14 @@ function renderTimelineEditingHook(input: {
}): {
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
unmount: () => void;
} {
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
function Harness() {
const commitRef = useRef(input.onZIndexCommit);
@@ -118,6 +125,8 @@ function renderTimelineEditingHook(input: {
});
move = hook.handleTimelineElementMove;
resize = hook.handleTimelineElementResize;
groupMove = hook.handleTimelineGroupMove;
groupResize = hook.handleTimelineGroupResize;
return null;
}
@@ -130,15 +139,23 @@ function renderTimelineEditingHook(input: {
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<typeof renderTimelineEditingHook>[0]["recordEdit"]
>;
function renderTimelineEditingHookWithLifecycle(input: {
timelineElements: TimelineElement[];
iframe: HTMLIFrameElement;
@@ -228,7 +245,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
const sdkSession = await openComposition(source);
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
const forceReloadSdkSession = vi.fn();
const reloadPreview = vi.fn();
const iframeWindow = iframe.contentWindow;
@@ -294,7 +311,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
const sdkSession = await openComposition(source);
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
const forceReloadSdkSession = vi.fn();
const reloadPreview = vi.fn();
const iframeWindow = iframe.contentWindow;
@@ -628,7 +645,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
const reloadPreview = vi.fn();
const fetchMock = vi.fn(
async (
@@ -739,4 +756,208 @@ describe("useTimelineEditing timeline z-index reorder", () => {
unmount();
});
it("persists a same-file group move with one write containing every clip timing", async () => {
const source = [
'<div id="a" data-start="0" data-duration="1"></div>',
'<div id="b" data-start="1" data-duration="1"></div>',
'<div id="c" data-start="2" data-duration="1"></div>',
].join("\n");
const iframe = createPreviewIframe([
{ id: "a", track: 0 },
{ id: "b", track: 1 },
{ id: "c", track: 2 },
]);
const clips = [
timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }),
timelineElement({ id: "b", track: 1, zIndex: 0, start: 1, duration: 1 }),
timelineElement({ id: "c", track: 2, zIndex: 0, start: 2, duration: 1 }),
];
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
throw new Error(`Unexpected fetch: ${url}`);
}),
);
const { groupMove, unmount } = renderTimelineEditingHook({
timelineElements: clips,
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit,
});
await act(async () => {
await groupMove([
{ element: clips[0], start: 0.5 },
{ element: clips[1], start: 1.5 },
{ element: clips[2], start: 2.5 },
]);
});
expect(writeProjectFile).toHaveBeenCalledTimes(1);
const written = writeProjectFile.mock.calls[0]![1] as string;
expect(written).toContain('id="a" data-start="0.5"');
expect(written).toContain('id="b" data-start="1.5"');
expect(written).toContain('id="c" data-start="2.5"');
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(Object.keys(recordEdit.mock.calls[0]![0].files)).toEqual(["index.html"]);
unmount();
});
it("partitions a group move by source file while keeping one undo entry", async () => {
const files: Record<string, string> = {
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
"scene.html": '<div id="b" data-start="1" data-duration="1"></div>',
};
const iframe = createPreviewIframe([
{ id: "a", track: 0 },
{ id: "b", track: 1 },
]);
const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
const b = timelineElement({
id: "b",
track: 1,
zIndex: 0,
start: 1,
duration: 1,
sourceFile: "scene.html",
});
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) {
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
return jsonResponse({ content: files[path] });
}
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
throw new Error(`Unexpected fetch: ${url}`);
}),
);
const { groupMove, unmount } = renderTimelineEditingHook({
timelineElements: [a, b],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit,
});
await act(async () => {
await groupMove([
{ element: a, start: 0.25 },
{ element: b, start: 1.25 },
]);
});
expect(writeProjectFile.mock.calls.map((call) => call[0])).toEqual([
"index.html",
"scene.html",
]);
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0.25"');
expect(writeProjectFile.mock.calls[1]![1]).toContain('data-start="1.25"');
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(Object.keys(recordEdit.mock.calls[0]![0].files).sort()).toEqual([
"index.html",
"scene.html",
]);
unmount();
});
it("waits for a z-index commit before the group timing write", async () => {
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
let releaseCommit!: () => void;
const zIndexCommit = new Promise<void>((resolve) => {
releaseCommit = resolve;
});
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
throw new Error(`Unexpected fetch: ${url}`);
}),
);
const { groupMove, unmount } = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit: vi.fn(async () => {}),
});
let movePromise!: Promise<unknown>;
await act(async () => {
movePromise = groupMove([{ element: clip, start: 0.75 }], { beforeTiming: zIndexCommit });
await flushAsyncWork();
});
expect(writeProjectFile).not.toHaveBeenCalled();
await act(async () => {
releaseCommit();
await movePromise;
await flushAsyncWork();
});
expect(writeProjectFile).toHaveBeenCalledTimes(1);
unmount();
});
it("matches the single-clip move output when a group move contains one clip", async () => {
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const single = renderTimelineEditingHook({
timelineElements: [clip],
iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile: singleWrite,
recordEdit: vi.fn(async () => {}),
});
await act(async () => {
await single.move(clip, { start: 0.5, track: clip.track });
});
single.unmount();
const groupWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const group = renderTimelineEditingHook({
timelineElements: [clip],
iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile: groupWrite,
recordEdit: vi.fn(async () => {}),
});
await act(async () => {
await group.groupMove([{ element: clip, start: 0.5 }]);
});
expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]);
group.unmount();
});
});
+38 -31
View File
@@ -1,5 +1,3 @@
// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale +
// playback-start resolution).
// fallow-ignore-file complexity
import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
@@ -26,9 +24,9 @@ import {
patchIframeDomTiming,
persistTimelineEdit,
readFileContent,
foldedShiftGsapMutation,
foldedScaleGsapMutation,
formatTimelineAttributeNumber,
shiftGsapPositions,
scaleGsapPositions,
finishTimelineTimingFallback,
extendRootDurationIfNeeded,
buildTimelineMoveTimingPatch,
@@ -40,6 +38,7 @@ import {
useTimelineElementVisibilityEditing,
useTimelineTrackVisibilityEditing,
} from "./timelineTrackVisibility";
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
import { sdkTimingPersist } from "../utils/sdkCutover";
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
@@ -47,8 +46,6 @@ type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
};
// ── Hook ──
export function useTimelineEditing({
projectId,
activeCompPath,
@@ -101,7 +98,6 @@ export function useTimelineEditing({
}),
)
.then(() => {
// Server wrote the file; resync the stale in-memory SDK doc.
forceReloadSdkSession?.();
});
editQueueRef.current = queued.catch((error) => {
@@ -120,8 +116,22 @@ export function useTimelineEditing({
forceReloadSdkSession,
],
);
const groupEditing = useTimelineGroupEditing({
activeCompPath,
domEditSaveTimestampRef,
editQueueRef,
forceReloadSdkSession,
isRecordingRef,
pendingTimelineEditPathRef,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
showToast,
writeProjectFile,
});
// fallow-ignore-next-line complexity
const handleTimelineElementMove = useCallback(
// fallow-ignore-next-line complexity
(element: TimelineElement, updates: TimelineMoveUpdates) => {
@@ -148,9 +158,6 @@ export function useTimelineEditing({
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
};
// Server-path fallback (no SDK session): persist the attr patch, then
// shift GSAP tween positions on the server. Extending edits can keep the
// iframe live unless a GSAP source rewrite needs a fresh run.
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
const moveFallback = () =>
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
@@ -164,16 +171,20 @@ export function useTimelineEditing({
reloadPreview,
gsapMutation:
delta !== 0 && domId && pid
? () => shiftGsapPositions(pid, targetPath, domId, delta)
? foldedShiftGsapMutation({
projectId: pid,
targetPath,
domId,
delta,
label: "Move timeline clip",
coalesceKey,
recordEdit,
})
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
});
});
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
// The z-index reorder above and this timing write target the same file on
// separate save queues, and the timing write is a full-file overwrite. Order
// it after the reorder so it reads disk with the z-index already applied and
// can't clobber it — one ordered writer per gesture (diagonal move+restack).
return reorderDone.then(() => {
if (sdkSession && element.hfId && !needsExtension) {
return sdkTimingPersist(
@@ -213,7 +224,6 @@ export function useTimelineEditing({
],
);
// fallow-ignore-next-line complexity
const handleTimelineElementResize = useCallback(
// fallow-ignore-next-line complexity
(
@@ -239,10 +249,6 @@ export function useTimelineEditing({
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
return buildTimelineResizeTimingPatch(original, target, element, updates);
};
// SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
// The second clause fires because trimming the start of a clip that has a
// playback-start attribute implicitly shifts that in-point — which the SDK
// setTiming op can't express — so those resizes must take the server path.
const hasPbsAdjustment =
updates.playbackStart != null ||
(updates.start !== element.start && element.playbackStart != null);
@@ -264,16 +270,16 @@ export function useTimelineEditing({
reloadPreview,
gsapMutation:
timingChanged && domId && pid
? () =>
scaleGsapPositions(
pid,
targetPath,
domId,
element.start,
element.duration,
updates.start,
updates.duration,
)
? foldedScaleGsapMutation({
projectId: pid,
targetPath,
domId,
from: { start: element.start, duration: element.duration },
to: { start: updates.start, duration: updates.duration },
label: "Resize timeline clip",
coalesceKey,
recordEdit,
})
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
});
@@ -589,5 +595,6 @@ export function useTimelineEditing({
handleTimelineAssetDrop,
handleTimelineFileDrop,
handleBlockedTimelineEdit,
...groupEditing,
};
}
@@ -0,0 +1,370 @@
import { useCallback, type MutableRefObject, type RefObject } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { TimelineElement } from "../player";
import { sdkTimingBatchPersist } from "../utils/sdkCutover";
import {
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
extendRootDurationIfNeeded,
finishTimelineTimingFallback,
foldGsapMutationIntoHistory,
formatTimelineAttributeNumber,
patchIframeDomTiming,
persistTimelineBatchEdit,
readFileContent,
scaleGsapPositions,
shiftGsapPositions,
type PersistTimelineBatchChange,
type RecordEditInput,
} from "./timelineEditingHelpers";
export interface TimelineGroupMoveChange {
element: TimelineElement;
start: number;
}
export interface TimelineGroupResizeChange {
element: TimelineElement;
start: number;
duration: number;
playbackStart?: number;
}
export interface TimelineGroupCommitOptions {
beforeTiming?: Promise<void>;
coalesceKey?: string;
}
interface UseTimelineGroupEditingOptions {
activeCompPath: string | null;
domEditSaveTimestampRef: MutableRefObject<number>;
editQueueRef: MutableRefObject<Promise<unknown>>;
forceReloadSdkSession?: () => void;
isRecordingRef?: RefObject<boolean>;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
previewIframeRef: RefObject<HTMLIFrameElement | null>;
projectIdRef: MutableRefObject<string | null>;
recordEdit: (input: RecordEditInput) => Promise<void>;
reloadPreview: () => void;
sdkSession?: Composition | null;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
}
function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
return element.sourceFile || activeCompPath || "index.html";
}
function allChangesSharePath(
changes: readonly { element: TimelineElement }[],
activeCompPath: string | null,
): string | null {
const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null;
if (!firstPath) return null;
return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath)
? firstPath
: null;
}
function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string {
return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
}
function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string {
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
}
function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
return (
change.playbackStart != null ||
(change.start !== change.element.start && change.element.playbackStart != null)
);
}
export function useTimelineGroupEditing({
activeCompPath,
domEditSaveTimestampRef,
editQueueRef,
forceReloadSdkSession,
isRecordingRef,
pendingTimelineEditPathRef,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
showToast,
writeProjectFile,
}: UseTimelineGroupEditingOptions) {
const enqueueGroupOperation = useCallback(
(label: string, operation: (projectId: string) => Promise<void>): Promise<void> => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return Promise.reject(new Error(`${label}: blocked while recording`));
}
const projectId = projectIdRef.current;
if (!projectId) return Promise.reject(new Error(`${label}: no active project`));
const run = editQueueRef.current.then(() => operation(projectId));
// Keep the shared edit queue from wedging on a rejection, but return the raw
// (rejecting) promise so the gesture owner can roll back on a real failure.
editQueueRef.current = run.then(
() => undefined,
(error) => {
console.error(`[Timeline] Failed to persist: ${label}`, error);
},
);
return run;
},
[editQueueRef, isRecordingRef, projectIdRef, showToast],
);
const persistServerBatch = useCallback(
async (
projectId: string,
label: string,
batchChanges: PersistTimelineBatchChange[],
coalesceKey: string,
) => {
await persistTimelineBatchEdit({
projectId,
activeCompPath,
label,
changes: batchChanges,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
coalesceKey,
});
forceReloadSdkSession?.();
},
[
activeCompPath,
domEditSaveTimestampRef,
forceReloadSdkSession,
pendingTimelineEditPathRef,
recordEdit,
writeProjectFile,
],
);
const handleTimelineGroupMove = useCallback(
(changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
if (changes.length === 0) return Promise.resolve();
for (const change of changes) {
patchIframeDomTiming(previewIframeRef.current, change.element, [
["data-start", formatTimelineAttributeNumber(change.start)],
]);
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
const needsExtension = extendRootDurationIfNeeded(maxEnd);
const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
return enqueueGroupOperation("Move timeline clips", async (projectId) => {
await options?.beforeTiming;
const sharedPath = allChangesSharePath(changes, activeCompPath);
const sdkChanges = changes.map((change) =>
change.element.hfId
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
: null,
);
const canUseSdk =
!needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null);
if (canUseSdk) {
const handled = await sdkTimingBatchPersist(
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Move timeline clips", coalesceKey },
);
if (handled) return;
}
await persistServerBatch(
projectId,
"Move timeline clips",
changes.map((change) => ({
element: change.element,
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration),
})),
coalesceKey,
);
await finishTimelineTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: maxEnd,
reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
projectId,
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
label: "Move timeline clips",
coalesceKey,
recordEdit,
gsapMutation: async () => {
let mutated = false;
for (const change of changes) {
const delta = change.start - change.element.start;
const domId = change.element.domId;
if (delta === 0 || !domId) continue;
const status = await shiftGsapPositions(
projectId,
targetPathFor(change.element, activeCompPath),
domId,
delta,
);
mutated = mutated || status.mutated;
}
return { mutated };
},
}),
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
});
});
},
[
activeCompPath,
domEditSaveTimestampRef,
enqueueGroupOperation,
persistServerBatch,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
],
);
const handleTimelineGroupResize = useCallback(
(changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => {
if (changes.length === 0) return Promise.resolve();
for (const change of changes) {
const liveAttrs: Array<[string, string]> = [
["data-start", formatTimelineAttributeNumber(change.start)],
["data-duration", formatTimelineAttributeNumber(change.duration)],
];
if (change.playbackStart != null) {
const liveAttr =
change.element.playbackStartAttr === "playback-start"
? "data-playback-start"
: "data-media-start";
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs);
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
const needsExtension = extendRootDurationIfNeeded(maxEnd);
const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
await options?.beforeTiming;
const sharedPath = allChangesSharePath(changes, activeCompPath);
const sdkChanges = changes.map((change) =>
change.element.hfId
? {
hfId: change.element.hfId,
timingUpdate: { start: change.start, duration: change.duration },
}
: null,
);
const canUseSdk =
!needsExtension &&
sharedPath !== null &&
changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
sdkChanges.every((change) => change !== null);
if (canUseSdk) {
const handled = await sdkTimingBatchPersist(
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
sharedPath,
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Resize timeline clips", coalesceKey },
);
if (handled) return;
}
await persistServerBatch(
projectId,
"Resize timeline clips",
changes.map((change) => ({
element: change.element,
buildPatches: (original, target) =>
buildTimelineResizeTimingPatch(original, target, change.element, {
start: change.start,
duration: change.duration,
playbackStart: change.playbackStart,
}),
})),
coalesceKey,
);
await finishTimelineTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: maxEnd,
reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
projectId,
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
label: "Resize timeline clips",
coalesceKey,
recordEdit,
gsapMutation: async () => {
let mutated = false;
for (const change of changes) {
const domId = change.element.domId;
const timingChanged =
change.start !== change.element.start ||
change.duration !== change.element.duration;
if (!timingChanged || !domId) continue;
const status = await scaleGsapPositions(
projectId,
targetPathFor(change.element, activeCompPath),
domId,
change.element.start,
change.element.duration,
change.start,
change.duration,
);
mutated = mutated || status.mutated;
}
return { mutated };
},
}),
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
});
});
},
[
activeCompPath,
domEditSaveTimestampRef,
enqueueGroupOperation,
persistServerBatch,
previewIframeRef,
projectIdRef,
recordEdit,
reloadPreview,
sdkSession,
writeProjectFile,
],
);
return { handleTimelineGroupMove, handleTimelineGroupResize };
}
@@ -0,0 +1,129 @@
// @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 { TimelineElement } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
import { useTimelineSelectionPreviewSync } from "./useTimelineSelectionPreviewSync";
installReactActEnvironment();
interface HarnessProps {
selectedElementId: string | null;
selectedElementIds: Set<string>;
timelineElements: TimelineElement[];
domEditSelection: DomEditSelection | null;
domEditGroupSelections: DomEditSelection[];
buildDomSelectionForTimelineElement: (
element: TimelineElement,
) => Promise<DomEditSelection | null>;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
) => void;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
afterEach(() => {
document.body.innerHTML = "";
});
function renderHarness() {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
function Harness(nextProps: HarnessProps) {
useTimelineSelectionPreviewSync({
...nextProps,
activeCompPath: "index.html",
});
return null;
}
const rerender = async (nextProps: HarnessProps) => {
await act(async () => {
root.render(React.createElement(Harness, nextProps));
await Promise.resolve();
});
};
return {
rerender,
cleanup: () => {
act(() => root.unmount());
host.remove();
},
};
}
function makeSyncFixture() {
const firstElement = document.createElement("div");
firstElement.id = "clip-1";
const secondElement = document.createElement("div");
secondElement.id = "clip-2";
const firstSelection = makeSelection("First", firstElement);
const secondSelection = makeSelection("Second", secondElement);
const timelineElements: TimelineElement[] = [
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
{ id: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
];
const selectionById = new Map([
["clip-1", firstSelection],
["clip-2", secondSelection],
]);
return { firstSelection, secondSelection, timelineElements, selectionById };
}
describe("useTimelineSelectionPreviewSync", () => {
it("syncs a multi-id timeline selection into preview group selections", async () => {
const { firstSelection, secondSelection, timelineElements, selectionById } = makeSyncFixture();
const applyDomSelection = vi.fn();
const applyMarqueeSelection = vi.fn();
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
return selectionById.get(element.id) ?? null;
});
const harness = renderHarness();
await harness.rerender({
selectedElementId: "clip-2",
selectedElementIds: new Set(["clip-1", "clip-2"]),
timelineElements,
domEditSelection: null,
domEditGroupSelections: [],
buildDomSelectionForTimelineElement,
applyDomSelection,
applyMarqueeSelection,
});
expect(applyMarqueeSelection).toHaveBeenCalledWith([secondSelection, firstSelection], false);
expect(applyDomSelection).not.toHaveBeenCalled();
harness.cleanup();
});
it("clears preview selection when the timeline selection set is empty", async () => {
const { firstSelection, timelineElements, selectionById } = makeSyncFixture();
const applyDomSelection = vi.fn();
const applyMarqueeSelection = vi.fn();
const harness = renderHarness();
await harness.rerender({
selectedElementId: null,
selectedElementIds: new Set(),
timelineElements,
domEditSelection: firstSelection,
domEditGroupSelections: [firstSelection],
buildDomSelectionForTimelineElement: vi.fn(async (element: TimelineElement) => {
return selectionById.get(element.id) ?? null;
}),
applyDomSelection,
applyMarqueeSelection,
});
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
expect(applyMarqueeSelection).not.toHaveBeenCalled();
harness.cleanup();
});
});
@@ -0,0 +1,130 @@
import { useEffect, useMemo } from "react";
import type { TimelineElement } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
interface UseTimelineSelectionPreviewSyncParams {
selectedElementId: string | null;
selectedElementIds: Set<string>;
timelineElements: TimelineElement[];
domEditSelection: DomEditSelection | null;
domEditGroupSelections: DomEditSelection[];
activeCompPath: string | null;
buildDomSelectionForTimelineElement: (
element: TimelineElement,
) => Promise<DomEditSelection | null>;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
) => void;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
const ordered = [...ids];
if (!anchor || !ids.has(anchor)) return ordered;
return [anchor, ...ordered.filter((id) => id !== anchor)];
}
function selectionIdsMatch(
currentIds: string[],
selectedIds: string[],
currentAnchor: string | null,
wantedAnchor: string | null,
): boolean {
// Compare as sets in BOTH directions: length equality misreads duplicates (two DOM
// children resolving to the same clip id) as a full match and skips mirroring the
// members that never made it into the preview.
const current = new Set(currentIds);
const selected = new Set(selectedIds);
if (current.size !== selected.size) return false;
for (const id of selected) {
if (!current.has(id)) return false;
}
// The primary/anchor must also agree, or a change of just the anchor within the
// same set would never re-sync the preview's primary selection.
return currentAnchor === wantedAnchor;
}
export function useTimelineSelectionPreviewSync({
selectedElementId,
selectedElementIds,
timelineElements,
domEditSelection,
domEditGroupSelections,
activeCompPath,
buildDomSelectionForTimelineElement,
applyDomSelection,
applyMarqueeSelection,
}: UseTimelineSelectionPreviewSyncParams): void {
const selectedIds = useMemo(
() => orderSelectedIds(selectedElementIds, selectedElementId),
[selectedElementId, selectedElementIds],
);
const selectedKey = selectedIds.join("\0");
useEffect(() => {
const currentSelections =
domEditGroupSelections.length > 1
? domEditGroupSelections
: domEditSelection
? [domEditSelection]
: [];
const currentIds = currentSelections
.map((selection) =>
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
)
.filter((id): id is string => Boolean(id));
const currentAnchor = domEditSelection
? resolveTimelineIdForSelection(domEditSelection, timelineElements, activeCompPath)
: null;
if (selectedIds.length === 0) {
if (currentSelections.length > 0) applyDomSelection(null, { revealPanel: false });
return;
}
if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) return;
let cancelled = false;
const syncSelection = async () => {
const selections: DomEditSelection[] = [];
let resolvableCount = 0;
for (const id of selectedIds) {
const element = timelineElements.find((item) => (item.key ?? item.id) === id);
if (!element) continue;
resolvableCount += 1;
const selection = await buildDomSelectionForTimelineElement(element);
if (selection) selections.push(selection);
}
if (cancelled) return;
// The store is the source of truth: applying a partial set would write that
// shrunk set back and silently drop the members whose DOM node was not ready.
// Bail instead; a later effect run (on timelineElements/DOM change) applies the
// full set once every resolvable member has a live node.
if (selections.length < resolvableCount) return;
if (selections.length === 0) {
applyDomSelection(null, { revealPanel: false });
} else if (selections.length === 1) {
applyDomSelection(selections[0], { revealPanel: false });
} else {
applyMarqueeSelection(selections, false);
}
};
void syncSelection();
return () => {
cancelled = true;
};
}, [
activeCompPath,
applyDomSelection,
applyMarqueeSelection,
buildDomSelectionForTimelineElement,
domEditGroupSelections,
domEditSelection,
selectedElementId,
selectedIds,
selectedKey,
timelineElements,
]);
}