feat(network): add useDiagramCommands — alignment and distribution command layer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-13 20:08:37 +00:00
parent 02c19a7580
commit bdaea68dd3
2 changed files with 146 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
import { useCallback } from 'react'
import { Node, Edge } from '@xyflow/react'
interface UseDiagramCommandsParams {
nodes: Node[]
edges: Edge[]
pushHistory: (nodes: Node[], edges: Edge[]) => void
setNodes: React.Dispatch<React.SetStateAction<Node[]>>
}
export function useDiagramCommands({
nodes,
edges,
pushHistory,
setNodes,
}: UseDiagramCommandsParams) {
const selectedNodes = nodes.filter(n => n.selected)
// ── Alignment ──────────────────────────────────────────────────────────
const alignLeft = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const minX = Math.min(...selectedNodes.map(n => n.position.x))
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, x: minX } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignRight = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100)))
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, x: maxX - (n.measured?.width ?? 100) } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignCenterH = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const minX = Math.min(...selectedNodes.map(n => n.position.x))
const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100)))
const centerX = (minX + maxX) / 2
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, x: centerX - (n.measured?.width ?? 100) / 2 } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignTop = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const minY = Math.min(...selectedNodes.map(n => n.position.y))
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, y: minY } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignBottom = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100)))
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, y: maxY - (n.measured?.height ?? 100) } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignCenterV = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const minY = Math.min(...selectedNodes.map(n => n.position.y))
const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100)))
const centerY = (minY + maxY) / 2
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, y: centerY - (n.measured?.height ?? 100) / 2 } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
// ── Distribution ───────────────────────────────────────────────────────
const distributeHorizontally = useCallback(() => {
if (selectedNodes.length < 3) return
pushHistory(nodes, edges)
const sorted = [...selectedNodes].sort((a, b) => a.position.x - b.position.x)
const minX = sorted[0].position.x
const maxX = sorted[sorted.length - 1].position.x + (sorted[sorted.length - 1].measured?.width ?? 100)
const totalWidth = sorted.reduce((sum, n) => sum + (n.measured?.width ?? 100), 0)
const gap = (maxX - minX - totalWidth) / (sorted.length - 1)
let cursor = minX
const positions: Record<string, number> = {}
for (const n of sorted) {
positions[n.id] = cursor
cursor += (n.measured?.width ?? 100) + gap
}
setNodes(prev => prev.map(n =>
n.selected && positions[n.id] !== undefined
? { ...n, position: { ...n.position, x: positions[n.id] } }
: n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const distributeVertically = useCallback(() => {
if (selectedNodes.length < 3) return
pushHistory(nodes, edges)
const sorted = [...selectedNodes].sort((a, b) => a.position.y - b.position.y)
const minY = sorted[0].position.y
const maxY = sorted[sorted.length - 1].position.y + (sorted[sorted.length - 1].measured?.height ?? 100)
const totalHeight = sorted.reduce((sum, n) => sum + (n.measured?.height ?? 100), 0)
const gap = (maxY - minY - totalHeight) / (sorted.length - 1)
let cursor = minY
const positions: Record<string, number> = {}
for (const n of sorted) {
positions[n.id] = cursor
cursor += (n.measured?.height ?? 100) + gap
}
setNodes(prev => prev.map(n =>
n.selected && positions[n.id] !== undefined
? { ...n, position: { ...n.position, y: positions[n.id] } }
: n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
// ── Helpers ────────────────────────────────────────────────────────────
const canAlign = selectedNodes.length >= 2
const canDistribute = selectedNodes.length >= 3
return {
alignLeft,
alignRight,
alignCenterH,
alignTop,
alignBottom,
alignCenterV,
distributeHorizontally,
distributeVertically,
canAlign,
canDistribute,
selectedNodes,
}
}

View File

@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css'
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'
import { DiagramHeader } from '@/components/network/DiagramHeader'
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
@@ -113,6 +114,13 @@ function DiagramEditorInner() {
const canUndo = historyIndex.current > 0
const canRedo = historyIndex.current < historyStack.current.length - 1
const diagramCommands = useDiagramCommands({
nodes,
edges,
pushHistory,
setNodes,
})
const onNudge = useCallback((dx: number, dy: number) => {
const selected = nodes.filter(n => n.selected)
if (selected.length === 0) return