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:
chihlasm
2026-02-18 20:41:34 -05:00
parent da2807b14b
commit af9e44d633

View 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 (~80120px 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)