Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions packages/studio/src/hooks/domSelectionTimelineMirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function announceTimelineSelection(
deps: TimelineMirrorDeps,
group: DomEditSelection[],
primary: DomEditSelection | null,
replaceSet = false,
): void {
const {
timelineElements,
Expand Down Expand Up @@ -72,10 +73,10 @@ export function announceTimelineSelection(
}
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 || !getTimelineSelectionSet().has(timelineAnchor)) {
// A late async primary inside the live set keeps the group. A click outside it, or a
// group the caller states in full (`replaceSet`: shift toggle, marquee, timeline sync), is published
// first; otherwise `preserveSet` clears or keeps a stale set and sync undoes the canvas.
if (replaceSet || group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
setTimelineSelectionSet(publishedMembers);
}
setSelectedTimelineElementId(timelineAnchor, { preserveSet: true });
Expand Down
88 changes: 56 additions & 32 deletions packages/studio/src/hooks/useDomSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ function renderHarness(
cleanup: () => void;
timeline: TimelineSpies;
} {
// Reads back what was last published, as the timeline store does.
let publishedSet: ReadonlySet<string> = new Set();
const timeline: TimelineSpies = {
setSelectedTimelineElementId: vi.fn(),
setTimelineSelectionSet: vi.fn(),
setTimelineSelectionSet: vi.fn((ids: Set<string>) => {
publishedSet = ids;
}),
};
const host = document.createElement("div");
document.body.append(host);
Expand All @@ -47,7 +51,7 @@ function renderHarness(
captionEditMode: false,
previewIframeRef: { current: null },
timelineElements: options.timelineElements ?? [],
getTimelineSelectionSet: () => new Set(),
getTimelineSelectionSet: () => publishedSet,
setSelectedTimelineElementId: timeline.setSelectedTimelineElementId,
setTimelineSelectionSet: timeline.setTimelineSelectionSet,
setRightCollapsed: vi.fn(),
Expand Down Expand Up @@ -107,6 +111,17 @@ function timelineElement(domId: string): TimelineElement {
} as TimelineElement;
}

function renderCardAndChip() {
const card = makeSelection("Card", Object.assign(document.createElement("div"), { id: "card" }));
const chip = makeSelection("Chip", Object.assign(document.createElement("div"), { id: "chip" }));
document.body.append(card.element, chip.element);
const harness = renderHarness(
{ activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
{ timelineElements: [timelineElement("card"), timelineElement("chip")] },
);
return { harness, card, chip };
}

/**
* A marquee builds the group correctly and then used to lose it: it announced only
* the primary to the timeline, the timeline is the source of truth for what is
Expand All @@ -116,24 +131,9 @@ function timelineElement(domId: string): TimelineElement {
*/
describe("useDomSelection marquee", () => {
it("announces every marquee'd element to the timeline, anchored on the primary", () => {
const first = document.createElement("div");
first.id = "card";
const second = document.createElement("div");
second.id = "chip";
document.body.append(first, second);
const harness = renderHarness(
{ activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
{ timelineElements: [timelineElement("card"), timelineElement("chip")] },
);
const { harness, card, chip } = renderCardAndChip();

act(() =>
harness
.current()
.applyMarqueeSelection(
[makeSelection("Card", first), makeSelection("Chip", second)],
false,
),
);
act(() => harness.current().applyMarqueeSelection([card, chip], false));

expect(harness.current().domEditGroupSelections).toHaveLength(2);
expect(harness.timeline.setTimelineSelectionSet).toHaveBeenCalledWith(
Expand Down Expand Up @@ -181,20 +181,10 @@ describe("useDomSelection marquee", () => {
*/
describe("useDomSelection additive", () => {
it("announces both members when a second element joins the selection", () => {
const first = document.createElement("div");
first.id = "card";
const second = document.createElement("div");
second.id = "chip";
document.body.append(first, second);
const harness = renderHarness(
{ activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
{ timelineElements: [timelineElement("card"), timelineElement("chip")] },
);
const { harness, card, chip } = renderCardAndChip();

act(() => harness.current().applyDomSelection(makeSelection("Card", first)));
act(() =>
harness.current().applyDomSelection(makeSelection("Chip", second), { additive: true }),
);
act(() => harness.current().applyDomSelection(card));
act(() => harness.current().applyDomSelection(chip, { additive: true }));

expect(harness.current().domEditGroupSelections).toHaveLength(2);
expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
Expand All @@ -205,6 +195,40 @@ describe("useDomSelection additive", () => {
});
harness.cleanup();
});

// The timeline syncs its set back onto the canvas, so a set left at two re-adds the member.
it.each([
["shift+click removes a member", "toggle"],
["a marquee catches one member of a live group", "marquee"],
] as const)("publishes the smaller set when %s", (_, shrink) => {
const { harness, card, chip } = renderCardAndChip();

act(() => harness.current().applyMarqueeSelection([card, chip], false));
act(() =>
shrink === "toggle"
? harness.current().applyDomSelection(chip, { additive: true })
: harness.current().applyMarqueeSelection([card], false),
);

expect(harness.current().domEditGroupSelections).toHaveLength(1);
expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(new Set(["card"]));
expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenLastCalledWith("card", {
preserveSet: true,
});
harness.cleanup();
});

it("keeps the live set when a single member is re-announced", () => {
const { harness, card, chip } = renderCardAndChip();

act(() => harness.current().applyMarqueeSelection([card, chip], false));
act(() => harness.current().applyDomSelection(chip));

expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
new Set(["card", "chip"]),
);
harness.cleanup();
});
});

describe("useDomSelection", () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/studio/src/hooks/useDomSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function useDomSelection({
// ── Callbacks ──

const announceTimelineSelection = useCallback(
(group: DomEditSelection[], primary: DomEditSelection | null) =>
(group: DomEditSelection[], primary: DomEditSelection | null, replaceSet?: boolean) =>
announceSelectionToTimeline(
{
timelineElements,
Expand All @@ -93,6 +93,7 @@ export function useDomSelection({
},
group,
primary,
replaceSet,
),
[
getTimelineSelectionSet,
Expand Down Expand Up @@ -179,7 +180,7 @@ export function useDomSelection({
setRightPanelTab("design");
}
}
announceTimelineSelection(nextGroup, nextSelection);
announceTimelineSelection(nextGroup, nextSelection, isAdditiveSelection);
return;
}

Expand Down Expand Up @@ -510,7 +511,7 @@ export function useDomSelection({
domEditGroupSelectionsRef.current = nextGroup;
setDomEditSelection(nextSelection);
setDomEditGroupSelections(nextGroup);
announceTimelineSelection(nextGroup, nextSelection);
announceTimelineSelection(nextGroup, nextSelection, true);
},
[applyDomSelection, announceTimelineSelection],
);
Expand Down
Loading