diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8f2969..733b8b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = { id, @@ -278,7 +279,7 @@ export default function App() { const newNode: Node = { 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 = { 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 = { id, type: zn.type, diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index d5e92f0..19082c0 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -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(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 ( -
+
= {} 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, diff --git a/frontend/src/utils/__tests__/viewportCenter.test.ts b/frontend/src/utils/__tests__/viewportCenter.test.ts new file mode 100644 index 0000000..f994e1c --- /dev/null +++ b/frontend/src/utils/__tests__/viewportCenter.test.ts @@ -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 }) + }) +}) diff --git a/frontend/src/utils/viewportCenter.ts b/frontend/src/utils/viewportCenter.ts new file mode 100644 index 0000000..a6d5c65 --- /dev/null +++ b/frontend/src/utils/viewportCenter.ts @@ -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 } +}