mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user