refactor(studio): single-source timeline selection id-resolution

The DOM-selection to timeline sync routes through the canonical resolveTimelineIdForSelection
(source-file, ancestor, active-comp fallback) instead of a narrow domId/id match that
mismatched sub-composition clips.

The preview-sync equality check compares selection as sets both ways and includes the
anchor, so duplicate resolutions no longer mask an unsynced member.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:34 -04:00
parent 04ddd411ec
commit 0fe38e8cc8
2 changed files with 28 additions and 9 deletions
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef } from "react";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
import { usePlayerStore } from "../player";
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
import { useGsapAnimationsForElement, usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
@@ -171,12 +172,12 @@ export function useDomEditWiring({
useEffect(() => {
if (!domEditSelection?.id) return;
const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
const matchKey = elements.find(
(el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
);
const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
// Resolve through the canonical resolver (source-file + ancestor + active-comp
// fallback) rather than a narrow domId/id match, so a sub-composition selection
// maps to the same clip the rest of the selection pipeline picks.
const key = resolveTimelineIdForSelection(domEditSelection, elements, activeCompPath);
if (key && key !== selectedElementId) setSelectedElementId(key);
}, [domEditSelection?.id]);
}, [domEditSelection, activeCompPath]);
// ── GSAP cache sync ──
@@ -26,10 +26,24 @@ function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
return [anchor, ...ordered.filter((id) => id !== anchor)];
}
function selectionIdsMatch(currentIds: string[], selectedIds: string[]): boolean {
if (currentIds.length !== selectedIds.length) return false;
function selectionIdsMatch(
currentIds: string[],
selectedIds: string[],
currentAnchor: string | null,
wantedAnchor: string | null,
): boolean {
// Compare as sets in BOTH directions: length equality misreads duplicates (two DOM
// children resolving to the same clip id) as a full match and skips mirroring the
// members that never made it into the preview.
const current = new Set(currentIds);
const selected = new Set(selectedIds);
return currentIds.every((id) => selected.has(id));
if (current.size !== selected.size) return false;
for (const id of selected) {
if (!current.has(id)) return false;
}
// The primary/anchor must also agree, or a change of just the anchor within the
// same set would never re-sync the preview's primary selection.
return currentAnchor === wantedAnchor;
}
export function useTimelineSelectionPreviewSync({
@@ -61,12 +75,15 @@ export function useTimelineSelectionPreviewSync({
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
)
.filter((id): id is string => Boolean(id));
const currentAnchor = domEditSelection
? resolveTimelineIdForSelection(domEditSelection, timelineElements, activeCompPath)
: null;
if (selectedIds.length === 0) {
if (currentSelections.length > 0) applyDomSelection(null, { revealPanel: false });
return;
}
if (selectionIdsMatch(currentIds, selectedIds)) return;
if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) return;
let cancelled = false;
const syncSelection = async () => {
@@ -98,6 +115,7 @@ export function useTimelineSelectionPreviewSync({
buildDomSelectionForTimelineElement,
domEditGroupSelections,
domEditSelection,
selectedElementId,
selectedIds,
selectedKey,
timelineElements,