diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts index 3d4232ebd..c4c3706c1 100644 --- a/packages/studio/src/hooks/useDomEditWiring.ts +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -171,12 +171,13 @@ export function useDomEditWiring({ useEffect(() => { if (!domEditSelection?.id) return; - const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState(); + const { selectedElementId, elements, setSelectionAnchor } = usePlayerStore.getState(); // 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. + // maps to the same clip the rest of the selection pipeline picks. Use the + // anchor-only setter: this is a DOM->store echo and must not collapse a group. const key = resolveTimelineIdForSelection(domEditSelection, elements, activeCompPath); - if (key && key !== selectedElementId) setSelectedElementId(key); + if (key && key !== selectedElementId) setSelectionAnchor(key); }, [domEditSelection, activeCompPath]); // ── GSAP cache sync ── diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 346432455..654d88848 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -203,12 +203,24 @@ export function useDomSelection({ setRightCollapsed(false); setRightPanelTab("design"); } - const nextSelectedTimelineId = resolveTimelineIdForSelection( + // 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, ); - setSelectedTimelineElementId(nextSelectedTimelineId); + 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; } diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index abf415cbe..8d70cc81b 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -249,16 +249,32 @@ describe("usePlayerStore", () => { expect(state.selectedElementId).toBe("el-3"); }); - it("re-selecting a current member keeps the multi-selection and moves the anchor", () => { + it("setSelectedElementId collapses to a single element even for a current member", () => { const store = usePlayerStore.getState(); store.setSelection(["el-1", "el-2", "el-3"], "el-1"); - // A DOM->selection sync echo during a group drag re-selects the grabbed - // member; this must NOT collapse the set to that single element. + // A genuine single selection (click) collapses the set, even if the id was a member. store.setSelectedElementId("el-2"); const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-2"]); + expect(state.selectedElementId).toBe("el-2"); + }); + + it("setSelectionAnchor moves the anchor within a group without collapsing it", () => { + const store = usePlayerStore.getState(); + store.setSelection(["el-1", "el-2", "el-3"], "el-1"); + // A DOM->store echo during a group gesture only moves the anchor. + store.setSelectionAnchor("el-2"); + + let state = usePlayerStore.getState(); expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]); expect(state.selectedElementId).toBe("el-2"); + + // A non-member anchor is a genuine new single selection. + store.setSelectionAnchor("outside"); + state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["outside"]); + expect(state.selectedElementId).toBe("outside"); }); it("clearing single selection empties the set", () => { diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 24ec3a258..ac7fcf0d4 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -161,6 +161,8 @@ interface PlayerState { setBeatDragging: (dragging: boolean) => void; setElements: (elements: TimelineElement[]) => void; setSelectedElementId: (id: string | null) => void; + /** Move the selection anchor within an active multi-selection without collapsing it. */ + setSelectionAnchor: (id: string | null) => void; updateElement: ( elementId: string, updates: Partial< @@ -409,18 +411,12 @@ export const usePlayerStore = create((set, get) => ({ setTimelineReady: (ready) => set({ timelineReady: ready }), setBeatDragging: (dragging) => set({ beatDragging: dragging }), setElements: (elements) => set({ elements }), + // A genuine single selection: always collapse the set to just this element. User + // intent (timeline click, preview click via applyDomSelection) flows here; DOM sync + // echoes that must preserve a group go through setSelectionAnchor instead. setSelectedElementId: (id) => set((s) => { - // Re-selecting an element that is already part of an active multi-selection - // (e.g. a DOM->selection sync echo while a group drag re-patches the preview) - // updates the anchor WITHOUT collapsing the set. A genuinely new element - // (not currently in the set) replaces the whole selection with itself. - const keepSet = id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id); - const selectedElementIds = keepSet - ? s.selectedElementIds - : id - ? new Set([id]) - : new Set(); + const selectedElementIds = id ? new Set([id]) : new Set(); // Selecting a different element drops any active keyframe selection — otherwise // a stale activeKeyframePct from a prior diamond click would force the next drag // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER @@ -434,6 +430,16 @@ export const usePlayerStore = create((set, get) => ({ } : { selectedElementId: id, selectedElementIds }; }), + // Move the anchor within an active multi-selection WITHOUT collapsing it — used by + // DOM->store sync echoes while a group gesture re-patches the preview. A non-member + // id is treated as a genuine new single selection. + setSelectionAnchor: (id) => + set((s) => { + if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) { + return { selectedElementId: id }; + } + return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set() }; + }), updateElement: (elementId, updates) => set((state) => ({ elements: state.elements.map((el) =>