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

42 KiB
Raw Permalink Blame History

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)
  • onEdgeTypeChange
  • onBringToFront
  • onSendToBack
  • onDeleteNode
  • onDeleteEdge
  • onConnect
  • handleDrop (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 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.