Files
resolutionflow/docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md
2026-04-13 18:23:23 +00:00

1321 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<number>(-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
<DiagramHeader
// ... existing props
onUndo={undo}
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
/>
```
- [ ] **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
<div className="flex items-center gap-1">
<button
onClick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Undo2 size={16} />
</button>
<button
onClick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Y)"
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Redo2 size={16} />
</button>
</div>
```
- [ ] **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<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,
}
}
```
- [ ] **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 && (
<>
<div className="border-t border-default my-1" />
<div className="px-2 py-0.5 text-[10px] font-medium text-muted uppercase tracking-wider">Align</div>
<button onClick={() => { onAlignLeft?.(); onClose() }} className={menuItem}>
<AlignStartVertical size={13} /> Align Left
</button>
<button onClick={() => { onAlignCenterH?.(); onClose() }} className={menuItem}>
<AlignCenterHorizontal size={13} /> Align Center
</button>
<button onClick={() => { onAlignRight?.(); onClose() }} className={menuItem}>
<AlignEndVertical size={13} /> Align Right
</button>
<button onClick={() => { onAlignTop?.(); onClose() }} className={menuItem}>
<AlignStartHorizontal size={13} /> Align Top
</button>
<button onClick={() => { onAlignCenterV?.(); onClose() }} className={menuItem}>
<AlignCenterVertical size={13} /> Align Middle
</button>
<button onClick={() => { onAlignBottom?.(); onClose() }} className={menuItem}>
<AlignEndHorizontal size={13} /> Align Bottom
</button>
{canDistribute && (
<>
<div className="border-t border-default my-1" />
<div className="px-2 py-0.5 text-[10px] font-medium text-muted uppercase tracking-wider">Distribute</div>
<button onClick={() => { onDistributeH?.(); onClose() }} className={menuItem}>
<AlignHorizontalSpaceAround size={13} /> Space Horizontally
</button>
<button onClick={() => { onDistributeV?.(); onClose() }} className={menuItem}>
<AlignVerticalSpaceAround size={13} /> Space Vertically
</button>
</>
)}
</>
)}
{(canGroup || canUngroup) && (
<>
<div className="border-t border-default my-1" />
{canGroup && (
<button onClick={() => { onGroupSelection?.(); onClose() }} className={menuItem}>
<BoxSelect size={13} /> Group Selection
</button>
)}
{canUngroup && (
<button onClick={() => { onUngroupSelection?.(); onClose() }} className={menuItem}>
<Ungroup size={13} /> Ungroup
</button>
)}
</>
)}
```
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
<ContextMenu
// ... existing props
onAlignLeft={diagramCommands.alignLeft}
onAlignRight={diagramCommands.alignRight}
onAlignCenterH={diagramCommands.alignCenterH}
onAlignTop={diagramCommands.alignTop}
onAlignBottom={diagramCommands.alignBottom}
onAlignCenterV={diagramCommands.alignCenterV}
onDistributeH={diagramCommands.distributeHorizontally}
onDistributeV={diagramCommands.distributeVertically}
canAlign={diagramCommands.canAlign}
canDistribute={diagramCommands.canDistribute}
/>
```
- [ ] **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 (
<div className="w-[260px] border-l border-default bg-sidebar flex flex-col">
<div className="px-4 py-3 border-b border-default">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{selectedNodeCount} nodes selected
</div>
</div>
<div className="p-3 flex flex-col gap-4">
<div>
<div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Align</div>
<div className="grid grid-cols-3 gap-1">
{[
{ 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 }) => (
<button
key={label}
onClick={action}
title={`Align ${label}`}
className="flex flex-col items-center gap-1 p-2 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary"
>
<Icon size={14} />
<span className="text-[9px]">{label}</span>
</button>
))}
</div>
</div>
{canDistribute && (
<div>
<div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Distribute</div>
<div className="grid grid-cols-2 gap-1">
<button
onClick={onDistributeH}
className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
>
<AlignHorizontalSpaceAround size={13} /> Horizontal
</button>
<button
onClick={onDistributeV}
className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
>
<AlignVerticalSpaceAround size={13} /> Vertical
</button>
</div>
</div>
)}
</div>
</div>
)
}
```
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
<PropertiesPanel
// ... existing props
selectedNodeCount={selectedNodeCount}
onAlignLeft={diagramCommands.alignLeft}
onAlignRight={diagramCommands.alignRight}
onAlignCenterH={diagramCommands.alignCenterH}
onAlignTop={diagramCommands.alignTop}
onAlignBottom={diagramCommands.alignBottom}
onAlignCenterV={diagramCommands.alignCenterV}
onDistributeH={diagramCommands.distributeHorizontally}
onDistributeV={diagramCommands.distributeVertically}
canAlign={diagramCommands.canAlign}
canDistribute={diagramCommands.canDistribute}
/>
```
- [ ] **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<string, string> = {
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<HTMLInputElement>(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 (
<>
<NodeResizer
isVisible={selected}
minWidth={120}
minHeight={80}
lineStyle={{ border: `1px solid ${color}` }}
handleStyle={{ width: 8, height: 8, borderRadius: 2, background: color }}
/>
<div
className="w-full h-full rounded-lg relative"
style={{
border: `1.5px dashed ${color}`,
background: `${color}0d`, // 5% opacity
boxSizing: 'border-box',
}}
>
{/* Label at top-left */}
<div className="absolute top-0 left-0 -translate-y-full pb-0.5 pl-1">
{editing ? (
<input
ref={inputRef}
value={labelValue}
onChange={e => 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 }}
/>
) : (
<span
className="text-[11px] font-medium cursor-text select-none"
style={{ color }}
onDoubleClick={() => setEditing(true)}
>
{labelValue || groupData.groupType}
</span>
)}
</div>
</div>
</>
)
})
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
<ContextMenu
// ... existing
onGroupSelection={diagramCommands.groupSelection}
onUngroupSelection={diagramCommands.ungroupSelection}
canGroup={diagramCommands.canGroup}
canUngroup={diagramCommands.canUngroup}
/>
```
- [ ] **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:
<button
onClick={() => onEdgeUpdate(selectedEdgeId, { routing: 'orthogonal' })}
title="Orthogonal"
className={cn(routingBtn, edge.data?.routing === 'orthogonal' && routingBtnActive)}
>
<CornerUpRight size={14} />
</button>
```
- [ ] **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<HTMLInputElement>(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 `<div>` or `<span>` in the node render and replace it:
```tsx
{editing ? (
<input
ref={inputRef}
value={labelValue}
onChange={e => 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"
/>
) : (
<span
style={{ fontSize: labelFontSize }}
className="font-medium text-primary text-center leading-tight line-clamp-2 cursor-default"
onDoubleClick={() => setEditing(true)}
>
{labelValue}
</span>
)}
```
- [ ] **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 111.
---
## 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.