feat: drop new nodes at centre of visible canvas

New nodes previously landed at a fixed canvas origin, often off-screen,
forcing the user to drag them into view. Add a projector (registered by
CanvasContainer inside ReactFlowProvider) that maps the visible-canvas
centre into flow coordinates, and use it for add-node, group rect, text,
Zigbee/Z-Wave imports, and pending-device approval (single + bulk).

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-28 00:27:18 +02:00
parent e811d83ceb
commit 2a8c9d618b
5 changed files with 101 additions and 9 deletions
@@ -23,6 +23,7 @@ import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
import { AlignmentGuides } from './AlignmentGuides'
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
import { setViewportCenterProjector } from '@/utils/viewportCenter'
import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps {
@@ -52,6 +53,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
cursorRef.current = { x: e.clientX, y: e.clientY }
}, [])
// Expose the visible-canvas centre (in flow coords) to add-node handlers that
// live outside ReactFlowProvider, so new nodes land where the user is looking.
const wrapperRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setViewportCenterProjector(() => {
const rect = wrapperRef.current?.getBoundingClientRect()
const screen = rect
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
return screenToFlowPosition(screen)
})
return () => setViewportCenterProjector(null)
}, [screenToFlowPosition])
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
// can project the cursor / viewport center into flow coordinates.
useEffect(() => {
@@ -146,7 +161,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
<div ref={wrapperRef} className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
<ReactFlow
nodes={visibleNodes}
edges={visibleEdges}
@@ -14,6 +14,7 @@ import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { getCenteredPosition } from '@/utils/viewportCenter'
interface PendingDevicesModalProps {
open: boolean
@@ -294,7 +295,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
addNode({
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
position: getCenteredPosition(),
data: { ...nodeData, status: wireless ? ('online' as const) : ('unknown' as const) },
})
injectAutoEdges(res.data.edges)
@@ -336,6 +337,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id))
const cols = Math.min(4, approvedDevices.length)
const rows = Math.ceil(approvedDevices.length / 4)
const origin = getCenteredPosition(cols * 160, rows * 100)
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
@@ -345,7 +349,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
addNode({
id: nodeId,
type,
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
position: { x: origin.x + (i % 4) * 160, y: origin.y + Math.floor(i / 4) * 100 },
data: {
label: deviceLabel(d),
type,