feat: React Flow migration for flow editor canvas #82
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)
|
||||
1474
docs/plans/2026-02-18-flow-editor-react-flow-impl.md
Normal file
1474
docs/plans/2026-02-18-flow-editor-react-flow-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
183
frontend/package-lock.json
generated
183
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
176
frontend/src/components/tree-editor/FlowCanvas.tsx
Normal file
176
frontend/src/components/tree-editor/FlowCanvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx
Normal file
69
frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx
Normal 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)
|
||||
154
frontend/src/components/tree-editor/FlowCanvasNode.tsx
Normal file
154
frontend/src/components/tree-editor/FlowCanvasNode.tsx
Normal 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 }
|
||||
264
frontend/src/components/tree-editor/NodeEditorPanel.tsx
Normal file
264
frontend/src/components/tree-editor/NodeEditorPanel.tsx
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
199
frontend/src/components/tree-editor/useTreeLayout.ts
Normal file
199
frontend/src/components/tree-editor/useTreeLayout.ts
Normal 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 }))
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
44
frontend/src/lib/dagreLayout.ts
Normal file
44
frontend/src/lib/dagreLayout.ts
Normal 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 }
|
||||
@@ -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) */}
|
||||
|
||||
Reference in New Issue
Block a user