From a0f871f9fda3bea937126f36a1cae9b3a0503e4f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 23:24:43 -0400 Subject: [PATCH 1/2] fix(editor): a placed item stays selected through the click that follows its drop A surface `:click` is synthesized on pointer-up (use-node-events), so a placement that commits there (a floor drop whose ray lands on the site, a wall, an item host) still has the browser's own click to come. Since #1024 the editor is back in select mode by then, and the grid takes that click for one on empty ground: the new item is placed and immediately deselected, and its inspector never opens. The coordinator now eats that click when a non-repeating drop lands between a pointer-up and its click, as the press-drag release already did. Co-Authored-By: Claude Opus 5.5 --- .../tools/item/placement-history.test.tsx | 33 ++++++++++++++++ .../tools/item/use-placement-coordinator.tsx | 38 ++++++++++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/editor/src/components/tools/item/placement-history.test.tsx b/packages/editor/src/components/tools/item/placement-history.test.tsx index 2195e3fb7b..5df1b66e81 100644 --- a/packages/editor/src/components/tools/item/placement-history.test.tsx +++ b/packages/editor/src/components/tools/item/placement-history.test.tsx @@ -472,3 +472,36 @@ describe('item move history', () => { expect(history()).toEqual({ past: before.past, tracking: true, depth: 0 }) }) }) + +describe('item placement selection', () => { + // A surface `:click` is synthesized on pointer-up (use-node-events), and the browser fires its + // own click after it. By then the editor is back in select mode, where the grid takes that click + // for one on empty ground and drops the selection of the item just placed. + test('a drop on pointer-up selects the new item and eats the click that follows', async () => { + const renderer = await create() + try { + await carry(10, 14) + const dropOnRelease = () => + emitter.emit('node:click', { + node: { id: 'site_placement_history', type: 'site' }, + position: [1.4, 0, 0.5], + localPosition: [1.4, 0, 0.5], + nativeEvent: { nativeEvent: {} }, + stopPropagation() {}, + } as never) + window.addEventListener('pointerup', dropOnRelease) + await act(async () => { + window.dispatchEvent(new Event('pointerup')) + }) + window.removeEventListener('pointerup', dropOnRelease) + const placed = levelItems().find((node) => node.id !== item.id) + expect(placed).toBeDefined() + expect(useViewer.getState().selection.selectedIds).toEqual([placed!.id]) + const click = new Event('click', { cancelable: true }) + window.dispatchEvent(click) + expect(click.defaultPrevented).toBe(true) + } finally { + await renderer.unmount() + } + }) +}) diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 69e570fe3d..89102fc5bb 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -607,6 +607,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (mesh) reconcileDraftRaycast(mesh) } + // Eat the click the browser fires after a pointer-up that committed, so it + // neither commits a second time through a surface `:click` handler nor reaches + // the grid, in select mode by then, as a click on empty ground. + const swallowNextClick = () => { + const swallow = (e: Event) => { + e.stopPropagation() + e.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300) + } + // True between a press's pointer-up and the click the browser fires after it. + // Surface `:click` events are synthesized on pointer-up (use-node-events), so a + // commit they trigger still has that click to come; a `grid:click` commit is + // that click. + let releasing = false + const onPointerUpCapture = () => { + releasing = true + } + const onClickCapture = () => { + releasing = false + } + const finishCommittedPlacement = ( committedId: string | null, wasAdopted: boolean, @@ -629,6 +652,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // lands on the just-placed (now selected) node instead of a tool-less build // limbo. Repeat placements took the early return above and stay armed. useEditor.getState().setMode('select') + if (releasing) swallowNextClick() } const commitDraft = ( @@ -1054,16 +1078,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // replays it. Captured once at setup — a fresh coordinator mounts per move. const dragMode = useEditor.getState().placementDragMode let releaseCommit: (() => void) | null = null - // Eat the click the browser fires after pointer-up so the surface - // `:click` handlers don't commit a second time. - const swallowNextClick = () => { - const swallow = (e: Event) => { - e.stopPropagation() - e.preventDefault() - } - window.addEventListener('click', swallow, { capture: true, once: true }) - setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300) - } const onReleaseCommit = () => { gridDispatch.flush() if (!releaseCommit) return @@ -2797,11 +2811,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } emitter.on('node:click', commitFloorOnSurfaceClick as never) if (dragMode) window.addEventListener('pointerup', onReleaseCommit) + window.addEventListener('pointerup', onPointerUpCapture, true) + window.addEventListener('click', onClickCapture, true) return () => { tearingDown = true feedback.clear() if (dragMode) window.removeEventListener('pointerup', onReleaseCommit) + window.removeEventListener('pointerup', onPointerUpCapture, true) + window.removeEventListener('click', onClickCapture, true) unsubDraftWatch() useAlignmentGuides.getState().clear() // Clear every live preview channel before restoring or deleting the draft. From d960aa4ded6765c5c1f911a6d7b68fce433b4fdf Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 14 Oct 2026 09:53:13 -0400 Subject: [PATCH 2/2] test(editor): the placement test waits out the swallow window, and a later click is not eaten The 300 ms timer that unregisters the swallow fired after the test file ended, where window is gone, and failed the run between tests. Co-Authored-By: Claude Opus 5.5 --- .../src/components/tools/item/placement-history.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/editor/src/components/tools/item/placement-history.test.tsx b/packages/editor/src/components/tools/item/placement-history.test.tsx index 5df1b66e81..667b6818cf 100644 --- a/packages/editor/src/components/tools/item/placement-history.test.tsx +++ b/packages/editor/src/components/tools/item/placement-history.test.tsx @@ -500,6 +500,11 @@ describe('item placement selection', () => { const click = new Event('click', { cancelable: true }) window.dispatchEvent(click) expect(click.defaultPrevented).toBe(true) + // Only that one click: once the swallow window closes, a click reaches the editor again. + await new Promise((resolve) => setTimeout(resolve, 350)) + const next = new Event('click', { cancelable: true }) + window.dispatchEvent(next) + expect(next.defaultPrevented).toBe(false) } finally { await renderer.unmount() }