diff --git a/frontend/src/components/network/hooks/useDiagramCommands.ts b/frontend/src/components/network/hooks/useDiagramCommands.ts new file mode 100644 index 00000000..4707932c --- /dev/null +++ b/frontend/src/components/network/hooks/useDiagramCommands.ts @@ -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> +} + +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 = {} + 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 = {} + 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, + } +} diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 24a93f9f..60e26ee3 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -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