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:
co-authored by
Claude Fable 5
parent
11135b608b
commit
7539387ff5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user