Merge pull request #234 from Pouzor/feat/center-new-nodes-on-screen

feat: drop new nodes at centre of visible canvas
This commit is contained in:
Rémy
2026-06-28 00:37:51 +02:00
committed by GitHub
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 { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { getCenteredPosition } from '@/utils/viewportCenter'
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown' import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { copyToClipboard } from '@/utils/clipboard' import { copyToClipboard } from '@/utils/clipboard'
@@ -259,7 +260,7 @@ export default function App() {
// extent, so we don't set them here. // extent, so we don't set them here.
const position = nestInParent && parentNode const position = nestInParent && parentNode
? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 } ? { 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> = { const newNode: Node<NodeData> = {
id, id,
@@ -278,7 +279,7 @@ export default function App() {
const newNode: Node<NodeData> = { const newNode: Node<NodeData> = {
id, id,
type: 'groupRect', type: 'groupRect',
position: { x: 200, y: 200 }, position: getCenteredPosition(360, 240),
data: { data: {
label: data.label, label: data.label,
type: 'groupRect', type: 'groupRect',
@@ -337,7 +338,7 @@ export default function App() {
// node fields; text_content is not in the schema and was lost on reload. // 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. // TextNode and the edit modal both already fall back to label.
type: 'text', type: 'text',
position: { x: 250, y: 250 }, position: getCenteredPosition(200, 60),
data: { data: {
label: data.text, label: data.text,
type: 'text', type: 'text',
@@ -502,15 +503,18 @@ export default function App() {
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => { const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
snapshotHistory() 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 COLS = 4
const SPACING_X = 170 const SPACING_X = 170
const SPACING_Y = 100 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) => { zigbeeNodes.forEach((zn, i) => {
const id = zn.id const id = zn.id
const col = i % COLS const col = i % COLS
const row = Math.floor(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> = { const newNode: import('@xyflow/react').Node<NodeData> = {
id, id,
type: zn.type, type: zn.type,
@@ -553,11 +557,14 @@ export default function App() {
const COLS = 4 const COLS = 4
const SPACING_X = 170 const SPACING_X = 170
const SPACING_Y = 100 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) => { zwaveNodes.forEach((zn, i) => {
const id = zn.id const id = zn.id
const col = i % COLS const col = i % COLS
const row = Math.floor(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> = { const newNode: import('@xyflow/react').Node<NodeData> = {
id, id,
type: zn.type, type: zn.type,
@@ -23,6 +23,7 @@ import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar' import { SearchBar } from './SearchBar'
import { AlignmentGuides } from './AlignmentGuides' import { AlignmentGuides } from './AlignmentGuides'
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides' import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
import { setViewportCenterProjector } from '@/utils/viewportCenter'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps { interface CanvasContainerProps {
@@ -52,6 +53,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
cursorRef.current = { x: e.clientX, y: e.clientY } 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 // Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
// can project the cursor / viewport center into flow coordinates. // can project the cursor / viewport center into flow coordinates.
useEffect(() => { useEffect(() => {
@@ -146,7 +161,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop]) }, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
return ( 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 <ReactFlow
nodes={visibleNodes} nodes={visibleNodes}
edges={visibleEdges} edges={visibleEdges}
@@ -14,6 +14,7 @@ import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties' import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty' import { buildMacProperty } from '@/utils/macProperty'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat' import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { getCenteredPosition } from '@/utils/viewportCenter'
interface PendingDevicesModalProps { interface PendingDevicesModalProps {
open: boolean open: boolean
@@ -294,7 +295,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
addNode({ addNode({
id: nodeId, id: nodeId,
type: nodeData.type, type: nodeData.type,
position: { x: 400, y: 300 }, position: getCenteredPosition(),
data: { ...nodeData, status: wireless ? ('online' as const) : ('unknown' as const) }, data: { ...nodeData, status: wireless ? ('online' as const) : ('unknown' as const) },
}) })
injectAutoEdges(res.data.edges) injectAutoEdges(res.data.edges)
@@ -336,6 +337,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const deviceToNode: Record<string, string> = {} const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] }) res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id)) 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) => { approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id] const nodeId = deviceToNode[d.id]
if (!nodeId) return if (!nodeId) return
@@ -345,7 +349,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
addNode({ addNode({
id: nodeId, id: nodeId,
type, 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: { data: {
label: deviceLabel(d), label: deviceLabel(d),
type, 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 }
}