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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:34 -04:00
parent cca31feffe
commit 1d858f004d
8 changed files with 363 additions and 4 deletions
@@ -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
@@ -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();
});
});
+17 -1
View File
@@ -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],
);
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
import { useTimelineSelectionPreviewSync } from "./useTimelineSelectionPreviewSync";
installReactActEnvironment();
interface HarnessProps {
selectedElementId: string | null;
selectedElementIds: Set<string>;
timelineElements: TimelineElement[];
domEditSelection: DomEditSelection | null;
domEditGroupSelections: DomEditSelection[];
buildDomSelectionForTimelineElement: (
element: TimelineElement,
) => Promise<DomEditSelection | null>;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
) => void;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
afterEach(() => {
document.body.innerHTML = "";
});
function renderHarness() {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
function Harness(nextProps: HarnessProps) {
useTimelineSelectionPreviewSync({
...nextProps,
activeCompPath: "index.html",
});
return null;
}
const rerender = async (nextProps: HarnessProps) => {
await act(async () => {
root.render(React.createElement(Harness, nextProps));
await Promise.resolve();
});
};
return {
rerender,
cleanup: () => {
act(() => root.unmount());
host.remove();
},
};
}
function makeSyncFixture() {
const firstElement = document.createElement("div");
firstElement.id = "clip-1";
const secondElement = document.createElement("div");
secondElement.id = "clip-2";
const firstSelection = makeSelection("First", firstElement);
const secondSelection = makeSelection("Second", secondElement);
const timelineElements: TimelineElement[] = [
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
{ id: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
];
const selectionById = new Map([
["clip-1", firstSelection],
["clip-2", secondSelection],
]);
return { firstSelection, secondSelection, timelineElements, selectionById };
}
describe("useTimelineSelectionPreviewSync", () => {
it("syncs a multi-id timeline selection into preview group selections", async () => {
const { firstSelection, secondSelection, timelineElements, selectionById } = makeSyncFixture();
const applyDomSelection = vi.fn();
const applyMarqueeSelection = vi.fn();
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
return selectionById.get(element.id) ?? null;
});
const harness = renderHarness();
await harness.rerender({
selectedElementId: "clip-2",
selectedElementIds: new Set(["clip-1", "clip-2"]),
timelineElements,
domEditSelection: null,
domEditGroupSelections: [],
buildDomSelectionForTimelineElement,
applyDomSelection,
applyMarqueeSelection,
});
expect(applyMarqueeSelection).toHaveBeenCalledWith([secondSelection, firstSelection], false);
expect(applyDomSelection).not.toHaveBeenCalled();
harness.cleanup();
});
it("clears preview selection when the timeline selection set is empty", async () => {
const { firstSelection, timelineElements, selectionById } = makeSyncFixture();
const applyDomSelection = vi.fn();
const applyMarqueeSelection = vi.fn();
const harness = renderHarness();
await harness.rerender({
selectedElementId: null,
selectedElementIds: new Set(),
timelineElements,
domEditSelection: firstSelection,
domEditGroupSelections: [firstSelection],
buildDomSelectionForTimelineElement: vi.fn(async (element: TimelineElement) => {
return selectionById.get(element.id) ?? null;
}),
applyDomSelection,
applyMarqueeSelection,
});
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
expect(applyMarqueeSelection).not.toHaveBeenCalled();
harness.cleanup();
});
});
@@ -0,0 +1,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<string>;
timelineElements: TimelineElement[];
domEditSelection: DomEditSelection | null;
domEditGroupSelections: DomEditSelection[];
activeCompPath: string | null;
buildDomSelectionForTimelineElement: (
element: TimelineElement,
) => Promise<DomEditSelection | null>;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
) => void;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
const ordered = [...ids];
if (!anchor || !ids.has(anchor)) return ordered;
return [anchor, ...ordered.filter((id) => id !== anchor)];
}
function 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,
]);
}
@@ -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", () => {
@@ -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
@@ -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());
});
});