# Network Diagrams Phase 2 — Draw.io-Grade Editing Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Elevate the network diagram editor from a basic canvas to a draw.io-grade editing experience by adding undo/redo, keyboard nudging, alignment/distribution commands, a group node component, improved edge routing, inline label editing, and a command layer that wires all of these consistently across keyboard, context menu, and toolbar. **Architecture:** All new commands are exposed through a single `useDiagramCommands` hook that is the single source of truth for every action — keyboard shortcuts, context menu items, and toolbar buttons all call the same functions. Undo/redo is implemented as a lightweight snapshot stack in `DiagramEditor.tsx` using `useRef` for the history array and two new state integers (`historyIndex`, stack depth cap of 50). No new state management library is introduced. **Tech Stack:** React 19, TypeScript, `@xyflow/react` (React Flow v12), Lucide React, Tailwind CSS v4, Zustand (not used for diagram state — stays in DiagramEditor local state). --- ## File Map | File | Action | Responsibility | |------|--------|----------------| | `frontend/src/components/network/hooks/useDiagramCommands.ts` | **Create** | Central command layer: align, distribute, group, ungroup, z-order, nudge, undo, redo | | `frontend/src/components/network/hooks/useCanvasShortcuts.ts` | **Modify** | Wire undo/redo + nudge shortcuts; delegate all other shortcuts to useDiagramCommands | | `frontend/src/components/network/nodes/GroupNode.tsx` | **Create** | Visual group/container node with resize and editable label | | `frontend/src/components/network/nodes/nodeTypes.ts` | **Modify** | Register GroupNode | | `frontend/src/components/network/ContextMenu.tsx` | **Modify** | Add align/distribute/group/ungroup menu sections | | `frontend/src/components/network/DiagramHeader.tsx` | **Modify** | Add undo/redo buttons | | `frontend/src/components/network/panels/PropertiesPanel.tsx` | **Modify** | Add alignment buttons for multi-select, group properties editor | | `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` | **Modify** | Add history stack, expose pushHistory/undo/redo, wire useDiagramCommands, selection tracking for multi-select | | `frontend/src/types/network-diagram.ts` | **Modify** | Add `zIndex` to DiagramNode, add `GroupNodeData` interface | --- ## Task 1: Undo/Redo History Stack in DiagramEditor **Files:** - Modify: `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` This task adds a snapshot-based undo/redo stack. Every mutation to `nodes` or `edges` must call `pushHistory` before applying the change. - [ ] **Step 1: Add history state at the top of DiagramEditor component** In `DiagramEditor.tsx`, after the existing state declarations, add: ```tsx // History const historyStack = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]) const historyIndex = useRef(-1) const MAX_HISTORY = 50 const pushHistory = useCallback((currentNodes: Node[], currentEdges: Edge[]) => { // Truncate any redo history beyond current index historyStack.current = historyStack.current.slice(0, historyIndex.current + 1) historyStack.current.push({ nodes: JSON.parse(JSON.stringify(currentNodes)), edges: JSON.parse(JSON.stringify(currentEdges)), }) if (historyStack.current.length > MAX_HISTORY) { historyStack.current.shift() } else { historyIndex.current += 1 } }, []) const undo = useCallback(() => { if (historyIndex.current <= 0) return historyIndex.current -= 1 const snapshot = historyStack.current[historyIndex.current] setNodes(snapshot.nodes) setEdges(snapshot.edges) setIsDirty(true) }, [setNodes, setEdges]) const redo = useCallback(() => { if (historyIndex.current >= historyStack.current.length - 1) return historyIndex.current += 1 const snapshot = historyStack.current[historyIndex.current] setNodes(snapshot.nodes) setEdges(snapshot.edges) setIsDirty(true) }, [setNodes, setEdges]) const canUndo = historyIndex.current > 0 const canRedo = historyIndex.current < historyStack.current.length - 1 ``` - [ ] **Step 2: Push history before every mutation** Find every place in `DiagramEditor.tsx` that calls `setNodes(...)` or `setEdges(...)` in response to a user action (not load/init), and prepend `pushHistory(nodes, edges)` before it. The main locations are: - `onNodeUpdate` (node property changes) - `onEdgeUpdate` (edge property changes) - `onEdgeTypeChange` - `onBringToFront` - `onSendToBack` - `onDeleteNode` - `onDeleteEdge` - `onConnect` - `handleDrop` (adding new node from palette) - `handleAIGenerate` (after AI result applied) - `handleImport` Example pattern: ```tsx const onDeleteNode = useCallback((nodeId: string) => { pushHistory(nodes, edges) // ← add this line before every setNodes/setEdges setNodes(prev => prev.filter(n => n.id !== nodeId)) setEdges(prev => prev.filter(e => e.source !== nodeId && e.target !== nodeId)) if (selectedNodeId === nodeId) setSelectedNodeId(null) }, [nodes, edges, pushHistory, selectedNodeId]) ``` - [ ] **Step 3: Initialize history on diagram load** In the `useEffect` that loads the diagram data (after `setNodes` and `setEdges` are called with loaded data), reset and push the initial snapshot: ```tsx historyStack.current = [] historyIndex.current = -1 pushHistory(loadedNodes, loadedEdges) ``` - [ ] **Step 4: Pass undo/redo/canUndo/canRedo down to children** Add these to the props passed to `DiagramHeader` and later to `useDiagramCommands`: ```tsx ``` - [ ] **Step 5: Build and verify no TypeScript errors** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output (clean). - [ ] **Step 6: Commit** ```bash cd /home/coder/resolutionflow git add frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx git commit -m "feat(network): add undo/redo snapshot history stack to DiagramEditor" ``` --- ## Task 2: Undo/Redo Buttons in DiagramHeader **Files:** - Modify: `frontend/src/components/network/DiagramHeader.tsx` - [ ] **Step 1: Add undo/redo props to DiagramHeader** Find the props interface in `DiagramHeader.tsx` and add: ```tsx onUndo: () => void onRedo: () => void canUndo: boolean canRedo: boolean ``` - [ ] **Step 2: Add undo/redo buttons to the header UI** In the header, find the left/center section (near the back button or title area) and add undo/redo buttons. Import `Undo2, Redo2` from `lucide-react`: ```tsx import { Undo2, Redo2, /* existing imports */ } from 'lucide-react' ``` Add the buttons in the header bar, grouped together: ```tsx
``` - [ ] **Step 3: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 4: Commit** ```bash git add frontend/src/components/network/DiagramHeader.tsx git commit -m "feat(network): add undo/redo buttons to DiagramHeader" ``` --- ## Task 3: Undo/Redo + Nudge Keyboard Shortcuts **Files:** - Modify: `frontend/src/components/network/hooks/useCanvasShortcuts.ts` - [ ] **Step 1: Add undo and redo to the keyboard handler** In `useCanvasShortcuts.ts`, the hook receives callbacks. Add `onUndo` and `onRedo` to the parameters: ```tsx interface UseCanvasShortcutsParams { // ... existing params onUndo: () => void onRedo: () => void onNudge: (dx: number, dy: number) => void } ``` - [ ] **Step 2: Wire Ctrl+Z and Ctrl+Y in the keydown handler** Inside the existing `keydown` event listener (where `isInputFocused` is checked), add: ```tsx if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault() onUndo() return } if ((e.key === 'y' && (e.ctrlKey || e.metaKey)) || (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey)) { e.preventDefault() onRedo() return } ``` - [ ] **Step 3: Add arrow key nudging** Arrow keys move selected nodes by 1px (plain) or 10px (Shift held). These should fire even when no input is focused — but only if nodes are selected. Add inside the keydown handler, after the input-focus guard: ```tsx const NUDGE_SMALL = 1 const NUDGE_LARGE = 10 if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { // Only nudge if we have selected nodes (not when input focused) if (isInputFocused) return e.preventDefault() const delta = e.shiftKey ? NUDGE_LARGE : NUDGE_SMALL switch (e.key) { case 'ArrowUp': onNudge(0, -delta); break case 'ArrowDown': onNudge(0, delta); break case 'ArrowLeft': onNudge(-delta, 0); break case 'ArrowRight': onNudge( delta, 0); break } return } ``` - [ ] **Step 4: Implement onNudge in DiagramEditor** In `DiagramEditor.tsx`, create and pass the nudge callback: ```tsx const onNudge = useCallback((dx: number, dy: number) => { const selected = nodes.filter(n => n.selected) if (selected.length === 0) return pushHistory(nodes, edges) setNodes(prev => prev.map(n => n.selected ? { ...n, position: { x: n.position.x + dx, y: n.position.y + dy } } : n )) }, [nodes, edges, pushHistory]) ``` Pass `onUndo={undo}`, `onRedo={redo}`, `onNudge={onNudge}` to `useCanvasShortcuts`. - [ ] **Step 5: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 6: Commit** ```bash git add frontend/src/components/network/hooks/useCanvasShortcuts.ts \ frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx git commit -m "feat(network): add undo/redo shortcuts (Ctrl+Z/Y) and arrow key nudging" ``` --- ## Task 4: useDiagramCommands — Alignment & Distribution Command Layer **Files:** - Create: `frontend/src/components/network/hooks/useDiagramCommands.ts` This is the central command layer. All alignment and distribution logic lives here and is called by context menu, keyboard, and toolbar buttons. - [ ] **Step 1: Create the hook file with alignment commands** Create `frontend/src/components/network/hooks/useDiagramCommands.ts`: ```tsx import { useCallback } from 'react' import { Node } from '@xyflow/react' interface UseDiagramCommandsParams { nodes: Node[] edges: any[] pushHistory: (nodes: Node[], edges: any[]) => 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, } } ``` - [ ] **Step 2: Wire useDiagramCommands into DiagramEditor** In `DiagramEditor.tsx`, import and instantiate the hook: ```tsx import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands' // Inside the component: const diagramCommands = useDiagramCommands({ nodes, edges, pushHistory, setNodes, }) ``` - [ ] **Step 3: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 4: Commit** ```bash git add frontend/src/components/network/hooks/useDiagramCommands.ts \ frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx git commit -m "feat(network): add useDiagramCommands — alignment and distribution command layer" ``` --- ## Task 5: Alignment Buttons in Context Menu **Files:** - Modify: `frontend/src/components/network/ContextMenu.tsx` - [ ] **Step 1: Add alignment commands to ContextMenu props** Find the ContextMenu props interface and add: ```tsx onAlignLeft?: () => void onAlignRight?: () => void onAlignCenterH?: () => void onAlignTop?: () => void onAlignBottom?: () => void onAlignCenterV?: () => void onDistributeH?: () => void onDistributeV?: () => void canAlign?: boolean canDistribute?: boolean onGroupSelection?: () => void onUngroupSelection?: () => void canGroup?: boolean canUngroup?: boolean ``` - [ ] **Step 2: Add align/distribute section to the node context menu** Inside the node context menu (the section that shows copy/duplicate/delete), add a new section when `canAlign` is true: ```tsx {canAlign && ( <>
Align
{canDistribute && ( <>
Distribute
)} )} {(canGroup || canUngroup) && ( <>
{canGroup && ( )} {canUngroup && ( )} )} ``` Add these imports from `lucide-react`: ```tsx import { AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignHorizontalSpaceAround, AlignVerticalSpaceAround, BoxSelect, Ungroup, // ... existing imports } from 'lucide-react' ``` - [ ] **Step 3: Pass diagramCommands props to ContextMenu in DiagramEditor** In `DiagramEditor.tsx`, wire the context menu: ```tsx ``` - [ ] **Step 4: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 5: Commit** ```bash git add frontend/src/components/network/ContextMenu.tsx \ frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx git commit -m "feat(network): add align/distribute/group sections to context menu" ``` --- ## Task 6: Alignment Buttons in PropertiesPanel (Multi-Select) **Files:** - Modify: `frontend/src/components/network/panels/PropertiesPanel.tsx` When multiple nodes are selected (no single node to inspect), show an alignment toolbar instead of the node properties form. - [ ] **Step 1: Add multi-select props to PropertiesPanel** Add to the PropertiesPanel props interface: ```tsx selectedNodeCount: number onAlignLeft: () => void onAlignRight: () => void onAlignCenterH: () => void onAlignTop: () => void onAlignBottom: () => void onAlignCenterV: () => void onDistributeH: () => void onDistributeV: () => void canAlign: boolean canDistribute: boolean ``` - [ ] **Step 2: Add a multi-select view** At the top of the PropertiesPanel render, before the existing single-node/edge views, add: ```tsx if (!selectedNodeId && !selectedEdgeId && selectedNodeCount >= 2) { return (
{selectedNodeCount} nodes selected
Align
{[ { label: 'Left', icon: AlignStartVertical, action: onAlignLeft }, { label: 'Center', icon: AlignCenterHorizontal, action: onAlignCenterH }, { label: 'Right', icon: AlignEndVertical, action: onAlignRight }, { label: 'Top', icon: AlignStartHorizontal, action: onAlignTop }, { label: 'Middle', icon: AlignCenterVertical, action: onAlignCenterV }, { label: 'Bottom', icon: AlignEndHorizontal, action: onAlignBottom }, ].map(({ label, icon: Icon, action }) => ( ))}
{canDistribute && (
Distribute
)}
) } ``` Add the same Lucide imports as Task 5. - [ ] **Step 3: Track multi-select count in DiagramEditor** In `DiagramEditor.tsx`, compute selected node count and pass it to PropertiesPanel: ```tsx const selectedNodeCount = nodes.filter(n => n.selected).length ``` - [ ] **Step 4: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 5: Commit** ```bash git add frontend/src/components/network/panels/PropertiesPanel.tsx \ frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx git commit -m "feat(network): add alignment toolbar to PropertiesPanel for multi-select" ``` --- ## Task 7: GroupNode Component **Files:** - Create: `frontend/src/components/network/nodes/GroupNode.tsx` - Modify: `frontend/src/components/network/nodes/nodeTypes.ts` - Modify: `frontend/src/types/network-diagram.ts` - [ ] **Step 1: Add GroupNodeData type** In `frontend/src/types/network-diagram.ts`, add: ```ts export interface GroupNodeData { label: string groupType: 'subnet' | 'vlan' | 'site' | 'dmz' | 'custom' [key: string]: unknown } ``` - [ ] **Step 2: Create GroupNode.tsx** Create `frontend/src/components/network/nodes/GroupNode.tsx`: ```tsx import { memo, useState, useRef, useEffect } from 'react' import { NodeProps, NodeResizer } from '@xyflow/react' import { GroupNodeData } from '@/types/network-diagram' const GROUP_COLORS: Record = { subnet: '#60a5fa', // blue vlan: '#a78bfa', // violet site: '#34d399', // green dmz: '#f87171', // red custom: '#94a3b8', // slate } const GroupNode = memo(({ data, selected, id }: NodeProps) => { const groupData = data as GroupNodeData const color = GROUP_COLORS[groupData.groupType] ?? GROUP_COLORS.custom const [editing, setEditing] = useState(false) const [labelValue, setLabelValue] = useState(groupData.label ?? '') const inputRef = useRef(null) useEffect(() => { if (editing) inputRef.current?.focus() }, [editing]) const handleLabelCommit = () => { setEditing(false) // Label changes propagate via React Flow's onNodesChange — data update // handled by parent via onNodeUpdate } return ( <>
{/* Label at top-left */}
{editing ? ( setLabelValue(e.target.value)} onBlur={handleLabelCommit} onKeyDown={e => { if (e.key === 'Enter' || e.key === 'Escape') handleLabelCommit() e.stopPropagation() }} className="text-[11px] font-medium bg-transparent border-none outline-none text-primary min-w-[40px] max-w-[200px]" style={{ color }} /> ) : ( setEditing(true)} > {labelValue || groupData.groupType} )}
) }) GroupNode.displayName = 'GroupNode' export default GroupNode ``` - [ ] **Step 3: Register GroupNode in nodeTypes.ts** Open `frontend/src/components/network/nodes/nodeTypes.ts` and add: ```tsx import GroupNode from './GroupNode' export const nodeTypes = { device: DeviceNode, group: GroupNode, // ← add this } ``` - [ ] **Step 4: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 5: Commit** ```bash git add frontend/src/components/network/nodes/GroupNode.tsx \ frontend/src/components/network/nodes/nodeTypes.ts \ frontend/src/types/network-diagram.ts git commit -m "feat(network): add GroupNode component with resize, inline label, and group type colors" ``` --- ## Task 8: Group/Ungroup Commands **Files:** - Modify: `frontend/src/components/network/hooks/useDiagramCommands.ts` - [ ] **Step 1: Add groupSelection and ungroupSelection to useDiagramCommands** Append to the hook (before the return statement): ```tsx const groupSelection = useCallback(() => { if (selectedNodes.length < 2) return pushHistory(nodes, edges) // Compute bounding box of selected nodes with padding 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, } // Re-position selected nodes relative to group origin 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(() => { // Find selected group nodes 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 = Object.fromEntries( prev.filter(n => groupIds.has(n.id)).map(n => [n.id, n.position]) ) return prev .filter(n => !groupIds.has(n.id)) .map(n => { if (n.parentId && groupIds.has(n.parentId)) { const groupPos = groupPositions[n.parentId] ?? { x: 0, y: 0 } return { ...n, parentId: undefined, extent: undefined, position: { x: groupPos.x + n.position.x, y: groupPos.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') ``` Update the return to include: ```tsx return { // ... existing groupSelection, ungroupSelection, canGroup, canUngroup, } ``` - [ ] **Step 2: Pass group commands to ContextMenu in DiagramEditor** ```tsx ``` - [ ] **Step 3: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 4: Commit** ```bash git add frontend/src/components/network/hooks/useDiagramCommands.ts \ frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx git commit -m "feat(network): add group/ungroup commands with bounding box calculation" ``` --- ## Task 9: Orthogonal Edge Routing Option **Files:** - Modify: `frontend/src/components/network/edges/ConnectionEdge.tsx` - Modify: `frontend/src/components/network/panels/PropertiesPanel.tsx` - Modify: `frontend/src/types/network-diagram.ts` The existing routing options are `null` (straight), `'curved'` (Bezier), `'step'` (SmoothStep). We add `'orthogonal'` as a true right-angle routing option using React Flow's `SmoothStepEdge` with `borderRadius: 0`. - [ ] **Step 1: Update routing type in network-diagram.ts** In `DiagramEdge`, change: ```ts routing?: string | null ``` to: ```ts routing?: 'curved' | 'step' | 'orthogonal' | null ``` - [ ] **Step 2: Add orthogonal path to ConnectionEdge** In `ConnectionEdge.tsx`, import `SmoothStepEdge` and handle the new routing value. Find where routing determines path type and add: ```tsx import { getStraightPath, getBezierPath, getSmoothStepPath, EdgeLabelRenderer, BaseEdge, } from '@xyflow/react' // In the component, where routing is checked: let pathData = '' let labelX = 0 let labelY = 0 if (routing === 'curved') { ;[pathData, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }) } else if (routing === 'step') { ;[pathData, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 8 }) } else if (routing === 'orthogonal') { ;[pathData, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 0 }) } else { ;[pathData, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY }) } ``` - [ ] **Step 3: Add orthogonal button to PropertiesPanel edge routing** In `PropertiesPanel.tsx`, find the three routing style buttons (Minus/Spline/GitBranch) and add a fourth for orthogonal: ```tsx import { Minus, Spline, GitBranch, CornerUpRight } from 'lucide-react' // In the routing buttons row, add: ``` - [ ] **Step 4: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 5: Commit** ```bash git add frontend/src/components/network/edges/ConnectionEdge.tsx \ frontend/src/components/network/panels/PropertiesPanel.tsx \ frontend/src/types/network-diagram.ts git commit -m "feat(network): add orthogonal edge routing option" ``` --- ## Task 10: Inline Label Editing on DeviceNode **Files:** - Modify: `frontend/src/components/network/nodes/DeviceNode.tsx` Double-clicking a node's label enters inline edit mode. On blur or Enter, the new label is committed via React Flow's node data update mechanism. - [ ] **Step 1: Add inline editing state to DeviceNode** In `DeviceNode.tsx`, add: ```tsx import { memo, useState, useRef, useEffect } from 'react' // Inside the component: const [editing, setEditing] = useState(false) const [labelValue, setLabelValue] = useState(data.label ?? '') const inputRef = useRef(null) useEffect(() => { if (editing) { inputRef.current?.focus() inputRef.current?.select() } }, [editing]) // Keep local state in sync if data.label changes externally useEffect(() => { if (!editing) setLabelValue(data.label ?? '') }, [data.label, editing]) ``` - [ ] **Step 2: Replace the label span with conditional edit/display** Find the label `
` or `` in the node render and replace it: ```tsx {editing ? ( setLabelValue(e.target.value)} onBlur={() => { setEditing(false) // Emit update via React Flow's updateNodeData if available, // or store in a ref for parent to pick up via onNodesChange if (labelValue !== data.label) { // Use the updateNodeData callback passed from React Flow // This is handled via the onNodeUpdate prop chain in DiagramEditor } }} onKeyDown={e => { if (e.key === 'Enter') inputRef.current?.blur() if (e.key === 'Escape') { setLabelValue(data.label ?? '') setEditing(false) } e.stopPropagation() }} style={{ fontSize: labelFontSize, width: '80%' }} className="bg-transparent border-none outline-none text-center text-primary font-medium" /> ) : ( setEditing(true)} > {labelValue} )} ``` - [ ] **Step 3: Wire label commit through useUpdateNodeInternals or onNodesChange** In `DiagramEditor.tsx`, update the `onNodeUpdate` handler to accept a label update triggered by inline edit. The cleanest pattern is to use React Flow's `useReactFlow().updateNodeData`: In `NetworkCanvas.tsx`, pass the `useReactFlow` hook's `updateNodeData` down, or handle it inside `DeviceNode` itself using `useReactFlow`: ```tsx // In DeviceNode.tsx, import useReactFlow import { useReactFlow } from '@xyflow/react' // Inside component: const { updateNodeData } = useReactFlow() // In onBlur: onBlur={() => { setEditing(false) if (labelValue !== data.label) { updateNodeData(id, { ...data, label: labelValue }) } }} ``` This keeps DiagramEditor's `onNodeUpdate` callback for external changes while inline edits go through React Flow's own data update mechanism. - [ ] **Step 4: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 5: Commit** ```bash git add frontend/src/components/network/nodes/DeviceNode.tsx git commit -m "feat(network): add inline label editing on DeviceNode (double-click)" ``` --- ## Task 11: Z-Order Normalization **Files:** - Modify: `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` The current bringToFront/sendToBack uses `Math.max`/`Math.min` increments that can overflow. This task normalizes z-order to always be 1..N after every operation. - [ ] **Step 1: Add normalizeZOrder utility** In `DiagramEditor.tsx`, add a helper near the top of the file (outside the component): ```tsx function normalizeZOrder(nodes: Node[]): Node[] { const sorted = [...nodes].sort((a, b) => ((a.zIndex ?? 0) - (b.zIndex ?? 0))) return sorted.map((n, i) => ({ ...n, zIndex: i + 1 })) } ``` - [ ] **Step 2: Apply normalization in bringToFront and sendToBack** Find `onBringToFront` and `onSendToBack` in `DiagramEditor.tsx` and update them: ```tsx const onBringToFront = useCallback((nodeId: string) => { pushHistory(nodes, edges) const maxZ = Math.max(...nodes.map(n => n.zIndex ?? 0)) setNodes(prev => normalizeZOrder( prev.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n) )) }, [nodes, edges, pushHistory]) const onSendToBack = useCallback((nodeId: string) => { pushHistory(nodes, edges) setNodes(prev => normalizeZOrder( prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n) )) }, [nodes, edges, pushHistory]) ``` - [ ] **Step 3: Build clean** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 4: Commit** ```bash git add frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx git commit -m "fix(network): normalize z-order to 1..N after bring-to-front/send-to-back" ``` --- ## Task 12: Push and Verify - [ ] **Step 1: Final build check** ```bash cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 ``` Expected: no output. - [ ] **Step 2: Push to remote** ```bash cd /home/coder/resolutionflow && git push origin feat/network-diagrams ``` - [ ] **Step 3: Confirm PR #139 is updated** ```bash gh pr view 139 ``` The PR should show all commits from Tasks 1–11. --- ## Self-Review Against Phase 2 Spec Checking against `docs/superpowers/plans/2026-04-13-network-diagram-drawio-style-implementation.md` Phase 2 scope: | Requirement | Task | Covered? | |-------------|------|----------| | Snap-to-guides (in addition to snap-to-grid) | — | ❌ Not in this plan — React Flow doesn't expose guide-snapping natively; deferred to Phase 2.5 | | Alignment commands | Tasks 4, 5, 6 | ✅ | | Distribution commands | Tasks 4, 5, 6 | ✅ | | Multi-select improvements | Tasks 4, 6 | ✅ | | Better z-order handling | Task 11 | ✅ | | Inline text editing | Task 10 | ✅ | | Better group/container behavior | Tasks 7, 8 | ✅ | | Rich edge routing choices | Task 9 | ✅ (straight/curved/step/orthogonal) | | Manual bend points | — | ❌ Deferred — requires custom edge with draggable waypoints, significant scope | | Port-aware connection handling | — | ❌ Deferred — DeviceNode already has 4 handles; advanced port config is Phase 3 | | Keyboard nudging | Task 3 | ✅ | | Undo/redo | Tasks 1, 2, 3 | ✅ | **Deferred items** (snap-to-guides, manual bend points, port-aware connections) are noted above. They are not gaps — they are intentionally scoped out of this plan as they each require significant standalone implementations. They should be planned as Phase 2.5 or Phase 3 items.