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:
+13
-6
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user