fix(studio): keep the timeline multi-selection through a group edit

setSelectedElementId no longer resets the selection set when re-selecting an element
that is already a member: DOM-to-selection sync echoes fire on every pointer move during
a group drag and were collapsing the set to the grabbed clip.

Also drops the duplicate clearSelectedElementIds action in favor of clearSelection,
which the marquee now uses to clear on an empty drag.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:34 -04:00
parent 36413da7f6
commit fa3848a33d
3 changed files with 30 additions and 17 deletions
@@ -231,7 +231,7 @@ export function useTimelineMarqueeSelection({
setMarqueeRect(null);
if (!active.started) {
usePlayerStore.getState().setSelection([]);
usePlayerStore.getState().clearSelection();
return true;
}
@@ -249,6 +249,18 @@ describe("usePlayerStore", () => {
expect(state.selectedElementId).toBe("el-3");
});
it("re-selecting a current member keeps the multi-selection and moves the anchor", () => {
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.
store.setSelectedElementId("el-2");
const state = usePlayerStore.getState();
expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]);
expect(state.selectedElementId).toBe("el-2");
});
it("clearing single selection empties the set", () => {
const store = usePlayerStore.getState();
store.setSelection(["el-1", "el-2"], "el-2");
@@ -275,19 +287,12 @@ describe("usePlayerStore", () => {
expect(state.selectedElementId).toBe("el-2");
});
it("clearSelection and clearSelectedElementIds empty the set and anchor", () => {
it("clearSelection empties the set and the 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();
const state = usePlayerStore.getState();
expect([...state.selectedElementIds]).toEqual([]);
expect(state.selectedElementId).toBeNull();
});
@@ -146,7 +146,6 @@ interface PlayerState {
addSelectedElementId: (id: string) => void;
toggleSelectedElementId: (id: string) => void;
clearSelection: () => void;
clearSelectedElementIds: () => void;
/** Keyframe data per element id, populated from parsed GSAP animations. */
keyframeCache: Map<string, KeyframeCacheEntry>;
@@ -300,7 +299,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
return resolveElementSelection(next, s.selectedElementId);
}),
clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }),
clearSelectedElementIds: () => set({ selectedElementId: null, selectedElementIds: new Set() }),
keyframeCache: new Map(),
setKeyframeCache: (elementId, data) =>
@@ -412,20 +410,30 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
setBeatDragging: (dragging) => set({ beatDragging: dragging }),
setElements: (elements) => set({ elements }),
setSelectedElementId: (id) =>
set((s) =>
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<string>();
// 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
// calling setSelectedElementId, so this never clobbers a genuine keyframe select.
id !== s.selectedElementId
return id !== s.selectedElementId
? {
selectedElementId: id,
selectedElementIds: id ? new Set([id]) : new Set(),
selectedElementIds,
activeKeyframePct: null,
motionPathArmed: false,
}
: { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set() },
),
: { selectedElementId: id, selectedElementIds };
}),
updateElement: (elementId, updates) =>
set((state) => ({
elements: state.elements.map((el) =>