From cca31feffeda60ab98e1cea7f01438ac299b0e42 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 00:54:09 -0400 Subject: [PATCH 01/15] feat(studio): activate selected element set state Use selectedElementIds as the live selection set with selectedElementId as anchor. Keep single-click selection as a one-element set and clear both fields together. --- .../src/player/store/playerStore.test.ts | 71 +++++++++++++++++++ .../studio/src/player/store/playerStore.ts | 41 +++++++++-- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 0d1750073d..0338dd60e5 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -222,6 +222,77 @@ describe("usePlayerStore", () => { }); }); + describe("selectedElementIds", () => { + it("sets a multi-id selection with a coherent anchor", () => { + usePlayerStore.getState().setSelection(["el-1", "el-2", "el-3"], "el-2"); + + const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]); + expect(state.selectedElementId).toBe("el-2"); + }); + + it("falls back to the first selected id when the anchor is outside the set", () => { + usePlayerStore.getState().setSelection(["el-1", "el-2"], "missing"); + + const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]); + expect(state.selectedElementId).toBe("el-1"); + }); + + it("single-click selection replaces the set with the selected id", () => { + const store = usePlayerStore.getState(); + store.setSelection(["el-1", "el-2"], "el-2"); + store.setSelectedElementId("el-3"); + + const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-3"]); + expect(state.selectedElementId).toBe("el-3"); + }); + + it("clearing single selection empties the set", () => { + const store = usePlayerStore.getState(); + store.setSelection(["el-1", "el-2"], "el-2"); + store.setSelectedElementId(null); + + const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual([]); + expect(state.selectedElementId).toBeNull(); + }); + + it("toggle adds and removes members while keeping the anchor in the set", () => { + const store = usePlayerStore.getState(); + store.setSelectedElementId("el-1"); + store.toggleSelectedElementId("el-2"); + + let state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]); + expect(state.selectedElementId).toBe("el-1"); + + store.toggleSelectedElementId("el-1"); + + state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-2"]); + expect(state.selectedElementId).toBe("el-2"); + }); + + it("clearSelection and clearSelectedElementIds empty the set and anchor", () => { + const store = usePlayerStore.getState(); + store.setSelection(["el-1", "el-2"], "el-2"); + store.clearSelection(); + + let state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual([]); + expect(state.selectedElementId).toBeNull(); + + store.setSelection(["el-3"], "el-3"); + store.clearSelectedElementIds(); + + state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual([]); + expect(state.selectedElementId).toBeNull(); + }); + }); + describe("updateElement", () => { it("updates the start time of a specific element", () => { usePlayerStore.getState().setElements([ diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 4efd575fbb..b3b884d449 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -75,6 +75,23 @@ export interface TimelineElement { export type ZoomMode = "fit" | "manual"; type TimelineTool = "select" | "razor"; +function resolveElementSelection( + ids: Iterable, + anchor?: string | null, +): { selectedElementIds: Set; selectedElementId: string | null } { + const selectedElementIds = new Set(ids); + if (selectedElementIds.size === 0) { + return { selectedElementIds, selectedElementId: null }; + } + if (anchor && selectedElementIds.has(anchor)) { + return { selectedElementIds, selectedElementId: anchor }; + } + return { + selectedElementIds, + selectedElementId: selectedElementIds.values().next().value ?? null, + }; +} + interface PlayerState { isPlaying: boolean; currentTime: number; @@ -125,7 +142,10 @@ interface PlayerState { /** Multi-select: additional selected elements beyond selectedElementId. */ selectedElementIds: Set; + setSelection: (ids: Iterable, anchor?: string | null) => void; + addSelectedElementId: (id: string) => void; toggleSelectedElementId: (id: string) => void; + clearSelection: () => void; clearSelectedElementIds: () => void; /** Keyframe data per element id, populated from parsed GSAP animations. */ @@ -265,14 +285,22 @@ export const usePlayerStore = create((set, get) => ({ setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }), selectedElementIds: new Set(), + setSelection: (ids, anchor) => set(resolveElementSelection(ids, anchor)), + addSelectedElementId: (id: string) => + set((s) => { + const next = new Set(s.selectedElementIds); + next.add(id); + return resolveElementSelection(next, s.selectedElementId); + }), toggleSelectedElementId: (id: string) => set((s) => { const next = new Set(s.selectedElementIds); if (next.has(id)) next.delete(id); else next.add(id); - return { selectedElementIds: next }; + return resolveElementSelection(next, s.selectedElementId); }), - clearSelectedElementIds: () => set({ selectedElementIds: new Set() }), + clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }), + clearSelectedElementIds: () => set({ selectedElementId: null, selectedElementIds: new Set() }), keyframeCache: new Map(), setKeyframeCache: (elementId, data) => @@ -390,8 +418,13 @@ export const usePlayerStore = create((set, get) => ({ // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER // calling setSelectedElementId, so this never clobbers a genuine keyframe select. id !== s.selectedElementId - ? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false } - : { selectedElementId: id }, + ? { + selectedElementId: id, + selectedElementIds: id ? new Set([id]) : new Set(), + activeKeyframePct: null, + motionPathArmed: false, + } + : { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set() }, ), updateElement: (elementId, updates) => set((state) => ({ From 1d858f004d6c6171d6c4df090448f11429bc15a4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 01:01:27 -0400 Subject: [PATCH 02/15] feat(studio): highlight timeline selection sets Render selected styling from selectedElementIds in the timeline. Sync the set into preview group selection boxes without collapsing the anchor. --- .../src/components/StudioPreviewArea.tsx | 16 +++ .../studio/src/hooks/useDomSelection.test.ts | 38 +++++- packages/studio/src/hooks/useDomSelection.ts | 18 ++- .../useTimelineSelectionPreviewSync.test.tsx | 129 ++++++++++++++++++ .../hooks/useTimelineSelectionPreviewSync.ts | 118 ++++++++++++++++ .../src/player/components/Timeline.test.ts | 34 +++++ .../src/player/components/TimelineCanvas.tsx | 3 +- .../player/components/TimelineClip.test.tsx | 11 ++ 8 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx create mode 100644 packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 66c6c31b30..1f03e67b1e 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -25,6 +25,7 @@ import { TimelineEditProvider } from "../contexts/TimelineEditContext"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import { readStudioUiPreferences } from "../utils/studioUiPreferences"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; +import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync"; export interface StudioPreviewAreaProps { timelineToolbar: ReactNode; @@ -148,6 +149,9 @@ export function StudioPreviewArea({ buildDomSelectionForTimelineElement, applyMarqueeSelection, } = useDomEditActionsContext(); + const selectedElementId = usePlayerStore((s) => s.selectedElementId); + const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); + const timelineElements = usePlayerStore((s) => s.elements); // fallow-ignore-next-line complexity const [snapPrefs, setSnapPrefs] = useState(() => { @@ -160,6 +164,18 @@ export function StudioPreviewArea({ }; }); + useTimelineSelectionPreviewSync({ + selectedElementId, + selectedElementIds, + timelineElements, + domEditSelection, + domEditGroupSelections, + activeCompPath, + buildDomSelectionForTimelineElement, + applyDomSelection, + applyMarqueeSelection, + }); + // Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its // tween-relative percentage (shared by the delete/move keyframe callbacks): the // diamond reports a clip-% but the script ops key on the tween-%. Prefers the diff --git a/packages/studio/src/hooks/useDomSelection.test.ts b/packages/studio/src/hooks/useDomSelection.test.ts index 53f4524644..b7628c4379 100644 --- a/packages/studio/src/hooks/useDomSelection.test.ts +++ b/packages/studio/src/hooks/useDomSelection.test.ts @@ -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(); + }); }); diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 72892dd744..423d4b70ad 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -24,6 +24,7 @@ import { type DomEditSelection, } from "../components/editor/domEditing"; import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; +import { usePlayerStore } from "../player/store/playerStore"; // ── Types ── @@ -537,7 +538,22 @@ export function useDomSelection({ timelineElements, nextSelection.sourceFile || "index.html", ); - setSelectedTimelineElementId(nextTimelineId); + const nextTimelineIds = nextGroup + .map( + (selection) => + findMatchingTimelineElementId(selection, timelineElements) ?? + findTimelineIdByAncestor( + selection.element, + timelineElements, + selection.sourceFile || "index.html", + ), + ) + .filter((id): id is string => Boolean(id)); + if (nextTimelineIds.length > 0) { + usePlayerStore.getState().setSelection(nextTimelineIds, nextTimelineId); + } else { + setSelectedTimelineElementId(null); + } }, [applyDomSelection, timelineElements, setSelectedTimelineElementId], ); diff --git a/packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx new file mode 100644 index 0000000000..038ae30dd5 --- /dev/null +++ b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx @@ -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; + timelineElements: TimelineElement[]; + domEditSelection: DomEditSelection | null; + domEditGroupSelections: DomEditSelection[]; + buildDomSelectionForTimelineElement: ( + element: TimelineElement, + ) => Promise; + 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(); + }); +}); diff --git a/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts new file mode 100644 index 0000000000..83e7105978 --- /dev/null +++ b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts @@ -0,0 +1,118 @@ +import { useEffect, useMemo } from "react"; +import type { TimelineElement } from "../player"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers"; + +interface UseTimelineSelectionPreviewSyncParams { + selectedElementId: string | null; + selectedElementIds: Set; + timelineElements: TimelineElement[]; + domEditSelection: DomEditSelection | null; + domEditGroupSelections: DomEditSelection[]; + activeCompPath: string | null; + buildDomSelectionForTimelineElement: ( + element: TimelineElement, + ) => Promise; + applyDomSelection: ( + selection: DomEditSelection | null, + options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean }, + ) => void; + applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void; +} + +function orderSelectedIds(ids: Set, anchor: string | null): string[] { + const ordered = [...ids]; + if (!anchor || !ids.has(anchor)) return ordered; + return [anchor, ...ordered.filter((id) => id !== anchor)]; +} + +function selectionTimelineId( + selection: DomEditSelection, + timelineElements: TimelineElement[], + activeCompPath: string | null, +): string | null { + return ( + findMatchingTimelineElementId(selection, timelineElements) ?? + findTimelineIdByAncestor( + selection.element, + timelineElements, + selection.sourceFile || activeCompPath || "index.html", + ) + ); +} + +function selectionIdsMatch(currentIds: string[], selectedIds: string[]): boolean { + if (currentIds.length !== selectedIds.length) return false; + const selected = new Set(selectedIds); + return currentIds.every((id) => selected.has(id)); +} + +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) => selectionTimelineId(selection, timelineElements, activeCompPath)) + .filter((id): id is string => Boolean(id)); + + if (selectedIds.length === 0) { + if (currentSelections.length > 0) applyDomSelection(null, { revealPanel: false }); + return; + } + if (selectionIdsMatch(currentIds, selectedIds)) return; + + let cancelled = false; + const syncSelection = async () => { + const selections: DomEditSelection[] = []; + for (const id of selectedIds) { + const element = timelineElements.find((item) => (item.key ?? item.id) === id); + if (!element) continue; + const selection = await buildDomSelectionForTimelineElement(element); + if (selection) selections.push(selection); + } + if (cancelled) 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, + selectedIds, + selectedKey, + timelineElements, + ]); +} diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 23686c5d85..f6f5258391 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -203,6 +203,40 @@ describe("Timeline provider boundary", () => { expect(onSeek).not.toHaveBeenCalled(); act(() => root.unmount()); }); + + it("marks every clip in selectedElementIds as selected", () => { + const host = document.createElement("div"); + document.body.append(host); + Object.defineProperty(host, "clientWidth", { + configurable: true, + value: 720, + }); + + usePlayerStore.setState({ + duration: 6, + timelineReady: true, + selectedElementId: "clip-2", + selectedElementIds: new Set(["clip-1", "clip-2"]), + elements: [ + { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 }, + { id: "clip-2", tag: "div", start: 1.5, duration: 1, track: 1 }, + { id: "clip-3", tag: "div", start: 3, duration: 1, track: 2 }, + ], + }); + + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + + const selectedClips = host.querySelectorAll(".timeline-clip.is-selected"); + expect(selectedClips).toHaveLength(2); + expect(host.querySelector('[data-el-id="clip-3"]')?.classList.contains("is-selected")).toBe( + false, + ); + + act(() => root.unmount()); + }); }); describe("generateTicks", () => { diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index d5e4f651e2..ba378ef52b 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -158,6 +158,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onRazorSplitAll, } = useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); + const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); const activeSnapGuideTime = draggedClip?.started ? (draggedClip.snapBeatTime ?? draggedClip.snapGuideTime) : resizingClip?.started @@ -359,7 +360,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ const clipStyle = getTrackStyle(el.tag); const elementKey = el.key ?? el.id; const capabilities = getTimelineEditCapabilities(el); - const isSelected = selectedElementId === elementKey; + const isSelected = selectedElementIds.has(elementKey); const isComposition = !!el.compositionSrc; // elementKey (el.key ?? el.id) is already unique per clip; do NOT // fold in the map index, or a splice/reorder remounts every clip diff --git a/packages/studio/src/player/components/TimelineClip.test.tsx b/packages/studio/src/player/components/TimelineClip.test.tsx index 7a3cc6de3d..0ec5c6340a 100644 --- a/packages/studio/src/player/components/TimelineClip.test.tsx +++ b/packages/studio/src/player/components/TimelineClip.test.tsx @@ -102,4 +102,15 @@ describe("TimelineClip", () => { act(() => root.unmount()); }); + + it("applies selected styling when rendered as selected", () => { + const { host, root } = renderClip({ + element: { id: "selected", label: "Selected", tag: "div", start: 0, duration: 1, track: 0 }, + isSelected: true, + }); + + expect(host.querySelector(".timeline-clip")?.classList.contains("is-selected")).toBe(true); + + act(() => root.unmount()); + }); }); From 1cf601202d6c7bd032cf56adef2a9b7d357fb3ed Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 01:11:55 -0400 Subject: [PATCH 03/15] feat(studio): batch timeline timing commits Persist group timing edits through one coalesced write per source file. Keep batch timing queued behind any z-index commit for the same gesture. --- .../src/contexts/TimelineEditContext.tsx | 2 + .../src/hooks/timelineEditingHelpers.ts | 60 +++ .../src/hooks/useTimelineEditing.test.tsx | 233 +++++++++++- .../studio/src/hooks/useTimelineEditing.ts | 33 +- .../src/hooks/useTimelineGroupEditing.ts | 350 ++++++++++++++++++ .../player/components/timelineCallbacks.ts | 13 + packages/studio/src/utils/sdkCutover.ts | 45 +++ 7 files changed, 714 insertions(+), 22 deletions(-) create mode 100644 packages/studio/src/hooks/useTimelineGroupEditing.ts diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 7f075d7838..7b523cbeb0 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -32,6 +32,8 @@ export function TimelineEditProvider({ [ value.onMoveElement, value.onResizeElement, + value.onMoveElements, + value.onResizeElements, value.onToggleTrackHidden, value.onToggleElementHidden, value.onBlockedEditAttempt, diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index d2414dd45c..d266012a2f 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -312,6 +312,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; + 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}`); diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index daa256a04e..cd3f88fdc6 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -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["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); @@ -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[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>(async () => {}); - const recordEdit = vi.fn(async () => {}); + const recordEdit = vi.fn(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>(async () => {}); - const recordEdit = vi.fn(async () => {}); + const recordEdit = vi.fn(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>().mockResolvedValue(undefined); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const recordEdit = vi.fn(async () => {}); + const recordEdit = vi.fn(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 = [ + '
', + '
', + '
', + ].join("\n"); + const iframe = createPreviewIframe([ + { id: "a", track: 0 }, + { id: "b", track: 1 }, + { id: "c", track: 2 }, + ]); + const clips = [ + timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }), + timelineElement({ id: "b", track: 1, zIndex: 0, start: 1, duration: 1 }), + timelineElement({ id: "c", track: 2, zIndex: 0, start: 2, duration: 1 }), + ]; + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const recordEdit = vi.fn(async (_entry) => {}); + vi.stubGlobal( + "fetch", + vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + const { groupMove, unmount } = renderTimelineEditingHook({ + timelineElements: clips, + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit, + }); + + await act(async () => { + await groupMove([ + { element: clips[0], start: 0.5 }, + { element: clips[1], start: 1.5 }, + { element: clips[2], start: 2.5 }, + ]); + }); + + expect(writeProjectFile).toHaveBeenCalledTimes(1); + const written = writeProjectFile.mock.calls[0]![1] as string; + expect(written).toContain('id="a" data-start="0.5"'); + expect(written).toContain('id="b" data-start="1.5"'); + expect(written).toContain('id="c" data-start="2.5"'); + expect(recordEdit).toHaveBeenCalledTimes(1); + expect(Object.keys(recordEdit.mock.calls[0]![0].files)).toEqual(["index.html"]); + + unmount(); + }); + + it("partitions a group move by source file while keeping one undo entry", async () => { + const files: Record = { + "index.html": '
', + "scene.html": '
', + }; + const iframe = createPreviewIframe([ + { id: "a", track: 0 }, + { id: "b", track: 1 }, + ]); + const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }); + const b = timelineElement({ + id: "b", + track: 1, + zIndex: 0, + start: 1, + duration: 1, + sourceFile: "scene.html", + }); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const recordEdit = vi.fn(async (_entry) => {}); + vi.stubGlobal( + "fetch", + vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) { + const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html"); + return jsonResponse({ content: files[path] }); + } + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + const { groupMove, unmount } = renderTimelineEditingHook({ + timelineElements: [a, b], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit, + }); + + await act(async () => { + await groupMove([ + { element: a, start: 0.25 }, + { element: b, start: 1.25 }, + ]); + }); + + expect(writeProjectFile.mock.calls.map((call) => call[0])).toEqual([ + "index.html", + "scene.html", + ]); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0.25"'); + expect(writeProjectFile.mock.calls[1]![1]).toContain('data-start="1.25"'); + expect(recordEdit).toHaveBeenCalledTimes(1); + expect(Object.keys(recordEdit.mock.calls[0]![0].files).sort()).toEqual([ + "index.html", + "scene.html", + ]); + + unmount(); + }); + + it("waits for a z-index commit before the group timing write", async () => { + const source = '
'; + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 }); + let releaseCommit!: () => void; + const zIndexCommit = new Promise((resolve) => { + releaseCommit = resolve; + }); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + vi.stubGlobal( + "fetch", + vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + const { groupMove, unmount } = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit: vi.fn(async () => {}), + }); + + let movePromise!: Promise; + await act(async () => { + movePromise = groupMove([{ element: clip, start: 0.75 }], { beforeTiming: zIndexCommit }); + await flushAsyncWork(); + }); + expect(writeProjectFile).not.toHaveBeenCalled(); + + await act(async () => { + releaseCommit(); + await movePromise; + await flushAsyncWork(); + }); + expect(writeProjectFile).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("matches the single-clip move output when a group move contains one clip", async () => { + const source = '
'; + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 }); + const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const singleWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const single = renderTimelineEditingHook({ + timelineElements: [clip], + iframe: createPreviewIframe([{ id: "clip", track: 0 }]), + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile: singleWrite, + recordEdit: vi.fn(async () => {}), + }); + await act(async () => { + await single.move(clip, { start: 0.5, track: clip.track }); + }); + single.unmount(); + + const groupWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const group = renderTimelineEditingHook({ + timelineElements: [clip], + iframe: createPreviewIframe([{ id: "clip", track: 0 }]), + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile: groupWrite, + recordEdit: vi.fn(async () => {}), + }); + await act(async () => { + await group.groupMove([{ element: clip, start: 0.5 }]); + }); + + expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]); + group.unmount(); + }); }); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index b5ca68e0cd..0f78896bb5 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -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"; @@ -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 & { 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,6 +116,21 @@ 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( @@ -148,9 +159,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(() => { @@ -170,10 +178,6 @@ export function useTimelineEditing({ }); }); 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( @@ -239,10 +243,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); @@ -589,5 +589,6 @@ export function useTimelineEditing({ handleTimelineAssetDrop, handleTimelineFileDrop, handleBlockedTimelineEdit, + ...groupEditing, }; } diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts new file mode 100644 index 0000000000..77a6e814f4 --- /dev/null +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -0,0 +1,350 @@ +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, + formatTimelineAttributeNumber, + patchIframeDomTiming, + persistTimelineBatchEdit, + readFileContent, + scaleGsapPositions, + shiftGsapPositions, + type PersistTimelineBatchChange, + type RecordEditInput, +} from "./timelineEditingHelpers"; + +export interface TimelineGroupMoveChange { + element: TimelineElement; + start: number; +} + +export interface TimelineGroupResizeChange { + element: TimelineElement; + start: number; + duration: number; + playbackStart?: number; +} + +export interface TimelineGroupCommitOptions { + beforeTiming?: Promise; + coalesceKey?: string; +} + +interface UseTimelineGroupEditingOptions { + activeCompPath: string | null; + domEditSaveTimestampRef: MutableRefObject; + editQueueRef: MutableRefObject>; + forceReloadSdkSession?: () => void; + isRecordingRef?: RefObject; + pendingTimelineEditPathRef: MutableRefObject>; + previewIframeRef: RefObject; + projectIdRef: MutableRefObject; + recordEdit: (input: RecordEditInput) => Promise; + reloadPreview: () => void; + sdkSession?: Composition | null; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; +} + +function targetPathFor(element: TimelineElement, activeCompPath: string | null): string { + return element.sourceFile || activeCompPath || "index.html"; +} + +function allChangesSharePath( + changes: readonly { element: TimelineElement }[], + activeCompPath: string | null, +): string | null { + const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null; + if (!firstPath) return null; + return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath) + ? firstPath + : null; +} + +function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string { + return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`; +} + +function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string { + return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`; +} + +function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean { + return ( + change.playbackStart != null || + (change.start !== change.element.start && change.element.playbackStart != null) + ); +} + +export function useTimelineGroupEditing({ + activeCompPath, + domEditSaveTimestampRef, + editQueueRef, + forceReloadSdkSession, + isRecordingRef, + pendingTimelineEditPathRef, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + showToast, + writeProjectFile, +}: UseTimelineGroupEditingOptions) { + const enqueueGroupOperation = useCallback( + (label: string, operation: (projectId: string) => Promise): Promise => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return Promise.resolve(); + } + const projectId = projectIdRef.current; + if (!projectId) return Promise.resolve(); + const queued = editQueueRef.current + .then(() => operation(projectId)) + .catch((error) => { + console.error(`[Timeline] Failed to persist: ${label}`, error); + }); + editQueueRef.current = queued; + return queued; + }, + [editQueueRef, isRecordingRef, projectIdRef, showToast], + ); + + const persistServerBatch = useCallback( + async ( + projectId: string, + label: string, + batchChanges: PersistTimelineBatchChange[], + coalesceKey: string, + ) => { + await persistTimelineBatchEdit({ + projectId, + activeCompPath, + label, + changes: batchChanges, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + coalesceKey, + }); + forceReloadSdkSession?.(); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + forceReloadSdkSession, + pendingTimelineEditPathRef, + recordEdit, + writeProjectFile, + ], + ); + + const handleTimelineGroupMove = useCallback( + (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => { + if (changes.length === 0) return Promise.resolve(); + for (const change of changes) { + patchIframeDomTiming(previewIframeRef.current, change.element, [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ]); + } + + const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration)); + const needsExtension = extendRootDurationIfNeeded(maxEnd); + const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes); + return enqueueGroupOperation("Move timeline clips", async (projectId) => { + await options?.beforeTiming; + const sharedPath = allChangesSharePath(changes, activeCompPath); + const sdkChanges = changes.map((change) => + change.element.hfId + ? { hfId: change.element.hfId, timingUpdate: { start: change.start } } + : null, + ); + const canUseSdk = + !needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null); + if (canUseSdk) { + const handled = await sdkTimingBatchPersist( + sdkChanges.filter((change): change is NonNullable => change !== null), + sharedPath, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + }, + { label: "Move timeline clips", coalesceKey }, + ); + if (handled) return; + } + + await persistServerBatch( + projectId, + "Move timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration), + })), + coalesceKey, + ); + await finishTimelineTimingFallback({ + iframe: previewIframeRef.current, + needsExtension, + rootDurationSeconds: maxEnd, + reloadPreview, + gsapMutation: async () => { + let mutated = false; + for (const change of changes) { + const delta = change.start - change.element.start; + const domId = change.element.domId; + if (delta === 0 || !domId) continue; + const status = await shiftGsapPositions( + projectId, + targetPathFor(change.element, activeCompPath), + domId, + delta, + ); + mutated = mutated || status.mutated; + } + return { mutated }; + }, + onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err), + }); + }); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + enqueueGroupOperation, + persistServerBatch, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + writeProjectFile, + ], + ); + + const handleTimelineGroupResize = useCallback( + (changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => { + if (changes.length === 0) return Promise.resolve(); + for (const change of changes) { + const liveAttrs: Array<[string, string]> = [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ["data-duration", formatTimelineAttributeNumber(change.duration)], + ]; + if (change.playbackStart != null) { + const liveAttr = + change.element.playbackStartAttr === "playback-start" + ? "data-playback-start" + : "data-media-start"; + liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]); + } + patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs); + } + + const maxEnd = Math.max(...changes.map((change) => change.start + change.duration)); + const needsExtension = extendRootDurationIfNeeded(maxEnd); + const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes); + return enqueueGroupOperation("Resize timeline clips", async (projectId) => { + await options?.beforeTiming; + const sharedPath = allChangesSharePath(changes, activeCompPath); + const sdkChanges = changes.map((change) => + change.element.hfId + ? { + hfId: change.element.hfId, + timingUpdate: { start: change.start, duration: change.duration }, + } + : null, + ); + const canUseSdk = + !needsExtension && + sharedPath !== null && + changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) && + sdkChanges.every((change) => change !== null); + if (canUseSdk) { + const handled = await sdkTimingBatchPersist( + sdkChanges.filter((change): change is NonNullable => change !== null), + sharedPath, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + }, + { label: "Resize timeline clips", coalesceKey }, + ); + if (handled) return; + } + + await persistServerBatch( + projectId, + "Resize timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineResizeTimingPatch(original, target, change.element, { + start: change.start, + duration: change.duration, + playbackStart: change.playbackStart, + }), + })), + coalesceKey, + ); + await finishTimelineTimingFallback({ + iframe: previewIframeRef.current, + needsExtension, + rootDurationSeconds: maxEnd, + reloadPreview, + gsapMutation: async () => { + let mutated = false; + for (const change of changes) { + const domId = change.element.domId; + const timingChanged = + change.start !== change.element.start || + change.duration !== change.element.duration; + if (!timingChanged || !domId) continue; + const status = await scaleGsapPositions( + projectId, + targetPathFor(change.element, activeCompPath), + domId, + change.element.start, + change.element.duration, + change.start, + change.duration, + ); + mutated = mutated || status.mutated; + } + return { mutated }; + }, + onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err), + }); + }); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + enqueueGroupOperation, + persistServerBatch, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + writeProjectFile, + ], + ); + + return { handleTimelineGroupMove, handleTimelineGroupResize }; +} diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 345c7602c9..6b8602e39a 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -2,6 +2,11 @@ // fallow-ignore-file dead-code import type { TimelineElement } from "../store/playerStore"; import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing"; +import type { + TimelineGroupCommitOptions, + TimelineGroupMoveChange, + TimelineGroupResizeChange, +} from "../../hooks/useTimelineGroupEditing"; /** * Shared callback signatures for timeline editing operations. @@ -34,6 +39,14 @@ export interface TimelineEditCallbacks { element: TimelineElement, updates: Pick, ) => Promise | void; + onMoveElements?: ( + changes: TimelineGroupMoveChange[], + options?: TimelineGroupCommitOptions, + ) => Promise | void; + onResizeElements?: ( + changes: TimelineGroupResizeChange[], + options?: TimelineGroupCommitOptions, + ) => Promise | void; onToggleTrackHidden?: (track: number, hidden: boolean) => Promise | void; onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 7039bf420a..c13ca519e2 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -196,6 +196,51 @@ export async function sdkTimingPersist( } } +export async function sdkTimingBatchPersist( + changes: Array<{ + hfId: string; + timingUpdate: { start?: number; duration?: number; trackIndex?: number }; + }>, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + const timingSrc = deps.readProjectFile; + for (const change of changes) { + void recordResolverParity( + sdkSession, + change.hfId, + "setTiming", + timingSrc ? () => timingSrc(targetPath) : undefined, + ); + } + if (!STUDIO_SDK_CUTOVER_ENABLED) return false; + if (!sdkSession || wrongCompositionFile(deps, targetPath)) return false; + if (changes.some((change) => !sdkSession.getElement(change.hfId))) return false; + try { + const serializedBefore = sdkSession.serialize(); + sdkSession.batch(() => { + for (const change of changes) sdkSession.setTiming(change.hfId, change.timingUpdate); + }); + const after = sdkSession.serialize(); + if (after === serializedBefore) return false; + const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore); + await persistSdkSerialize(after, targetPath, undoBefore, deps, options); + trackStudioEvent("sdk_cutover_success", { + hfId: changes[0]?.hfId ?? null, + opCount: changes.length, + }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { + hfId: changes[0]?.hfId ?? null, + error: String(err), + }); + return false; + } +} + type SdkGsapTweenOp = | { kind: "add"; target: string; spec: GsapTweenSpec } | { kind: "set"; animationId: string; properties: Partial } From 45d09047b7ef65ad4a30347dba4d91002e1c5d38 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 01:30:54 -0400 Subject: [PATCH 04/15] feat(studio): add timeline marquee selection --- .../studio/src/player/components/Timeline.tsx | 84 +++--- .../src/player/components/TimelineCanvas.tsx | 27 +- .../components/TimelineLayerGroupHeader.tsx | 2 +- .../components/TimelineSelectionOverlays.tsx | 52 ++++ .../src/player/components/timelineEditing.ts | 102 +++++++ .../timelineMarqueeSelection.test.ts | 87 ++++++ .../components/useTimelineKeyframeHandlers.ts | 73 +++++ .../useTimelineMarqueeSelection.test.tsx | 226 +++++++++++++++ .../components/useTimelineMarqueeSelection.ts | 263 ++++++++++++++++++ 9 files changed, 856 insertions(+), 60 deletions(-) create mode 100644 packages/studio/src/player/components/TimelineSelectionOverlays.tsx create mode 100644 packages/studio/src/player/components/timelineMarqueeSelection.test.ts create mode 100644 packages/studio/src/player/components/useTimelineKeyframeHandlers.ts create mode 100644 packages/studio/src/player/components/useTimelineMarqueeSelection.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineMarqueeSelection.ts diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 92040c67bf..937554bcc2 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -8,6 +8,7 @@ import { useMountEffect } from "../../hooks/useMountEffect"; import { EditPopover } from "./EditModal"; import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; +import { useTimelineMarqueeSelection } from "./useTimelineMarqueeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons"; @@ -21,6 +22,7 @@ import { type KeyframeDiamondContextMenuState, } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; +import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; import { ClipContextMenu } from "./ClipContextMenu"; import { TimelineShortcutHint } from "./TimelineShortcutHint"; import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder"; @@ -36,7 +38,6 @@ import { import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; -// Re-export pure utilities so existing imports from "./Timeline" still resolve. export { generateTicks, formatTimelineTickLabel, @@ -206,7 +207,6 @@ export const Timeline = memo(function Timeline({ const ppsRef = useRef(100); const durationRef = useRef(Number.isFinite(duration) ? duration : 0); - // Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null); const { @@ -230,7 +230,6 @@ export const Timeline = memo(function Timeline({ setRangeSelectionRef, }); - // basis drives the zoom (committed); effective adds the live preview (see timelineLayout). const basisDuration = useMemo( () => computeTimelineBasisDuration( @@ -269,6 +268,15 @@ export const Timeline = memo(function Timeline({ const keyframeCache = usePlayerStore((s) => s.keyframeCache); const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const keyframeHandlers = useTimelineKeyframeHandlers({ + expandedElements, + keyframeCache, + onSelectElement, + onSeek, + setSelectedElementId, + setKfContextMenu, + toggleSelectedKeyframe, + }); const selectedElement = useMemo( () => @@ -278,7 +286,6 @@ export const Timeline = memo(function Timeline({ const selectedElementRef = useRef(selectedElement); selectedElementRef.current = selectedElement; - // Fit to basisDuration, not effectiveDuration, so a live drag can't rezoom. const fitPps = viewportWidth > GUTTER && basisDuration > 0 ? (viewportWidth - GUTTER - 2) / basisDuration @@ -346,7 +353,20 @@ export const Timeline = memo(function Timeline({ isDragging, setShowPopover, }); - // Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag + const { + marqueeRect, + handlePointerDown: handleMarqueePointerDown, + handlePointerMove: handleMarqueePointerMove, + handlePointerUp: handleMarqueePointerUp, + } = useTimelineMarqueeSelection({ + scrollRef, + ppsRef, + trackOrderRef, + timelineLayersRef, + disabled: activeTool === "razor", + setShowPopover, + setRangeSelectionRef, + }); setRangeSelectionRef.current = setRangeSelection; const prevSelectedRef = useRef(selectedElementRef.current); @@ -445,11 +465,21 @@ export const Timeline = memo(function Timeline({ onRazorSplitAll?.(splitTime); return; } + if (handleMarqueePointerDown(e)) return; handlePointerDown(e); }} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onLostPointerCapture={handlePointerUp} + onPointerMove={(e) => { + if (handleMarqueePointerMove(e)) return; + handlePointerMove(e); + }} + onPointerUp={(e) => { + if (handleMarqueePointerUp(e)) return; + handlePointerUp(); + }} + onLostPointerCapture={(e) => { + if (handleMarqueePointerUp(e)) return; + handlePointerUp(); + }} > { - usePlayerStore.getState().clearSelectedKeyframes(); - const elKey = el.key ?? el.id; - setSelectedElementId(elKey); - onSelectElement?.(el); - // Visually select the clicked diamond (matches shift-click / motion-path - // selection); cleared above so this single-selects it. - toggleSelectedKeyframe(`${elKey}:${pct}`); - const absTime = el.start + (pct / 100) * el.duration; - onSeek?.(absTime); - const kfData = keyframeCache?.get(elKey); - const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5); - usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null); - }} - onShiftClickKeyframe={(elId, pct) => { - toggleSelectedKeyframe(`${elId}:${pct}`); - }} + onClickKeyframe={keyframeHandlers.onClickKeyframe} + onShiftClickKeyframe={keyframeHandlers.onShiftClickKeyframe} onMoveKeyframe={onMoveKeyframe} - onContextMenuKeyframe={(e, elId, pct) => { - const el = expandedElements.find((x) => (x.key ?? x.id) === elId); - if (el) { - setSelectedElementId(elId); - onSelectElement?.(el); - } - const kfData = keyframeCache.get(elId); - const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); - setKfContextMenu({ - x: e.clientX + 4, - y: e.clientY + 2, - elementId: elId, - percentage: pct, - tweenPercentage: kf?.tweenPercentage, - currentEase: kf?.ease ?? kfData?.ease, - }); - }} + onContextMenuKeyframe={keyframeHandlers.onContextMenuKeyframe} onContextMenuClip={(e, el) => { e.preventDefault(); setSelectedElementId(el.key ?? el.id); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index ba378ef52b..7c206072dd 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -11,7 +11,7 @@ import { type TimelineRangeSelection, } from "./timelineEditing"; import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"; -import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; +import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; import { usePlayerStore, type TimelineElement, @@ -32,6 +32,8 @@ import { import { resolveTimelineDropIndicator } from "./timelineDropIndicator"; import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine"; import { TimelineDragGhost } from "./TimelineDragGhost"; +import { TimelineSelectionOverlays } from "./TimelineSelectionOverlays"; +import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection"; function ClipLintDot({ element }: { element: TimelineElement }) { const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id)); @@ -54,6 +56,7 @@ interface TimelineCanvasProps { effectiveDuration: number; majorTickInterval: number; rangeSelection: TimelineRangeSelection | null; + marqueeRect: TimelineMarqueeOverlayRect | null; theme: TimelineTheme; displayTrackOrder: TimelineLayerId[]; trackOrder: TimelineLayerId[]; @@ -112,6 +115,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ effectiveDuration, majorTickInterval, rangeSelection, + marqueeRect, theme, displayTrackOrder, trackOrder, @@ -561,22 +565,11 @@ export const TimelineCanvas = memo(function TimelineCanvas({ )} - {/* Range highlight */} - {rangeSelection && ( -
- )} + {/* Playhead — hidden while dragging a beat so its guideline doesn't track the scrub and clutter the beat being moved. */} diff --git a/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx index 569b15720b..c8fed4a8cc 100644 --- a/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx +++ b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx @@ -2,7 +2,7 @@ import type { TimelineTheme } from "./timelineTheme"; import { GUTTER } from "./timelineLayout"; import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder"; -const TIMELINE_LAYER_GROUP_HEADER_H = 18; +export const TIMELINE_LAYER_GROUP_HEADER_H = 18; export function shouldShowTimelineLayerGroupHeader( contextKey: string, diff --git a/packages/studio/src/player/components/TimelineSelectionOverlays.tsx b/packages/studio/src/player/components/TimelineSelectionOverlays.tsx new file mode 100644 index 0000000000..edcb03bd0d --- /dev/null +++ b/packages/studio/src/player/components/TimelineSelectionOverlays.tsx @@ -0,0 +1,52 @@ +import type { TimelineRangeSelection } from "./timelineEditing"; +import { GUTTER, RULER_H } from "./timelineLayout"; +import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection"; + +interface TimelineSelectionOverlaysProps { + rangeSelection: TimelineRangeSelection | null; + marqueeRect: TimelineMarqueeOverlayRect | null; + pps: number; +} + +export function TimelineSelectionOverlays({ + rangeSelection, + marqueeRect, + pps, +}: TimelineSelectionOverlaysProps) { + return ( + <> + {rangeSelection && ( +
+ )} + {marqueeRect && ( +