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] 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 66c6c31b3..1f03e67b1 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 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..423d4b70a 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 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..83e710597 --- /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 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/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index d5e4f651e..ba378ef52 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 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()); + }); });