42 KiB
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:
// 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)onEdgeTypeChangeonBringToFrontonSendToBackonDeleteNodeonDeleteEdgeonConnecthandleDrop(adding new node from palette)handleAIGenerate(after AI result applied)handleImport
Example pattern:
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:
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:
<DiagramHeader
// ... existing props
onUndo={undo}
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
/>
- Step 5: Build and verify no TypeScript errors
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
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:
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:
import { Undo2, Redo2, /* existing imports */ } from 'lucide-react'
Add the buttons in the header bar, grouped together:
<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
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
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:
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:
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:
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:
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
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
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:
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:
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'
// Inside the component:
const diagramCommands = useDiagramCommands({
nodes,
edges,
pushHistory,
setNodes,
})
- Step 3: Build clean
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
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:
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:
{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:
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:
<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
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
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:
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:
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:
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
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
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:
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:
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:
import GroupNode from './GroupNode'
export const nodeTypes = {
device: DeviceNode,
group: GroupNode, // ← add this
}
- Step 4: Build clean
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
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):
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:
return {
// ... existing
groupSelection,
ungroupSelection,
canGroup,
canUngroup,
}
- Step 2: Pass group commands to ContextMenu in DiagramEditor
<ContextMenu
// ... existing
onGroupSelection={diagramCommands.groupSelection}
onUngroupSelection={diagramCommands.ungroupSelection}
canGroup={diagramCommands.canGroup}
canUngroup={diagramCommands.canUngroup}
/>
- Step 3: Build clean
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
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:
routing?: string | null
to:
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:
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:
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
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
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:
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:
{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:
// 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
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
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):
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:
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
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
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
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
cd /home/coder/resolutionflow && git push origin feat/network-diagrams
- Step 3: Confirm PR #139 is updated
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.