FloorplanRegistryLayer: strip drag-to-move, keep click-to-select
The drag-with-grab-cursor model was wrong design — 3D doesn't drag, it uses select → inspector "Move" button → click-to-place. Floor plan should match. Drag also had a coord-conversion bug (used outer <svg> CTM instead of the floorplanSceneRef <g>, so screen→meter conversion didn't account for the floor plan's pan/zoom/building- rotation transforms). Strips the drag pointerdown/move/up handlers, the imperative transform override, the temporal pause bracketing, and the global window listeners. Cursor goes back to 'pointer'. Only click-to- select remains. The right pattern (move via inspector / action menu + cursor-driven placement) needs: - Registry-aware FloorplanActionMenuLayer path - Generic movingNode handler in floor-plan-panel for any registered kind with capabilities.movable - Shared floorplanSceneRef for accurate coord conversion Both flagged in the plan as Phase 4 follow-on gaps with their acceptance criteria. 3D-realtime-sync-while-moving is documented as deferred (legacy doesn't do it for any kind either; design + ship in a dedicated PR later). 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
d10d7f8deb
commit
a3fb5bd622
@@ -9,7 +9,7 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,14 +18,22 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|||||||
* For every node in the active level whose definition exposes
|
* For every node in the active level whose definition exposes
|
||||||
* `def.floorplan`, builds a `GeometryContext`, calls the builder, and
|
* `def.floorplan`, builds a `GeometryContext`, calls the builder, and
|
||||||
* emits the resulting SVG via `<FloorplanGeometryRenderer>`. Each entry
|
* emits the resulting SVG via `<FloorplanGeometryRenderer>`. Each entry
|
||||||
* is wrapped in an interactive `<g>` that handles:
|
* is wrapped in an interactive `<g>` that handles **click → select**.
|
||||||
*
|
*
|
||||||
* - **Click → select**. Sets `useViewer.selection.selectedIds = [id]`.
|
* Move and delete happen via the same flow as 3D: select sets
|
||||||
* - **Drag → move**. Pure imperative translation via the wrapping `<g>`'s
|
* `useViewer.selection`, the `<ParametricInspector>` opens with Move /
|
||||||
* transform attribute during drag; one `updateNode(id, { position })`
|
* Delete buttons, and the user enters move mode from there. Floor-plan
|
||||||
* call on pointerup. Same pattern as `MoveRegistryNodeTool` (the
|
* cursor-driven placement for registry kinds (the "while in moving
|
||||||
* validated "smooth move" from Phase 2/3): no per-tick store update,
|
* state, click in floor plan to place") is a follow-up — see the
|
||||||
* no re-render storm, no zundo bloat. Only the dragged node mutates.
|
* plan's Phase 4 follow-on section.
|
||||||
|
*
|
||||||
|
* Drag-to-move was prototyped here briefly but removed: the grab cursor
|
||||||
|
* + drag interaction model is inconsistent with the rest of the editor
|
||||||
|
* (3D doesn't drag, it uses select → Move button → cursor-driven
|
||||||
|
* placement) and the SVG coord conversion needs to route through the
|
||||||
|
* floor-plan scene `<g>` (legacy `floorplanSceneRef`) to account for
|
||||||
|
* pan/zoom transforms. Both warrant a proper port via the action menu
|
||||||
|
* + movingNode flow, not an inline drag in this layer.
|
||||||
*
|
*
|
||||||
* Coexists with the legacy `floorplan-panel.tsx` inline rendering —
|
* Coexists with the legacy `floorplan-panel.tsx` inline rendering —
|
||||||
* unmigrated kinds keep their hand-written branches. As each kind ports
|
* unmigrated kinds keep their hand-written branches. As each kind ports
|
||||||
@@ -41,101 +49,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
|
||||||
// Drag state — tracks the active pointer drag across global pointermove
|
const handleSelect = useCallback(
|
||||||
// / pointerup so the pointer can leave the dragged element without
|
|
||||||
// breaking the gesture. Imperative DOM updates avoid React re-renders;
|
|
||||||
// store update happens once on commit.
|
|
||||||
const dragRef = useRef<{
|
|
||||||
id: AnyNodeId
|
|
||||||
pointerId: number
|
|
||||||
startSvgX: number
|
|
||||||
startSvgY: number
|
|
||||||
originalPosition: [number, number, number]
|
|
||||||
element: SVGGElement
|
|
||||||
moved: boolean
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
|
||||||
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
const node = useScene.getState().nodes[id]
|
|
||||||
if (!node || typeof (node as { position?: unknown }).position === 'undefined') return
|
|
||||||
const position = (node as unknown as { position: [number, number, number] }).position
|
|
||||||
if (!Array.isArray(position) || position.length < 3) return
|
|
||||||
|
|
||||||
const svg = event.currentTarget.ownerSVGElement
|
|
||||||
if (!svg) return
|
|
||||||
const pt = svgPoint(svg, event.clientX, event.clientY)
|
|
||||||
|
|
||||||
setSelection({ selectedIds: [id] })
|
setSelection({ selectedIds: [id] })
|
||||||
|
|
||||||
dragRef.current = {
|
|
||||||
id,
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
startSvgX: pt.x,
|
|
||||||
startSvgY: pt.y,
|
|
||||||
originalPosition: [position[0], position[1], position[2]],
|
|
||||||
element: event.currentTarget,
|
|
||||||
moved: false,
|
|
||||||
}
|
|
||||||
// Pause undo while we drag so the commit on pointerup lands as a
|
|
||||||
// single history step. Resume in the pointerup handler.
|
|
||||||
useScene.temporal.getState().pause()
|
|
||||||
},
|
},
|
||||||
[setSelection],
|
[setSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Global pointermove / pointerup so the drag survives the cursor
|
|
||||||
// leaving the entry's bounding box.
|
|
||||||
useEffect(() => {
|
|
||||||
const onMove = (event: PointerEvent) => {
|
|
||||||
const drag = dragRef.current
|
|
||||||
if (!drag || event.pointerId !== drag.pointerId) return
|
|
||||||
const svg = drag.element.ownerSVGElement
|
|
||||||
if (!svg) return
|
|
||||||
const pt = svgPoint(svg, event.clientX, event.clientY)
|
|
||||||
const dx = pt.x - drag.startSvgX
|
|
||||||
const dy = pt.y - drag.startSvgY
|
|
||||||
if (!drag.moved && (dx !== 0 || dy !== 0)) drag.moved = true
|
|
||||||
drag.element.setAttribute('transform', `translate(${dx} ${dy})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onUp = (event: PointerEvent) => {
|
|
||||||
const drag = dragRef.current
|
|
||||||
if (!drag || event.pointerId !== drag.pointerId) return
|
|
||||||
|
|
||||||
// Clear the imperative override before committing; the store update
|
|
||||||
// will re-render the entry with the new position baked in via the
|
|
||||||
// builder, so the temporary transform is no longer needed.
|
|
||||||
drag.element.removeAttribute('transform')
|
|
||||||
|
|
||||||
if (drag.moved) {
|
|
||||||
const svg = drag.element.ownerSVGElement
|
|
||||||
if (svg) {
|
|
||||||
const pt = svgPoint(svg, event.clientX, event.clientY)
|
|
||||||
const dx = pt.x - drag.startSvgX
|
|
||||||
const dy = pt.y - drag.startSvgY
|
|
||||||
const [ox, oy, oz] = drag.originalPosition
|
|
||||||
useScene
|
|
||||||
.getState()
|
|
||||||
.updateNode(drag.id, { position: [ox + dx, oy, oz + dy] } as Partial<AnyNode>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
dragRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('pointermove', onMove)
|
|
||||||
window.addEventListener('pointerup', onUp)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('pointermove', onMove)
|
|
||||||
window.removeEventListener('pointerup', onUp)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const entries = useMemo(() => {
|
const entries = useMemo(() => {
|
||||||
if (!levelId) return []
|
if (!levelId) return []
|
||||||
const out: {
|
const out: {
|
||||||
@@ -180,8 +102,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
}
|
}
|
||||||
data-node-id={id}
|
data-node-id={id}
|
||||||
key={id}
|
key={id}
|
||||||
onPointerDown={(e) => handlePointerDown(id, e)}
|
onPointerDown={(e) => handleSelect(id, e)}
|
||||||
style={{ cursor: 'grab' }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<FloorplanGeometryRenderer geometry={geometry} />
|
<FloorplanGeometryRenderer geometry={geometry} />
|
||||||
{isSelected && <SelectionOutline geometry={geometry} />}
|
{isSelected && <SelectionOutline geometry={geometry} />}
|
||||||
@@ -208,16 +130,6 @@ function withSelectionStyle(g: FloorplanGeometry): FloorplanGeometry {
|
|||||||
return { ...g, ...accent }
|
return { ...g, ...accent }
|
||||||
}
|
}
|
||||||
|
|
||||||
function svgPoint(svg: SVGSVGElement, clientX: number, clientY: number): { x: number; y: number } {
|
|
||||||
const pt = svg.createSVGPoint()
|
|
||||||
pt.x = clientX
|
|
||||||
pt.y = clientY
|
|
||||||
const ctm = svg.getScreenCTM()
|
|
||||||
if (!ctm) return { x: 0, y: 0 }
|
|
||||||
const transformed = pt.matrixTransform(ctm.inverse())
|
|
||||||
return { x: transformed.x, y: transformed.y }
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContext(node: AnyNode, nodes: Record<string, AnyNode>): GeometryContext {
|
function buildContext(node: AnyNode, nodes: Record<string, AnyNode>): GeometryContext {
|
||||||
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user