feat(network): add useDiagramCommands — alignment and distribution command layer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
138
frontend/src/components/network/hooks/useDiagramCommands.ts
Normal file
138
frontend/src/components/network/hooks/useDiagramCommands.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css'
|
|||||||
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
|
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
|
||||||
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
||||||
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
|
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
|
||||||
|
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'
|
||||||
import { DiagramHeader } from '@/components/network/DiagramHeader'
|
import { DiagramHeader } from '@/components/network/DiagramHeader'
|
||||||
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
||||||
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
||||||
@@ -113,6 +114,13 @@ function DiagramEditorInner() {
|
|||||||
const canUndo = historyIndex.current > 0
|
const canUndo = historyIndex.current > 0
|
||||||
const canRedo = historyIndex.current < historyStack.current.length - 1
|
const canRedo = historyIndex.current < historyStack.current.length - 1
|
||||||
|
|
||||||
|
const diagramCommands = useDiagramCommands({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
pushHistory,
|
||||||
|
setNodes,
|
||||||
|
})
|
||||||
|
|
||||||
const onNudge = useCallback((dx: number, dy: number) => {
|
const onNudge = useCallback((dx: number, dy: number) => {
|
||||||
const selected = nodes.filter(n => n.selected)
|
const selected = nodes.filter(n => n.selected)
|
||||||
if (selected.length === 0) return
|
if (selected.length === 0) return
|
||||||
|
|||||||
Reference in New Issue
Block a user