Merge pull request #2111 from heygen-com/feat/timeline-multiselect

feat(studio): timeline multi-select (marquee) + relative group time editing
This commit is contained in:
Miguel Ángel
2026-07-09 17:38:37 -04:00
committed by GitHub
39 changed files with 3686 additions and 240 deletions
+49 -74
View File
@@ -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<DomEditSelection | null>;
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
@@ -133,6 +129,9 @@ export function useDomSelection({
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
const activeGroupElementRef = useRef<HTMLElement | null>(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;
@@ -213,20 +212,36 @@ export function useDomSelection({
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(() => {
@@ -366,12 +381,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],
@@ -406,55 +424,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
@@ -536,16 +505,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
@@ -582,7 +558,6 @@ export function useDomSelection({
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
applyMarqueeSelection,
};
}