editor: fix preset double-commit ghosts, show compass in all view modes (#393)

* fix: guard preset placement against double commit from duplicate click events

A single physical click reaches commitAtCursor twice: node clicks are
synthesized on pointerup (use-node-events) while grid:click rides the
native click event from a canvas DOM listener (use-grid-events) that
deliberately ignores stopPropagation. The second pass found the fresh
draft already deleted and took the orphan re-create path, minting a
hidden ghost copy, replaying the placement SFX, and abandoning the
re-armed clone as a second ghost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: show the compass in all view modes via the viewer-area container

The floorplan compass now portals onto the always-visible viewer area
instead of living inside the (display:none in 3D) floorplan pane. The
2D/3D navigation poses already sync through navigationSyncPose, so the
needle and align-to-north stay correct in 2d, 3d, and split alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-10 09:05:00 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 11135b608b
commit 7539387ff5
3 changed files with 46 additions and 9 deletions
@@ -4519,7 +4519,17 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
)
})
export function FloorplanPanel() {
export function FloorplanPanel({
/**
* Element to portal the compass button into. The 2D/3D navigation poses stay
* in sync (`navigationSyncPose`), so hosting the compass on the always-visible
* viewer-area container keeps it correct needle and align-to-north alike
* in 2d, 3d, and split modes, while this panel itself may be display:none.
*/
compassHost,
}: {
compassHost?: HTMLElement | null
}) {
const viewportHostRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const floorplanSceneRef = useRef<SVGGElement>(null)
@@ -10135,12 +10145,21 @@ export function FloorplanPanel() {
only action menu the floor plan mounts. */}
<FloorplanRegistryActionMenu />
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) && (
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
(compassHost ? (
createPortal(
<FloorplanCompassButton
northRotationDeg={-floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>,
compassHost,
)
) : (
<FloorplanCompassButton
northRotationDeg={-floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>
)}
))}
{referenceScaleDraft && (
<div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm">
@@ -816,6 +816,13 @@ const ViewerCanvas = memo(function ViewerCanvas({
)
const viewerAreaRef = useRef<HTMLDivElement>(null)
// State mirror of `viewerAreaRef` so the floorplan compass portal re-renders
// once the container exists (a plain ref mutation wouldn't trigger it).
const [viewerAreaEl, setViewerAreaEl] = useState<HTMLDivElement | null>(null)
const setViewerAreaNode = useCallback((el: HTMLDivElement | null) => {
viewerAreaRef.current = el
setViewerAreaEl(el)
}, [])
const viewer3dRef = useRef<HTMLDivElement>(null)
const isResizingFloorplan = useRef(false)
@@ -861,7 +868,9 @@ const ViewerCanvas = memo(function ViewerCanvas({
return (
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
<div className="flex h-full" ref={viewerAreaRef}>
{/* `relative` so the floorplan compass (portaled here to stay visible in
2d / 3d / split alike) can anchor to this container's bottom-left. */}
<div className="relative flex h-full" ref={setViewerAreaNode}>
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
<div
className="relative h-full flex-shrink-0"
@@ -871,7 +880,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
}}
>
<div className="h-full w-full overflow-hidden">
<FloorplanPanel />
<FloorplanPanel compassHost={viewerAreaEl} />
</div>
{viewMode === 'split' && (
<div
@@ -360,6 +360,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
* AND scene updated) — never the original.
*/
const commitAtCursor = (event: ClickTriggerEvent) => {
// One physical click can reach here twice: node clicks (`slab:click`,
// `item:click`, …) are synthesized on *pointerup* (`use-node-events`),
// while `grid:click` rides the browser's native *click* event from a
// canvas DOM listener (`use-grid-events`) that deliberately ignores
// stopPropagation — and this effect stays subscribed until React
// re-renders after `exitMoveMode`. Without this guard the second pass
// finds the fresh draft already deleted and takes the orphan re-create
// path below, minting a hidden ghost copy and replaying the SFX.
if (committed) return
// Ignore a commit that fires before the cursor has moved into place —
// it's the stray trailing click of whatever armed this move, not a
// deliberate drop. Prevents preset re-arm from double-placing.