From 773b58ccd4cc029866bc485b79666c38ba231aca Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 11:18:45 -0400 Subject: [PATCH] Floor-plan: registry-driven action menu + cursor-driven move overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). - Reads useViewer.selection — when a registered kind is selected and we're not in a move state, queries the rendered [data-node-id] for its bounding rect (polled via rAF for pan/zoom/move reactivity). - Portals an HTML overlay above the bounding box with the existing . 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 per kind, but driven by registry data. - 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 '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 . 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 so the overlay can find the scene without sharing a ref. - mounted alongside the legacy action menu layer. - 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) --- .../floorplan-registry-action-menu.tsx | 125 ++++++++++++++ .../floorplan-registry-move-overlay.tsx | 152 ++++++++++++++++++ .../renderers/floorplan-registry-layer.tsx | 9 ++ .../src/components/editor/floorplan-panel.tsx | 12 ++ 4 files changed, 298 insertions(+) create mode 100644 packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx create mode 100644 packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx new file mode 100644 index 00000000..214e3828 --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -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]` 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 `` 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) + : {} + 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( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
, + document.body, + ) +} diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx new file mode 100644 index 00000000..45b3fee5 --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -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]` `` (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 ``'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, + ) + // Clear isNew so duplicated nodes don't try to re-place themselves. + const meta = (movingNode as unknown as { metadata?: Record }).metadata + if (meta?.isNew) { + useScene.getState().updateNode( + movingNode.id as AnyNodeId, + { + metadata: { ...meta, isNew: false }, + } as Partial, + ) + } + 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: (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( + + + + + , + scene as unknown as Element, + ) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 8d7709e1..e531873c 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -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) => { + 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' }} > diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index b2bc07e0..2cb66c2a 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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. */} + {referenceScaleDraft && (
@@ -17445,6 +17451,7 @@ export function FloorplanPanel() { /> + {/* Cursor-driven placement ghost for movingNode when the + active kind is registry-driven. Renders via a portal + into the floor-plan scene (the data-floorplan-scene + attribute below); see floorplan-registry-move-overlay.tsx. */} +