diff --git a/.changeset/canvas-click-selects-again.md b/.changeset/canvas-click-selects-again.md new file mode 100644 index 0000000000..f51cd5de81 --- /dev/null +++ b/.changeset/canvas-click-selects-again.md @@ -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. diff --git a/packages/builder/src/canvas-drag.test.tsx b/packages/builder/src/canvas-drag.test.tsx index bfe87f17fb..34d2a5090c 100644 --- a/packages/builder/src/canvas-drag.test.tsx +++ b/packages/builder/src/canvas-drag.test.tsx @@ -37,8 +37,12 @@ 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. @@ -46,7 +50,9 @@ beforeAll(() => { 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; @@ -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(); diff --git a/packages/builder/src/canvas-drag.tsx b/packages/builder/src/canvas-drag.tsx index 28bf8a6443..f9ee306da0 100644 --- a/packages/builder/src/canvas-drag.tsx +++ b/packages/builder/src/canvas-drag.tsx @@ -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`. }, [] ); @@ -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.