From b7b0d41f92766f592c324854845417715175ef41 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:14:26 +0000 Subject: [PATCH] feat(network): add group/ungroup commands with bounding box calculation Co-Authored-By: Claude Sonnet 4.6 --- .../network/hooks/useDiagramCommands.ts | 68 +++++++++++++++++++ .../components/network/nodes/GroupNode.tsx | 2 +- .../pages/NetworkDiagrams/DiagramEditor.tsx | 8 +-- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/network/hooks/useDiagramCommands.ts b/frontend/src/components/network/hooks/useDiagramCommands.ts index 6a361d73..4d57e08b 100644 --- a/frontend/src/components/network/hooks/useDiagramCommands.ts +++ b/frontend/src/components/network/hooks/useDiagramCommands.ts @@ -122,6 +122,70 @@ export function useDiagramCommands({ const canAlign = selectedNodes.length >= 2 const canDistribute = selectedNodes.length >= 3 + // ── Grouping ─────────────────────────────────────────────────────────── + const groupSelection = useCallback(() => { + if (selectedNodes.length < 2) return + pushHistory(nodes, edges) + const PADDING = 24 + const minX = Math.min(...selectedNodes.map(n => n.position.x)) - PADDING + const minY = Math.min(...selectedNodes.map(n => n.position.y)) - PADDING + const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100))) + PADDING + const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100))) + PADDING + const groupId = `group-${Date.now()}` + const groupNode: Node = { + id: groupId, + type: 'group', + position: { x: minX, y: minY }, + style: { width: maxX - minX, height: maxY - minY }, + data: { label: 'Group', groupType: 'custom' }, + selected: false, + } + setNodes(prev => [ + groupNode, + ...prev.map(n => + n.selected + ? { + ...n, + parentId: groupId, + extent: 'parent' as const, + position: { x: n.position.x - minX, y: n.position.y - minY }, + selected: false, + } + : n + ), + ]) + }, [nodes, edges, selectedNodes, pushHistory, setNodes]) + + const ungroupSelection = useCallback(() => { + const selectedGroups = selectedNodes.filter(n => n.type === 'group') + if (selectedGroups.length === 0) return + pushHistory(nodes, edges) + const groupIds = new Set(selectedGroups.map(g => g.id)) + setNodes(prev => { + const groupPositions: Record = {} + for (const n of prev) { + if (groupIds.has(n.id)) groupPositions[n.id] = n.position + } + return prev + .filter(n => !groupIds.has(n.id)) + .map(n => { + if (n.parentId && groupIds.has(n.parentId)) { + const gPos = groupPositions[n.parentId] ?? { x: 0, y: 0 } + return { + ...n, + parentId: undefined, + extent: undefined, + position: { x: gPos.x + n.position.x, y: gPos.y + n.position.y }, + } + } + return n + }) + }) + }, [nodes, edges, selectedNodes, pushHistory, setNodes]) + + const canGroup = selectedNodes.length >= 2 && !selectedNodes.some(n => n.type === 'group') + const canUngroup = selectedNodes.some(n => n.type === 'group') + return { alignLeft, alignRight, @@ -134,5 +198,9 @@ export function useDiagramCommands({ canAlign, canDistribute, selectedNodes, + groupSelection, + ungroupSelection, + canGroup, + canUngroup, } } diff --git a/frontend/src/components/network/nodes/GroupNode.tsx b/frontend/src/components/network/nodes/GroupNode.tsx index 69f04be2..c65feac7 100644 --- a/frontend/src/components/network/nodes/GroupNode.tsx +++ b/frontend/src/components/network/nodes/GroupNode.tsx @@ -1,5 +1,5 @@ import { memo, useState, useRef, useEffect } from 'react' -import { NodeProps, NodeResizer, useReactFlow } from '@xyflow/react' +import { NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react' import type { GroupNodeData } from '@/types/network-diagram' const GROUP_COLORS: Record = { diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 7b04fcff..e10629fc 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -757,10 +757,10 @@ function DiagramEditorInner() { onDistributeV={diagramCommands.distributeVertically} canAlign={contextMenu.type === 'node' ? diagramCommands.canAlign : false} canDistribute={contextMenu.type === 'node' ? diagramCommands.canDistribute : false} - onGroupSelection={() => {}} - onUngroupSelection={() => {}} - canGroup={false} - canUngroup={false} + onGroupSelection={diagramCommands.groupSelection} + onUngroupSelection={diagramCommands.ungroupSelection} + canGroup={contextMenu?.type === 'node' ? diagramCommands.canGroup : false} + canUngroup={contextMenu?.type === 'node' ? diagramCommands.canUngroup : false} /> )} {pendingDeleteNodeId && (