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
+13 -6
View File
@@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid'
import { getCenteredPosition } from '@/utils/viewportCenter'
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { copyToClipboard } from '@/utils/clipboard'
@@ -259,7 +260,7 @@ export default function App() {
// extent, so we don't set them here.
const position = nestInParent && parentNode
? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 }
: { x: 300, y: 300 }
: getCenteredPosition(isContainerNode ? 300 : 0, isContainerNode ? 200 : 0)
const newNode: Node<NodeData> = {
id,
@@ -278,7 +279,7 @@ export default function App() {
const newNode: Node<NodeData> = {
id,
type: 'groupRect',
position: { x: 200, y: 200 },
position: getCenteredPosition(360, 240),
data: {
label: data.label,
type: 'groupRect',
@@ -337,7 +338,7 @@ export default function App() {
// node fields; text_content is not in the schema and was lost on reload.
// TextNode and the edit modal both already fall back to label.
type: 'text',
position: { x: 250, y: 250 },
position: getCenteredPosition(200, 60),
data: {
label: data.text,
type: 'text',
@@ -502,15 +503,18 @@ export default function App() {
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
snapshotHistory()
// Place nodes in a grid starting at x=500, y=100
// Place nodes in a grid centred on the visible canvas.
const COLS = 4
const SPACING_X = 170
const SPACING_Y = 100
const cols = Math.min(COLS, zigbeeNodes.length)
const rows = Math.ceil(zigbeeNodes.length / COLS)
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
zigbeeNodes.forEach((zn, i) => {
const id = zn.id
const col = i % COLS
const row = Math.floor(i / COLS)
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y }
const newNode: import('@xyflow/react').Node<NodeData> = {
id,
type: zn.type,
@@ -553,11 +557,14 @@ export default function App() {
const COLS = 4
const SPACING_X = 170
const SPACING_Y = 100
const cols = Math.min(COLS, zwaveNodes.length)
const rows = Math.ceil(zwaveNodes.length / COLS)
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
zwaveNodes.forEach((zn, i) => {
const id = zn.id
const col = i % COLS
const row = Math.floor(i / COLS)
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y }
const newNode: import('@xyflow/react').Node<NodeData> = {
id,
type: zn.type,
@@ -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,
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
getCenteredPosition,
getViewportCenter,
setViewportCenterProjector,
} from '../viewportCenter'
afterEach(() => setViewportCenterProjector(null))
describe('viewportCenter', () => {
it('falls back to a fixed point when no projector is registered', () => {
expect(getViewportCenter()).toEqual({ x: 300, y: 300 })
expect(getCenteredPosition()).toEqual({ x: 300, y: 300 })
})
it('returns the registered projector value as the centre', () => {
setViewportCenterProjector(() => ({ x: 1000, y: 500 }))
expect(getViewportCenter()).toEqual({ x: 1000, y: 500 })
})
it('offsets by half the box size so the box is centred', () => {
setViewportCenterProjector(() => ({ x: 1000, y: 500 }))
expect(getCenteredPosition(360, 240)).toEqual({ x: 820, y: 380 })
})
it('treats a zero size as the raw centre point', () => {
setViewportCenterProjector(() => ({ x: 42, y: 7 }))
expect(getCenteredPosition(0, 0)).toEqual({ x: 42, y: 7 })
})
it('clears the projector when set to null', () => {
setViewportCenterProjector(() => ({ x: 1, y: 2 }))
setViewportCenterProjector(null)
expect(getViewportCenter()).toEqual({ x: 300, y: 300 })
})
})
+30
View File
@@ -0,0 +1,30 @@
import type { XYPosition } from '@xyflow/react'
// Projects the centre of the visible canvas into flow-space coordinates.
// Registered by CanvasContainer (inside ReactFlowProvider) so that add-node
// handlers living outside the provider — App handlers, modals — can still drop
// new nodes where the user is actually looking instead of at a fixed canvas
// origin.
type Projector = () => XYPosition
let projector: Projector | null = null
// Used before any canvas is mounted (tests, first render). Matches the old
// hard-coded add position so behaviour degrades gracefully.
const FALLBACK: XYPosition = { x: 300, y: 300 }
export function setViewportCenterProjector(fn: Projector | null): void {
projector = fn
}
// Flow-space coordinate at the centre of the visible canvas.
export function getViewportCenter(): XYPosition {
return projector ? projector() : { ...FALLBACK }
}
// Flow-space top-left position so that a box of `width`×`height` ends up
// centred on screen. With no size it returns the raw centre point.
export function getCenteredPosition(width = 0, height = 0): XYPosition {
const c = getViewportCenter()
return { x: c.x - width / 2, y: c.y - height / 2 }
}