fix(studio): preserve canvas-only group anchors

This commit is contained in:
Miguel Angel Simon Sierra
2026-08-09 19:10:57 -04:00
parent 59e864cd42
commit 560ff338db
3 changed files with 42 additions and 7 deletions
@@ -48,20 +48,26 @@ export function announceTimelineSelection(
);
const members = group.map(timelineIdFor).filter((id): id is string => Boolean(id));
const anchor = timelineIdFor(primary);
const publishedMembers = new Set(members);
if (anchor) publishedMembers.add(anchor);
const timelineAnchor = anchor ?? members[0] ?? null;
// A member with no timeline row of its own resolves to null and is dropped here,
// so a group can announce fewer ids than it has — or none, which reads back as an
// empty selection and takes the canvas selection with it.
logSelect("announce", {
group: group.length,
published: members.length,
published: publishedMembers.size,
anchor,
anchorPublished: anchor != null && members.includes(anchor),
anchorPublished: anchor != null && publishedMembers.has(anchor),
});
// A canvas target can be editable without owning a timeline row. Preserve that
// canvas-only selection when the timeline has nothing truthful to represent.
if (!timelineAnchor) return;
// A late async primary that already belongs to the live set must preserve the
// group. A fresh single click does not belong to it, so publish the singleton
// first; otherwise `preserveSet` clears the set and sync wipes the canvas.
if (group.length > 1 || !anchor || !getTimelineSelectionSet().has(anchor)) {
setTimelineSelectionSet(new Set(members));
if (group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
setTimelineSelectionSet(publishedMembers);
}
setSelectedTimelineElementId(anchor, { preserveSet: true });
setSelectedTimelineElementId(timelineAnchor, { preserveSet: true });
}
@@ -144,6 +144,33 @@ describe("useDomSelection marquee", () => {
});
harness.cleanup();
});
it("uses a surviving group member as the timeline anchor when the canvas primary has no row", () => {
const canvasOnly = document.createElement("div");
canvasOnly.id = "canvas-only";
const card = document.createElement("div");
card.id = "card";
document.body.append(canvasOnly, card);
const harness = renderHarness(
{ activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
{ timelineElements: [timelineElement("card")] },
);
act(() =>
harness
.current()
.applyMarqueeSelection(
[makeSelection("Canvas only", canvasOnly), makeSelection("Card", card)],
false,
),
);
expect(harness.timeline.setTimelineSelectionSet).toHaveBeenCalledWith(new Set(["card"]));
expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenCalledWith("card", {
preserveSet: true,
});
harness.cleanup();
});
});
/**
+4 -2
View File
@@ -482,10 +482,12 @@ export function useDomSelection({
[activeCompPath, announceTimelineSelection, buildDomSelectionFromTarget, previewIframeRef],
);
refreshDomEditGroupSelectionsFromPreviewRef.current = refreshDomEditGroupSelectionsFromPreview;
// ── Effects ──
useEffect(() => {
refreshDomEditGroupSelectionsFromPreviewRef.current = refreshDomEditGroupSelectionsFromPreview;
}, [refreshDomEditGroupSelectionsFromPreview]);
// Clear hover unconditionally on composition/project/preview change
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {