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