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:
co-authored by
Claude Opus 4.7
parent
a3fb5bd622
commit
773b58ccd4
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user