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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:34 -04:00
parent 6080f5ad3e
commit cca31feffe
2 changed files with 108 additions and 4 deletions
@@ -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([
@@ -75,6 +75,23 @@ export interface TimelineElement {
export type ZoomMode = "fit" | "manual";
type TimelineTool = "select" | "razor";
function resolveElementSelection(
ids: Iterable<string>,
anchor?: string | null,
): { selectedElementIds: Set<string>; 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<string>;
setSelection: (ids: Iterable<string>, 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<PlayerState>((set, get) => ({
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
selectedElementIds: new Set<string>(),
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<PlayerState>((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) => ({