From 0fe38e8cc87dfa125d65023147d1f838ef1bfd57 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 16:16:10 -0400 Subject: [PATCH] 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. --- packages/studio/src/hooks/useDomEditWiring.ts | 11 ++++---- .../hooks/useTimelineSelectionPreviewSync.ts | 26 ++++++++++++++++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts index f598cb54e..3d4232ebd 100644 --- a/packages/studio/src/hooks/useDomEditWiring.ts +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -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 ── diff --git a/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts index 71861e529..d26005c0e 100644 --- a/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts +++ b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts @@ -26,10 +26,24 @@ function orderSelectedIds(ids: Set, 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,