docs: add React Flow migration design for flow editor canvas
Replaces hand-built CSS flexbox canvas with @xyflow/react for zoom/pan, dagre auto-layout, collapsible minimap, and side-panel editing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
235
docs/plans/2026-02-18-flow-editor-react-flow-design.md
Normal file
235
docs/plans/2026-02-18-flow-editor-react-flow-design.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Flow Editor — React Flow Migration Design
|
||||
|
||||
> **Date:** 2026-02-18
|
||||
> **Scope:** Replace hand-built CSS flexbox canvas with @xyflow/react for zoom, pan, auto-layout, and improved collapse UX
|
||||
|
||||
## Overview
|
||||
|
||||
The current flow editor canvas (`TreeCanvas.tsx`) uses pure CSS flexbox to position nodes. This works for small trees but breaks down with large flows — nodes overlap, there's no zoom/pan, and collapsing subtrees is hard to discover. This design replaces the canvas with React Flow (`@xyflow/react`), adds dagre-based auto-layout, and moves editing to a right-side panel.
|
||||
|
||||
## Problems Solved
|
||||
|
||||
1. **No zoom/pan** — users can only scroll; can't zoom out for a bird's-eye view or zoom into a section
|
||||
2. **Node overlap** — wide trees with many branches cause flexbox lanes to overlap
|
||||
3. **Collapse is hidden** — the subtree collapse toggle is a small icon in the node header, easy to miss
|
||||
4. **Inline editing bloats cards** — expanded cards are huge, disrupting the visual tree layout
|
||||
|
||||
## Architecture
|
||||
|
||||
### Source of Truth
|
||||
|
||||
The Zustand store's `treeStructure` (recursive nested object) remains the single source of truth. No store changes required. The canvas maintains a **derived** flat representation (`Node[]` and `Edge[]`) computed from the tree structure.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
treeStructure (Zustand) → useTreeLayout hook → { nodes, edges } → ReactFlow → renders
|
||||
↓
|
||||
user clicks node → NodeEditorPanel opens
|
||||
user saves edits → updateNode(id, data) → store updates → re-derive
|
||||
```
|
||||
|
||||
### New Dependencies
|
||||
|
||||
- `@xyflow/react` — canvas framework (MIT, 20k+ GitHub stars)
|
||||
- `@dagrejs/dagre` — directed graph layout algorithm
|
||||
- `@types/dagre` — TypeScript types
|
||||
|
||||
## Interactions
|
||||
|
||||
### Zoom
|
||||
|
||||
Ctrl/Cmd + scroll wheel to zoom. Zoom range: 25%–200%. Plain scroll pans vertically (natural page scrolling feel).
|
||||
|
||||
### Pan
|
||||
|
||||
Click and drag on empty canvas space. Plain scroll pans vertically. Middle-click drag also pans.
|
||||
|
||||
### Node Selection
|
||||
|
||||
Single-click on a node body selects it and opens the side panel editor.
|
||||
|
||||
### Subtree Collapse
|
||||
|
||||
Single-click on a visible chevron icon at the bottom edge of any node that has children. Always visible (not behind hover). When collapsed:
|
||||
- Children and their edges are removed from the React Flow graph entirely
|
||||
- A pill below the node shows "N nodes hidden"
|
||||
- Clicking the pill or chevron again expands
|
||||
|
||||
### Zoom Controls
|
||||
|
||||
Small floating toolbar in bottom-left corner: zoom in (+), zoom out (−), fit-to-view. Uses React Flow's built-in `<Controls>` component.
|
||||
|
||||
### Minimap
|
||||
|
||||
Bottom-right corner. Collapsible via a toggle button — user can minimize or close it. Pannable and zoomable (click on minimap to jump to that area). Uses React Flow's built-in `<MiniMap>` component. Node colors in minimap match type accent colors (blue/yellow/green).
|
||||
|
||||
### Fit View
|
||||
|
||||
Auto-fits on initial load and when clicking the fit button. Applies padding so nodes aren't pressed against viewport edges.
|
||||
|
||||
## Custom Node Types
|
||||
|
||||
Four React Flow custom node types, all **compact** (no inline editing):
|
||||
|
||||
| Type | Accent | Icon | Content |
|
||||
|------|--------|------|---------|
|
||||
| `decision` | Blue left border | `HelpCircle` | Question text (1-2 lines), "N options" badge, option labels |
|
||||
| `action` | Yellow left border | `Zap` | Title, description preview (truncated) |
|
||||
| `solution` | Green left border | `CheckCircle` | Title, description preview (truncated) |
|
||||
| `answer` | Dashed border, muted | — | Label + "Choose Type" prompt |
|
||||
|
||||
### Card Specs
|
||||
|
||||
- **Width:** 280px (fixed — gives dagre consistent widths)
|
||||
- **Height:** Variable based on content (~80–120px estimated)
|
||||
- **Selected state:** `ring-1 ring-primary`
|
||||
- **Validation errors:** Red dot badge on nodes with errors
|
||||
- **Collapse chevron:** Visible at bottom of node when it has children
|
||||
|
||||
### Edges
|
||||
|
||||
Smoothstep edges (React Flow built-in) — route around nodes with rounded corners. Color: `border-border`.
|
||||
|
||||
**Edge labels:** Show the option text leading to each child. **Truncated to 35 characters + ellipsis** for long option text (e.g., "User reports intermittent VPN di…"). Full text visible on hover tooltip and in the side panel when the parent decision is selected.
|
||||
|
||||
## Side Panel Editor
|
||||
|
||||
When a node is selected, a right-side editor panel opens. The canvas container **resizes** (shrinks by panel width) rather than the panel overlaying the canvas — this prevents covering the selected node. React Flow handles container resize natively.
|
||||
|
||||
### Panel Specs
|
||||
|
||||
- **Width:** 400px
|
||||
- **Position:** Right side, part of the layout (not floating)
|
||||
- **Open triggers:** Single-click a node
|
||||
- **Close triggers:** X button, Escape key, clicking empty canvas
|
||||
- **Auto-center:** When panel opens, auto-pan so the selected node stays centered in the remaining canvas area (via React Flow's `setCenter` / `fitBounds`)
|
||||
|
||||
### Panel Structure
|
||||
|
||||
- **Header:** Node type icon + badge, node title (or "New Decision"), close button
|
||||
- **Body:** Renders existing form components — `NodeFormDecision`, `NodeFormAction`, `NodeFormResolution`. For `answer` nodes: type picker buttons (Decision/Action/Solution)
|
||||
- **Footer:** Save (`bg-gradient-brand`), Cancel, Delete (with confirmation), Duplicate
|
||||
|
||||
### Draft Model
|
||||
|
||||
Same local-draft-then-commit pattern as current inline editor:
|
||||
- Panel opens with a clone of the node data
|
||||
- Edits modify the draft only
|
||||
- Save writes to Zustand store → triggers re-derive of React Flow nodes/edges
|
||||
- Cancel discards draft and closes panel
|
||||
- Switching nodes while editing prompts save/discard
|
||||
|
||||
### Panel Coexistence
|
||||
|
||||
Only one right panel open at a time. Opening node editor closes metadata panel and vice versa.
|
||||
|
||||
## Layout Engine (Dagre)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Direction: `rankdir: 'TB'` (top-to-bottom)
|
||||
- Node width: 280px
|
||||
- Node height: estimated heuristic (~80px base + content)
|
||||
- Rank separation (vertical gap): ~100px
|
||||
- Node separation (horizontal gap): ~40px
|
||||
|
||||
### Height Measurement Correction
|
||||
|
||||
Dagre needs node heights before rendering, but content varies. Strategy:
|
||||
1. **First pass:** Use heuristic height estimates based on node type and content length
|
||||
2. **After first paint:** Measure actual rendered heights via refs
|
||||
3. **If any height differs by >10px from estimate:** Re-run dagre with actual heights (single correction pass, no infinite loops)
|
||||
|
||||
This avoids visible layout jumps in most cases while catching edge cases like decision nodes with 8+ options.
|
||||
|
||||
### When Re-layout Runs
|
||||
|
||||
| Trigger | Re-layout? |
|
||||
|---------|-----------|
|
||||
| Node added/deleted | Yes |
|
||||
| Node moved (reparented) | Yes |
|
||||
| Options added/removed on a decision (structural change) | Yes |
|
||||
| Content-only edits (title, description text) | No |
|
||||
| Collapse/expand toggle | Yes (different nodes visible) |
|
||||
| Panel open/close | No (React Flow handles container resize) |
|
||||
|
||||
After re-layout, `fitView` is called with padding.
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `components/tree-editor/FlowCanvas.tsx` | React Flow canvas — replaces `TreeCanvas.tsx` |
|
||||
| `components/tree-editor/FlowCanvasNode.tsx` | Custom compact node component (decision/action/solution) |
|
||||
| `components/tree-editor/FlowCanvasAnswerNode.tsx` | Custom node for answer stubs |
|
||||
| `components/tree-editor/NodeEditorPanel.tsx` | Right-side editor panel — replaces inline card editing |
|
||||
| `components/tree-editor/useTreeLayout.ts` | Hook: treeStructure → nodes/edges + dagre + measure-correct |
|
||||
| `lib/dagreLayout.ts` | Pure function: positioned nodes from dagre |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `TreeEditorLayout.tsx` | Flow mode renders `FlowCanvas` + `NodeEditorPanel` instead of `TreeCanvas` |
|
||||
| `TreeEditorPage.tsx` | Panel state: node editor vs metadata, single-panel-at-a-time |
|
||||
|
||||
### Unchanged
|
||||
|
||||
- `treeEditorStore.ts` — no store changes needed
|
||||
- `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` — reused inside panel
|
||||
- `MetadataSidePanel.tsx` — already works, gets single-panel-at-a-time rule
|
||||
- Code mode — completely untouched
|
||||
|
||||
### Removed from Active Flow Mode Path
|
||||
|
||||
- `TreeCanvas.tsx` → replaced by `FlowCanvas.tsx`
|
||||
- `TreeCanvasNode.tsx` → replaced by `FlowCanvasNode.tsx`
|
||||
- `AnswerStubCard.tsx` → logic moves to `FlowCanvasAnswerNode.tsx`
|
||||
|
||||
Old components stay in the repo but are no longer imported in Flow mode.
|
||||
|
||||
## React Flow Configuration
|
||||
|
||||
```tsx
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
fitView
|
||||
minZoom={0.25}
|
||||
maxZoom={2}
|
||||
zoomOnScroll={false} // plain scroll pans
|
||||
zoomOnPinch={true} // trackpad pinch zooms
|
||||
panOnScroll={true} // plain scroll pans vertically
|
||||
panOnScrollMode="vertical"
|
||||
selectionOnDrag={false} // no multi-select
|
||||
nodesDraggable={false} // dagre controls layout
|
||||
nodesConnectable={false} // no edge reconnection
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant="dots" gap={24} size={1} color="var(--border)" />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={getNodeColor}
|
||||
style={{ display: minimapVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
```
|
||||
|
||||
## Not Included (YAGNI)
|
||||
|
||||
- No drag-to-reparent nodes on canvas
|
||||
- No visual edge reconnection (dragging edges)
|
||||
- No multi-select nodes
|
||||
- No undo/redo on canvas position changes (undo/redo stays on tree data only)
|
||||
- No manual node drag repositioning (dagre controls layout)
|
||||
- No light mode (dark-first design system)
|
||||
Reference in New Issue
Block a user