1321 lines
42 KiB
Markdown
1321 lines
42 KiB
Markdown
# 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 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.
|