Floor-plan: registry-driven action menu + cursor-driven move overlay

Two new files in packages/editor/src/components/editor-2d/ keep the
work out of the 18k-line floorplan-panel.tsx monolith. The panel
itself gets only four tiny additions (two imports, two component
mounts, one data attribute).

<FloorplanRegistryActionMenu>
 - Reads useViewer.selection — when a registered kind is selected and
   we're not in a move state, queries the rendered [data-node-id] <g>
   for its bounding rect (polled via rAF for pan/zoom/move reactivity).
 - Portals an HTML overlay above the bounding box with the existing
   <NodeActionMenu>. Buttons gated by def.capabilities:
     * Move → setMovingNode(node)
     * Duplicate → structuredClone + schema.parse + createNode + set
       movingNode (placement cursor) — matches 3D duplicate UX.
     * Delete → deleteNode(id) + clear selection.
 - Same visual styling as the legacy <FloorplanActionMenuLayer> per
   kind, but driven by registry data.

<FloorplanRegistryMoveOverlay>
 - Activates when useEditor.movingNode is a kind with def.floorplan.
 - Listens on window for pointermove (to track cursor in floor plan
   meters via the scene <g>'s getScreenCTM — matches the legacy
   getSvgPointFromClientPoint coordinate path so cursor → meters
   accounts for pan/zoom/building rotation).
 - Renders a 50%-opacity ghost via portal into the floor-plan scene
   <g>. Builder reused from def.floorplan — no per-kind ghost code.
 - Click commits via updateNode({ position: [cx, oldY, cz] }) and
   clears movingNode. Clears `isNew` metadata on duplicates so they
   don't loop. Esc cancels.

floorplan-panel.tsx touches:
 - Two imports (action menu + move overlay).
 - data-floorplan-scene="" attribute on the floorplanSceneRef <g>
   so the overlay can find the scene without sharing a ref.
 - <FloorplanRegistryActionMenu /> mounted alongside the legacy
   action menu layer.
 - <FloorplanRegistryMoveOverlay /> mounted inside the SVG tree
   alongside the registry render layer.

FloorplanRegistryLayer: also stopPropagation on click events so the
outer SVG's onClick={handleBackgroundClick} doesn't deselect right
after our pointerDown sets selection. Fixes "click-in-2D doesn't
select" bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 11:18:45 -04:00
co-authored by Claude Opus 4.7
parent a3fb5bd622
commit 773b58ccd4
4 changed files with 298 additions and 0 deletions
@@ -0,0 +1,125 @@
'use client'
import { type AnyNode, type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from '../editor/node-action-menu'
/**
* Floating Move / Duplicate / Delete buttons that appear above the
* selected registered kind in the floor plan view.
*
* Lives outside the floorplan-panel.tsx monolith. Reads selection from
* `useViewer`, finds the rendered `[data-node-id]` <g> inside the floor
* plan scene, polls its bounding rect via rAF while open, and portals
* an HTML overlay positioned at the top of the bounding box.
*
* Buttons:
* - Move: sets `movingNode` in useEditor. The `<FloorplanRegistryMove
* Overlay>` component picks that up and lets the user click in the
* floor plan to commit the new position.
* - Duplicate: deep-clones the node, marks it new, sets it as the
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
* `relations.cascadeDelete` if declared on the def.
*
* Hidden while in a move state (so we don't show buttons over a ghost).
*/
export function FloorplanRegistryActionMenu() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
// Only show for registered kinds (skip legacy kinds — they have their
// own FloorplanActionMenuLayer entries).
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def
const isVisible = isRegistryKind && !movingNode
useEffect(() => {
if (!(isVisible && selectedId)) {
setPosition(null)
return
}
let raf = 0
const tick = () => {
const el = document.querySelector(
`[data-floorplan-scene] [data-node-id="${selectedId}"]`,
) as SVGGElement | null
if (el) {
const rect = el.getBoundingClientRect()
// Position centered horizontally, ~12px above the bounding box.
setPosition({ left: rect.left + rect.width / 2, top: rect.top - 12 })
} else {
setPosition(null)
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [isVisible, selectedId])
if (!(isVisible && selectedId && position && def)) return null
const node = useScene.getState().nodes[selectedId]
if (!node) return null
const canMove = !!def.capabilities.movable
const canDuplicate = def.capabilities.duplicable !== false
const canDelete = def.capabilities.deletable !== false
const handleMove = () => {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never)
// Selection stays — the move overlay reads movingNode, not selection.
}
const handleDuplicate = () => {
if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
delete (cloned as { id?: AnyNodeId }).id
const prevMeta =
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
? (cloned.metadata as Record<string, unknown>)
: {}
cloned.metadata = { ...prevMeta, isNew: true }
const parsed = def.schema.parse(cloned) as AnyNode
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
setMovingNode(parsed as never)
useScene.temporal.getState().resume()
}
const handleDelete = () => {
sfxEmitter.emit('sfx:item-delete')
useScene.getState().deleteNode(selectedId)
useViewer.getState().setSelection({ selectedIds: [] })
}
return createPortal(
<div
className="pointer-events-none fixed z-30"
style={{
left: position.left,
top: position.top,
transform: 'translate(-50%, -100%)',
}}
>
<NodeActionMenu
onDelete={canDelete ? handleDelete : undefined}
onDuplicate={canDuplicate ? handleDuplicate : undefined}
onMove={canMove ? handleMove : undefined}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>,
document.body,
)
}
@@ -0,0 +1,152 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type FloorplanGeometry,
type GeometryContext,
nodeRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import useEditor from '../../store/use-editor'
import { FloorplanGeometryRenderer } from './renderers/floorplan-geometry-renderer'
/**
* Cursor-driven placement for registered kinds in the floor plan.
*
* Activates when `useEditor.movingNode` is set to a node whose kind is
* registered with `def.floorplan`. Tracks the pointer on the floor plan
* SVG via the `[data-floorplan-scene]` `<g>` (set by floorplan-panel.tsx
* via a one-line attribute) and renders a translucent ghost at the
* cursor position. Click commits via `updateNode`; Esc cancels.
*
* Coordinate conversion routes through the scene `<g>`'s `getScreenCTM`,
* matching the legacy `getSvgPointFromClientPoint` so cursor → meters
* accounts for the floor plan's pan / zoom / building rotation.
*
* Lives outside the floorplan-panel.tsx monolith. Mounts once globally
* at the panel root; renders nothing unless the active movingNode is a
* registered kind.
*
* Wired to wall / item / etc. as those kinds migrate — same shape for
* every kind that supplies `def.floorplan`.
*/
export function FloorplanRegistryMoveOverlay() {
const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null)
const def = movingNode ? nodeRegistry.get(movingNode.type) : null
const builder = def?.floorplan
const isActive = !!movingNode && !!builder
useEffect(() => {
if (!isActive) {
setCursor(null)
return
}
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
if (!scene) return
const toMeters = (clientX: number, clientY: number): { x: number; y: number } | null => {
const svg = scene.ownerSVGElement
if (!svg) return null
const ctm = scene.getScreenCTM()
if (!ctm) return null
const pt = svg.createSVGPoint()
pt.x = clientX
pt.y = clientY
const m = pt.matrixTransform(ctm.inverse())
return { x: m.x, y: m.y }
}
const onMove = (event: PointerEvent) => {
const m = toMeters(event.clientX, event.clientY)
if (m) setCursor(m)
}
const onClick = (event: MouseEvent) => {
const m = toMeters(event.clientX, event.clientY)
if (!(m && movingNode)) return
// Only commit when click happens inside the floor plan SVG.
const path = event.composedPath()
if (!path.some((el) => el === scene)) return
event.stopPropagation()
const node = useScene.getState().nodes[movingNode.id as AnyNodeId]
// Treat the existing position's Y as preserved (floor plan only
// moves on the X-Z plane). For new (`isNew` metadata) nodes from
// duplicate, this is still the cloned source's height — correct.
const oldPos = ((node ?? movingNode) as unknown as { position?: [number, number, number] })
.position ?? [0, 0, 0]
useScene.getState().updateNode(
movingNode.id as AnyNodeId,
{
position: [m.x, oldPos[1], m.y],
} as Partial<AnyNode>,
)
// Clear isNew so duplicated nodes don't try to re-place themselves.
const meta = (movingNode as unknown as { metadata?: Record<string, unknown> }).metadata
if (meta?.isNew) {
useScene.getState().updateNode(
movingNode.id as AnyNodeId,
{
metadata: { ...meta, isNew: false },
} as Partial<AnyNode>,
)
}
setMovingNode(null)
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMovingNode(null)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('click', onClick, { capture: true })
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('click', onClick, { capture: true } as EventListenerOptions)
window.removeEventListener('keydown', onKey)
}
}, [isActive, movingNode, setMovingNode])
if (!(isActive && cursor && movingNode && builder)) return null
// Build the ghost at the cursor position. Clone the node and swap
// position to the cursor; pass to the kind's builder. Same path the
// FloorplanRegistryLayer uses for the static render — visual identity
// matches automatically.
const nodes = useScene.getState().nodes
const ghostNode = {
...(movingNode as unknown as { position: [number, number, number] }),
position: [cursor.x, 0, cursor.y],
} as unknown as AnyNode
const ctx: GeometryContext = {
resolve: <N = AnyNode>(id: AnyNodeId) => nodes[id] as N | undefined,
children: [],
siblings: [],
parent: null,
}
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
ghostNode,
ctx,
)
if (!geometry) return null
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
if (!scene) return null
return createPortal(
<g pointerEvents="none">
<g opacity={0.5}>
<FloorplanGeometryRenderer geometry={geometry} />
</g>
</g>,
scene as unknown as Element,
)
}
@@ -58,6 +58,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
[setSelection],
)
// Outer SVG has `onClick={handleBackgroundClick}` which can deselect on
// empty-area clicks. stopPropagation on onPointerDown doesn't block the
// synthesized click that follows pointer-up. Stopping the click too
// keeps the selection set by `handleSelect`.
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
event.stopPropagation()
}, [])
const entries = useMemo(() => {
if (!levelId) return []
const out: {
@@ -102,6 +110,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
}
data-node-id={id}
key={id}
onClick={handleClickStop}
onPointerDown={(e) => handleSelect(id, e)}
style={{ cursor: 'pointer' }}
>
@@ -84,6 +84,8 @@ import {
FloorplanDuplicateHotkey,
FloorplanSiteKeyHandler,
} from '../editor-2d/floorplan-hotkey-handlers'
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
import {
@@ -17272,6 +17274,10 @@ export function FloorplanPanel() {
onMove: handleSelectedWallMove,
}}
/>
{/* Floating Move / Duplicate / Delete buttons for registered
kinds (shelf today; others as Phase 5 ports add def.floorplan).
Renders nothing for legacy kinds they keep the layer above. */}
<FloorplanRegistryActionMenu />
{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">
@@ -17445,6 +17451,7 @@ export function FloorplanPanel() {
/>
<g
data-floorplan-scene=""
ref={floorplanSceneRef}
transform={
floorplanSceneRotationDeg !== 0 ? `rotate(${floorplanSceneRotationDeg})` : undefined
@@ -17680,6 +17687,11 @@ export function FloorplanPanel() {
today) overlay on top until their inline equivalent is
removed in their Phase 5 migration PR. */}
<FloorplanRegistryLayer />
{/* Cursor-driven placement ghost for movingNode when the
active kind is registry-driven. Renders via a portal
into the floor-plan scene <g> (the data-floorplan-scene
attribute below); see floorplan-registry-move-overlay.tsx. */}
<FloorplanRegistryMoveOverlay />
<FloorplanMarqueeLayer
bounds={visibleSvgMarqueeBounds}