diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b82f59b79..622e229ad 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -524,6 +524,8 @@ export function StudioApp() { handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop} handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementResize={timelineEditing.handleTimelineElementResize} + handleTimelineGroupMove={timelineEditing.handleTimelineGroupMove} + handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize} handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden} handleToggleElementHidden={timelineEditing.handleToggleElementHidden} handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 66c6c31b3..c033c828e 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -25,6 +25,15 @@ 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"; +import type { + TimelineGroupMoveChange, + TimelineGroupResizeChange, +} from "../hooks/useTimelineGroupEditing"; +import { + formatTimelineAttributeNumber, + patchIframeDomTiming, +} from "../hooks/timelineEditingHelpers"; export interface StudioPreviewAreaProps { timelineToolbar: ReactNode; @@ -58,6 +67,8 @@ export interface StudioPreviewAreaProps { element: TimelineElement, updates: Pick, ) => Promise | void; + handleTimelineGroupMove: (changes: TimelineGroupMoveChange[]) => Promise | void; + handleTimelineGroupResize: (changes: TimelineGroupResizeChange[]) => Promise | void; handleToggleTrackHidden: (track: number, hidden: boolean) => Promise | void; handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise | void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; @@ -85,6 +96,8 @@ export function StudioPreviewArea({ handleTimelineFileDrop, handleTimelineElementMove, handleTimelineElementResize, + handleTimelineGroupMove, + handleTimelineGroupResize, handleToggleTrackHidden, handleToggleElementHidden, handleBlockedTimelineEdit, @@ -148,6 +161,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 +176,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 @@ -178,11 +206,47 @@ export function StudioPreviewArea({ [domEditSelection?.id, selectedGsapAnimations], ); + const handleTimelineGroupMovePreview = useCallback( + (changes: TimelineGroupMoveChange[]) => { + for (const change of changes) { + patchIframeDomTiming(previewIframeRef.current, change.element, [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ]); + } + }, + [previewIframeRef], + ); + + const handleTimelineGroupResizePreview = useCallback( + (changes: TimelineGroupResizeChange[]) => { + for (const change of changes) { + const attrs: Array<[string, string]> = [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ["data-duration", formatTimelineAttributeNumber(change.duration)], + ]; + if (change.playbackStart != null) { + attrs.push([ + change.element.playbackStartAttr === "playback-start" + ? "data-playback-start" + : "data-media-start", + formatTimelineAttributeNumber(change.playbackStart), + ]); + } + patchIframeDomTiming(previewIframeRef.current, change.element, attrs); + } + }, + [previewIframeRef], + ); + // fallow-ignore-next-line complexity const timelineEditCallbacks = useMemo( () => ({ onMoveElement: handleTimelineElementMove, onResizeElement: handleTimelineElementResize, + onMoveElements: handleTimelineGroupMove, + onResizeElements: handleTimelineGroupResize, + onPreviewMoveElements: handleTimelineGroupMovePreview, + onPreviewResizeElements: handleTimelineGroupResizePreview, onToggleTrackHidden: handleToggleTrackHidden, onToggleElementHidden: handleToggleElementHidden, onBlockedEditAttempt: handleBlockedTimelineEdit, @@ -191,7 +255,7 @@ export function StudioPreviewArea({ onRazorSplitAll: handleRazorSplitAll, onDeleteAllKeyframes: () => { // Hold the element where it is (collapse keyframes to a static set) rather - // than deleting the whole animation — deleting strands a stale GSAP base + // than deleting the whole animation, deleting strands a stale GSAP base // that the next drag adds to, flinging the element off-screen. const anim = selectedGsapAnimations.find((a) => a.keyframes); if (!anim) return; @@ -211,7 +275,7 @@ export function StudioPreviewArea({ // absolute time (via the clip's timing basis) and let resolveKeyframeRetime // decide: a drop inside the tween window is a plain move (re-key tween-%); a // drop past the boundary (last keyframe past the end, first before the start) - // resizes the tween — position/duration grow so the dragged keyframe lands at + // resizes the tween, position/duration grow so the dragged keyframe lands at // the drop while every other keyframe keeps its absolute time (value+ease too). // fallow-ignore-next-line complexity onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => { @@ -284,6 +348,10 @@ export function StudioPreviewArea({ [ handleTimelineElementMove, handleTimelineElementResize, + handleTimelineGroupMove, + handleTimelineGroupMovePreview, + handleTimelineGroupResize, + handleTimelineGroupResizePreview, handleToggleTrackHidden, handleToggleElementHidden, handleBlockedTimelineEdit, @@ -327,7 +395,7 @@ export function StudioPreviewArea({ onCompositionLoadingChange={setCompositionLoading} onCompositionChange={(compPath) => { // Sync activeCompPath when user drills down via timeline double-click - // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync. + // or navigates back via breadcrumb, keeps sidebar + thumbnails in sync. // Guard against no-op updates to prevent circular refresh cascades // between activeCompPath → compositionStack → onCompositionChange. if (compPath !== activeCompPath) { diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 7f075d783..bfc47c6bd 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -10,7 +10,7 @@ export function useTimelineEditContext(): TimelineEditCallbacks { } /** - * Optional access — returns an empty object when outside a provider. + * Optional access, returns an empty object when outside a provider. * Useful in components that can render both inside and outside the NLE. */ export function useTimelineEditContextOptional(): TimelineEditCallbacks { @@ -26,12 +26,16 @@ export function TimelineEditProvider({ }) { const memoized = useMemo( () => value, - // Each callback is a stable reference from the parent — memoize the bag + // Each callback is a stable reference from the parent, memoize the bag // so consumers don't re-render when unrelated parent state changes. // eslint-disable-next-line react-hooks/exhaustive-deps [ value.onMoveElement, value.onResizeElement, + value.onMoveElements, + value.onResizeElements, + value.onPreviewMoveElements, + value.onPreviewResizeElements, value.onToggleTrackHidden, value.onToggleElementHidden, value.onBlockedEditAttempt, diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index d2414dd45..28d2e09ec 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -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; } @@ -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; + 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}`); @@ -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; + gsapMutation: () => Promise; +}): Promise { + const uniquePaths = [...new Set(input.paths)]; + const before = new Map(); + for (const path of uniquePaths) { + before.set(path, await readFileContent(input.projectId, path)); + } + const status = await input.gsapMutation(); + if (status.mutated) { + const files: Record = {}; + for (const path of uniquePaths) { + const priorContent = before.get(path); + const finalContent = await readFileContent(input.projectId, path); + if (priorContent !== undefined && finalContent !== priorContent) { + files[path] = { before: priorContent, after: finalContent }; + } + } + if (Object.keys(files).length > 0) { + await input.recordEdit({ + label: input.label, + kind: "timeline", + coalesceKey: input.coalesceKey, + coalesceMs: GSAP_HISTORY_COALESCE_MS, + files, + }); + } + } + return status; +} + /** * 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; +}): () => Promise { + return () => + foldGsapMutationIntoHistory({ + projectId: input.projectId, + paths: [input.targetPath], + label: input.label, + coalesceKey: input.coalesceKey, + recordEdit: input.recordEdit, + gsapMutation: () => + shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta), + }); +} + +/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */ +export function foldedScaleGsapMutation(input: { + projectId: string; + targetPath: string; + domId: string; + from: { start: number; duration: number }; + to: { start: number; duration: number }; + label: string; + coalesceKey?: string; + recordEdit: (edit: RecordEditInput) => Promise; +}): () => Promise { + return () => + foldGsapMutationIntoHistory({ + projectId: input.projectId, + paths: [input.targetPath], + label: input.label, + coalesceKey: input.coalesceKey, + recordEdit: input.recordEdit, + gsapMutation: () => + scaleGsapPositions( + input.projectId, + input.targetPath, + input.domId, + input.from.start, + input.from.duration, + input.to.start, + input.to.duration, + ), + }); +} + // Re-export applyPatchByTarget for use in the hook (avoids double import in callers) export { applyPatchByTarget, formatTimelineAttributeNumber }; diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts index f598cb54e..c4c3706c1 100644 --- a/packages/studio/src/hooks/useDomEditWiring.ts +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -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 ── diff --git a/packages/studio/src/hooks/useDomSelection.test.ts b/packages/studio/src/hooks/useDomSelection.test.ts index 53f452464..b7628c437 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 72892dd74..f124d3753 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -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; handleTimelineElementSelect: (element: TimelineElement | null) => Promise; refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise; - refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise; applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void; } @@ -131,6 +127,9 @@ export function useDomSelection({ const domEditHoverSelectionRef = useRef(domEditHoverSelection); const activeGroupElementRef = useRef(activeGroupElement); const compositionIdentityRef = useRef({ activeCompPath, projectId }); + // Monotonic token so a rapid A->B timeline-clip select can't let A's slower async + // resolution land after B and restore the wrong selection. + const timelineSelectSeqRef = useRef(0); // Keep refs in sync with state domEditSelectionRef.current = domEditSelection; @@ -207,20 +206,36 @@ export function useDomSelection({ setRightCollapsed(false); 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(() => { @@ -360,12 +375,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], @@ -400,55 +418,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 @@ -530,16 +499,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 @@ -576,7 +552,6 @@ export function useDomSelection({ buildDomSelectionForTimelineElement, handleTimelineElementSelect, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, applyMarqueeSelection, }; } diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index daa256a04..cd3f88fdc 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 b5ca68e0c..b873f8517 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"; @@ -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 & { 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, }; } diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts new file mode 100644 index 000000000..0ad9d365d --- /dev/null +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -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; + coalesceKey?: string; +} + +interface UseTimelineGroupEditingOptions { + activeCompPath: string | null; + domEditSaveTimestampRef: MutableRefObject; + editQueueRef: MutableRefObject>; + forceReloadSdkSession?: () => void; + isRecordingRef?: RefObject; + pendingTimelineEditPathRef: MutableRefObject>; + previewIframeRef: RefObject; + projectIdRef: MutableRefObject; + recordEdit: (input: RecordEditInput) => Promise; + reloadPreview: () => void; + sdkSession?: Composition | null; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; +} + +function targetPathFor(element: TimelineElement, activeCompPath: string | null): string { + return element.sourceFile || activeCompPath || "index.html"; +} + +function allChangesSharePath( + changes: readonly { element: TimelineElement }[], + activeCompPath: string | null, +): string | null { + const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null; + if (!firstPath) return null; + return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath) + ? firstPath + : null; +} + +function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string { + return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`; +} + +function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string { + return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`; +} + +function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean { + return ( + change.playbackStart != null || + (change.start !== change.element.start && change.element.playbackStart != null) + ); +} + +export function useTimelineGroupEditing({ + activeCompPath, + domEditSaveTimestampRef, + editQueueRef, + forceReloadSdkSession, + isRecordingRef, + pendingTimelineEditPathRef, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + showToast, + writeProjectFile, +}: UseTimelineGroupEditingOptions) { + const enqueueGroupOperation = useCallback( + (label: string, operation: (projectId: string) => Promise): Promise => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return Promise.reject(new Error(`${label}: blocked while recording`)); + } + const projectId = projectIdRef.current; + if (!projectId) return Promise.reject(new Error(`${label}: no active project`)); + const run = editQueueRef.current.then(() => operation(projectId)); + // Keep the shared edit queue from wedging on a rejection, but return the raw + // (rejecting) promise so the gesture owner can roll back on a real failure. + editQueueRef.current = run.then( + () => undefined, + (error) => { + console.error(`[Timeline] Failed to persist: ${label}`, error); + }, + ); + return run; + }, + [editQueueRef, isRecordingRef, projectIdRef, showToast], + ); + + const persistServerBatch = useCallback( + async ( + projectId: string, + label: string, + batchChanges: PersistTimelineBatchChange[], + coalesceKey: string, + ) => { + await persistTimelineBatchEdit({ + projectId, + activeCompPath, + label, + changes: batchChanges, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + coalesceKey, + }); + forceReloadSdkSession?.(); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + forceReloadSdkSession, + pendingTimelineEditPathRef, + recordEdit, + writeProjectFile, + ], + ); + + const handleTimelineGroupMove = useCallback( + (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => { + if (changes.length === 0) return Promise.resolve(); + for (const change of changes) { + patchIframeDomTiming(previewIframeRef.current, change.element, [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ]); + } + + const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration)); + const needsExtension = extendRootDurationIfNeeded(maxEnd); + const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes); + return enqueueGroupOperation("Move timeline clips", async (projectId) => { + await options?.beforeTiming; + const sharedPath = allChangesSharePath(changes, activeCompPath); + const sdkChanges = changes.map((change) => + change.element.hfId + ? { hfId: change.element.hfId, timingUpdate: { start: change.start } } + : null, + ); + const canUseSdk = + !needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null); + if (canUseSdk) { + const handled = await sdkTimingBatchPersist( + sdkChanges.filter((change): change is NonNullable => change !== null), + sharedPath, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + }, + { label: "Move timeline clips", coalesceKey }, + ); + if (handled) return; + } + + await persistServerBatch( + projectId, + "Move timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration), + })), + coalesceKey, + ); + await finishTimelineTimingFallback({ + iframe: previewIframeRef.current, + needsExtension, + rootDurationSeconds: maxEnd, + reloadPreview, + gsapMutation: () => + foldGsapMutationIntoHistory({ + projectId, + paths: changes.map((change) => targetPathFor(change.element, activeCompPath)), + label: "Move timeline clips", + coalesceKey, + recordEdit, + gsapMutation: async () => { + let mutated = false; + for (const change of changes) { + const delta = change.start - change.element.start; + const domId = change.element.domId; + if (delta === 0 || !domId) continue; + const status = await shiftGsapPositions( + projectId, + targetPathFor(change.element, activeCompPath), + domId, + delta, + ); + mutated = mutated || status.mutated; + } + return { mutated }; + }, + }), + onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err), + }); + }); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + enqueueGroupOperation, + persistServerBatch, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + writeProjectFile, + ], + ); + + const handleTimelineGroupResize = useCallback( + (changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => { + if (changes.length === 0) return Promise.resolve(); + for (const change of changes) { + const liveAttrs: Array<[string, string]> = [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ["data-duration", formatTimelineAttributeNumber(change.duration)], + ]; + if (change.playbackStart != null) { + const liveAttr = + change.element.playbackStartAttr === "playback-start" + ? "data-playback-start" + : "data-media-start"; + liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]); + } + patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs); + } + + const maxEnd = Math.max(...changes.map((change) => change.start + change.duration)); + const needsExtension = extendRootDurationIfNeeded(maxEnd); + const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes); + return enqueueGroupOperation("Resize timeline clips", async (projectId) => { + await options?.beforeTiming; + const sharedPath = allChangesSharePath(changes, activeCompPath); + const sdkChanges = changes.map((change) => + change.element.hfId + ? { + hfId: change.element.hfId, + timingUpdate: { start: change.start, duration: change.duration }, + } + : null, + ); + const canUseSdk = + !needsExtension && + sharedPath !== null && + changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) && + sdkChanges.every((change) => change !== null); + if (canUseSdk) { + const handled = await sdkTimingBatchPersist( + sdkChanges.filter((change): change is NonNullable => change !== null), + sharedPath, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + }, + { label: "Resize timeline clips", coalesceKey }, + ); + if (handled) return; + } + + await persistServerBatch( + projectId, + "Resize timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineResizeTimingPatch(original, target, change.element, { + start: change.start, + duration: change.duration, + playbackStart: change.playbackStart, + }), + })), + coalesceKey, + ); + await finishTimelineTimingFallback({ + iframe: previewIframeRef.current, + needsExtension, + rootDurationSeconds: maxEnd, + reloadPreview, + gsapMutation: () => + foldGsapMutationIntoHistory({ + projectId, + paths: changes.map((change) => targetPathFor(change.element, activeCompPath)), + label: "Resize timeline clips", + coalesceKey, + recordEdit, + gsapMutation: async () => { + let mutated = false; + for (const change of changes) { + const domId = change.element.domId; + const timingChanged = + change.start !== change.element.start || + change.duration !== change.element.duration; + if (!timingChanged || !domId) continue; + const status = await scaleGsapPositions( + projectId, + targetPathFor(change.element, activeCompPath), + domId, + change.element.start, + change.element.duration, + change.start, + change.duration, + ); + mutated = mutated || status.mutated; + } + return { mutated }; + }, + }), + onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err), + }); + }); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + enqueueGroupOperation, + persistServerBatch, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + writeProjectFile, + ], + ); + + return { handleTimelineGroupMove, handleTimelineGroupResize }; +} diff --git a/packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx new file mode 100644 index 000000000..038ae30dd --- /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 000000000..33281c4df --- /dev/null +++ b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts @@ -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; + 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 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, + ]); +} diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 23686c5d8..f6f525839 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/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 92040c67b..7557b2234 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, @@ -70,6 +71,10 @@ export const Timeline = memo(function Timeline({ const { onMoveElement, onResizeElement, + onMoveElements, + onResizeElements, + onPreviewMoveElements, + onPreviewResizeElements, onBlockedEditAttempt, onSplitElement, onRazorSplitAll, @@ -103,14 +108,12 @@ export const Timeline = memo(function Timeline({ const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); const playheadRef = useRef(null); - const containerRef = useRef(null); const scrollRef = useRef(null); const activeTool = usePlayerStore((s) => s.activeTool); const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); const [shiftHeld, setShiftHeld] = useState(false); const [razorGuideX, setRazorGuideX] = useState(null); - useMountEffect(() => { const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true); const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false); @@ -136,7 +139,6 @@ export const Timeline = memo(function Timeline({ const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); const shortcutHintRafRef = useRef(0); - const syncShortcutHintVisibility = useCallback(() => { const scroll = scrollRef.current; setShowShortcutHint( @@ -152,10 +154,6 @@ export const Timeline = memo(function Timeline({ }); }, [syncShortcutHintVisibility]); - const setContainerRef = useCallback((el: HTMLDivElement | null) => { - containerRef.current = el; - }, []); - const setScrollRef = useCallback( (el: HTMLDivElement | null) => { if (roRef.current) { @@ -206,7 +204,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 { @@ -225,12 +222,15 @@ export const Timeline = memo(function Timeline({ timelineElementsRef: expandedElementsRef, onMoveElement, onResizeElement, + onMoveElements, + onResizeElements, + onPreviewMoveElements, + onPreviewResizeElements, onBlockedEditAttempt, setShowPopover, setRangeSelectionRef, }); - // basis drives the zoom (committed); effective adds the live preview (see timelineLayout). const basisDuration = useMemo( () => computeTimelineBasisDuration( @@ -269,6 +269,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 +287,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,8 +354,27 @@ 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, + seekFromX, + }); setRangeSelectionRef.current = setRangeSelection; + // Pointer-up and lost-capture end a gesture identically (marquee-claims-first). + const releasePointer = (event: Parameters[0]) => { + if (handleMarqueePointerUp(event)) return; + handlePointerUp(); + }; const prevSelectedRef = useRef(selectedElementRef.current); // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps @@ -414,7 +441,6 @@ export const Timeline = memo(function Timeline({ return (
{ @@ -445,11 +471,15 @@ 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={releasePointer} + onLostPointerCapture={releasePointer} > { - 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 d5e4f651e..55bb299bf 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, @@ -158,6 +162,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 +364,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 @@ -560,22 +565,12 @@ 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/TimelineClip.test.tsx b/packages/studio/src/player/components/TimelineClip.test.tsx index 7a3cc6de3..0ec5c6340 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()); + }); }); diff --git a/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx index 569b15720..c8fed4a8c 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 000000000..249b92ede --- /dev/null +++ b/packages/studio/src/player/components/TimelineSelectionOverlays.tsx @@ -0,0 +1,55 @@ +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; + /** Primary/accent color (hex) shared with the rest of the timeline chrome. */ + accentColor: string; +} + +export function TimelineSelectionOverlays({ + rangeSelection, + marqueeRect, + pps, + accentColor, +}: TimelineSelectionOverlaysProps) { + return ( + <> + {rangeSelection && ( +
+ )} + {marqueeRect && ( +