diff --git a/packages/studio/src/hooks/domSelectionTimelineMirror.ts b/packages/studio/src/hooks/domSelectionTimelineMirror.ts index 2a28b998d..f67577925 100644 --- a/packages/studio/src/hooks/domSelectionTimelineMirror.ts +++ b/packages/studio/src/hooks/domSelectionTimelineMirror.ts @@ -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 }); } diff --git a/packages/studio/src/hooks/useDomSelection.test.ts b/packages/studio/src/hooks/useDomSelection.test.ts index 1590117a5..4b407c35e 100644 --- a/packages/studio/src/hooks/useDomSelection.test.ts +++ b/packages/studio/src/hooks/useDomSelection.test.ts @@ -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(); + }); }); /** diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 1c7bbb9ee..115d54fca 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -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(() => {