mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 ──
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user