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 viewportHostRef = useRef<HTMLDivElement>(null)
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const floorplanSceneRef = useRef<SVGGElement>(null)
|
const floorplanSceneRef = useRef<SVGGElement>(null)
|
||||||
@@ -10135,12 +10145,21 @@ export function FloorplanPanel() {
|
|||||||
only action menu the floor plan mounts. */}
|
only action menu the floor plan mounts. */}
|
||||||
<FloorplanRegistryActionMenu />
|
<FloorplanRegistryActionMenu />
|
||||||
|
|
||||||
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) && (
|
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
|
||||||
<FloorplanCompassButton
|
(compassHost ? (
|
||||||
northRotationDeg={-floorplanUserRotationDeg}
|
createPortal(
|
||||||
onAlignNorth={alignFloorplanViewToNorth}
|
<FloorplanCompassButton
|
||||||
/>
|
northRotationDeg={-floorplanUserRotationDeg}
|
||||||
)}
|
onAlignNorth={alignFloorplanViewToNorth}
|
||||||
|
/>,
|
||||||
|
compassHost,
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<FloorplanCompassButton
|
||||||
|
northRotationDeg={-floorplanUserRotationDeg}
|
||||||
|
onAlignNorth={alignFloorplanViewToNorth}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{referenceScaleDraft && (
|
{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">
|
<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)
|
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 viewer3dRef = useRef<HTMLDivElement>(null)
|
||||||
const isResizingFloorplan = useRef(false)
|
const isResizingFloorplan = useRef(false)
|
||||||
|
|
||||||
@@ -861,7 +868,9 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
<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 */}
|
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
||||||
<div
|
<div
|
||||||
className="relative h-full flex-shrink-0"
|
className="relative h-full flex-shrink-0"
|
||||||
@@ -871,7 +880,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full w-full overflow-hidden">
|
<div className="h-full w-full overflow-hidden">
|
||||||
<FloorplanPanel />
|
<FloorplanPanel compassHost={viewerAreaEl} />
|
||||||
</div>
|
</div>
|
||||||
{viewMode === 'split' && (
|
{viewMode === 'split' && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -360,6 +360,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
* AND scene updated) — never the original.
|
* AND scene updated) — never the original.
|
||||||
*/
|
*/
|
||||||
const commitAtCursor = (event: ClickTriggerEvent) => {
|
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 —
|
// 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
|
// it's the stray trailing click of whatever armed this move, not a
|
||||||
// deliberate drop. Prevents preset re-arm from double-placing.
|
// deliberate drop. Prevents preset re-arm from double-placing.
|
||||||
|
|||||||
Reference in New Issue
Block a user