From cca31feffeda60ab98e1cea7f01438ac299b0e42 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 00:54:09 -0400 Subject: [PATCH] feat(studio): activate selected element set state Use selectedElementIds as the live selection set with selectedElementId as anchor. Keep single-click selection as a one-element set and clear both fields together. --- .../src/player/store/playerStore.test.ts | 71 +++++++++++++++++++ .../studio/src/player/store/playerStore.ts | 41 +++++++++-- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 0d1750073..0338dd60e 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -222,6 +222,77 @@ describe("usePlayerStore", () => { }); }); + describe("selectedElementIds", () => { + it("sets a multi-id selection with a coherent anchor", () => { + usePlayerStore.getState().setSelection(["el-1", "el-2", "el-3"], "el-2"); + + const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]); + expect(state.selectedElementId).toBe("el-2"); + }); + + it("falls back to the first selected id when the anchor is outside the set", () => { + usePlayerStore.getState().setSelection(["el-1", "el-2"], "missing"); + + const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]); + expect(state.selectedElementId).toBe("el-1"); + }); + + it("single-click selection replaces the set with the selected id", () => { + const store = usePlayerStore.getState(); + store.setSelection(["el-1", "el-2"], "el-2"); + store.setSelectedElementId("el-3"); + + const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-3"]); + expect(state.selectedElementId).toBe("el-3"); + }); + + it("clearing single selection empties the set", () => { + const store = usePlayerStore.getState(); + store.setSelection(["el-1", "el-2"], "el-2"); + store.setSelectedElementId(null); + + const state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual([]); + expect(state.selectedElementId).toBeNull(); + }); + + it("toggle adds and removes members while keeping the anchor in the set", () => { + const store = usePlayerStore.getState(); + store.setSelectedElementId("el-1"); + store.toggleSelectedElementId("el-2"); + + let state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]); + expect(state.selectedElementId).toBe("el-1"); + + store.toggleSelectedElementId("el-1"); + + state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual(["el-2"]); + expect(state.selectedElementId).toBe("el-2"); + }); + + it("clearSelection and clearSelectedElementIds empty the set and anchor", () => { + const store = usePlayerStore.getState(); + store.setSelection(["el-1", "el-2"], "el-2"); + store.clearSelection(); + + let state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual([]); + expect(state.selectedElementId).toBeNull(); + + store.setSelection(["el-3"], "el-3"); + store.clearSelectedElementIds(); + + state = usePlayerStore.getState(); + expect([...state.selectedElementIds]).toEqual([]); + expect(state.selectedElementId).toBeNull(); + }); + }); + describe("updateElement", () => { it("updates the start time of a specific element", () => { usePlayerStore.getState().setElements([ diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 4efd575fb..b3b884d44 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -75,6 +75,23 @@ export interface TimelineElement { export type ZoomMode = "fit" | "manual"; type TimelineTool = "select" | "razor"; +function resolveElementSelection( + ids: Iterable, + anchor?: string | null, +): { selectedElementIds: Set; selectedElementId: string | null } { + const selectedElementIds = new Set(ids); + if (selectedElementIds.size === 0) { + return { selectedElementIds, selectedElementId: null }; + } + if (anchor && selectedElementIds.has(anchor)) { + return { selectedElementIds, selectedElementId: anchor }; + } + return { + selectedElementIds, + selectedElementId: selectedElementIds.values().next().value ?? null, + }; +} + interface PlayerState { isPlaying: boolean; currentTime: number; @@ -125,7 +142,10 @@ interface PlayerState { /** Multi-select: additional selected elements beyond selectedElementId. */ selectedElementIds: Set; + setSelection: (ids: Iterable, anchor?: string | null) => void; + addSelectedElementId: (id: string) => void; toggleSelectedElementId: (id: string) => void; + clearSelection: () => void; clearSelectedElementIds: () => void; /** Keyframe data per element id, populated from parsed GSAP animations. */ @@ -265,14 +285,22 @@ export const usePlayerStore = create((set, get) => ({ setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }), selectedElementIds: new Set(), + setSelection: (ids, anchor) => set(resolveElementSelection(ids, anchor)), + addSelectedElementId: (id: string) => + set((s) => { + const next = new Set(s.selectedElementIds); + next.add(id); + return resolveElementSelection(next, s.selectedElementId); + }), toggleSelectedElementId: (id: string) => set((s) => { const next = new Set(s.selectedElementIds); if (next.has(id)) next.delete(id); else next.add(id); - return { selectedElementIds: next }; + return resolveElementSelection(next, s.selectedElementId); }), - clearSelectedElementIds: () => set({ selectedElementIds: new Set() }), + clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }), + clearSelectedElementIds: () => set({ selectedElementId: null, selectedElementIds: new Set() }), keyframeCache: new Map(), setKeyframeCache: (elementId, data) => @@ -390,8 +418,13 @@ export const usePlayerStore = create((set, get) => ({ // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER // calling setSelectedElementId, so this never clobbers a genuine keyframe select. id !== s.selectedElementId - ? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false } - : { selectedElementId: id }, + ? { + selectedElementId: id, + selectedElementIds: id ? new Set([id]) : new Set(), + activeKeyframePct: null, + motionPathArmed: false, + } + : { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set() }, ), updateElement: (elementId, updates) => set((state) => ({