diff --git a/docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md b/docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md new file mode 100644 index 00000000..3fab8da5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md @@ -0,0 +1,1320 @@ +# 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.