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
32 changes: 32 additions & 0 deletions .changeset/canvas-click-selects-again.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/ui": patch
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/eslint-plugin": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/builder": patch
"@nextlyhq/module-specifiers": patch
---

Clicking a block on the page-builder canvas selects it again. A drag took control of the pointer
as soon as the mouse went down, which made the browser report every click as landing on the canvas
background rather than on a block — so clicking a block cleared the selection instead of setting
it. The canvas now takes control only once a drag has actually started.
44 changes: 43 additions & 1 deletion packages/builder/src/canvas-drag.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,22 @@ import { registrySlotSource } from "./inserter";
afterEach(() => {
cleanup();
clearBlocks();
captured = [];
});

/** Pointer ids the canvas has taken capture of, newest last. */
let captured: number[] = [];

beforeAll(() => {
// Absent from jsdom entirely. Without them the first pointerdown throws and
// every case below fails on the harness rather than on the behaviour.
const element = window.Element.prototype as unknown as Record<
string,
unknown
>;
element.setPointerCapture = function setPointerCapture(): void {};
element.setPointerCapture = function setPointerCapture(id: number): void {
captured.push(id);
};
element.releasePointerCapture = function releasePointerCapture(): void {};
element.hasPointerCapture = function hasPointerCapture(): boolean {
return true;
Expand Down Expand Up @@ -205,6 +211,42 @@ describe("useCanvasDrag", () => {
expect(editorRef?.undoDepth).toBe(before);
});

it("does not capture the pointer for a press that stays a click", () => {
/*
* The mechanism behind a regression that made every canvas click CLEAR the
* selection instead of setting it.
*
* Capture retargets later pointer events to the capturing element, and the
* browser derives a click's target from where the press and the release
* landed — so capturing on `pointerdown` made every click report the canvas
* ROOT as its target, and the canvas read "no block above this target" as a
* click on the background.
*
* jsdom implements no capture retargeting and synthesises no click from a
* press, so the SYMPTOM cannot be reproduced here. When capture is taken is
* observable, and it is what the defect actually was.
*/
const { container, root } = renderThree();

press(root, blockElement(container, "a"), 200, 50);
moveTo(root, 201, 51);
release(root, 201, 51);

expect(captured).toEqual([]);
});

it("captures the pointer once the drag activates", () => {
// The positive control, and the reason capture exists at all: a drag may
// leave the canvas and has to keep receiving moves. Without this, "never
// captures" would also pass on a canvas that never captures.
const { container, root } = renderThree();

press(root, blockElement(container, "a"), 200, 50);
moveTo(root, 200, 250);

expect(captured).toEqual([1]);
});

it("shows an indicator once the pointer has travelled far enough", () => {
const { container, root } = renderThree();

Expand Down
24 changes: 20 additions & 4 deletions packages/builder/src/canvas-drag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,7 @@ export function useCanvasDrag({
switchState: NO_TARGET,
targets: new Map(),
};
// Capture on the ROOT, so a pointer that leaves the canvas keeps
// delivering. Without it a drag that wanders outside simply stops
// reporting, and the gesture ends wherever the pointer happened to exit.
root.setPointerCapture(event.pointerId);
// NOT captured here. See the activation branch in `onPointerMove`.
},
[]
);
Expand All @@ -257,6 +254,25 @@ export function useCanvasDrag({
);
if (travelled < activationPx) return;
drag.active = true;
/*
* Capture the pointer HERE rather than on the press, so that a press
* which stays a click never captures at all.
*
* Capture retargets every later pointer event to the capturing element,
* and the browser derives a `click`'s target from where the press and
* the release landed — so capturing on `pointerdown` makes every click
* on the canvas report the CANVAS ROOT as its target. The canvas
* resolves a click by walking up from the target to the nearest block,
* finds none above the root, and reads that as "the author clicked the
* background": selecting a block by clicking it cleared the selection
* instead.
*
* A drag needs capture because it may leave the canvas and must keep
* receiving moves. A click does not, and until the pointer has travelled
* far enough there is no way to tell which one this is — so the capture
* waits for the answer.
*/
event.currentTarget.setPointerCapture(event.pointerId);
// Selecting on activation rather than on press: a press that turns out
// to be a click is handled by the canvas's own click handler, and
// selecting here as well would run the same decision twice.
Expand Down
Loading