fix(studio): make the player store the single source of truth for selection

setSelectedElementId now always collapses to one element (genuine user intent); a new
setSelectionAnchor moves the anchor within a multi-selection without collapsing it, used
only by the DOM-to-store sync echoes so a group survives a gesture.

applyDomSelection mirrors the whole DOM group into the store via setSelection instead of
writing only the anchor, so the store stays authoritative and a preview click collapses
while a preserved-group echo keeps every member.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:35 -04:00
parent 6673c32868
commit 9cf575c6f9
4 changed files with 53 additions and 18 deletions
@@ -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 ──
+14 -2
View File
@@ -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;
}
@@ -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", () => {
+16 -10
View File
@@ -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<PlayerState>((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<string>();
const 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
@@ -434,6 +430,16 @@ export const usePlayerStore = create<PlayerState>((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<string>() };
}),
updateElement: (elementId, updates) =>
set((state) => ({
elements: state.elements.map((el) =>