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