feat: React Flow migration for flow editor canvas #82

Merged
chihlasm merged 19 commits from feat/react-flow-canvas into main 2026-02-19 05:43:34 +00:00
15 changed files with 2948 additions and 12 deletions

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)

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,14 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@stripe/stripe-js": "^8.7.0",
"@types/lodash": "^4.17.23",
"@xyflow/react": "^12.10.0",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -563,6 +565,21 @@
"node": ">=20.19.0"
}
},
"node_modules/@dagrejs/dagre": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz",
"integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "3.0.4"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz",
"integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==",
"license": "MIT"
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@@ -1972,6 +1989,15 @@
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
@@ -2002,6 +2028,12 @@
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -2023,6 +2055,25 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -2546,6 +2597,66 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.74",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.74",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -3043,6 +3154,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -3248,6 +3365,28 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -3303,6 +3442,15 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -3348,6 +3496,41 @@
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",

View File

@@ -13,12 +13,14 @@
"analyze": "vite-bundle-visualizer"
},
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@stripe/stripe-js": "^8.7.0",
"@types/lodash": "^4.17.23",
"@xyflow/react": "^12.10.0",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -1,3 +1,5 @@
import { useState } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
interface CategoryItem {
@@ -13,16 +15,23 @@ interface CategoryListProps {
onSelect: (id: string | null) => void
}
const VISIBLE_COUNT = 4
export function CategoryList({ categories, activeId, onSelect }: CategoryListProps) {
const [expanded, setExpanded] = useState(false)
if (categories.length === 0) return null
const hasMore = categories.length > VISIBLE_COUNT
const visible = expanded ? categories : categories.slice(0, VISIBLE_COUNT)
return (
<div className="px-3 py-2">
<p className="mb-2 px-3 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground">
Categories
</p>
<div className="space-y-0.5">
{categories.map(cat => (
{visible.map(cat => (
<button
key={cat.id}
onClick={() => onSelect(activeId === cat.id ? null : cat.id)}
@@ -41,6 +50,24 @@ export function CategoryList({ categories, activeId, onSelect }: CategoryListPro
<span className="font-label text-[0.6875rem] text-[hsl(var(--text-dimmed))]">{cat.count}</span>
</button>
))}
{hasMore && (
<button
onClick={() => setExpanded(v => !v)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground transition-colors"
>
{expanded ? (
<>
<ChevronUp className="h-3.5 w-3.5" />
<span>Show less</span>
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" />
<span>{categories.length - VISIBLE_COUNT} more</span>
</>
)}
</button>
)}
</div>
</div>
)

View File

@@ -0,0 +1,176 @@
import { useCallback, useMemo, useState, useEffect } from 'react'
import {
ReactFlow,
Background,
Controls,
MiniMap,
BackgroundVariant,
useReactFlow,
useNodesState,
useEdgesState,
ReactFlowProvider,
PanOnScrollMode,
type NodeMouseHandler,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode'
import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
import { useTreeLayout } from './useTreeLayout'
import { cn } from '@/lib/utils'
import { Map as MapIcon, MapPinOff } from 'lucide-react'
import type { FlowCanvasNodeData } from './FlowCanvasNode'
import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode'
const nodeTypes = {
flowNode: FlowCanvasNode,
answerStub: FlowCanvasAnswerNode,
}
interface FlowCanvasProps {
selectedNodeId: string | null
onNodeSelect: (nodeId: string | null) => void
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) {
const { fitView, setCenter } = useReactFlow()
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
const [minimapVisible, setMinimapVisible] = useState(true)
// Inject callbacks into node data (because useTreeLayout creates placeholder functions)
const nodesWithCallbacks = useMemo(() => {
return layoutNodes.map(n => {
if (n.type === 'flowNode') {
const data = n.data as unknown as FlowCanvasNodeData
return {
...n,
selected: n.id === selectedNodeId,
data: { ...data, onToggleCollapse: toggleCollapse },
}
}
if (n.type === 'answerStub') {
const data = n.data as unknown as FlowCanvasAnswerNodeData
return {
...n,
selected: n.id === selectedNodeId,
data: { ...data, onSelectType: onSelectAnswerType },
}
}
return n
})
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType])
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
// Sync layout changes into React Flow state
useEffect(() => {
setNodes(nodesWithCallbacks)
setEdges(layoutEdges)
}, [nodesWithCallbacks, layoutEdges, setNodes, setEdges])
// Fit view after layout changes
useEffect(() => {
// Small delay to let React Flow process the node updates
const timer = setTimeout(() => {
fitView({ padding: 0.1, duration: 200 })
}, 50)
return () => clearTimeout(timer)
}, [layoutNodes.length, collapsedNodeIds.size]) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-center on selected node when panel opens
useEffect(() => {
if (!selectedNodeId) return
const node = nodes.find(n => n.id === selectedNodeId)
if (node) {
const x = node.position.x + 140 // center of 280px node
const y = node.position.y + 50
setCenter(x, y, { duration: 300, zoom: 1 })
}
}, [selectedNodeId]) // eslint-disable-line react-hooks/exhaustive-deps
// Height measurement correction
useEffect(() => {
if (nodes.length > 0 && nodes.some(n => n.measured?.height)) {
onNodesMeasured(nodes)
}
}, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps
const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => {
onNodeSelect(node.id)
}, [onNodeSelect])
const handlePaneClick = useCallback(() => {
onNodeSelect(null)
}, [onNodeSelect])
// Custom minimap node color based on actual tree node type
const minimapNodeColor = useCallback((rfNode: { data?: unknown }) => {
const data = rfNode.data as (FlowCanvasNodeData & FlowCanvasAnswerNodeData) | undefined
if (!data || !('node' in data)) return '#6b7280'
const treeNode = data.node
if (treeNode.type === 'answer') return '#6b7280'
const config = NODE_TYPE_CONFIG[treeNode.type as keyof typeof NODE_TYPE_CONFIG]
return config?.minimapColor ?? '#6b7280'
}, [])
return (
<div className="relative h-full w-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
fitView
minZoom={0.25}
maxZoom={2}
zoomOnScroll={false}
zoomOnPinch={true}
panOnScroll={true}
panOnScrollMode={PanOnScrollMode.Vertical}
selectionOnDrag={false}
nodesDraggable={false}
nodesConnectable={false}
proOptions={{ hideAttribution: true }}
className="bg-accent/30"
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1.5} color="hsl(var(--muted-foreground) / 0.25)" />
<Controls showInteractive={false} className="!bg-card !border-border !shadow-lg" />
{minimapVisible && (
<MiniMap
pannable
zoomable
nodeColor={minimapNodeColor}
className="!bg-card !border-border"
nodeStrokeWidth={2}
/>
)}
</ReactFlow>
{/* Minimap toggle button */}
<button
onClick={() => setMinimapVisible(v => !v)}
className={cn(
'absolute bottom-2 right-2 z-10 rounded-lg border border-border bg-card p-2 text-muted-foreground shadow-lg hover:bg-accent hover:text-foreground transition-colors',
minimapVisible && 'bottom-[170px]'
)}
title={minimapVisible ? 'Hide minimap' : 'Show minimap'}
>
{minimapVisible ? <MapPinOff className="h-4 w-4" /> : <MapIcon className="h-4 w-4" />}
</button>
</div>
)
}
// Wrap in ReactFlowProvider (required by useReactFlow hook)
export function FlowCanvas(props: FlowCanvasProps) {
return (
<ReactFlowProvider>
<FlowCanvasInner {...props} />
</ReactFlowProvider>
)
}

View File

@@ -0,0 +1,69 @@
import { memo, useState } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { HelpCircle, Zap, CheckCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TreeStructure } from '@/types'
export interface FlowCanvasAnswerNodeData {
node: TreeStructure
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
function FlowCanvasAnswerNodeComponent({ data, selected }: NodeProps) {
const { node, onSelectType } = data as unknown as FlowCanvasAnswerNodeData
const [picking, setPicking] = useState(false)
const label = node.title || 'Answer'
return (
<>
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
<div
className={cn(
'w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50 transition-all',
!picking && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30',
selected && 'ring-1 ring-primary'
)}
onClick={() => !picking && setPicking(true)}
>
<div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
{label}
</div>
{!picking ? (
<div className="pb-2.5 text-center text-[10px] text-muted-foreground font-label">
+ Choose Type
</div>
) : (
<div className="flex items-center justify-center gap-1.5 pb-2.5 px-2">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'decision') }}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20"
>
<HelpCircle className="h-2.5 w-2.5" /> Decision
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'action') }}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20"
>
<Zap className="h-2.5 w-2.5" /> Action
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'solution') }}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20"
>
<CheckCircle className="h-2.5 w-2.5" /> Solution
</button>
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
</>
)
}
export const FlowCanvasAnswerNode = memo(FlowCanvasAnswerNodeComponent)

View File

@@ -0,0 +1,154 @@
import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TreeStructure, NodeType } from '@/types'
const NODE_TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, {
icon: typeof HelpCircle
label: string
borderClass: string
badgeClass: string
minimapColor: string
}> = {
decision: {
icon: HelpCircle,
label: 'Decision',
borderClass: 'border-l-4 border-l-blue-500',
badgeClass: 'bg-blue-500/20 text-blue-400',
minimapColor: '#3b82f6',
},
action: {
icon: Zap,
label: 'Action',
borderClass: 'border-l-4 border-l-yellow-500',
badgeClass: 'bg-yellow-500/20 text-yellow-400',
minimapColor: '#eab308',
},
solution: {
icon: CheckCircle,
label: 'Solution',
borderClass: 'border-l-4 border-l-green-500',
badgeClass: 'bg-green-500/20 text-green-400',
minimapColor: '#22c55e',
},
}
export interface FlowCanvasNodeData {
node: TreeStructure
hasChildren: boolean
isCollapsed: boolean
hasValidationErrors: boolean
isNew: boolean
onToggleCollapse: (nodeId: string) => void
}
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as unknown as FlowCanvasNodeData
const nodeType = node.type as Exclude<NodeType, 'answer'>
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
const Icon = config.icon
const title = node.type === 'decision'
? (node.question || 'Untitled Decision')
: (node.title || `Untitled ${config.label}`)
const optionCount = node.options?.length ?? 0
return (
<>
{/* Target handle at top */}
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
<div
className={cn(
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
config.borderClass,
selected && 'ring-1 ring-primary shadow-md'
)}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2.5">
{/* Type badge */}
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
<Icon className="h-3 w-3" />
</span>
{/* Title */}
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
{title}
</span>
{/* Badges */}
{isNew && (
<span className="rounded-full bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-label text-yellow-400">
New
</span>
)}
{hasValidationErrors && (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
)}
</div>
{/* Decision options preview */}
{node.type === 'decision' && optionCount > 0 && (
<div className="border-t border-border px-3 py-1.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="font-label">{optionCount} option{optionCount !== 1 ? 's' : ''}</span>
</div>
<div className="mt-1 space-y-0.5">
{node.options!.slice(0, 3).map((opt, i) => (
<div key={opt.id} className="truncate text-xs text-muted-foreground">
<span className="font-label text-foreground/60">{String.fromCharCode(65 + i)}</span>{' '}
{opt.label || '(empty)'}
</div>
))}
{optionCount > 3 && (
<div className="text-xs text-muted-foreground">+{optionCount - 3} more</div>
)}
</div>
</div>
)}
{/* Description preview for action/solution */}
{(node.type === 'action' || node.type === 'solution') && node.description && (
<div className="border-t border-border px-3 py-1.5">
<div className="line-clamp-2 text-xs text-muted-foreground">{node.description}</div>
</div>
)}
{/* Collapse chevron */}
{hasChildren && (
<div className="flex justify-center border-t border-border py-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleCollapse(node.id)
}}
className="flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
{isCollapsed ? (
<>
<ChevronRight className="h-3 w-3" />
<span className="font-label">Expand</span>
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
<span className="font-label">Collapse</span>
</>
)}
</button>
</div>
)}
</div>
{/* Source handle at bottom */}
<Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
</>
)
}
export const FlowCanvasNode = memo(FlowCanvasNodeComponent)
export { NODE_TYPE_CONFIG }

View File

@@ -0,0 +1,264 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react'
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
import { NodeFormDecision } from './NodeFormDecision'
import { NodeFormAction } from './NodeFormAction'
import { NodeFormResolution } from './NodeFormResolution'
import { cn } from '@/lib/utils'
import type { TreeStructure, NodeType } from '@/types'
interface NodeEditorPanelProps {
nodeId: string
onClose: () => void
onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
const TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, { icon: typeof HelpCircle; label: string; badgeClass: string }> = {
decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' },
action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' },
solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' },
}
function cloneWithoutChildren(node: TreeStructure): TreeStructure {
const { children: _children, ...rest } = node
return structuredClone(rest) as TreeStructure
}
export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) {
const treeStructure = useTreeEditorStore(s => s.treeStructure)
const updateNode = useTreeEditorStore(s => s.updateNode)
const deleteNode = useTreeEditorStore(s => s.deleteNode)
const duplicateNode = useTreeEditorStore(s => s.duplicateNode)
const addNode = useTreeEditorStore(s => s.addNode)
const selectNode = useTreeEditorStore(s => s.selectNode)
const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null
const [draft, setDraft] = useState<TreeStructure | null>(null)
const [isDirty, setIsDirty] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
// Initialize/reset draft when nodeId changes
useEffect(() => {
if (node) {
setDraft(cloneWithoutChildren(node))
setIsDirty(false)
setShowDeleteConfirm(false)
}
}, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
setDraft(prev => prev ? { ...prev, ...updates } : prev)
setIsDirty(true)
}, [])
const handleSave = useCallback(() => {
if (!draft || !node) return
const { children: _children, ...draftWithoutChildren } = draft
updateNode(nodeId, draftWithoutChildren)
// Auto-create answer stubs for new decision options without next_node_id
if (draft.options) {
const options = draft.options.filter(o => o.label.trim())
const stubsCreated: Array<{ optId: string; stubId: string }> = []
options.forEach(opt => {
if (!opt.next_node_id) {
const stubId = addNode(nodeId, 'answer')
updateNode(stubId, { title: opt.label })
stubsCreated.push({ optId: opt.id, stubId })
}
})
if (stubsCreated.length > 0) {
const updatedOptions = options.map(o => {
const stub = stubsCreated.find(s => s.optId === o.id)
return stub ? { ...o, next_node_id: stub.stubId } : o
})
updateNode(nodeId, { options: updatedOptions })
}
}
setIsDirty(false)
}, [draft, node, nodeId, updateNode, addNode])
const handleClose = useCallback(() => {
if (isDirty) {
if (!window.confirm('You have unsaved changes. Discard them?')) return
}
onClose()
}, [isDirty, onClose])
// Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleClose])
const handleDelete = useCallback(() => {
if (!treeStructure) return
clearInboundReferences(nodeId, treeStructure, updateNode)
deleteNode(nodeId)
onClose()
}, [nodeId, treeStructure, updateNode, deleteNode, onClose])
const handleDuplicate = useCallback(() => {
const newId = duplicateNode(nodeId)
if (newId) {
selectNode(newId)
}
}, [nodeId, duplicateNode, selectNode])
if (!node || !draft) return null
// Answer stub: show type picker instead of form
if (node.type === 'answer') {
return (
<div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-heading font-medium text-foreground">
{node.title || 'Answer Placeholder'}
</span>
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4">
<p className="text-sm text-muted-foreground text-center">Choose a type for this node:</p>
<div className="flex gap-2">
{(['decision', 'action', 'solution'] as const).map(type => {
const cfg = TYPE_CONFIG[type]
const TypeIcon = cfg.icon
return (
<button
key={type}
type="button"
onClick={() => onSelectType?.(nodeId, type)}
className={cn(
'flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-label border transition-colors',
type === 'decision' && 'border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20',
type === 'action' && 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20',
type === 'solution' && 'border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20',
)}
>
<TypeIcon className="h-4 w-4" /> {cfg.label}
</button>
)
})}
</div>
</div>
</div>
)
}
const config = TYPE_CONFIG[node.type as Exclude<NodeType, 'answer'>] ?? TYPE_CONFIG.decision
const TypeIcon = config.icon
const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`)
const isRoot = treeStructure?.id === nodeId
return (
<div ref={panelRef} className="flex h-[calc(100vh-105px)] w-[400px] shrink-0 flex-col border-l border-border bg-card">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3 shrink-0">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
<TypeIcon className="h-3 w-3" />
</span>
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">{title}</span>
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
{/* Body — scrollable form area */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
{draft.type === 'decision' && <NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />}
{draft.type === 'action' && <NodeFormAction node={draft} onUpdate={handleDraftUpdate} />}
{draft.type === 'solution' && <NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />}
</div>
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-4 py-3 shrink-0">
<button
onClick={handleSave}
disabled={!isDirty}
className={cn(
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 transition-opacity',
isDirty ? 'bg-gradient-brand hover:opacity-90' : 'bg-gradient-brand opacity-50 cursor-not-allowed'
)}
>
<Save className="h-3.5 w-3.5" /> Save
</button>
<button
onClick={handleClose}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<div className="flex-1" />
{!isRoot && (
<>
<button
onClick={handleDuplicate}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Duplicate"
>
<Copy className="h-4 w-4" />
</button>
{showDeleteConfirm ? (
<div className="flex items-center gap-1">
<button
onClick={handleDelete}
className="rounded-md bg-red-500/20 px-2 py-1 text-xs text-red-400 hover:bg-red-500/30"
>
Confirm
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-red-400"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</>
)}
</div>
</div>
)
}
// Clear all next_node_id references to a node before deleting
function clearInboundReferences(
nodeId: string,
treeStructure: TreeStructure,
updateNode: (id: string, updates: Partial<TreeStructure>) => void
) {
function walk(node: TreeStructure) {
if (node.type === 'decision' && node.options) {
const needsUpdate = node.options.some(o => o.next_node_id === nodeId)
if (needsUpdate) {
updateNode(node.id, {
options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o),
})
}
}
if (node.type === 'action' && node.next_node_id === nodeId) {
updateNode(node.id, { next_node_id: '' })
}
node.children?.forEach(walk)
}
walk(treeStructure)
}

View File

@@ -1,6 +1,7 @@
import { lazy, Suspense } from 'react'
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
import { TreeCanvas } from './TreeCanvas'
import { FlowCanvas } from './FlowCanvas'
import { NodeEditorPanel } from './NodeEditorPanel'
import { MetadataSidePanel } from './MetadataSidePanel'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { cn } from '@/lib/utils'
@@ -14,19 +15,25 @@ interface TreeEditorLayoutProps {
isMobile?: boolean
isMetadataOpen?: boolean
onCloseMetadata?: () => void
editingNodeId: string | null
onNodeSelect: (nodeId: string | null) => void
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
export function TreeEditorLayout({
isMobile = false,
isMetadataOpen = false,
onCloseMetadata = () => {},
editingNodeId,
onNodeSelect,
onSelectAnswerType,
}: TreeEditorLayoutProps) {
const editorMode = useTreeEditorStore(s => s.editorMode)
return (
<div
className={cn(
'flex flex-1 overflow-hidden',
'flex min-h-0 flex-1 overflow-hidden',
isMobile ? 'flex-col' : 'flex-row'
)}
>
@@ -56,16 +63,31 @@ export function TreeEditorLayout({
</>
) : (
<>
{/* Flow Mode: Full-width visual canvas */}
{/* Flow Mode: React Flow canvas + side panels */}
<div className="flex-1 overflow-hidden">
<TreeCanvas />
<FlowCanvas
selectedNodeId={editingNodeId}
onNodeSelect={onNodeSelect}
onSelectAnswerType={onSelectAnswerType}
/>
</div>
{/* Metadata side panel — overlays the canvas from the right */}
<MetadataSidePanel
isOpen={isMetadataOpen}
onClose={onCloseMetadata}
/>
{/* Node editor panel — takes real layout space */}
{editingNodeId && (
<NodeEditorPanel
nodeId={editingNodeId}
onClose={() => onNodeSelect(null)}
onSelectType={onSelectAnswerType}
/>
)}
{/* Metadata side panel — only show when node editor is closed */}
{!editingNodeId && (
<MetadataSidePanel
isOpen={isMetadataOpen}
onClose={onCloseMetadata}
/>
)}
</>
)}
</div>

View File

@@ -7,3 +7,7 @@ export { NodeFormAction } from './NodeFormAction'
export { NodeFormResolution } from './NodeFormResolution'
export { DynamicArrayField } from './DynamicArrayField'
export { NodePicker } from './NodePicker'
export { FlowCanvas } from './FlowCanvas'
export { FlowCanvasNode } from './FlowCanvasNode'
export { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
export { NodeEditorPanel } from './NodeEditorPanel'

View File

@@ -0,0 +1,199 @@
import { useMemo, useCallback, useState, useRef, useEffect } from 'react'
import type { Node, Edge } from '@xyflow/react'
import type { TreeStructure } from '@/types'
import { getLayoutedElements, NODE_WIDTH } from '@/lib/dagreLayout'
import type { FlowCanvasNodeData } from './FlowCanvasNode'
import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode'
import { useTreeEditorStore } from '@/store/treeEditorStore'
const MAX_EDGE_LABEL_LENGTH = 35
function truncateLabel(label: string): string {
if (label.length <= MAX_EDGE_LABEL_LENGTH) return label
return label.slice(0, MAX_EDGE_LABEL_LENGTH).trimEnd() + '…'
}
function estimateNodeHeight(node: TreeStructure): number {
let height = 52 // header baseline
if (node.type === 'decision' && node.options) {
height += 24 // options header line
height += Math.min(node.options.length, 3) * 18 // option rows (max 3 shown)
if (node.options.length > 3) height += 18 // "+N more" row
}
if ((node.type === 'action' || node.type === 'solution') && node.description) {
height += 36 // description preview (2 lines)
}
if (node.type === 'answer') {
return 70 // fixed height for answer stubs
}
return height
}
interface UseTreeLayoutResult {
nodes: Node[]
edges: Edge[]
collapsedNodeIds: Set<string>
toggleCollapse: (nodeId: string) => void
onNodesMeasured: (measuredNodes: Node[]) => void
}
export function useTreeLayout(): UseTreeLayoutResult {
const treeStructure = useTreeEditorStore(s => s.treeStructure)
const validationErrors = useTreeEditorStore(s => s.validationErrors)
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
const [measuredHeights, setMeasuredHeights] = useState<Map<string, number>>(new Map())
const correctionDone = useRef(false)
const toggleCollapse = useCallback((nodeId: string) => {
setCollapsedNodeIds(prev => {
const next = new Set(prev)
if (next.has(nodeId)) next.delete(nodeId)
else next.add(nodeId)
return next
})
}, [])
// Convert tree structure to flat nodes and edges
const { rawNodes, rawEdges } = useMemo(() => {
const nodes: Node[] = []
const edges: Edge[] = []
if (!treeStructure) return { rawNodes: nodes, rawEdges: edges }
function walk(node: TreeStructure, _parentId?: string | null) {
const isCollapsed = collapsedNodeIds.has(node.id)
const hasChildren = (node.children?.length ?? 0) > 0
const hasErrors = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error')
const estimatedHeight = measuredHeights.get(node.id) ?? estimateNodeHeight(node)
if (node.type === 'answer') {
nodes.push({
id: node.id,
type: 'answerStub',
position: { x: 0, y: 0 }, // dagre will set this
data: {
node,
onSelectType: () => {}, // placeholder — set by FlowCanvas
} satisfies FlowCanvasAnswerNodeData,
style: { width: NODE_WIDTH },
measured: { width: NODE_WIDTH, height: estimatedHeight },
})
} else {
nodes.push({
id: node.id,
type: 'flowNode',
position: { x: 0, y: 0 },
data: {
node,
hasChildren,
isCollapsed,
hasValidationErrors: hasErrors,
isNew: false,
onToggleCollapse: () => {}, // placeholder — set by FlowCanvas
} satisfies FlowCanvasNodeData,
style: { width: NODE_WIDTH },
measured: { width: NODE_WIDTH, height: estimatedHeight },
})
}
// Skip children if collapsed
if (isCollapsed) return
// Create edges and recurse into children
if (node.children) {
// For decision nodes: order children by option link, then unlinked
const orderedChildren = orderChildren(node)
for (const { child, optionLabel } of orderedChildren) {
const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined
edges.push({
id: `${node.id}->${child.id}`,
source: node.id,
target: child.id,
type: 'smoothstep',
label: edgeLabel,
labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 11 },
labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.9 },
labelBgPadding: [4, 2] as [number, number],
style: { stroke: 'hsl(var(--border))' },
})
walk(child, node.id)
}
}
}
walk(treeStructure, null)
return { rawNodes: nodes, rawEdges: edges }
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])
// Run dagre layout
const { nodes, edges } = useMemo(() => {
if (rawNodes.length === 0) return { nodes: rawNodes, edges: rawEdges }
const layouted = getLayoutedElements(rawNodes, rawEdges)
return { nodes: layouted, edges: rawEdges }
}, [rawNodes, rawEdges])
// Height measurement correction callback
const onNodesMeasured = useCallback((measuredNodes: Node[]) => {
if (correctionDone.current) return
let needsCorrection = false
const newHeights = new Map(measuredHeights)
for (const mNode of measuredNodes) {
const actual = mNode.measured?.height
if (!actual) continue
const estimated = measuredHeights.get(mNode.id) ?? estimateNodeHeight(
(mNode.data as unknown as FlowCanvasNodeData)?.node ?? (mNode.data as unknown as FlowCanvasAnswerNodeData)?.node
)
if (Math.abs(actual - estimated) > 10) {
newHeights.set(mNode.id, actual)
needsCorrection = true
}
}
if (needsCorrection) {
correctionDone.current = true
setMeasuredHeights(newHeights)
}
}, [measuredHeights])
// Reset correction flag when tree structure changes
useEffect(() => {
correctionDone.current = false
}, [treeStructure, collapsedNodeIds])
return { nodes, edges, collapsedNodeIds, toggleCollapse, onNodesMeasured }
}
// Helper: order children by decision option links
function orderChildren(node: TreeStructure): Array<{ child: TreeStructure; optionLabel?: string }> {
if (!node.children || node.children.length === 0) return []
if (node.type === 'decision' && node.options) {
const linked: Array<{ child: TreeStructure; optionLabel: string }> = []
const linkedIds = new Set<string>()
for (const opt of node.options) {
if (opt.next_node_id) {
const child = node.children.find(c => c.id === opt.next_node_id)
if (child) {
linked.push({ child, optionLabel: opt.label })
linkedIds.add(child.id)
}
}
}
const unlinked = node.children
.filter(c => !linkedIds.has(c.id))
.map(child => ({ child, optionLabel: undefined }))
return [...linked, ...unlinked]
}
return node.children.map(child => ({ child, optionLabel: undefined }))
}

View File

@@ -315,3 +315,60 @@
@apply invisible;
}
}
/* React Flow dark theme overrides */
.react-flow__background {
background-color: transparent !important;
}
.react-flow__controls {
background-color: hsl(var(--card)) !important;
border: 1px solid hsl(var(--border)) !important;
border-radius: 0.75rem !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3) !important;
overflow: hidden;
}
.react-flow__controls-button {
background-color: hsl(var(--card)) !important;
border-color: hsl(var(--border)) !important;
fill: hsl(var(--muted-foreground)) !important;
color: hsl(var(--muted-foreground)) !important;
}
.react-flow__controls-button:hover {
background-color: hsl(var(--accent)) !important;
fill: hsl(var(--foreground)) !important;
}
.react-flow__controls-button svg {
fill: inherit !important;
}
.react-flow__minimap {
background-color: hsl(var(--card)) !important;
border: 1px solid hsl(var(--border)) !important;
border-radius: 0.75rem !important;
}
.react-flow__edge-path {
stroke: hsl(var(--border));
}
.react-flow__edge-text {
fill: hsl(var(--muted-foreground));
}
.react-flow__edge-textbg {
fill: hsl(var(--card));
}
/* Hide default React Flow attribution */
.react-flow__attribution {
display: none;
}
/* Handle styles */
.react-flow__handle {
background-color: hsl(var(--border));
}

View File

@@ -0,0 +1,44 @@
import dagre from '@dagrejs/dagre'
import type { Node, Edge } from '@xyflow/react'
const NODE_WIDTH = 280
const DEFAULT_NODE_HEIGHT = 100
interface LayoutOptions {
direction?: 'TB' | 'LR'
rankSep?: number
nodeSep?: number
}
export function getLayoutedElements(
nodes: Node[],
edges: Edge[],
options: LayoutOptions = {}
): Node[] {
const { direction = 'TB', rankSep = 100, nodeSep = 40 } = options
const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
nodes.forEach((node) => {
const height = node.measured?.height ?? node.data?.estimatedHeight ?? DEFAULT_NODE_HEIGHT
g.setNode(node.id, { width: NODE_WIDTH, height })
})
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target)
})
dagre.layout(g)
return nodes.map((node) => {
const dagreNode = g.node(node.id)
// dagre gives center positions — convert to top-left for React Flow
const x = dagreNode.x - NODE_WIDTH / 2
const y = dagreNode.y - (dagreNode.height ?? DEFAULT_NODE_HEIGHT) / 2
return { ...node, position: { x, y } }
})
}
export { NODE_WIDTH, DEFAULT_NODE_HEIGHT }

View File

@@ -45,6 +45,7 @@ export function TreeEditorPage() {
setLoading,
setSaving,
selectNode,
updateNode,
setEditorMode,
} = useTreeEditorStore()
@@ -55,6 +56,7 @@ export function TreeEditorPage() {
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
const [showAnalytics, setShowAnalytics] = useState(false)
const [isMetadataOpen, setIsMetadataOpen] = useState(false)
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
// Mobile detection
const [isMobile, setIsMobile] = useState(false)
@@ -211,6 +213,21 @@ export function TreeEditorPage() {
selectNode(nodeId)
}
const handleNodeSelect = useCallback((nodeId: string | null) => {
if (nodeId) {
setIsMetadataOpen(false) // close metadata when opening node editor
}
setEditingNodeId(nodeId)
selectNode(nodeId)
}, [selectNode])
const handleSelectAnswerType = useCallback((nodeId: string, type: 'decision' | 'action' | 'solution') => {
updateNode(nodeId, { type })
// Keep the panel open on the same node — it will now show the form for the new type
setEditingNodeId(nodeId)
selectNode(nodeId)
}, [updateNode, selectNode])
const handleSaveDraft = useCallback(async () => {
setSaving(true)
try {
@@ -390,7 +407,7 @@ export function TreeEditorPage() {
}
return (
<div className="flex h-full flex-col">
<div className="flex h-full flex-col overflow-hidden">
{/* Draft Restore Prompt */}
{showDraftPrompt && (
@@ -507,6 +524,7 @@ export function TreeEditorPage() {
onClick={() => {
setEditorMode('code')
setIsMetadataOpen(false) // Auto-close metadata panel on Code mode
setEditingNodeId(null) // Close node editor on Code mode
}}
title="Code Mode — markdown editing (Ctrl+Shift+M)"
className={cn(
@@ -562,7 +580,12 @@ export function TreeEditorPage() {
{editorMode === 'form' && (
<button
type="button"
onClick={() => setIsMetadataOpen(!isMetadataOpen)}
onClick={() => {
if (!isMetadataOpen) {
setEditingNodeId(null) // close node editor when opening metadata
}
setIsMetadataOpen(!isMetadataOpen)
}}
title="Edit flow metadata (name, description, category, tags)"
className={cn(
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
@@ -652,6 +675,9 @@ export function TreeEditorPage() {
isMobile={isMobile}
isMetadataOpen={isMetadataOpen}
onCloseMetadata={() => setIsMetadataOpen(false)}
editingNodeId={editingNodeId}
onNodeSelect={handleNodeSelect}
onSelectAnswerType={handleSelectAnswerType}
/>
{/* Flow Analytics Panel (collapsible) */}