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,