Compare commits
10 Commits
e9ed508bfc
...
fc265266c7
| Author | SHA1 | Date | |
|---|---|---|---|
| fc265266c7 | |||
| 2d8f13aebb | |||
| 2ff7e1f5f2 | |||
| 4dca9c6478 | |||
| b71d32da5e | |||
| 0578b26241 | |||
| 029bab8917 | |||
| 92fdba7eef | |||
| fb1df18256 | |||
| 35b2e9d538 |
11
README.md
11
README.md
@@ -36,7 +36,7 @@ cd frontend && npm install && npm run dev
|
||||
# → http://localhost:3000
|
||||
```
|
||||
|
||||
**Frontend + backend** (so the app can show “Backend API: N todos”):
|
||||
**Frontend + backend** (for AI agent and health):
|
||||
|
||||
```bash
|
||||
# Terminal 1 – backend
|
||||
@@ -45,7 +45,7 @@ cd backend && npm install && npm run dev
|
||||
|
||||
# Terminal 2 – frontend
|
||||
cd frontend && npm install && npm run dev
|
||||
# → http://localhost:3000 (Vite proxies /api/todos to backend)
|
||||
# → http://localhost:3000 (Vite proxies /api/* and /health to backend)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -89,16 +89,9 @@ For self-hosting (e.g. Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL;
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/todos` | List all todos. |
|
||||
| GET | `/api/todos/:id` | Get one todo. |
|
||||
| POST | `/api/todos` | Create (`{ "title": "...", "completed": false }`). |
|
||||
| PUT | `/api/todos/:id` | Update. |
|
||||
| DELETE | `/api/todos/:id` | Delete. |
|
||||
| GET | `/health` | Health check (e.g. for Docker). |
|
||||
| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }` → `{ "markdown" }`. |
|
||||
|
||||
Data is in-memory (resets on restart). Add a JSON file or DB later if needed.
|
||||
|
||||
---
|
||||
|
||||
## Agent node (local LLM or OpenAI)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "zui-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Minimal Express API for Zui (todos CRUD, no DB)",
|
||||
"description": "Minimal Express API for Zui (agent, health; no DB)",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Minimal Express API: /api/todos CRUD (in-memory).
|
||||
* Minimal Express API: /api/agent, /health.
|
||||
* No DB, no auth. CORS allowed for frontend. Production-ready env (PORT, CORS_ORIGIN).
|
||||
*/
|
||||
|
||||
@@ -14,72 +14,6 @@ const app = express()
|
||||
app.use(cors({ origin: CORS_ORIGIN }))
|
||||
app.use(express.json())
|
||||
|
||||
// In-memory store (replace with JSON file or DB later)
|
||||
let todos = [
|
||||
{ id: '1', title: 'Sample todo', completed: false },
|
||||
{ id: '2', title: 'Another item', completed: true },
|
||||
]
|
||||
let nextId = 3
|
||||
|
||||
/** GET /api/todos — list all */
|
||||
app.get('/api/todos', (req, res) => {
|
||||
try {
|
||||
res.json(todos)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** GET /api/todos/:id — get one */
|
||||
app.get('/api/todos/:id', (req, res) => {
|
||||
try {
|
||||
const todo = todos.find((t) => t.id === req.params.id)
|
||||
if (!todo) return res.status(404).json({ error: 'Not found' })
|
||||
res.json(todo)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/todos — create */
|
||||
app.post('/api/todos', (req, res) => {
|
||||
try {
|
||||
const { title, completed } = req.body ?? {}
|
||||
const id = String(nextId++)
|
||||
const todo = { id, title: title ?? '', completed: Boolean(completed) }
|
||||
todos.push(todo)
|
||||
res.status(201).json(todo)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** PUT /api/todos/:id — update */
|
||||
app.put('/api/todos/:id', (req, res) => {
|
||||
try {
|
||||
const idx = todos.findIndex((t) => t.id === req.params.id)
|
||||
if (idx === -1) return res.status(404).json({ error: 'Not found' })
|
||||
const { title, completed } = req.body ?? {}
|
||||
if (title !== undefined) todos[idx].title = title
|
||||
if (completed !== undefined) todos[idx].completed = Boolean(completed)
|
||||
res.json(todos[idx])
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/todos/:id — delete */
|
||||
app.delete('/api/todos/:id', (req, res) => {
|
||||
try {
|
||||
const idx = todos.findIndex((t) => t.id === req.params.id)
|
||||
if (idx === -1) return res.status(404).json({ error: 'Not found' })
|
||||
const removed = todos.splice(idx, 1)[0]
|
||||
res.json(removed)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
|
||||
function buildAgentRequest(body) {
|
||||
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
|
||||
|
||||
218
frontend/docs/CANVAS_STATE_DESIGN.md
Normal file
218
frontend/docs/CANVAS_STATE_DESIGN.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Canvas state: design pattern for controlled, centralized, debuggable flow
|
||||
|
||||
This doc proposes a **store + commands + selectors** pattern so canvas state is:
|
||||
|
||||
- **Controlled** – every change goes through one place
|
||||
- **Centralized** – one store holds graph, path, and UI slices
|
||||
- **Predictable** – same action → same state transition; easy to reason about
|
||||
- **Easier to debug** – log commands, inspect store, optional time-travel
|
||||
|
||||
It complements [CANVAS_PERFORMANCE_OPTIONS.md](./CANVAS_PERFORMANCE_OPTIONS.md) and [state.ts](../src/lib/graph/state.ts).
|
||||
|
||||
---
|
||||
|
||||
## 1. Core idea: single store + commands + selectors
|
||||
|
||||
### 1.1 Single store (one source of truth)
|
||||
|
||||
Keep all canvas-related state in **one store** with **slices**:
|
||||
|
||||
```
|
||||
Store
|
||||
├── graph: { nodes, edges } // current graph (with history if needed)
|
||||
├── path: ConnectionPathState // trigger/updating/paused/error node IDs
|
||||
├── ui: FlowUIState // renaming, fullscreen, connectionFrom, etc.
|
||||
└── (optional) history: HistoryState // undo/redo stack
|
||||
```
|
||||
|
||||
- **No duplicate sources**: nodes/edges live only in the store, not in context + refs.
|
||||
- **Reads**: components get data via **selectors** (e.g. `useStore(s => s.graph.nodes)` or `useStore(selectPathForEdge, edgeId)`).
|
||||
- **Writes**: only via **commands** (e.g. `dispatch({ type: 'graph/setNodes', payload: updater })`).
|
||||
|
||||
### 1.2 Commands (controlled mutations)
|
||||
|
||||
Every mutation is a **command** (action):
|
||||
|
||||
- **Graph**: `graph/setNodes`, `graph/setEdges`, `graph/applySilent` (position), `graph/undo`, `graph/redo`
|
||||
- **Path**: `path/addTrigger`, `path/startUpdate`, `path/endUpdate`, `path/setPaused`, `path/setError`
|
||||
- **UI**: `ui/setRenaming`, `ui/setFullscreen`, `ui/setConnectionFrom`
|
||||
|
||||
Benefits:
|
||||
|
||||
- **Predictable**: one command → one reducer → one new state; no scattered `setState` in hooks.
|
||||
- **Traceable**: log every command (and payload) in dev; replay or inspect.
|
||||
- **Testable**: test reducers with command + prev state → next state.
|
||||
- **Time-travel (optional)**: store past states or inverse deltas per command for debug UI.
|
||||
|
||||
### 1.3 Selectors (derived state and subscriptions)
|
||||
|
||||
**Selectors** are pure functions `(state) => value`. They:
|
||||
|
||||
- **Derive** values (e.g. path node IDs from trigger/updating/paused).
|
||||
- **Scope** data (e.g. “incoming edges for node X”, “connection status for edge Y”).
|
||||
- **Stabilize** references when the logical value hasn’t changed (e.g. same path IDs → same Set reference).
|
||||
|
||||
Components **subscribe via selectors**:
|
||||
|
||||
- `useStore(selectGraph)` → re-render when `graph` slice changes.
|
||||
- `useStore(selectPathNodeIds)` → re-render only when path node IDs change.
|
||||
- `useStore(selectConnectionStatusForEdge, edgeId)` → re-render only when that edge’s status changes.
|
||||
|
||||
So:
|
||||
|
||||
- **Centralized**: all reads go through the store.
|
||||
- **Predictable**: same state in → same selector out.
|
||||
- **Performance**: only components whose selected value changed re-render (with a store that supports shallow equality, e.g. Zustand).
|
||||
|
||||
---
|
||||
|
||||
## 2. Data structures
|
||||
|
||||
### 2.1 Store shape (TypeScript)
|
||||
|
||||
```ts
|
||||
// Slices match current concepts; easy to migrate from existing state.ts + useCanvasConnectionPath.
|
||||
|
||||
interface CanvasStore {
|
||||
graph: {
|
||||
nodes: AppNode[]
|
||||
edges: AppEdge[]
|
||||
}
|
||||
path: ConnectionPathState // from state.ts
|
||||
ui: FlowUIState
|
||||
// optional, for undo/redo
|
||||
_history?: {
|
||||
past: HistoryDelta[]
|
||||
future: HistoryDelta[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Commands (discriminated union)
|
||||
|
||||
```ts
|
||||
type CanvasCommand =
|
||||
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
|
||||
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
|
||||
| { type: 'graph/applySilent'; payload: (prev: GraphState) => GraphState }
|
||||
| { type: 'path/addTrigger'; payload: string }
|
||||
| { type: 'path/startUpdate'; payload: string }
|
||||
| { type: 'path/endUpdate'; payload: string }
|
||||
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
|
||||
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
|
||||
| { type: 'ui/setRenaming'; payload: string | null }
|
||||
| { type: 'ui/setFullscreen'; payload: string | null }
|
||||
// ...
|
||||
```
|
||||
|
||||
Single dispatcher:
|
||||
|
||||
```ts
|
||||
function dispatch(cmd: CanvasCommand): void
|
||||
```
|
||||
|
||||
### 2.3 Selectors (examples)
|
||||
|
||||
```ts
|
||||
// Raw slices
|
||||
const selectGraph = (s: CanvasStore) => s.graph
|
||||
const selectPath = (s: CanvasStore) => s.path
|
||||
|
||||
// Stable derived path sets (same ref if same IDs)
|
||||
const selectPathNodeIds = (s: CanvasStore) => getPathNodeIds(s.graph.edges, s.path...)
|
||||
|
||||
// Per-edge status (for AnimatedEdge) – only changes when this edge’s status changes
|
||||
const selectConnectionStatusForEdge = (s: CanvasStore, source: string, target: string) =>
|
||||
getConnectionStatus({ source, target, pathNodeIds: s.path.connectionPathNodeIds, ... })
|
||||
|
||||
// Per-node: “am I on path?” (for BaseNode)
|
||||
const selectPathRoleForNode = (s: CanvasStore, nodeId: string) =>
|
||||
getConnectionPathRole(nodeId, s.path)
|
||||
```
|
||||
|
||||
Use with a store that supports **selector + equality** so components only re-render when the selected value actually changes (e.g. Zustand’s `useStore(selector, shallowEqual)` or custom `useSelector`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Why this helps
|
||||
|
||||
| Goal | How the pattern helps |
|
||||
|------|------------------------|
|
||||
| **Controlled** | All writes go through `dispatch(cmd)`. No ad-hoc `setState` in hooks or context. |
|
||||
| **Centralized** | One store; no split between context, refs, and local state for the same concept. |
|
||||
| **Predictable** | One command → one reducer → one new state. Order of updates is explicit. |
|
||||
| **Easier to debug** | Log commands; inspect store (e.g. Redux DevTools or a simple `store.getState()` logger); optional time-travel by replaying or reverting commands. |
|
||||
| **Fewer redraws** | Selectors + equality checks mean components only re-render when their slice or derived value changes. |
|
||||
| **Clear data flow** | Data flow is “store → selectors → components” and “events → commands → store”; no implicit propagation. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation options
|
||||
|
||||
### Option A: Zustand (recommended for React)
|
||||
|
||||
- **Store**: `create<CanvasStore>()` with a `dispatch` that applies commands and updates the store.
|
||||
- **Selectors**: `useCanvasStore(selectPathNodeIds)` etc.; Zustand re-renders only when the selected value changes (with shallow or custom equality).
|
||||
- **Commands**: either one `setState` that takes a reducer, or a separate `dispatch` that maps commands to `setState` calls.
|
||||
- **Debug**: middleware that logs commands and state (or use Redux DevTools with a small adapter).
|
||||
|
||||
### Option B: Redux Toolkit
|
||||
|
||||
- **Store**: one RTK store; slices: `graph`, `path`, `ui`.
|
||||
- **Commands**: RTK actions; reducers are pure and easy to test.
|
||||
- **Selectors**: `createSelector` for derived state; `useSelector` for subscriptions.
|
||||
- **Debug**: Redux DevTools out of the box (time-travel, action log, state diff).
|
||||
|
||||
### Option C: Minimal custom store (no new deps)
|
||||
|
||||
- **Store**: a single `useReducer` (or `useState` + reducer) at the top (e.g. CanvasPage or a provider).
|
||||
- **Commands**: dispatch to the reducer; reducer returns new state by slice.
|
||||
- **Selectors**: pass store (or state) to a `useSelector(store, selector, equality)` hook that subscribes and only re-renders when the selected value changes (e.g. by comparing with `Object.is` or shallow compare).
|
||||
- **Debug**: log `dispatch` and state in dev; optional snapshot history in the reducer.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration path from current setup
|
||||
|
||||
1. **Introduce the store** (e.g. Zustand or RTK) next to existing context; keep feeding React Flow and current consumers from the store so behavior stays the same.
|
||||
2. **Move graph state** from `useGraphStateWithHistory` into the store (graph slice + history if needed); keep `setNodes`/`setEdges` as commands that update the store.
|
||||
3. **Move path state** from `useCanvasConnectionPath` into the store (path slice); replace path context with `useStore(selectPath...)` or per-edge/per-node selectors.
|
||||
4. **Move UI state** from CanvasPage `useState` into the store (ui slice); replace FlowUIContext with store selectors.
|
||||
5. **Remove redundant context** (GraphContext, ConnectionPathContext, FlowUIContext) once all reads go through selectors and all writes through commands.
|
||||
6. **Add logging / DevTools** for commands and state; add optional time-travel if desired.
|
||||
|
||||
This can be done slice-by-slice (e.g. path first, then graph, then UI) to keep changes small and testable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation (Zustand)
|
||||
|
||||
The store is implemented under `frontend/src/app/canvas/`:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `canvasStore.types.ts` | `CanvasStore`, `CanvasCommand`, slice types |
|
||||
| `canvasStore.reducer.ts` | Pure reducer + `initialCanvasStore` |
|
||||
| `canvasStore.selectors.ts` | Selectors (graph, path derived sets, per-edge status, per-node role) |
|
||||
| `canvasStore.ts` | Zustand store, `getCanvasStore()`, `dispatchCanvasCommand()`, `useCanvasStore()`, `useCanvasStoreDispatch()`; dev logging of commands |
|
||||
| `canvasStore.index.ts` | Re-exports for consumers |
|
||||
| `canvasStore.test.ts` | Test suite (reducer, selectors, store integration) |
|
||||
|
||||
**Run tests:** `npm run test:run` (or `npm run test` for watch) in `frontend/`.
|
||||
|
||||
**Usage:** Import from `@/app/canvas/canvasStore` or `@/app/canvas/canvasStore.index`:
|
||||
|
||||
- `dispatchCanvasCommand({ type: 'graph/setNodes', payload: nodes })`
|
||||
- `useCanvasStore(selectPathNodeIds)` or `useCanvasStore(selectConnectionStatusForEdge, ...)` (selectors take state; for per-edge/per-node use a factory selector in the component)
|
||||
- `useCanvasStoreDispatch()` for stable dispatch in components
|
||||
|
||||
Migration from existing context: feed the store from CanvasPage (or sync store ↔ existing hooks) and gradually replace context consumers with `useCanvasStore(selector)` and `dispatchCanvasCommand`. See §5 migration path.
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary
|
||||
|
||||
- **Pattern**: one **store** (graph + path + ui), **commands** for all mutations, **selectors** for reads and derived state.
|
||||
- **Data structures**: flat slices in the store; commands as a discriminated union; selectors as pure functions (state [, args]) → value.
|
||||
- **Benefits**: controlled, centralized, predictable, easier to debug, and fewer unnecessary redraws via selector-based subscriptions.
|
||||
- **Concrete next step**: migrate one consumer (e.g. AnimatedEdge) to `useCanvasStore(selectConnectionStatusForEdge)` with a per-edge selector and `dispatchCanvasCommand` for path updates; then remove its ConnectionPathContext dependency.
|
||||
1981
frontend/package-lock.json
generated
1981
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"zustand": "^5.0.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
@@ -25,24 +26,28 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/react-codemirror": "^4.25.7",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@wireweave/core": "^2.6.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"markdown-to-jsx": "^9.7.9",
|
||||
"marked": "^17.0.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nunjucks": "^3.2.4",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
@@ -52,6 +57,7 @@
|
||||
"shadcn": "^4.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^2.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Undo2 } from 'lucide-react'
|
||||
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Save, Undo2 } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export type CanvasMenubarProps = {
|
||||
onImport: () => void
|
||||
onExport: () => void
|
||||
onSave?: () => void
|
||||
canSave?: boolean
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
@@ -34,6 +36,7 @@ export type CanvasMenubarProps = {
|
||||
|
||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||
const REDO_KEYS = { key: 'z', shiftKey: true }
|
||||
const SAVE_KEYS = { key: 's', shiftKey: false }
|
||||
|
||||
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
||||
const mod = ev.ctrlKey || ev.metaKey
|
||||
@@ -43,6 +46,8 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
||||
export function CanvasMenubar({
|
||||
onImport,
|
||||
onExport,
|
||||
onSave,
|
||||
canSave = true,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
@@ -114,11 +119,19 @@ export function CanvasMenubar({
|
||||
ev.stopPropagation()
|
||||
redo()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (matchKey(ev, SAVE_KEYS)) {
|
||||
if (onSave && canSave) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [undo, redo, canUndo, canRedo])
|
||||
}, [undo, redo, canUndo, canRedo, onSave, canSave])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
|
||||
@@ -135,6 +148,20 @@ export function CanvasMenubar({
|
||||
<MenubarContent>
|
||||
{projectId && (
|
||||
<>
|
||||
{onSave != null && (
|
||||
<>
|
||||
<MenubarItem onClick={onSave} disabled={!canSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘S</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
</>
|
||||
)}
|
||||
<MenubarItem
|
||||
onClick={() => setIsRenamingProject(true)}
|
||||
className="gap-2"
|
||||
|
||||
@@ -31,10 +31,11 @@ import {
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
||||
import { getExampleGraph } from '@/app/canvas/canvasGraphUtils'
|
||||
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
||||
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
|
||||
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||
@@ -169,6 +170,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
canUndo,
|
||||
canRedo,
|
||||
setStateImmediate,
|
||||
save,
|
||||
} = useCanvasGraph(projectId)
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -185,20 +187,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const [isSelecting, setIsSelecting] = React.useState(false)
|
||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
|
||||
const connectionPath = useCanvasConnectionPath(edges)
|
||||
useEffect(() => {
|
||||
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
|
||||
}, [nodes, edges])
|
||||
|
||||
const connectionPath = useCanvasConnectionPathFromStore()
|
||||
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
|
||||
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
|
||||
React.useEffect(() => {
|
||||
fetch('/api/todos')
|
||||
.then((r) => r.json())
|
||||
.then((data: unknown) => {
|
||||
if (Array.isArray(data)) setApiTodosCount(data.length)
|
||||
})
|
||||
.catch(() => setApiTodosCount(-1))
|
||||
}, [])
|
||||
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
|
||||
graphRef.current.nodes = nodes
|
||||
graphRef.current.edges = edges
|
||||
|
||||
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
||||
const rafRef = useRef<number | null>(null)
|
||||
@@ -244,7 +243,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
)
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
(params: Connection) => {
|
||||
const targetType =
|
||||
nodesRef.current.find((n) => n.id === params.target)?.type ?? ''
|
||||
const conn = { ...params, data: { targetType } as Record<string, unknown> }
|
||||
setEdges((eds) => addEdge(conn, eds))
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
@@ -324,7 +328,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
toast.error('Invalid file: expected nodes and edges arrays')
|
||||
return
|
||||
}
|
||||
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
|
||||
const nodes = state.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
|
||||
setStateImmediate({ nodes, edges })
|
||||
if (state.version != null && state.version > PROJECT_VERSION) {
|
||||
toast.error('Project was created with a newer app version')
|
||||
} else {
|
||||
@@ -393,8 +399,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
}, [])
|
||||
|
||||
const graphContextValue = useMemo(
|
||||
() => ({ nodes, setNodes, edges, setEdges }),
|
||||
[nodes, setNodes, edges, setEdges]
|
||||
() => ({ setNodes, setEdges, graphRef, edges }),
|
||||
[setNodes, setEdges, edges]
|
||||
)
|
||||
const connectionPathContextValue = useMemo(
|
||||
() => ({
|
||||
@@ -438,18 +444,36 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
]
|
||||
)
|
||||
|
||||
const nodesForFlow = useMemo(
|
||||
() =>
|
||||
nodes.map((n) => ({
|
||||
const prevNodesRef = useRef<AppNode[]>([])
|
||||
const prevNodesForFlowRef = useRef<Node[]>([])
|
||||
const nodesForFlow = useMemo(() => {
|
||||
const prev = prevNodesRef.current
|
||||
if (nodes === prev) return prevNodesForFlowRef.current
|
||||
const prevById = new Map(prev.map((n) => [n.id, n]))
|
||||
const prevWrappedById = new Map(
|
||||
prevNodesForFlowRef.current.map((w, i) => [prev[i]?.id, w])
|
||||
)
|
||||
const result = nodes.map((n) => {
|
||||
const prevNode = prevById.get(n.id)
|
||||
if (prevNode === n && prevWrappedById.has(n.id)) {
|
||||
return prevWrappedById.get(n.id)!
|
||||
}
|
||||
return {
|
||||
...n,
|
||||
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
||||
})),
|
||||
[nodes]
|
||||
)
|
||||
}
|
||||
})
|
||||
prevNodesRef.current = nodes
|
||||
prevNodesForFlowRef.current = result
|
||||
return result
|
||||
}, [nodes])
|
||||
const edgesForFlow = useMemo(
|
||||
() =>
|
||||
edges.map((e) => {
|
||||
const targetType = nodes.find((nd) => nd.id === e.target)?.type ?? ''
|
||||
const targetType =
|
||||
(typeof e.data === 'object' && e.data !== null && (e.data as Record<string, unknown>).targetType != null
|
||||
? (e.data as Record<string, unknown>).targetType
|
||||
: '') as string
|
||||
const connectionLabel = getConnectionLabelForTarget(targetType)
|
||||
const baseData =
|
||||
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
||||
@@ -458,7 +482,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
data: { ...baseData, connectionLabel },
|
||||
}
|
||||
}),
|
||||
[edges, nodes]
|
||||
[edges]
|
||||
)
|
||||
|
||||
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
|
||||
@@ -595,6 +619,15 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
<CanvasMenubar
|
||||
onImport={handleImportProject}
|
||||
onExport={handleExportProject}
|
||||
onSave={
|
||||
projectId
|
||||
? () => {
|
||||
save()
|
||||
toast.success('Saved')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
canSave={Boolean(projectId)}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
canUndo={canUndo}
|
||||
@@ -606,11 +639,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
canCopy={selectedNodes.length === 1}
|
||||
onFitView={() => flowActionsRef.current?.fitView?.()}
|
||||
/>
|
||||
{apiTodosCount !== null && (
|
||||
<div className="shrink-0 px-3 py-1 text-xs text-muted-foreground border-b border-border/50">
|
||||
Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<GraphContext.Provider value={graphContextValue}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CanvasPage } from './CanvasPage'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
@@ -10,12 +10,14 @@ import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
export function CanvasRoute() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { projects, updateLastEdited } = usePlatform()
|
||||
const updateLastEditedRef = useRef(updateLastEdited)
|
||||
updateLastEditedRef.current = updateLastEdited
|
||||
|
||||
const project = projects.find((p) => p.id === projectId)
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) updateLastEdited(projectId)
|
||||
}, [projectId, updateLastEdited])
|
||||
if (projectId) updateLastEditedRef.current(projectId)
|
||||
}, [projectId])
|
||||
|
||||
if (!projectId) return null
|
||||
if (!project) {
|
||||
|
||||
@@ -17,25 +17,34 @@ export { ViewportDisplayContext }
|
||||
|
||||
/**
|
||||
* Must be rendered inside ReactFlowProvider. Subscribes to viewport once,
|
||||
* maps zoom to displayMode with hysteresis, and provides it to descendants.
|
||||
* maps zoom to displayMode with hysteresis. Throttles updates via rAF to avoid
|
||||
* re-rendering all contextual nodes on every zoom tick.
|
||||
*/
|
||||
export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) {
|
||||
const { zoom } = useViewport()
|
||||
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
|
||||
zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full'
|
||||
)
|
||||
const lastRef = useRef(displayMode)
|
||||
const lastModeRef = useRef(displayMode)
|
||||
const zoomRef = useRef(zoom)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
zoomRef.current = zoom
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
|
||||
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
|
||||
let next: ViewportDisplayMode = lastRef.current
|
||||
if (zoom <= low) next = 'compact'
|
||||
else if (zoom >= high) next = 'full'
|
||||
if (next !== lastRef.current) {
|
||||
lastRef.current = next
|
||||
setDisplayMode(next)
|
||||
}
|
||||
if (rafRef.current !== null) return
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
const z = zoomRef.current
|
||||
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
|
||||
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
|
||||
let next: ViewportDisplayMode = lastModeRef.current
|
||||
if (z <= low) next = 'compact'
|
||||
else if (z >= high) next = 'full'
|
||||
if (next !== lastModeRef.current) {
|
||||
lastModeRef.current = next
|
||||
setDisplayMode(next)
|
||||
}
|
||||
})
|
||||
}, [zoom])
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,20 @@ import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage'
|
||||
|
||||
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
||||
export function backfillEdgeTargetTypes(
|
||||
nodes: AppNode[],
|
||||
edges: AppEdge[]
|
||||
): AppEdge[] {
|
||||
const typeById = new Map(nodes.map((n) => [n.id, n.type ?? '']))
|
||||
return edges.map((e) => {
|
||||
const targetType = typeById.get(e.target) ?? (e.data as Record<string, unknown>)?.targetType ?? ''
|
||||
const data = typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
||||
if (data.targetType === targetType) return e
|
||||
return { ...e, data: { ...data, targetType } }
|
||||
})
|
||||
}
|
||||
|
||||
const NODE_GAP = 150
|
||||
|
||||
const EXAMPLE_NODES: AppNode[] = [
|
||||
@@ -45,20 +59,24 @@ const EXAMPLE_EDGES: AppEdge[] = [
|
||||
]
|
||||
|
||||
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
return {
|
||||
nodes: EXAMPLE_NODES.map((n) => ({
|
||||
...n,
|
||||
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
||||
})),
|
||||
edges: EXAMPLE_EDGES.map((e) => ({ ...e })),
|
||||
}
|
||||
const nodes = EXAMPLE_NODES.map((n) => ({
|
||||
...n,
|
||||
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
||||
}))
|
||||
const edges = backfillEdgeTargetTypes(
|
||||
nodes,
|
||||
EXAMPLE_EDGES.map((e) => ({ ...e }))
|
||||
)
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
if (projectId) {
|
||||
const stored = loadGraphFromStorage(projectId)
|
||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||
return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] }
|
||||
const nodes = stored.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
||||
return { nodes, edges }
|
||||
}
|
||||
return { nodes: [], edges: [] }
|
||||
}
|
||||
|
||||
31
frontend/src/app/canvas/canvasStore.index.ts
Normal file
31
frontend/src/app/canvas/canvasStore.index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Canvas store: centralized state + commands + selectors.
|
||||
* Entry point for store usage.
|
||||
*/
|
||||
|
||||
export {
|
||||
getCanvasStore,
|
||||
dispatchCanvasCommand,
|
||||
useCanvasStore,
|
||||
useCanvasStoreDispatch,
|
||||
canvasStore,
|
||||
} from './canvasStore'
|
||||
export type { CanvasStore, CanvasCommand } from './canvasStore'
|
||||
export { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
|
||||
export type { CanvasStore as CanvasStoreState, PathSlice, UISlice, GraphSlice, ConnectionFrom } from './canvasStore.types'
|
||||
export {
|
||||
selectGraph,
|
||||
selectPath,
|
||||
selectUI,
|
||||
selectNodes,
|
||||
selectEdges,
|
||||
selectPathNodeIds,
|
||||
selectPathPausedSegmentNodeIds,
|
||||
selectPathActiveSegmentNodeIds,
|
||||
selectConnectionStatusForEdge,
|
||||
selectPathRoleForNode,
|
||||
selectRenamingNodeId,
|
||||
selectFullscreenNodeId,
|
||||
selectConnectionFrom,
|
||||
} from './canvasStore.selectors'
|
||||
export type { ConnectionPathRole } from './canvasStore.selectors'
|
||||
143
frontend/src/app/canvas/canvasStore.reducer.ts
Normal file
143
frontend/src/app/canvas/canvasStore.reducer.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Pure reducer for the canvas store. One command → one state transition.
|
||||
*/
|
||||
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import type { CanvasStore, CanvasCommand, GraphSlice, PathSlice, UISlice } from './canvasStore.types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initialGraph: GraphSlice = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
const initialPath: PathSlice = {
|
||||
updatingNodeIds: [],
|
||||
triggerNodeIds: [],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: [],
|
||||
}
|
||||
|
||||
const initialUI: UISlice = {
|
||||
renamingNodeId: null,
|
||||
fullscreenNodeId: null,
|
||||
connectionFrom: null,
|
||||
}
|
||||
|
||||
export const initialCanvasStore: CanvasStore = {
|
||||
graph: initialGraph,
|
||||
path: initialPath,
|
||||
ui: initialUI,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function reduceGraph(prev: GraphSlice, cmd: CanvasCommand): GraphSlice {
|
||||
switch (cmd.type) {
|
||||
case 'graph/setNodes': {
|
||||
const next =
|
||||
typeof cmd.payload === 'function' ? cmd.payload(prev.nodes) : cmd.payload
|
||||
return { ...prev, nodes: next }
|
||||
}
|
||||
case 'graph/setEdges': {
|
||||
const next =
|
||||
typeof cmd.payload === 'function' ? cmd.payload(prev.edges) : cmd.payload
|
||||
return { ...prev, edges: next }
|
||||
}
|
||||
case 'graph/apply': {
|
||||
return {
|
||||
nodes: cmd.payload.nodes ?? prev.nodes,
|
||||
edges: cmd.payload.edges ?? prev.edges,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return prev
|
||||
}
|
||||
}
|
||||
|
||||
function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
|
||||
switch (cmd.type) {
|
||||
case 'path/addTrigger': {
|
||||
const id = cmd.payload
|
||||
if (prev.triggerNodeIds.includes(id)) return prev
|
||||
return { ...prev, triggerNodeIds: [...prev.triggerNodeIds, id] }
|
||||
}
|
||||
case 'path/clearTriggers':
|
||||
return { ...prev, triggerNodeIds: [] }
|
||||
case 'path/startUpdate': {
|
||||
const id = cmd.payload
|
||||
if (prev.updatingNodeIds.includes(id)) return prev
|
||||
return { ...prev, updatingNodeIds: [...prev.updatingNodeIds, id] }
|
||||
}
|
||||
case 'path/endUpdate': {
|
||||
const id = cmd.payload
|
||||
return {
|
||||
...prev,
|
||||
updatingNodeIds: prev.updatingNodeIds.filter((x) => x !== id),
|
||||
}
|
||||
}
|
||||
case 'path/setPaused': {
|
||||
const { nodeId, paused } = cmd.payload
|
||||
const has = prev.pausedNodeIds.includes(nodeId)
|
||||
if (paused === has) return prev
|
||||
return {
|
||||
...prev,
|
||||
pausedNodeIds: paused
|
||||
? [...prev.pausedNodeIds, nodeId]
|
||||
: prev.pausedNodeIds.filter((x) => x !== nodeId),
|
||||
}
|
||||
}
|
||||
case 'path/setError': {
|
||||
const { nodeId, error } = cmd.payload
|
||||
const has = prev.errorNodeIds.includes(nodeId)
|
||||
if (error === has) return prev
|
||||
return {
|
||||
...prev,
|
||||
errorNodeIds: error
|
||||
? [...prev.errorNodeIds, nodeId]
|
||||
: prev.errorNodeIds.filter((x) => x !== nodeId),
|
||||
}
|
||||
}
|
||||
case 'path/clearPathSession':
|
||||
return {
|
||||
...initialPath,
|
||||
errorNodeIds: prev.errorNodeIds,
|
||||
}
|
||||
case 'path/clearErrors':
|
||||
return { ...prev, errorNodeIds: [] }
|
||||
default:
|
||||
return prev
|
||||
}
|
||||
}
|
||||
|
||||
function reduceUI(prev: UISlice, cmd: CanvasCommand): UISlice {
|
||||
switch (cmd.type) {
|
||||
case 'ui/setRenaming':
|
||||
return { ...prev, renamingNodeId: cmd.payload }
|
||||
case 'ui/setFullscreen':
|
||||
return { ...prev, fullscreenNodeId: cmd.payload }
|
||||
case 'ui/setConnectionFrom':
|
||||
return { ...prev, connectionFrom: cmd.payload }
|
||||
default:
|
||||
return prev
|
||||
}
|
||||
}
|
||||
|
||||
export function canvasStoreReducer(state: CanvasStore, command: CanvasCommand): CanvasStore {
|
||||
const graph = reduceGraph(state.graph, command)
|
||||
const path = reducePath(state.path, command)
|
||||
const ui = reduceUI(state.ui, command)
|
||||
if (
|
||||
graph === state.graph &&
|
||||
path === state.path &&
|
||||
ui === state.ui
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return { graph, path, ui }
|
||||
}
|
||||
129
frontend/src/app/canvas/canvasStore.selectors.ts
Normal file
129
frontend/src/app/canvas/canvasStore.selectors.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Selectors for the canvas store. Pure (state) => value.
|
||||
* Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here.
|
||||
*/
|
||||
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
||||
import type { CanvasStore } from './canvasStore.types'
|
||||
|
||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' | null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw slices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function selectGraph(state: CanvasStore) {
|
||||
return state.graph
|
||||
}
|
||||
|
||||
export function selectPath(state: CanvasStore) {
|
||||
return state.path
|
||||
}
|
||||
|
||||
export function selectUI(state: CanvasStore) {
|
||||
return state.ui
|
||||
}
|
||||
|
||||
export function selectNodes(state: CanvasStore) {
|
||||
return state.graph.nodes
|
||||
}
|
||||
|
||||
export function selectEdges(state: CanvasStore) {
|
||||
return state.graph.edges
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived path (Sets) – depend on graph.edges + path primitive arrays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const emptySet = new Set<string>()
|
||||
|
||||
function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) {
|
||||
return edges.map((e) => ({ source: e.source, target: e.target }))
|
||||
}
|
||||
|
||||
export function selectPathNodeIds(state: CanvasStore): Set<string> {
|
||||
const { edges } = state.graph
|
||||
const { updatingNodeIds, triggerNodeIds, pausedNodeIds } = state.path
|
||||
return getPathNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
updatingNodeIds,
|
||||
triggerNodeIds,
|
||||
pausedNodeIds
|
||||
)
|
||||
}
|
||||
|
||||
export function selectPathPausedSegmentNodeIds(state: CanvasStore): Set<string> {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const { edges } = state.graph
|
||||
const { triggerNodeIds, pausedNodeIds } = state.path
|
||||
return getPausedSegmentNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
pathNodeIds,
|
||||
triggerNodeIds,
|
||||
pausedNodeIds
|
||||
)
|
||||
}
|
||||
|
||||
export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set<string> {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const pausedSegment = selectPathPausedSegmentNodeIds(state)
|
||||
const active = new Set(pathNodeIds)
|
||||
pausedSegment.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-edge connection status (for AnimatedEdge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function selectConnectionStatusForEdge(
|
||||
state: CanvasStore,
|
||||
source: string,
|
||||
target: string
|
||||
): ConnectionStatus {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const pausedSegmentNodeIds = selectPathPausedSegmentNodeIds(state)
|
||||
const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state)
|
||||
const errorTargetNodeIds = new Set(state.path.errorNodeIds)
|
||||
return getConnectionStatus({
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-node path role (for BaseNode styling)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function selectPathRoleForNode(
|
||||
state: CanvasStore,
|
||||
nodeId: string
|
||||
): ConnectionPathRole {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
if (!pathNodeIds.has(nodeId)) return null
|
||||
if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger'
|
||||
if (state.path.updatingNodeIds.includes(nodeId)) return 'updating'
|
||||
return 'on-path'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function selectRenamingNodeId(state: CanvasStore): string | null {
|
||||
return state.ui.renamingNodeId
|
||||
}
|
||||
|
||||
export function selectFullscreenNodeId(state: CanvasStore): string | null {
|
||||
return state.ui.fullscreenNodeId
|
||||
}
|
||||
|
||||
export function selectConnectionFrom(state: CanvasStore) {
|
||||
return state.ui.connectionFrom
|
||||
}
|
||||
399
frontend/src/app/canvas/canvasStore.test.ts
Normal file
399
frontend/src/app/canvas/canvasStore.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
initialCanvasStore,
|
||||
canvasStoreReducer,
|
||||
getCanvasStore,
|
||||
dispatchCanvasCommand,
|
||||
} from './canvasStore'
|
||||
import {
|
||||
selectPathNodeIds,
|
||||
selectPathPausedSegmentNodeIds,
|
||||
selectPathActiveSegmentNodeIds,
|
||||
selectConnectionStatusForEdge,
|
||||
selectPathRoleForNode,
|
||||
selectNodes,
|
||||
selectEdges,
|
||||
selectRenamingNodeId,
|
||||
selectFullscreenNodeId,
|
||||
selectConnectionFrom,
|
||||
} from './canvasStore.selectors'
|
||||
import type { CanvasStore, CanvasCommand } from './canvasStore.types'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeNode(id: string, type = 'config'): AppNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
}
|
||||
}
|
||||
|
||||
function makeEdge(id: string, source: string, target: string): AppEdge {
|
||||
return { id, source, target }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reducer: graph commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('canvasStoreReducer', () => {
|
||||
describe('graph commands', () => {
|
||||
it('graph/setNodes replaces nodes', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a')],
|
||||
edges: [],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'graph/setNodes',
|
||||
payload: [makeNode('b'), makeNode('c')],
|
||||
})
|
||||
expect(next.graph.nodes).toHaveLength(2)
|
||||
expect(next.graph.nodes.map((n) => n.id)).toEqual(['b', 'c'])
|
||||
expect(next.graph.edges).toEqual(state.graph.edges)
|
||||
})
|
||||
|
||||
it('graph/setNodes with updater function', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a'), makeNode('b')],
|
||||
edges: [],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'graph/setNodes',
|
||||
payload: (prev) => prev.filter((n) => n.id !== 'a'),
|
||||
})
|
||||
expect(next.graph.nodes).toHaveLength(1)
|
||||
expect(next.graph.nodes[0].id).toBe('b')
|
||||
})
|
||||
|
||||
it('graph/setEdges replaces edges', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'graph/setEdges',
|
||||
payload: [makeEdge('e2', 'b', 'c')],
|
||||
})
|
||||
expect(next.graph.edges).toHaveLength(1)
|
||||
expect(next.graph.edges[0].id).toBe('e2')
|
||||
})
|
||||
|
||||
it('graph/apply updates nodes and edges', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a')],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'graph/apply',
|
||||
payload: { nodes: [makeNode('x')] },
|
||||
})
|
||||
expect(next.graph.nodes).toHaveLength(1)
|
||||
expect(next.graph.nodes[0].id).toBe('x')
|
||||
expect(next.graph.edges).toEqual(state.graph.edges)
|
||||
})
|
||||
})
|
||||
|
||||
describe('path commands', () => {
|
||||
it('path/addTrigger adds node id', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/addTrigger',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.triggerNodeIds).toEqual(['n1'])
|
||||
})
|
||||
|
||||
it('path/addTrigger is idempotent', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: { ...initialCanvasStore.path, triggerNodeIds: ['n1'] },
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/addTrigger',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
|
||||
it('path/clearTriggers empties triggerNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: { ...initialCanvasStore.path, triggerNodeIds: ['a', 'b'] },
|
||||
}
|
||||
const next = canvasStoreReducer(state, { type: 'path/clearTriggers' })
|
||||
expect(next.path.triggerNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('path/startUpdate adds to updatingNodeIds', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/startUpdate',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.updatingNodeIds).toEqual(['n1'])
|
||||
})
|
||||
|
||||
it('path/endUpdate removes from updatingNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
updatingNodeIds: ['n1', 'n2'],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/endUpdate',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.updatingNodeIds).toEqual(['n2'])
|
||||
})
|
||||
|
||||
it('path/setPaused adds and removes paused node', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
let next = canvasStoreReducer(state, {
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId: 'n1', paused: true },
|
||||
})
|
||||
expect(next.path.pausedNodeIds).toEqual(['n1'])
|
||||
next = canvasStoreReducer(next, {
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId: 'n1', paused: false },
|
||||
})
|
||||
expect(next.path.pausedNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('path/setError adds and removes error node', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
let next = canvasStoreReducer(state, {
|
||||
type: 'path/setError',
|
||||
payload: { nodeId: 'n1', error: true },
|
||||
})
|
||||
expect(next.path.errorNodeIds).toEqual(['n1'])
|
||||
next = canvasStoreReducer(next, {
|
||||
type: 'path/setError',
|
||||
payload: { nodeId: 'n1', error: false },
|
||||
})
|
||||
expect(next.path.errorNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('path/clearPathSession resets updating, trigger, paused; keeps error', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: {
|
||||
updatingNodeIds: ['u1'],
|
||||
triggerNodeIds: ['t1'],
|
||||
pausedNodeIds: ['p1'],
|
||||
errorNodeIds: ['e1'],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
|
||||
expect(next.path.updatingNodeIds).toEqual([])
|
||||
expect(next.path.triggerNodeIds).toEqual([])
|
||||
expect(next.path.pausedNodeIds).toEqual([])
|
||||
expect(next.path.errorNodeIds).toEqual(['e1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui commands', () => {
|
||||
it('ui/setRenaming updates renamingNodeId', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'ui/setRenaming',
|
||||
payload: 'node-1',
|
||||
})
|
||||
expect(next.ui.renamingNodeId).toBe('node-1')
|
||||
})
|
||||
|
||||
it('ui/setFullscreen updates fullscreenNodeId', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'ui/setFullscreen',
|
||||
payload: 'node-2',
|
||||
})
|
||||
expect(next.ui.fullscreenNodeId).toBe('node-2')
|
||||
})
|
||||
|
||||
it('ui/setConnectionFrom updates connectionFrom', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'ui/setConnectionFrom',
|
||||
payload: { nodeId: 'n1', sourceHandle: 'out' },
|
||||
})
|
||||
expect(next.ui.connectionFrom).toEqual({ nodeId: 'n1', sourceHandle: 'out' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('no-op returns same reference', () => {
|
||||
it('path/addTrigger with existing id returns state', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: { ...initialCanvasStore.path, triggerNodeIds: ['a'] },
|
||||
}
|
||||
const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'a' })
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('canvasStore selectors', () => {
|
||||
it('selectNodes and selectEdges return graph slice', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a')],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
}
|
||||
expect(selectNodes(state)).toHaveLength(1)
|
||||
expect(selectEdges(state)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('selectPathNodeIds derives path from edges and path arrays', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a'), makeNode('b'), makeNode('c')],
|
||||
edges: [
|
||||
makeEdge('e1', 'a', 'b'),
|
||||
makeEdge('e2', 'b', 'c'),
|
||||
],
|
||||
},
|
||||
path: {
|
||||
updatingNodeIds: ['c'],
|
||||
triggerNodeIds: ['a'],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: [],
|
||||
},
|
||||
}
|
||||
const pathIds = selectPathNodeIds(state)
|
||||
expect(pathIds.has('a')).toBe(true)
|
||||
expect(pathIds.has('b')).toBe(true)
|
||||
expect(pathIds.has('c')).toBe(true)
|
||||
})
|
||||
|
||||
it('selectConnectionStatusForEdge returns default when edge not on path', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')] },
|
||||
path: initialCanvasStore.path,
|
||||
}
|
||||
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('default')
|
||||
})
|
||||
|
||||
it('selectConnectionStatusForEdge returns error when target has error', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
triggerNodeIds: ['a'],
|
||||
updatingNodeIds: ['b'],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: ['b'],
|
||||
},
|
||||
}
|
||||
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
|
||||
})
|
||||
|
||||
it('selectPathRoleForNode returns trigger when node in triggerNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
triggerNodeIds: ['a'],
|
||||
updatingNodeIds: ['b'],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: [],
|
||||
},
|
||||
}
|
||||
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
|
||||
expect(selectPathRoleForNode(state, 'b')).toBe('updating')
|
||||
expect(selectPathRoleForNode(state, 'x')).toBe(null)
|
||||
})
|
||||
|
||||
it('selectRenamingNodeId, selectFullscreenNodeId, selectConnectionFrom return ui slice', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
ui: {
|
||||
renamingNodeId: 'r1',
|
||||
fullscreenNodeId: 'f1',
|
||||
connectionFrom: { nodeId: 'c1' },
|
||||
},
|
||||
}
|
||||
expect(selectRenamingNodeId(state)).toBe('r1')
|
||||
expect(selectFullscreenNodeId(state)).toBe('f1')
|
||||
expect(selectConnectionFrom(state)).toEqual({ nodeId: 'c1' })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store integration (dispatch + getState)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('canvas store integration', () => {
|
||||
beforeEach(() => {
|
||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [] })
|
||||
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [] })
|
||||
dispatchCanvasCommand({ type: 'path/clearPathSession' })
|
||||
dispatchCanvasCommand({ type: 'path/clearTriggers' })
|
||||
dispatchCanvasCommand({ type: 'path/clearErrors' })
|
||||
dispatchCanvasCommand({ type: 'ui/setRenaming', payload: null })
|
||||
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: null })
|
||||
dispatchCanvasCommand({ type: 'ui/setConnectionFrom', payload: null })
|
||||
})
|
||||
|
||||
it('dispatch graph/setNodes updates getCanvasStore().graph.nodes', () => {
|
||||
const node = makeNode('test-1')
|
||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [node] })
|
||||
const state = getCanvasStore()
|
||||
expect(state.graph.nodes).toHaveLength(1)
|
||||
expect(state.graph.nodes[0].id).toBe('test-1')
|
||||
})
|
||||
|
||||
it('dispatch path/addTrigger updates path and selectPathNodeIds', () => {
|
||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
|
||||
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
|
||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: 'b' })
|
||||
const state = getCanvasStore()
|
||||
expect(state.path.triggerNodeIds).toContain('a')
|
||||
expect(state.path.updatingNodeIds).toContain('b')
|
||||
const pathIds = selectPathNodeIds(state)
|
||||
expect(pathIds.has('a')).toBe(true)
|
||||
expect(pathIds.has('b')).toBe(true)
|
||||
})
|
||||
|
||||
it('dispatch ui/setFullscreen updates getCanvasStore().ui', () => {
|
||||
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: 'full-node' })
|
||||
const state = getCanvasStore()
|
||||
expect(state.ui.fullscreenNodeId).toBe('full-node')
|
||||
})
|
||||
})
|
||||
74
frontend/src/app/canvas/canvasStore.ts
Normal file
74
frontend/src/app/canvas/canvasStore.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Centralized canvas store (Zustand). Single source of truth for graph, path, and UI.
|
||||
* Mutate only via dispatch(command); read via useCanvasStore(selector) or getCanvasStore().
|
||||
*/
|
||||
|
||||
import { createStore, useStore } from 'zustand'
|
||||
import type { CanvasStore as CanvasStoreState, CanvasCommand } from './canvasStore.types'
|
||||
import { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store type (state + dispatch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasStoreWithDispatch = CanvasStoreState & {
|
||||
dispatch: (command: CanvasCommand) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createCanvasStore() {
|
||||
return createStore<CanvasStoreWithDispatch>((set, get) => {
|
||||
const applyCommand = (command: CanvasCommand) => {
|
||||
const prev = get()
|
||||
const next = canvasStoreReducer(prev, command)
|
||||
if (next === prev) return
|
||||
set({ ...next, dispatch: prev.dispatch })
|
||||
}
|
||||
const dispatch: (command: CanvasCommand) => void =
|
||||
typeof import.meta !== 'undefined' && import.meta.env?.DEV
|
||||
? (command) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[canvas]', command.type, command.payload)
|
||||
applyCommand(command)
|
||||
}
|
||||
: applyCommand
|
||||
return { ...initialCanvasStore, dispatch }
|
||||
})
|
||||
}
|
||||
|
||||
const canvasStore = createCanvasStore()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get current state (for use outside React or in selectors). Excludes dispatch. */
|
||||
export function getCanvasStore(): CanvasStoreState {
|
||||
const s = canvasStore.getState()
|
||||
return { graph: s.graph, path: s.path, ui: s.ui }
|
||||
}
|
||||
|
||||
/** Dispatch a command. Use for all mutations. */
|
||||
export function dispatchCanvasCommand(command: CanvasCommand): void {
|
||||
canvasStore.getState().dispatch(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the store. Pass a selector to re-render only when the selected value changes.
|
||||
* For object/array selectors consider useShallow from 'zustand/react/shallow' to avoid unnecessary re-renders.
|
||||
*/
|
||||
export function useCanvasStore<T>(selector: (state: CanvasStoreState) => T): T {
|
||||
return useStore(canvasStore, selector)
|
||||
}
|
||||
|
||||
/** Hook that returns dispatch (stable reference). */
|
||||
export function useCanvasStoreDispatch(): (command: CanvasCommand) => void {
|
||||
return useStore(canvasStore, (s) => s.dispatch, Object.is)
|
||||
}
|
||||
|
||||
export { canvasStore, initialCanvasStore, canvasStoreReducer }
|
||||
export type { CanvasStoreState as CanvasStore }
|
||||
export type { CanvasCommand }
|
||||
68
frontend/src/app/canvas/canvasStore.types.ts
Normal file
68
frontend/src/app/canvas/canvasStore.types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Canvas store: types for the centralized store (graph + path + ui).
|
||||
* All mutations go through commands; reads go through selectors.
|
||||
*/
|
||||
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph slice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GraphSlice = {
|
||||
nodes: AppNode[]
|
||||
edges: AppEdge[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path slice (primitive arrays; derived Sets are in selectors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PathSlice = {
|
||||
updatingNodeIds: string[]
|
||||
triggerNodeIds: string[]
|
||||
pausedNodeIds: string[]
|
||||
errorNodeIds: string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI slice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||
|
||||
export type UISlice = {
|
||||
renamingNodeId: string | null
|
||||
fullscreenNodeId: string | null
|
||||
connectionFrom: ConnectionFrom
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasStore = {
|
||||
graph: GraphSlice
|
||||
path: PathSlice
|
||||
ui: UISlice
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands (discriminated union)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasCommand =
|
||||
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
|
||||
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
|
||||
| { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } }
|
||||
| { type: 'path/addTrigger'; payload: string }
|
||||
| { type: 'path/clearTriggers' }
|
||||
| { type: 'path/startUpdate'; payload: string }
|
||||
| { type: 'path/endUpdate'; payload: string }
|
||||
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
|
||||
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
|
||||
| { type: 'path/clearPathSession' }
|
||||
| { type: 'path/clearErrors' }
|
||||
| { type: 'ui/setRenaming'; payload: string | null }
|
||||
| { type: 'ui/setFullscreen'; payload: string | null }
|
||||
| { type: 'ui/setConnectionFrom'; payload: ConnectionFrom }
|
||||
@@ -14,8 +14,8 @@ function setToStableKey(s: Set<string>): string {
|
||||
|
||||
export type EdgeLike = { source: string; target: string }
|
||||
|
||||
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
|
||||
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
|
||||
/** Short tail (ms) after last updating node ends so the path doesn't vanish instantly. */
|
||||
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
|
||||
|
||||
export type UseCanvasConnectionPathResult = {
|
||||
connectionPathUpdatingNodeIds: string[]
|
||||
@@ -41,7 +41,6 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
||||
|
||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const pathUpdateStartTimeRef = useRef<number | null>(null)
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
||||
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
||||
@@ -61,7 +60,6 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.add(nodeId)
|
||||
if (ref.size === 1) {
|
||||
pathUpdateStartTimeRef.current = Date.now()
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
@@ -77,17 +75,10 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
return
|
||||
}
|
||||
const startedAt = pathUpdateStartTimeRef.current ?? 0
|
||||
const elapsed = Date.now() - startedAt
|
||||
const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed)
|
||||
if (remaining === 0) {
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
clearPathUpdateSession()
|
||||
} else {
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
clearPathUpdateSession()
|
||||
}, remaining)
|
||||
}
|
||||
}, CONNECTION_PATH_UPDATE_TAIL_MS)
|
||||
}, [clearPathUpdateSession])
|
||||
|
||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||
|
||||
154
frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts
Normal file
154
frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Connection-path state and callbacks backed by the canvas store.
|
||||
* Replaces useCanvasConnectionPath when the store is the source of truth for path.
|
||||
* Sync graph to store (nodes, edges) from CanvasPage so path selectors have current edges.
|
||||
*
|
||||
* Subscribes only to path and edges (stable refs); derived Sets are computed in useMemo
|
||||
* so getSnapshot stays stable and we avoid "Maximum update depth" / getSnapshot loops.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
dispatchCanvasCommand,
|
||||
useCanvasStore,
|
||||
} from '@/app/canvas/canvasStore'
|
||||
import {
|
||||
selectPathRoleForNode,
|
||||
type ConnectionPathRole,
|
||||
} from '@/app/canvas/canvasStore.selectors'
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath'
|
||||
|
||||
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
|
||||
|
||||
function edgesAsGraphEdges(
|
||||
edges: Array<{ source: string; target: string }>
|
||||
): Array<{ source: string; target: string }> {
|
||||
return edges.map((e) => ({ source: e.source, target: e.target }))
|
||||
}
|
||||
|
||||
export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult {
|
||||
const path = useCanvasStore((s) => s.path)
|
||||
const edges = useCanvasStore((s) => s.graph.edges)
|
||||
|
||||
const pathNodeIds = useMemo(
|
||||
() =>
|
||||
getPathNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
path.updatingNodeIds,
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds
|
||||
),
|
||||
[
|
||||
edges,
|
||||
path.updatingNodeIds,
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds,
|
||||
]
|
||||
)
|
||||
const connectionPathPausedSegmentNodeIds = useMemo(
|
||||
() =>
|
||||
getPausedSegmentNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
pathNodeIds,
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds
|
||||
),
|
||||
[edges, pathNodeIds, path.triggerNodeIds, path.pausedNodeIds]
|
||||
)
|
||||
const connectionPathActiveSegmentNodeIds = useMemo(() => {
|
||||
const active = new Set(pathNodeIds)
|
||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}, [pathNodeIds, connectionPathPausedSegmentNodeIds])
|
||||
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const prevUpdatingLengthRef = useRef(path.updatingNodeIds.length)
|
||||
|
||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
||||
}, [])
|
||||
|
||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
}, [])
|
||||
|
||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId })
|
||||
}, [])
|
||||
|
||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId, paused: true },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId, paused: false },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setError',
|
||||
payload: { nodeId, error: true },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathError = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setError',
|
||||
payload: { nodeId, error: false },
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevUpdatingLengthRef.current
|
||||
const now = path.updatingNodeIds.length
|
||||
prevUpdatingLengthRef.current = now
|
||||
if (prev > 0 && now === 0) {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
dispatchCanvasCommand({ type: 'path/clearPathSession' })
|
||||
}, CONNECTION_PATH_UPDATE_TAIL_MS)
|
||||
}
|
||||
return () => {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [path.updatingNodeIds.length])
|
||||
|
||||
return {
|
||||
connectionPathUpdatingNodeIds: path.updatingNodeIds,
|
||||
connectionPathTriggerNodeIds: path.triggerNodeIds,
|
||||
connectionPathPausedNodeIds: path.pausedNodeIds,
|
||||
connectionPathErrorNodeIds: path.errorNodeIds,
|
||||
connectionPathNodeIds: pathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds: connectionPathPausedSegmentNodeIds,
|
||||
connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
addConnectionPathTrigger,
|
||||
addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode,
|
||||
addConnectionPathError,
|
||||
removeConnectionPathError,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path role for a node (trigger / updating / on-path) from the store.
|
||||
* Use in node components so they only re-render when their path role changes.
|
||||
*/
|
||||
export function useConnectionPathRoleFromStore(nodeId: string | undefined): ConnectionPathRole {
|
||||
return useCanvasStore((s) =>
|
||||
nodeId != null ? selectPathRoleForNode(s, nodeId) : null
|
||||
)
|
||||
}
|
||||
@@ -1,78 +1,38 @@
|
||||
/**
|
||||
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
|
||||
* with initial graph from project storage (or example) and debounced + idle-based save.
|
||||
* with initial graph from project storage (or example). Save is explicit via save().
|
||||
* Keeps CanvasPage focused on composition and layout.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory>
|
||||
|
||||
/** Debounce delay (ms) before we schedule a save. */
|
||||
const SAVE_DEBOUNCE_MS = 800
|
||||
/** Max wait (ms) for requestIdleCallback before falling back to setTimeout. */
|
||||
const SAVE_IDLE_TIMEOUT_MS = 2000
|
||||
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
|
||||
/** Persist current nodes/edges to storage. No-op when projectId is missing. */
|
||||
save: () => void
|
||||
}
|
||||
|
||||
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
|
||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||
const { nodes, edges } = result
|
||||
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const idleCallbackRef = useRef<number | null>(null)
|
||||
const pendingSaveRef = useRef<{ projectId: string; nodes: AppNode[]; edges: AppEdge[] } | null>(
|
||||
null
|
||||
)
|
||||
const nodesRef = useRef(nodes)
|
||||
const edgesRef = useRef(edges)
|
||||
nodesRef.current = nodes
|
||||
edgesRef.current = edges
|
||||
|
||||
useEffect(() => {
|
||||
const save = useCallback(() => {
|
||||
if (!projectId) return
|
||||
saveGraphToStorage(projectId, {
|
||||
version: PROJECT_VERSION,
|
||||
nodes: nodesRef.current,
|
||||
edges: edgesRef.current,
|
||||
})
|
||||
}, [projectId])
|
||||
|
||||
const scheduleSave = () => {
|
||||
pendingSaveRef.current = { projectId, nodes, edges }
|
||||
|
||||
const doSave = () => {
|
||||
const pending = pendingSaveRef.current
|
||||
pendingSaveRef.current = null
|
||||
if (pending && pending.projectId === projectId) {
|
||||
saveGraphToStorage(pending.projectId, {
|
||||
version: PROJECT_VERSION,
|
||||
nodes: pending.nodes,
|
||||
edges: pending.edges,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
idleCallbackRef.current = requestIdleCallback(doSave, {
|
||||
timeout: SAVE_IDLE_TIMEOUT_MS,
|
||||
})
|
||||
} else {
|
||||
idleCallbackRef.current = window.setTimeout(doSave, 0) as unknown as number
|
||||
}
|
||||
}
|
||||
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||
saveTimeoutRef.current = setTimeout(scheduleSave, SAVE_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
saveTimeoutRef.current = null
|
||||
}
|
||||
if (idleCallbackRef.current != null) {
|
||||
if (typeof cancelIdleCallback !== 'undefined') {
|
||||
cancelIdleCallback(idleCallbackRef.current)
|
||||
} else {
|
||||
clearTimeout(idleCallbackRef.current)
|
||||
}
|
||||
idleCallbackRef.current = null
|
||||
}
|
||||
}
|
||||
}, [projectId, nodes, edges])
|
||||
|
||||
return result
|
||||
return { ...result, save }
|
||||
}
|
||||
|
||||
113
frontend/src/components/editor/CodeEditor.tsx
Normal file
113
frontend/src/components/editor/CodeEditor.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Shared code editor with Nunjucks support by default.
|
||||
* Uses react-simple-code-editor + Prism + prism-react-renderer; highlights base language + {{ }}, {% %}, {# #}.
|
||||
* Wherever this editor is used, it provides the same features (syntax highlighting + Nunjucks).
|
||||
* The parent (node) provides the expected base language for highlighting.
|
||||
*/
|
||||
import React, { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { highlight, type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||
|
||||
export type CodeEditorProps = {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
/** Base language for syntax highlighting (Nunjucks is always applied on top). */
|
||||
language: HighlightLanguage
|
||||
/** Stable id for the underlying textarea (for insert-at-cursor). */
|
||||
textareaId?: string
|
||||
readOnly?: boolean
|
||||
placeholder?: string
|
||||
padding?: number
|
||||
tabSize?: number
|
||||
insertSpaces?: boolean
|
||||
ignoreTabKey?: boolean
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
textareaClassName?: string
|
||||
preClassName?: string
|
||||
/** Minimum height so the container doesn't collapse before resize. */
|
||||
minHeight?: number
|
||||
}
|
||||
|
||||
const defaultStyle: React.CSSProperties = {
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
overflow: 'auto',
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onValueChange,
|
||||
language,
|
||||
textareaId,
|
||||
readOnly = false,
|
||||
padding = 8,
|
||||
tabSize = 2,
|
||||
insertSpaces = true,
|
||||
ignoreTabKey = false,
|
||||
style,
|
||||
className,
|
||||
textareaClassName,
|
||||
preClassName,
|
||||
minHeight = 120,
|
||||
}: CodeEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const selectionRestoreRef = useRef<{ start: number; end: number } | null>(null)
|
||||
|
||||
const highlightCode = useCallback(
|
||||
(code: string) => highlight(code, language),
|
||||
[language]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (readOnly) {
|
||||
onValueChange(newValue)
|
||||
return
|
||||
}
|
||||
const ta =
|
||||
(textareaId ? document.getElementById(textareaId) : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
|
||||
const start = ta?.selectionStart ?? newValue.length
|
||||
const end = ta?.selectionEnd ?? newValue.length
|
||||
selectionRestoreRef.current = { start, end }
|
||||
onValueChange(newValue)
|
||||
},
|
||||
[onValueChange, readOnly, textareaId]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pending = selectionRestoreRef.current
|
||||
if (pending == null) return
|
||||
selectionRestoreRef.current = null
|
||||
const el = (textareaId
|
||||
? document.getElementById(textareaId)
|
||||
: containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
|
||||
if (el) {
|
||||
const { start, end } = pending
|
||||
const safeEnd = Math.min(end, el.value.length)
|
||||
const safeStart = Math.min(start, safeEnd)
|
||||
el.setSelectionRange(safeStart, safeEnd)
|
||||
}
|
||||
}, [value, textareaId])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="code-editor" style={{ minHeight: 0 }}>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
highlight={highlightCode}
|
||||
tabSize={tabSize}
|
||||
insertSpaces={insertSpaces}
|
||||
ignoreTabKey={ignoreTabKey}
|
||||
padding={padding}
|
||||
readOnly={readOnly}
|
||||
textareaId={textareaId}
|
||||
style={{ ...defaultStyle, minHeight, ...style }}
|
||||
className={className}
|
||||
textareaClassName={textareaClassName}
|
||||
preClassName={preClassName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useContext, useMemo, memo } from 'react'
|
||||
import React, { memo } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
||||
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
||||
import { useCanvasStore } from '@/app/canvas/canvasStore'
|
||||
import { selectConnectionStatusForEdge } from '@/app/canvas/canvasStore.selectors'
|
||||
import { CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
||||
|
||||
const EDGE_STROKE_WIDTH = 2
|
||||
const DOT_MARKER_R = 1.5
|
||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
||||
|
||||
function AnimatedEdgeInner({
|
||||
id,
|
||||
@@ -26,38 +26,13 @@ function AnimatedEdgeInner({
|
||||
target,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const ctx = useContext(ConnectionPathContext)
|
||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const errorTargetNodeIds = useMemo(
|
||||
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
|
||||
[ctx?.connectionPathErrorNodeIds]
|
||||
)
|
||||
|
||||
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
|
||||
|
||||
const connectionStatus = useMemo(
|
||||
() =>
|
||||
getConnectionStatus({
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
}),
|
||||
[
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
]
|
||||
const connectionStatus = useCanvasStore((s) =>
|
||||
selectConnectionStatusForEdge(s, source, target)
|
||||
)
|
||||
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
|
||||
|
||||
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
|
||||
|
||||
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
import { NodeResizer } from "@xyflow/react";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { FlowUIContext, useConnectionPathRole } from "@/lib/graph/flowContext";
|
||||
import { FlowUIContext } from "@/lib/graph/flowContext";
|
||||
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||
@@ -38,7 +39,7 @@ export function BaseNode({
|
||||
}: BaseNodeProps) {
|
||||
const flowUIContext = useContext(FlowUIContext);
|
||||
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
||||
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
|
||||
const hasSize =
|
||||
dimensions &&
|
||||
dimensions.width > 0 &&
|
||||
|
||||
@@ -16,7 +16,6 @@ export function FlowKeyboardShortcuts() {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setConnectionFrom = uiCtx?.setConnectionFrom
|
||||
const flowActionsRef = uiCtx?.flowActionsRef
|
||||
@@ -80,6 +79,7 @@ export function FlowKeyboardShortcuts() {
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||
if (ev.key === 'Escape') {
|
||||
const openDialog = document.querySelector('[role="dialog"]')
|
||||
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
|
||||
@@ -152,7 +152,7 @@ export function FlowKeyboardShortcuts() {
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [
|
||||
nodes,
|
||||
graphCtx?.graphRef,
|
||||
setNodes,
|
||||
setConnectionFrom,
|
||||
pasteAtViewportCenter,
|
||||
|
||||
@@ -11,9 +11,7 @@ type Props = {
|
||||
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const setEdges = graphCtx?.setEdges
|
||||
const renamingNodeId = uiCtx?.renamingNodeId ?? null
|
||||
const setRenamingNodeId = uiCtx?.setRenamingNodeId
|
||||
@@ -32,7 +30,9 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
}, [isRenaming, nodeId])
|
||||
|
||||
const applyRename = useCallback(() => {
|
||||
if (!setNodes || !setEdges || !setRenamingNodeId) return
|
||||
if (!setNodes || !setEdges || !setRenamingNodeId || !graphCtx?.graphRef) return
|
||||
const nodes = graphCtx.graphRef.current.nodes
|
||||
const edges = graphCtx.graphRef.current.edges
|
||||
const newId = inputValue.trim()
|
||||
if (!newId || newId === nodeId) {
|
||||
setRenamingNodeId(null)
|
||||
@@ -46,7 +46,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
setNodes(nextNodes as AppNode[])
|
||||
setEdges(nextEdges)
|
||||
setRenamingNodeId(null)
|
||||
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
|
||||
}, [nodeId, inputValue, graphCtx?.graphRef, setNodes, setEdges, setRenamingNodeId])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingNodeId?.(null)
|
||||
|
||||
@@ -45,10 +45,9 @@ type Props = {
|
||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setEdges = graphCtx?.setEdges
|
||||
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const node = nodes.find((n: any) => n.id === nodeId)
|
||||
const nodeMenuExtraContent = useMemo(
|
||||
|
||||
@@ -63,9 +63,7 @@ function BorderLoadingIndicator({
|
||||
container.firstElementChild instanceof HTMLElement
|
||||
? container.firstElementChild
|
||||
: container
|
||||
const cw = (target as HTMLElement).offsetWidth
|
||||
const ch = (target as HTMLElement).offsetHeight
|
||||
if (cw > 0 && ch > 0) setMeasured({ w: cw, h: ch })
|
||||
// Use ResizeObserver only to avoid forced synchronous layout (offsetWidth/offsetHeight).
|
||||
const unObserve = observeResize(target, ({ width: w, height: h }) => {
|
||||
if (w > 0 && h > 0) setMeasured({ w, h })
|
||||
})
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import React, { useCallback, useContext, useId, useMemo } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
getConnectedNodesByType,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '@/lib/graph/abstractNode'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
|
||||
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||
import {
|
||||
getConfigTypes,
|
||||
getConfigContent,
|
||||
@@ -58,32 +54,31 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const configTypeId = getConfigTypeId(data ?? {})
|
||||
const configType = getConfigType(configTypeId)
|
||||
const content = getConfigContent(data ?? {})
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const connectedConfigNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedDataNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'data'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
|
||||
|
||||
const editorId = useId()
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||
[updateData, configTypeId]
|
||||
)
|
||||
const insertAt = useSimpleEditorInsert(editorId, content, onChange)
|
||||
|
||||
const connectedConfigNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'config'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedDataNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'data'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
|
||||
|
||||
const setConfigType = useCallback(
|
||||
(newTypeId: ConfigTypeId) => {
|
||||
@@ -96,36 +91,6 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[configTypeId, data, updateData]
|
||||
)
|
||||
|
||||
const insertAt = useCallback(
|
||||
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
const ref = editorRef.current as { view: { state: { doc: { length: number; toString(): string }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
|
||||
if (ref?.view) {
|
||||
const view = ref.view
|
||||
const doc = view.state.doc
|
||||
const len = doc.length
|
||||
let from: number
|
||||
if (mode === 'prepend') {
|
||||
from = 0
|
||||
} else if (mode === 'append') {
|
||||
from = len
|
||||
} else {
|
||||
const main = view.state.selection.main
|
||||
from = main.from
|
||||
}
|
||||
view.dispatch({ changes: { from, to: from, insert: insertText } })
|
||||
const newVal = view.state.doc.toString()
|
||||
onChange(newVal)
|
||||
return
|
||||
}
|
||||
if (mode === 'prepend') {
|
||||
onChange(insertText + content)
|
||||
} else {
|
||||
onChange(content + insertText)
|
||||
}
|
||||
},
|
||||
[onChange, content]
|
||||
)
|
||||
|
||||
const insertExtendsFromNode = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
||||
@@ -175,21 +140,8 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[connectedConfigNodes],
|
||||
)
|
||||
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
|
||||
const extensions = useMemo(() => {
|
||||
const lang =
|
||||
configTypeId === 'wireframe'
|
||||
? javascript()
|
||||
: configType.language === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [
|
||||
lang,
|
||||
autocompletion({
|
||||
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds, dataIds)],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
]
|
||||
}, [configTypeId, configType.language, variableIds, functionIds, configTitles, dataIds])
|
||||
const highlightLang: HighlightLanguage =
|
||||
configTypeId === 'wireframe' ? 'javascript' : configType.language === 'plantuml' ? 'plantuml' : 'markdown'
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||
|
||||
const insertBlocksContent = useMemo(() => {
|
||||
@@ -355,17 +307,17 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<CodeMirror
|
||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||
ref={editorRef}
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
|
||||
<CodeEditor
|
||||
textareaId={editorId}
|
||||
value={content}
|
||||
height={`${editorHeight}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
||||
onValueChange={onChange}
|
||||
language={highlightLang}
|
||||
minHeight={editorHeight}
|
||||
style={{ fontSize: 14 }}
|
||||
textareaClassName="text-sm outline-none border-0 resize-none nodrag nopan"
|
||||
preClassName="text-sm nodrag nopan"
|
||||
className="min-h-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import React, { useCallback, useContext, useId, useMemo } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
getConnectedNodesByType,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '@/lib/graph/abstractNode'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
@@ -29,50 +28,30 @@ export type FunctionNodeData = { body?: string }
|
||||
|
||||
type Props = AbstractNodeProps<FunctionNodeData>
|
||||
|
||||
const LANGUAGE: HighlightLanguage = 'javascript'
|
||||
|
||||
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
|
||||
const bodyValue = data?.body ?? ''
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const editorId = useId()
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ body: val }),
|
||||
[updateData]
|
||||
)
|
||||
const insertAt = useSimpleEditorInsert(editorId, bodyValue, onChange)
|
||||
|
||||
const insertAt = useCallback(
|
||||
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
const ref = editorRef.current as { view: { state: { doc: { length: number }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
|
||||
if (ref?.view) {
|
||||
const view = ref.view
|
||||
const doc = view.state.doc
|
||||
const len = doc.length
|
||||
let from: number
|
||||
if (mode === 'prepend') from = 0
|
||||
else if (mode === 'append') from = len
|
||||
else from = view.state.selection.main.from
|
||||
view.dispatch({ changes: { from, to: from, insert: insertText } })
|
||||
onChange(view.state.doc.toString())
|
||||
return
|
||||
}
|
||||
if (mode === 'prepend') onChange(insertText + bodyValue)
|
||||
else onChange(bodyValue + insertText)
|
||||
},
|
||||
[onChange, bodyValue]
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const insertVariableAtCursor = useCallback(
|
||||
(variableNode: any) => {
|
||||
@@ -88,7 +67,6 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const extensions = useMemo(() => [javascript()], [])
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
@@ -140,17 +118,17 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<CodeMirror
|
||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||
ref={editorRef}
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
|
||||
<CodeEditor
|
||||
textareaId={editorId}
|
||||
value={bodyValue}
|
||||
height={`${editorHeight}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
||||
onValueChange={onChange}
|
||||
language="javascript"
|
||||
minHeight={editorHeight}
|
||||
style={{ fontSize: 12 }}
|
||||
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
|
||||
preClassName="text-xs nodrag nopan"
|
||||
className="min-h-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
@@ -39,7 +39,13 @@ import {
|
||||
useRenderingNodeState,
|
||||
type RenderingNodeData,
|
||||
} from './useRenderingNodeState'
|
||||
import { ImageOutputView, MarkdownOutputView, RawOutputView } from './views'
|
||||
import {
|
||||
ImageOutputView,
|
||||
MarkdownJsxView,
|
||||
RawOutputView,
|
||||
StaticMarkdownHtmlView,
|
||||
StreamingMarkdownView,
|
||||
} from './views'
|
||||
|
||||
export type { RenderingNodeData }
|
||||
|
||||
@@ -114,10 +120,19 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <MarkdownOutputView state={state} streaming={false} />
|
||||
const isConfigMarkdown =
|
||||
state.sourceNodeType === 'config' && state.rawLanguage === 'markdown'
|
||||
if (isConfigMarkdown) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<MarkdownJsxView markdown={state.resolvedContent ?? ''} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <StaticMarkdownHtmlView state={state} />
|
||||
}
|
||||
if (state.loading && state.streamingMarkdown !== null) {
|
||||
return <MarkdownOutputView state={state} streaming={true} />
|
||||
return <StreamingMarkdownView state={state} />
|
||||
}
|
||||
if (state.loading) {
|
||||
return (
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* pipeline interface.
|
||||
*/
|
||||
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||
@@ -118,6 +118,8 @@ export function useRenderingNodeState(
|
||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||
|
||||
const deferredNodes = useDeferredValue(nodes)
|
||||
const deferredEdges = useDeferredValue(edges)
|
||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||
const srcNode = useMemo(
|
||||
() => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null),
|
||||
@@ -150,8 +152,8 @@ export function useRenderingNodeState(
|
||||
: ''
|
||||
|
||||
const signatures = useMemo(
|
||||
() => buildSourceSignatures(nodes as NodeLike[], edges as EdgeLike[], id, incomingIds),
|
||||
[nodes, edges, id, incomingIds]
|
||||
() => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds),
|
||||
[deferredNodes, deferredEdges, id, incomingIds]
|
||||
)
|
||||
const {
|
||||
connectedNodeIds,
|
||||
@@ -187,8 +189,14 @@ export function useRenderingNodeState(
|
||||
const lastManualRunTriggerRef = useRef(0)
|
||||
const manualRunTriggerSyncedRef = useRef(false)
|
||||
|
||||
const pathCtx = useContext(ConnectionPathContext)
|
||||
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
|
||||
const triggerNodeIds = useCanvasStore((s) => s.path.triggerNodeIds)
|
||||
|
||||
const updateDataRef = useRef(updateData)
|
||||
updateDataRef.current = updateData
|
||||
const setNodesRef = useRef(setNodes)
|
||||
setNodesRef.current = setNodes
|
||||
const aiConnectionRef = useRef(aiConnection)
|
||||
aiConnectionRef.current = aiConnection
|
||||
const hasPendingInputs =
|
||||
effectiveUpdateMode === 'manual' &&
|
||||
!loading &&
|
||||
@@ -254,6 +262,7 @@ export function useRenderingNodeState(
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||
loadingStartedAtRef.current = Date.now()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -261,7 +270,7 @@ export function useRenderingNodeState(
|
||||
setResolvedContent(null)
|
||||
setStreamingMarkdown(null)
|
||||
setReasoningContent('')
|
||||
updateData({
|
||||
updateDataRef.current({
|
||||
cachedRenderedContent: undefined,
|
||||
cachedResolvedContent: undefined,
|
||||
cachedReasoningContent: undefined,
|
||||
@@ -276,8 +285,8 @@ export function useRenderingNodeState(
|
||||
renderNodeId: id,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
setNodes: setNodes ?? undefined,
|
||||
aiConnection,
|
||||
setNodes: setNodesRef.current ?? undefined,
|
||||
aiConnection: aiConnectionRef.current,
|
||||
...(isAgentSource && {
|
||||
onStreamingStart: () => setStreamingMarkdown(''),
|
||||
onStreamingChunk: (chunk: string) =>
|
||||
@@ -295,7 +304,7 @@ export function useRenderingNodeState(
|
||||
if (thisRunId !== runIdRef.current) return
|
||||
setRenderedContent(htmlOrSvg)
|
||||
setError(null)
|
||||
updateData({
|
||||
updateDataRef.current({
|
||||
cachedRenderedContent: htmlOrSvg,
|
||||
cachedResolvedContent: resolved,
|
||||
cachedReasoningContent: reasoning ?? '',
|
||||
@@ -349,26 +358,18 @@ export function useRenderingNodeState(
|
||||
minLoadingTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
// Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport.
|
||||
// React Flow updates (position, selection, context ref churn) do not trigger re-runs.
|
||||
}, [
|
||||
id,
|
||||
srcId,
|
||||
srcNode?.type,
|
||||
effectiveUpdateMode,
|
||||
runTrigger,
|
||||
sourceContent,
|
||||
sourceSignature,
|
||||
configSignature,
|
||||
edgesSignature,
|
||||
variablesSignature,
|
||||
functionsSignature,
|
||||
dataSignature,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
retryCount,
|
||||
updateData,
|
||||
setNodes,
|
||||
aiConnection,
|
||||
isAgentSource,
|
||||
incomingIds.length,
|
||||
])
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from 'react'
|
||||
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
export type ImageOutputViewProps = {
|
||||
@@ -13,8 +11,6 @@ export type ImageOutputViewProps = {
|
||||
|
||||
export function ImageOutputView({
|
||||
state,
|
||||
selected,
|
||||
viewportFocused,
|
||||
onViewportFocus,
|
||||
onViewportBlur,
|
||||
}: ImageOutputViewProps) {
|
||||
@@ -25,59 +21,10 @@ export function ImageOutputView({
|
||||
onFocus={onViewportFocus}
|
||||
onBlur={onViewportBlur}
|
||||
>
|
||||
<TransformWrapper
|
||||
initialScale={1}
|
||||
initialPositionX={0}
|
||||
initialPositionY={0}
|
||||
minScale={0.2}
|
||||
maxScale={4}
|
||||
centerOnInit={false}
|
||||
panning={{ disabled: !selected && !viewportFocused }}
|
||||
wheel={{ disabled: !selected && !viewportFocused }}
|
||||
doubleClick={{ disabled: !selected && !viewportFocused }}
|
||||
>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomIn()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomOut()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetTransform()}
|
||||
className="react-flow__controls-button"
|
||||
title="Reset view (fit all)"
|
||||
>
|
||||
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute inset-0 nodrag nopan overflow-hidden [&_.react-transform-component]:!w-full [&_.react-transform-component]:!h-full [&_.react-transform-wrapper]:!w-full [&_.react-transform-wrapper]:!h-full">
|
||||
<TransformComponent
|
||||
wrapperClass="!w-full !h-full"
|
||||
contentClass="nodrag nopan !w-full !h-full !block !min-h-0"
|
||||
>
|
||||
<div
|
||||
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
|
||||
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
<div
|
||||
className="absolute inset-0 nodrag nopan overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Renders markdown as React (JSX) using markdown-to-jsx.
|
||||
* Used for config node markdown preview to avoid dangerouslySetInnerHTML and support
|
||||
* safe, composable rendering. See https://markdown-to-jsx.quantizor.dev/
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import Markdown from 'markdown-to-jsx/react'
|
||||
|
||||
const MARKDOWN_PREVIEW_CLASS =
|
||||
'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm nodrag nopan overflow-auto [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
|
||||
|
||||
export type MarkdownJsxViewProps = {
|
||||
/** Raw markdown string (e.g. resolved content from config node). */
|
||||
markdown: string
|
||||
/** Optional wrapper className (defaults to MARKDOWN_PREVIEW_CLASS). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownJsxView({ markdown, className = MARKDOWN_PREVIEW_CLASS }: MarkdownJsxViewProps) {
|
||||
if (!markdown.trim()) {
|
||||
return <div className={className} />
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<Markdown>{markdown}</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
const MARKDOWN_CLASS = 'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
|
||||
const MARKDOWN_MAIN_CLASS = 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
|
||||
|
||||
export type MarkdownOutputViewProps = {
|
||||
/** Final content: reasoning + think + main */
|
||||
state: RenderingNodeState
|
||||
/** When true, show streaming content (streamingThinkSplit, streamingPreviewHtml, streamingMarkdown) instead of final */
|
||||
streaming: boolean
|
||||
}
|
||||
|
||||
export function MarkdownOutputView({ state, streaming }: MarkdownOutputViewProps) {
|
||||
if (streaming) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{state.streamingThinkSplit.think ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Thinking
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{state.streamingPreviewHtml ? (
|
||||
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.think }} />
|
||||
) : (
|
||||
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
|
||||
{state.streamingThinkSplit.think}
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{state.streamingPreviewHtml ? (
|
||||
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.main || state.streamingPreviewHtml }} />
|
||||
) : (
|
||||
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
|
||||
{state.streamingThinkSplit.main || state.streamingMarkdown}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{state.reasoningHtml ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Reasoning
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.reasoningHtml }} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{state.renderedThinkSplit.think ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Thinking
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.think }} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.main }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -15,21 +13,19 @@ export type RawOutputViewProps = {
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
export function RawOutputView({ state, height, containerRef, theme }: RawOutputViewProps) {
|
||||
const extensions = useMemo(() => {
|
||||
const lang =
|
||||
state.rawLanguage === 'wireframe'
|
||||
? javascript()
|
||||
: state.rawLanguage === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [lang]
|
||||
}, [state.rawLanguage])
|
||||
function languageForRaw(rawLanguage: string): HighlightLanguage {
|
||||
if (rawLanguage === 'wireframe') return 'javascript'
|
||||
if (rawLanguage === 'plantuml') return 'plantuml'
|
||||
return 'markdown'
|
||||
}
|
||||
|
||||
export function RawOutputView({ state, height, containerRef }: RawOutputViewProps) {
|
||||
const language = useMemo(() => languageForRaw(state.rawLanguage), [state.rawLanguage])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"
|
||||
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -50,15 +46,16 @@ export function RawOutputView({ state, height, containerRef, theme }: RawOutputV
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<CodeMirror
|
||||
<CodeEditor
|
||||
value={state.rawDisplayContent}
|
||||
height={`${height}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onValueChange={() => {}}
|
||||
language={language}
|
||||
readOnly
|
||||
editable={false}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
|
||||
minHeight={height}
|
||||
style={{ fontSize: 12 }}
|
||||
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
|
||||
preClassName="text-xs nodrag nopan min-h-0"
|
||||
className="min-h-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Renders final (non-streaming) markdown as HTML: reasoning + think collapsibles + main.
|
||||
* Used for agent final output and any other source that uses the marked → HTML pipeline.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
const MARKDOWN_CLASS =
|
||||
'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
|
||||
const MARKDOWN_MAIN_CLASS =
|
||||
'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
|
||||
|
||||
export type StaticMarkdownHtmlViewProps = {
|
||||
state: RenderingNodeState
|
||||
}
|
||||
|
||||
export function StaticMarkdownHtmlView({ state }: StaticMarkdownHtmlViewProps) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{state.reasoningHtml ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Reasoning
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
|
||||
dangerouslySetInnerHTML={{ __html: state.reasoningHtml }}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{state.renderedThinkSplit.think ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Thinking
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
|
||||
dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.think }}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<div
|
||||
className={MARKDOWN_MAIN_CLASS}
|
||||
dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.main }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Renders streaming markdown from the agent node.
|
||||
* Uses marked → HTML for live preview during stream; think/reasoning in a collapsible.
|
||||
* Kept separate from config-node markdown preview (MarkdownJsxView) so streaming (agent)
|
||||
* and static markdown (config) are distinct code paths.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
const MARKDOWN_CLASS =
|
||||
'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
|
||||
const MARKDOWN_MAIN_CLASS =
|
||||
'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
|
||||
|
||||
export type StreamingMarkdownViewProps = {
|
||||
state: RenderingNodeState
|
||||
}
|
||||
|
||||
export function StreamingMarkdownView({ state }: StreamingMarkdownViewProps) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{state.streamingThinkSplit.think ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Thinking
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{state.streamingPreviewHtml ? (
|
||||
<div
|
||||
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
|
||||
dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.think }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
|
||||
{state.streamingThinkSplit.think}
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{state.streamingPreviewHtml ? (
|
||||
<div
|
||||
className={MARKDOWN_MAIN_CLASS}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: state.streamingThinkSplit.main || state.streamingPreviewHtml,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
|
||||
{state.streamingThinkSplit.main || state.streamingMarkdown}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
/**
|
||||
* Output view components: "Display" step of the pipeline (see lib/graph/rendering.ts).
|
||||
* Which view is used comes from the source's outputType ('image' | 'html').
|
||||
*
|
||||
* - Streaming (agent): StreamingMarkdownView
|
||||
* - Config markdown preview: MarkdownJsxView (markdown-to-jsx)
|
||||
* - Agent final / other HTML: StaticMarkdownHtmlView
|
||||
* RenderingNode chooses the view directly; no shared dispatcher.
|
||||
*/
|
||||
|
||||
export { ImageOutputView } from './ImageOutputView'
|
||||
export { MarkdownOutputView } from './MarkdownOutputView'
|
||||
export { MarkdownJsxView } from './MarkdownJsxView'
|
||||
export { RawOutputView } from './RawOutputView'
|
||||
export { StaticMarkdownHtmlView } from './StaticMarkdownHtmlView'
|
||||
export { StreamingMarkdownView } from './StreamingMarkdownView'
|
||||
|
||||
export type { ImageOutputViewProps } from './ImageOutputView'
|
||||
export type { MarkdownOutputViewProps } from './MarkdownOutputView'
|
||||
export type { MarkdownJsxViewProps } from './MarkdownJsxView'
|
||||
export type { RawOutputViewProps } from './RawOutputView'
|
||||
export type { StaticMarkdownHtmlViewProps } from './StaticMarkdownHtmlView'
|
||||
export type { StreamingMarkdownViewProps } from './StreamingMarkdownView'
|
||||
|
||||
@@ -21,8 +21,7 @@ export function useResizeHeight(
|
||||
const unobserve = observeResize(el, (size) => {
|
||||
if (size.height > 0) setHeight(size.height)
|
||||
})
|
||||
const initial = el.getBoundingClientRect().height
|
||||
if (initial > 0) setHeight(initial)
|
||||
// Rely on ResizeObserver for initial size to avoid forced synchronous layout (getBoundingClientRect).
|
||||
return unobserve
|
||||
}, deps ?? [])
|
||||
|
||||
|
||||
52
frontend/src/hooks/useSimpleEditorInsert.ts
Normal file
52
frontend/src/hooks/useSimpleEditorInsert.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
export type InsertPosition = 'prepend' | 'append' | 'cursor'
|
||||
|
||||
/**
|
||||
* Returns a stable insertAt(insertText, mode) that inserts text into the simple code editor
|
||||
* (textarea identified by textareaId) at the given position, then calls onChange with the new content.
|
||||
* Cursor is restored after the next render via a scheduled effect.
|
||||
*/
|
||||
export function useSimpleEditorInsert(
|
||||
textareaId: string,
|
||||
currentContent: string,
|
||||
onChange: (value: string) => void
|
||||
): (insertText: string, mode: InsertPosition) => void {
|
||||
const pendingCursorRef = useRef<number | null>(null)
|
||||
|
||||
const insertAt = useCallback(
|
||||
(insertText: string, mode: InsertPosition) => {
|
||||
const ta = document.getElementById(textareaId) as HTMLTextAreaElement | null
|
||||
let start: number
|
||||
let end: number
|
||||
if (ta) {
|
||||
start = mode === 'cursor' ? ta.selectionStart : mode === 'prepend' ? 0 : ta.value.length
|
||||
end = mode === 'cursor' ? ta.selectionEnd : start
|
||||
} else {
|
||||
start = mode === 'prepend' ? 0 : currentContent.length
|
||||
end = start
|
||||
}
|
||||
const newValue =
|
||||
currentContent.slice(0, start) + insertText + currentContent.slice(end)
|
||||
const nextCursor = start + insertText.length
|
||||
pendingCursorRef.current = nextCursor
|
||||
onChange(newValue)
|
||||
// Restore cursor after React re-renders
|
||||
if (ta) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(textareaId) as HTMLTextAreaElement | null
|
||||
if (el && pendingCursorRef.current !== null) {
|
||||
el.focus()
|
||||
el.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
|
||||
pendingCursorRef.current = null
|
||||
}
|
||||
})
|
||||
} else {
|
||||
pendingCursorRef.current = null
|
||||
}
|
||||
},
|
||||
[textareaId, currentContent, onChange]
|
||||
)
|
||||
|
||||
return insertAt
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
||||
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
||||
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
|
||||
* also reports this node as a trigger for connection path (lifecycle "trigger").
|
||||
* also marks this node as a connection-path trigger so edges update on data changes.
|
||||
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
||||
*
|
||||
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
|
||||
@@ -16,7 +16,8 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import { GraphContext, ConnectionPathContext } from './flowContext'
|
||||
import { GraphContext } from './flowContext'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { nodePropsAreEqual } from './flowUtils'
|
||||
import type { AppNode } from './nodeTypes'
|
||||
|
||||
@@ -59,6 +60,15 @@ export type AbstractNodeContext<TData = Record<string, unknown>> = {
|
||||
targetIds: string[]
|
||||
}
|
||||
|
||||
/** Returns nodes that are connected to this node (in sourceIds) and have the given type. */
|
||||
export function getConnectedNodesByType<T extends FlowNode = FlowNode>(
|
||||
nodes: FlowNode[],
|
||||
sourceIds: string[],
|
||||
type: string
|
||||
): T[] {
|
||||
return nodes.filter((n) => sourceIds.includes(n.id) && n.type === type) as T[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,13 +82,11 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
data: TData
|
||||
): AbstractNodeContext<TData> {
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const pathCtx = useContext(ConnectionPathContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setEdges = graphCtx?.setEdges
|
||||
|
||||
const addConnectionPathTrigger = pathCtx?.addConnectionPathTrigger
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<TData>) => {
|
||||
if (!setNodes) return
|
||||
@@ -87,9 +95,9 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||
) as AppNode[]
|
||||
)
|
||||
addConnectionPathTrigger?.(id)
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||
},
|
||||
[id, setNodes, addConnectionPathTrigger]
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const incomingEdges = useMemo(
|
||||
|
||||
@@ -25,14 +25,20 @@ export type FlowActions = {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph context (nodes, edges, setters)
|
||||
// Graph context (setters + graphRef for reads; edges in context so edge changes trigger re-renders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GraphContextRef = {
|
||||
current: { nodes: AppNode[]; edges: AppEdge[] }
|
||||
}
|
||||
|
||||
export type GraphContextValue = {
|
||||
nodes: AppNode[]
|
||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||
edges: AppEdge[]
|
||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
||||
/** Current nodes/edges; updated every render. Read from here to avoid re-rendering on position-only changes. */
|
||||
graphRef: GraphContextRef
|
||||
/** Edges in context so consumers (e.g. edge indicators, useAbstractNode) re-render when edges change. */
|
||||
edges: AppEdge[]
|
||||
}
|
||||
|
||||
const GraphContext = React.createContext<GraphContextValue | null>(null)
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
* ## State flow
|
||||
*
|
||||
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
|
||||
* This hook updates FlowContext sets; edges read them via getConnectionStatus() in connectionStatus.ts.
|
||||
* See lib/graph/state.ts for the overall state flow (graph state, connection path state).
|
||||
* This hook dispatches to the canvas store; edges read path state via selectors.
|
||||
*
|
||||
* ## Lifecycle phases (conceptual)
|
||||
*
|
||||
@@ -19,14 +18,14 @@
|
||||
* Priority for edge status: error > paused > updating > default.
|
||||
*/
|
||||
|
||||
import { useContext, useEffect, useRef } from 'react'
|
||||
import { ConnectionPathContext } from './flowContext'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
|
||||
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
||||
|
||||
/**
|
||||
* State that drives connection status for this node.
|
||||
* Pass the current values from your node; the hook syncs them to FlowContext.
|
||||
* Pass the current values from your node; the hook syncs them to the canvas store.
|
||||
*/
|
||||
export type NodeConnectionStatusState = {
|
||||
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
|
||||
@@ -38,9 +37,9 @@ export type NodeConnectionStatusState = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs this node's lifecycle state to FlowContext so connection status (edge colors)
|
||||
* Syncs this node's lifecycle state to the canvas store so connection status (edge colors)
|
||||
* and path animation are correct. Call once per node with the current updating/error/paused
|
||||
* state; the hook will add/remove this node from the appropriate sets.
|
||||
* state; the hook will dispatch path commands to add/remove this node from the appropriate sets.
|
||||
*
|
||||
* Use in any node that can be updating, in error, or paused:
|
||||
*
|
||||
@@ -53,7 +52,6 @@ export function useSyncConnectionStatus(
|
||||
nodeId: string,
|
||||
state: NodeConnectionStatusState
|
||||
): void {
|
||||
const ctx = useContext(ConnectionPathContext)
|
||||
const { updating, error, paused } = state
|
||||
const prevRef = useRef({ updating: false, error: false, paused: false })
|
||||
|
||||
@@ -64,26 +62,24 @@ export function useSyncConnectionStatus(
|
||||
const nowPaused = Boolean(paused)
|
||||
|
||||
if (prev.updating !== nowUpdating) {
|
||||
if (nowUpdating) ctx?.startConnectionPathUpdate?.(nodeId)
|
||||
else ctx?.endConnectionPathUpdate?.(nodeId)
|
||||
if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
||||
else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
prev.updating = nowUpdating
|
||||
}
|
||||
if (prev.error !== nowError) {
|
||||
if (nowError) ctx?.addConnectionPathError?.(nodeId)
|
||||
else ctx?.removeConnectionPathError?.(nodeId)
|
||||
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
|
||||
prev.error = nowError
|
||||
}
|
||||
if (prev.paused !== nowPaused) {
|
||||
if (nowPaused) ctx?.addConnectionPathPausedNode?.(nodeId)
|
||||
else ctx?.removeConnectionPathPausedNode?.(nodeId)
|
||||
dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } })
|
||||
prev.paused = nowPaused
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (prevRef.current.updating) ctx?.endConnectionPathUpdate?.(nodeId)
|
||||
if (prevRef.current.error) ctx?.removeConnectionPathError?.(nodeId)
|
||||
if (prevRef.current.paused) ctx?.removeConnectionPathPausedNode?.(nodeId)
|
||||
if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
|
||||
if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } })
|
||||
prevRef.current = { updating: false, error: false, paused: false }
|
||||
}
|
||||
}, [nodeId, updating, error, paused, ctx])
|
||||
}, [nodeId, updating, error, paused])
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
|
||||
const NUNJUCKS_KEYWORDS = [
|
||||
'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not',
|
||||
'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock',
|
||||
'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw',
|
||||
]
|
||||
|
||||
const NUNJUCKS_FILTERS = [
|
||||
'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace',
|
||||
'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby',
|
||||
'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice',
|
||||
]
|
||||
|
||||
/** Get line text (CodeMirror 6: doc.line(n) is 1-based) */
|
||||
function getLineText(state: EditorState, lineNo0Based: number): string {
|
||||
return state.doc.line(lineNo0Based + 1).text
|
||||
}
|
||||
|
||||
/** Detect if position is inside {{ or {% from the start of the line */
|
||||
function insideNunjucks(state: EditorState, lineNo0Based: number, posInLine: number): boolean {
|
||||
const line = getLineText(state, lineNo0Based)
|
||||
const before = line.slice(0, posInLine)
|
||||
const openVar = before.lastIndexOf('{{')
|
||||
const openTag = before.lastIndexOf('{%')
|
||||
const closeVar = before.lastIndexOf('}}')
|
||||
const closeTag = before.lastIndexOf('%}')
|
||||
if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true
|
||||
if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Get the word fragment before the cursor for matching */
|
||||
function wordBefore(state: EditorState, lineNo0Based: number, posInLine: number): string {
|
||||
const line = getLineText(state, lineNo0Based)
|
||||
let start = posInLine
|
||||
while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1
|
||||
return line.slice(start, posInLine)
|
||||
}
|
||||
|
||||
export function nunjucksCompletionSource(
|
||||
variableIds: string[],
|
||||
configTitles?: string[],
|
||||
functionIds?: string[],
|
||||
dataIds?: string[],
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
return (context: CompletionContext) => {
|
||||
const { state, pos } = context
|
||||
const line = state.doc.lineAt(pos)
|
||||
if (!insideNunjucks(state, line.number - 1, pos - line.from)) return null
|
||||
|
||||
const word = wordBefore(state, line.number - 1, pos - line.from)
|
||||
const from = pos - word.length
|
||||
|
||||
const options: { label: string; type?: string; info?: string }[] = []
|
||||
|
||||
for (const id of variableIds) {
|
||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: id, type: 'variable', info: 'Variable' })
|
||||
}
|
||||
}
|
||||
for (const id of functionIds ?? []) {
|
||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" })
|
||||
}
|
||||
}
|
||||
for (const kw of NUNJUCKS_KEYWORDS) {
|
||||
if (!word || kw.startsWith(word.toLowerCase())) {
|
||||
options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' })
|
||||
}
|
||||
}
|
||||
for (const f of NUNJUCKS_FILTERS) {
|
||||
if (!word || f.startsWith(word.toLowerCase())) {
|
||||
options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` })
|
||||
}
|
||||
}
|
||||
if (configTitles?.length) {
|
||||
for (const t of configTitles) {
|
||||
if (!word || t.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: t, type: 'variable', info: 'Config' })
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const dataId of dataIds ?? []) {
|
||||
if (!word || dataId.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: dataId, type: 'variable', info: 'Data (array of rows)' })
|
||||
}
|
||||
}
|
||||
|
||||
if (options.length === 0) return null
|
||||
return {
|
||||
from,
|
||||
options: options.slice(0, 50),
|
||||
validFor: /^[\w.-]*$/,
|
||||
}
|
||||
}
|
||||
}
|
||||
109
frontend/src/lib/nunjucksTokenizer.ts
Normal file
109
frontend/src/lib/nunjucksTokenizer.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Nunjucks-aware tokenizer: splits content by {{ }}, {% %}, {# #} and highlights
|
||||
* code parts with Prism and nunjucks parts with fixed token types.
|
||||
* Used so the code editor always supports Nunjucks templating syntax.
|
||||
*/
|
||||
import Prism from 'prismjs'
|
||||
import type { Grammar } from 'prismjs'
|
||||
|
||||
export type Token = { types: string[]; content: string }
|
||||
|
||||
/** Single-line nunjucks patterns (variable {{ }}, tag {% %}, comment {# #}) */
|
||||
const NUNJUCKS_VAR = /\{\{[^}]*\}\}/g
|
||||
const NUNJUCKS_TAG = /\{\%[^%]*\%\}/g
|
||||
const NUNJUCKS_COMMENT = /\{\#[^#]*\#\}/g
|
||||
|
||||
/** Combined: match the first nunjucks block on a line (variable, tag, or comment) */
|
||||
const NUNJUCKS_PATTERN = /\{\{[^}]*\}\}|\{\%[^%]*\%\}|\{\#[^#]*\#\}/g
|
||||
|
||||
/** Nunjucks-specific token types so we can style them differently from the main language. */
|
||||
const TOKEN_VARIABLE = ['nunjucks-var']
|
||||
const TOKEN_TAG = ['nunjucks-tag']
|
||||
const TOKEN_COMMENT = ['nunjucks-comment']
|
||||
|
||||
function prismTokenToTypes(t: string | Prism.Token): string[] {
|
||||
if (typeof t === 'string') return ['plain']
|
||||
const type = t.type
|
||||
const types = Array.isArray(type) ? type : [type]
|
||||
const alias = (t as Prism.Token & { alias?: string | string[] }).alias
|
||||
if (alias) {
|
||||
const a = Array.isArray(alias) ? alias : [alias]
|
||||
return [...types, ...a]
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
function flattenPrismTokens(
|
||||
tokens: (string | Prism.Token)[],
|
||||
acc: Token[] = []
|
||||
): Token[] {
|
||||
for (const t of tokens) {
|
||||
if (typeof t === 'string') {
|
||||
acc.push({ types: ['plain'], content: t })
|
||||
} else {
|
||||
const types = prismTokenToTypes(t)
|
||||
const content = t.content
|
||||
if (typeof content === 'string') {
|
||||
acc.push({ types, content })
|
||||
} else {
|
||||
flattenPrismTokens(content as (string | Prism.Token)[], acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a line into segments: alternating code and nunjucks (variable/tag/comment).
|
||||
* Each nunjucks segment is one match; code segments are tokenized with Prism.
|
||||
*/
|
||||
function tokenizeLine(
|
||||
line: string,
|
||||
grammar: Grammar
|
||||
): Token[] {
|
||||
const result: Token[] = []
|
||||
let lastIndex = 0
|
||||
const re = new RegExp(NUNJUCKS_PATTERN.source, 'g')
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const codeSegment = line.slice(lastIndex, m.index)
|
||||
if (codeSegment.length > 0) {
|
||||
try {
|
||||
const prismTokens = Prism.tokenize(codeSegment, grammar)
|
||||
result.push(...flattenPrismTokens(prismTokens))
|
||||
} catch {
|
||||
result.push({ types: ['plain'], content: codeSegment })
|
||||
}
|
||||
}
|
||||
const nunjucksContent = m[0]
|
||||
if (nunjucksContent.startsWith('{{')) {
|
||||
result.push({ types: TOKEN_VARIABLE, content: nunjucksContent })
|
||||
} else if (nunjucksContent.startsWith('{%')) {
|
||||
result.push({ types: TOKEN_TAG, content: nunjucksContent })
|
||||
} else {
|
||||
result.push({ types: TOKEN_COMMENT, content: nunjucksContent })
|
||||
}
|
||||
lastIndex = re.lastIndex
|
||||
}
|
||||
const tail = line.slice(lastIndex)
|
||||
if (tail.length > 0) {
|
||||
try {
|
||||
const prismTokens = Prism.tokenize(tail, grammar)
|
||||
result.push(...flattenPrismTokens(prismTokens))
|
||||
} catch {
|
||||
result.push({ types: ['plain'], content: tail })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize code with Nunjucks support. Returns lines of tokens (same shape as prism-react-renderer).
|
||||
*/
|
||||
export function tokenizeWithNunjucks(
|
||||
code: string,
|
||||
grammar: Grammar
|
||||
): Token[][] {
|
||||
const lines = code.split('\n')
|
||||
return lines.map((line) => tokenizeLine(line, grammar))
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
|
||||
/** Nunjucks block comment {# ... #} */
|
||||
function tokenNunjucksComment(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) {
|
||||
if (stream.match(/^\{#/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/#\}/)) return 'comment'
|
||||
stream.next()
|
||||
}
|
||||
return 'comment'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
|
||||
function tokenNunjucksBlock(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) {
|
||||
if (stream.match(/^\{\{/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/\}\}/)) return 'variableName.special'
|
||||
stream.next()
|
||||
}
|
||||
return 'variableName.special'
|
||||
}
|
||||
if (stream.match(/^\{\%/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/%\}/)) return 'keyword'
|
||||
stream.next()
|
||||
}
|
||||
return 'keyword'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Simple PlantUML + Nunjucks stream parser for syntax highlighting in CodeMirror */
|
||||
const plantumlParser = StreamLanguage.define({
|
||||
name: 'plantuml',
|
||||
token(stream) {
|
||||
// Nunjucks {# ... #} comment
|
||||
const nunjucksComment = tokenNunjucksComment(stream)
|
||||
if (nunjucksComment) return nunjucksComment
|
||||
// Nunjucks {{ }} and {% %}
|
||||
const nunjucksBlock = tokenNunjucksBlock(stream)
|
||||
if (nunjucksBlock) return nunjucksBlock
|
||||
|
||||
// Single-quote line comment (PlantUML)
|
||||
if (stream.match(/^'/)) {
|
||||
stream.skipToEnd()
|
||||
return 'comment'
|
||||
}
|
||||
// Double-quoted string
|
||||
if (stream.match(/^"/)) {
|
||||
let escaped = false
|
||||
while (!stream.eol()) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
stream.next()
|
||||
continue
|
||||
}
|
||||
const ch = stream.next()
|
||||
if (ch === '\\') escaped = true
|
||||
else if (ch === '"') break
|
||||
}
|
||||
return 'string'
|
||||
}
|
||||
// @directives (@startuml, @enduml, etc.)
|
||||
if (stream.match(/^@\w+/)) return 'meta'
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null
|
||||
// Arrows and connectors
|
||||
if (stream.match(/^->>?|<-<?|-->>?|<<--?|<-?>/)) return 'keyword'
|
||||
// Keywords (participant, actor, as, title, etc.)
|
||||
if (stream.match(/^(participant|actor|as|title|autonumber|left|right|of|over|activate|deactivate|destroy|create|group|opt|alt|else|loop|par|end|note|legend|skinparam|start|stop|if|endif|elseif|while|endwhile|repeat|until|switch|case|endswitch|class|interface|enum|package|namespace|abstract|static|extends|implements)\b/i)) return 'keyword'
|
||||
// Any other character (identifier, punctuation, etc.)
|
||||
stream.next()
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
export const plantumlLanguage = plantumlParser
|
||||
7
frontend/src/lib/prismSetup.ts
Normal file
7
frontend/src/lib/prismSetup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Load Prism and register extra languages. Import this once before any syntax highlighting (e.g. in main.tsx).
|
||||
* setPrismGlobal must run first so component IIFEs see Prism on global.
|
||||
*/
|
||||
import './setPrismGlobal'
|
||||
import 'prismjs/components/prism-markdown'
|
||||
import 'prismjs/components/prism-plant-uml'
|
||||
4
frontend/src/lib/setPrismGlobal.ts
Normal file
4
frontend/src/lib/setPrismGlobal.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Run first so Prism is on global when language components load. */
|
||||
import Prism from 'prismjs'
|
||||
;(globalThis as Record<string, unknown>).Prism = Prism
|
||||
export {}
|
||||
55
frontend/src/lib/syntaxHighlight.tsx
Normal file
55
frontend/src/lib/syntaxHighlight.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Syntax highlighting for the code editor: Prism + prism-react-renderer + Nunjucks.
|
||||
* Nunjucks ({{ }}, {% %}, {# #}) is always applied so the editor supports templating everywhere.
|
||||
* Ensure prismSetup.ts is imported once in main.tsx, and prism theme: import 'prismjs/themes/prism.css'
|
||||
*/
|
||||
import React from 'react'
|
||||
import Prism from 'prismjs'
|
||||
import type { Grammar } from 'prismjs'
|
||||
import { tokenizeWithNunjucks } from '@/lib/nunjucksTokenizer'
|
||||
|
||||
export type HighlightLanguage = 'javascript' | 'markdown' | 'plantuml'
|
||||
|
||||
const prismLang: Record<HighlightLanguage, string> = {
|
||||
javascript: 'javascript',
|
||||
markdown: 'markdown',
|
||||
plantuml: 'plantuml',
|
||||
}
|
||||
|
||||
function getGrammar(language: HighlightLanguage): Grammar {
|
||||
const lang = prismLang[language]
|
||||
const g = (Prism.languages as Record<string, Grammar>)[lang]
|
||||
return g ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns highlighted code as React nodes with Nunjucks support.
|
||||
* Used by the shared CodeEditor so all usages get the same features (base language + Nunjucks).
|
||||
*/
|
||||
export function highlight(
|
||||
code: string,
|
||||
language: HighlightLanguage
|
||||
): React.ReactNode {
|
||||
const grammar = getGrammar(language)
|
||||
const lines = tokenizeWithNunjucks(code, grammar)
|
||||
return (
|
||||
<>
|
||||
{lines.map((lineTokens, i) => (
|
||||
<div key={i} className="token-line">
|
||||
{lineTokens.length > 0 ? (
|
||||
lineTokens.map((token, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className={`token ${token.types.join(' ')}`}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="token">​</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { KosmosPage } from './app/kosmos/KosmosPage'
|
||||
import { ProjectsPage } from './app/pleroma/PleromaPage'
|
||||
import { KeromaPage } from './app/keroma/KeromaPage'
|
||||
import { CanvasRoute } from './app/canvas/CanvasRoute'
|
||||
import './lib/prismSetup'
|
||||
import 'prismjs/themes/prism.css'
|
||||
import './styles.css'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
|
||||
@@ -143,11 +143,11 @@ body {
|
||||
fill: none;
|
||||
stroke-dasharray: 8 6;
|
||||
stroke-dashoffset: 0;
|
||||
animation: edge-flow 1.2s linear infinite;
|
||||
animation: edge-flow 0.5s linear infinite;
|
||||
will-change: stroke-dashoffset;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
transition: stroke 0.4s ease-out;
|
||||
transition: stroke 0.2s ease-out;
|
||||
}
|
||||
|
||||
.react-flow__edge path.animated-edge-path.animated-edge-path--updating,
|
||||
@@ -366,4 +366,31 @@ pre {
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Nunjucks tokens in code editor: distinct from main language (Prism) */
|
||||
.token.nunjucks-var {
|
||||
color: #7c3aed;
|
||||
}
|
||||
.token.nunjucks-tag {
|
||||
color: #c2410c;
|
||||
}
|
||||
.token.nunjucks-comment {
|
||||
color: #0d9488;
|
||||
}
|
||||
.dark .token.nunjucks-var {
|
||||
color: #a78bfa;
|
||||
}
|
||||
.dark .token.nunjucks-tag {
|
||||
color: #fb923c;
|
||||
}
|
||||
.dark .token.nunjucks-comment {
|
||||
color: #2dd4bf;
|
||||
}
|
||||
|
||||
/* Prism default theme adds a background to .token.operator (e.g. "="); remove it in our code editor */
|
||||
.code-editor .token.operator,
|
||||
.code-editor .token.entity,
|
||||
.code-editor .token.url {
|
||||
background: none;
|
||||
}
|
||||
@@ -8,14 +8,6 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
// Backend API (dev): proxy to avoid CORS
|
||||
'/api/todos': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/todos/': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
|
||||
15
frontend/vitest.config.ts
Normal file
15
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user