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
|
# → http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend + backend** (so the app can show “Backend API: N todos”):
|
**Frontend + backend** (for AI agent and health):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 – backend
|
# Terminal 1 – backend
|
||||||
@@ -45,7 +45,7 @@ cd backend && npm install && npm run dev
|
|||||||
|
|
||||||
# Terminal 2 – frontend
|
# Terminal 2 – frontend
|
||||||
cd frontend && npm install && npm run dev
|
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 |
|
| 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). |
|
| GET | `/health` | Health check (e.g. for Docker). |
|
||||||
| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }` → `{ "markdown" }`. |
|
| 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)
|
## Agent node (local LLM or OpenAI)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "zui-backend",
|
"name": "zui-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"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",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"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).
|
* 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(cors({ origin: CORS_ORIGIN }))
|
||||||
app.use(express.json())
|
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 }. */
|
/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
|
||||||
function buildAgentRequest(body) {
|
function buildAgentRequest(body) {
|
||||||
const { prompt, context, contextNodes, connection: conn, reasoning } = 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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"zustand": "^5.0.2",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
@@ -25,24 +26,28 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/react-codemirror": "^4.25.7",
|
"@types/prismjs": "^1.26.6",
|
||||||
"@wireweave/core": "^2.6.0",
|
"@wireweave/core": "^2.6.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
"markdown-to-jsx": "^9.7.9",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nunjucks": "^3.2.4",
|
"nunjucks": "^3.2.4",
|
||||||
|
"prism-react-renderer": "^2.4.1",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-router-dom": "^6.28.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",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
@@ -52,6 +57,7 @@
|
|||||||
"shadcn": "^4.0.0",
|
"shadcn": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.0.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'
|
} from '@/components/ui/menubar'
|
||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
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'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
export type CanvasMenubarProps = {
|
export type CanvasMenubarProps = {
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onSave?: () => void
|
||||||
|
canSave?: boolean
|
||||||
undo: () => void
|
undo: () => void
|
||||||
redo: () => void
|
redo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
@@ -34,6 +36,7 @@ export type CanvasMenubarProps = {
|
|||||||
|
|
||||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||||
const REDO_KEYS = { key: 'z', shiftKey: true }
|
const REDO_KEYS = { key: 'z', shiftKey: true }
|
||||||
|
const SAVE_KEYS = { key: 's', shiftKey: false }
|
||||||
|
|
||||||
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
||||||
const mod = ev.ctrlKey || ev.metaKey
|
const mod = ev.ctrlKey || ev.metaKey
|
||||||
@@ -43,6 +46,8 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
|||||||
export function CanvasMenubar({
|
export function CanvasMenubar({
|
||||||
onImport,
|
onImport,
|
||||||
onExport,
|
onExport,
|
||||||
|
onSave,
|
||||||
|
canSave = true,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
canUndo,
|
canUndo,
|
||||||
@@ -114,11 +119,19 @@ export function CanvasMenubar({
|
|||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
redo()
|
redo()
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (matchKey(ev, SAVE_KEYS)) {
|
||||||
|
if (onSave && canSave) {
|
||||||
|
ev.preventDefault()
|
||||||
|
ev.stopPropagation()
|
||||||
|
onSave()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
}, [undo, redo, canUndo, canRedo])
|
}, [undo, redo, canUndo, canRedo, onSave, canSave])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
|
<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>
|
<MenubarContent>
|
||||||
{projectId && (
|
{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
|
<MenubarItem
|
||||||
onClick={() => setIsRenamingProject(true)}
|
onClick={() => setIsRenamingProject(true)}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ import {
|
|||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
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 { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
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 { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||||
@@ -169,6 +170,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
setStateImmediate,
|
setStateImmediate,
|
||||||
|
save,
|
||||||
} = useCanvasGraph(projectId)
|
} = useCanvasGraph(projectId)
|
||||||
|
|
||||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
@@ -185,20 +187,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const [isSelecting, setIsSelecting] = React.useState(false)
|
const [isSelecting, setIsSelecting] = React.useState(false)
|
||||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||||
const [fullscreenNodeId, setFullscreenNodeId] = 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)
|
const nodesRef = useRef(nodes)
|
||||||
nodesRef.current = nodes
|
nodesRef.current = nodes
|
||||||
|
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
|
||||||
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
|
graphRef.current.nodes = nodes
|
||||||
React.useEffect(() => {
|
graphRef.current.edges = edges
|
||||||
fetch('/api/todos')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: unknown) => {
|
|
||||||
if (Array.isArray(data)) setApiTodosCount(data.length)
|
|
||||||
})
|
|
||||||
.catch(() => setApiTodosCount(-1))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
@@ -244,7 +243,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onConnect = useCallback(
|
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]
|
[setEdges]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -324,7 +328,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
toast.error('Invalid file: expected nodes and edges arrays')
|
toast.error('Invalid file: expected nodes and edges arrays')
|
||||||
return
|
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) {
|
if (state.version != null && state.version > PROJECT_VERSION) {
|
||||||
toast.error('Project was created with a newer app version')
|
toast.error('Project was created with a newer app version')
|
||||||
} else {
|
} else {
|
||||||
@@ -393,8 +399,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const graphContextValue = useMemo(
|
const graphContextValue = useMemo(
|
||||||
() => ({ nodes, setNodes, edges, setEdges }),
|
() => ({ setNodes, setEdges, graphRef, edges }),
|
||||||
[nodes, setNodes, edges, setEdges]
|
[setNodes, setEdges, edges]
|
||||||
)
|
)
|
||||||
const connectionPathContextValue = useMemo(
|
const connectionPathContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -438,18 +444,36 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const nodesForFlow = useMemo(
|
const prevNodesRef = useRef<AppNode[]>([])
|
||||||
() =>
|
const prevNodesForFlowRef = useRef<Node[]>([])
|
||||||
nodes.map((n) => ({
|
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,
|
...n,
|
||||||
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
||||||
})),
|
}
|
||||||
[nodes]
|
})
|
||||||
)
|
prevNodesRef.current = nodes
|
||||||
|
prevNodesForFlowRef.current = result
|
||||||
|
return result
|
||||||
|
}, [nodes])
|
||||||
const edgesForFlow = useMemo(
|
const edgesForFlow = useMemo(
|
||||||
() =>
|
() =>
|
||||||
edges.map((e) => {
|
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 connectionLabel = getConnectionLabelForTarget(targetType)
|
||||||
const baseData =
|
const baseData =
|
||||||
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
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 },
|
data: { ...baseData, connectionLabel },
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[edges, nodes]
|
[edges]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
|
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
|
||||||
@@ -595,6 +619,15 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
<CanvasMenubar
|
<CanvasMenubar
|
||||||
onImport={handleImportProject}
|
onImport={handleImportProject}
|
||||||
onExport={handleExportProject}
|
onExport={handleExportProject}
|
||||||
|
onSave={
|
||||||
|
projectId
|
||||||
|
? () => {
|
||||||
|
save()
|
||||||
|
toast.success('Saved')
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
canSave={Boolean(projectId)}
|
||||||
undo={undo}
|
undo={undo}
|
||||||
redo={redo}
|
redo={redo}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
@@ -606,11 +639,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
canCopy={selectedNodes.length === 1}
|
canCopy={selectedNodes.length === 1}
|
||||||
onFitView={() => flowActionsRef.current?.fitView?.()}
|
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 relative flex flex-col">
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
<GraphContext.Provider value={graphContextValue}>
|
<GraphContext.Provider value={graphContextValue}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
|
* 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 { useParams } from 'react-router-dom'
|
||||||
import { CanvasPage } from './CanvasPage'
|
import { CanvasPage } from './CanvasPage'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
@@ -10,12 +10,14 @@ import { usePlatform } from '@/app/kosmos/KosmosContext'
|
|||||||
export function CanvasRoute() {
|
export function CanvasRoute() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
const { projects, updateLastEdited } = usePlatform()
|
const { projects, updateLastEdited } = usePlatform()
|
||||||
|
const updateLastEditedRef = useRef(updateLastEdited)
|
||||||
|
updateLastEditedRef.current = updateLastEdited
|
||||||
|
|
||||||
const project = projects.find((p) => p.id === projectId)
|
const project = projects.find((p) => p.id === projectId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) updateLastEdited(projectId)
|
if (projectId) updateLastEditedRef.current(projectId)
|
||||||
}, [projectId, updateLastEdited])
|
}, [projectId])
|
||||||
|
|
||||||
if (!projectId) return null
|
if (!projectId) return null
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|||||||
@@ -17,25 +17,34 @@ export { ViewportDisplayContext }
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be rendered inside ReactFlowProvider. Subscribes to viewport once,
|
* 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 }) {
|
export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { zoom } = useViewport()
|
const { zoom } = useViewport()
|
||||||
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
|
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
|
||||||
zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full'
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (rafRef.current !== null) return
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null
|
||||||
|
const z = zoomRef.current
|
||||||
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
|
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
|
||||||
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
|
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
|
||||||
let next: ViewportDisplayMode = lastRef.current
|
let next: ViewportDisplayMode = lastModeRef.current
|
||||||
if (zoom <= low) next = 'compact'
|
if (z <= low) next = 'compact'
|
||||||
else if (zoom >= high) next = 'full'
|
else if (z >= high) next = 'full'
|
||||||
if (next !== lastRef.current) {
|
if (next !== lastModeRef.current) {
|
||||||
lastRef.current = next
|
lastModeRef.current = next
|
||||||
setDisplayMode(next)
|
setDisplayMode(next)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}, [zoom])
|
}, [zoom])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
|||||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||||
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage'
|
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 NODE_GAP = 150
|
||||||
|
|
||||||
const EXAMPLE_NODES: AppNode[] = [
|
const EXAMPLE_NODES: AppNode[] = [
|
||||||
@@ -45,20 +59,24 @@ const EXAMPLE_EDGES: AppEdge[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||||
return {
|
const nodes = EXAMPLE_NODES.map((n) => ({
|
||||||
nodes: EXAMPLE_NODES.map((n) => ({
|
|
||||||
...n,
|
...n,
|
||||||
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
||||||
})),
|
}))
|
||||||
edges: EXAMPLE_EDGES.map((e) => ({ ...e })),
|
const edges = backfillEdgeTargetTypes(
|
||||||
}
|
nodes,
|
||||||
|
EXAMPLE_EDGES.map((e) => ({ ...e }))
|
||||||
|
)
|
||||||
|
return { nodes, edges }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
const stored = loadGraphFromStorage(projectId)
|
const stored = loadGraphFromStorage(projectId)
|
||||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
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: [] }
|
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 }
|
export type EdgeLike = { source: string; target: string }
|
||||||
|
|
||||||
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
|
/** Short tail (ms) after last updating node ends so the path doesn't vanish instantly. */
|
||||||
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
|
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
|
||||||
|
|
||||||
export type UseCanvasConnectionPathResult = {
|
export type UseCanvasConnectionPathResult = {
|
||||||
connectionPathUpdatingNodeIds: string[]
|
connectionPathUpdatingNodeIds: string[]
|
||||||
@@ -41,7 +41,6 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
||||||
|
|
||||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
||||||
const pathUpdateStartTimeRef = useRef<number | null>(null)
|
|
||||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
||||||
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
||||||
@@ -61,7 +60,6 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
const ref = pathUpdateNodeIdsRef.current
|
const ref = pathUpdateNodeIdsRef.current
|
||||||
ref.add(nodeId)
|
ref.add(nodeId)
|
||||||
if (ref.size === 1) {
|
if (ref.size === 1) {
|
||||||
pathUpdateStartTimeRef.current = Date.now()
|
|
||||||
if (pathUpdateEndTimeoutRef.current != null) {
|
if (pathUpdateEndTimeoutRef.current != null) {
|
||||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||||
pathUpdateEndTimeoutRef.current = null
|
pathUpdateEndTimeoutRef.current = null
|
||||||
@@ -77,17 +75,10 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||||
return
|
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) {
|
|
||||||
clearPathUpdateSession()
|
|
||||||
} else {
|
|
||||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||||
pathUpdateEndTimeoutRef.current = null
|
pathUpdateEndTimeoutRef.current = null
|
||||||
clearPathUpdateSession()
|
clearPathUpdateSession()
|
||||||
}, remaining)
|
}, CONNECTION_PATH_UPDATE_TAIL_MS)
|
||||||
}
|
|
||||||
}, [clearPathUpdateSession])
|
}, [clearPathUpdateSession])
|
||||||
|
|
||||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
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
|
* 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.
|
* 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 { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory>
|
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
|
||||||
|
/** Persist current nodes/edges to storage. No-op when projectId is missing. */
|
||||||
/** Debounce delay (ms) before we schedule a save. */
|
save: () => void
|
||||||
const SAVE_DEBOUNCE_MS = 800
|
}
|
||||||
/** Max wait (ms) for requestIdleCallback before falling back to setTimeout. */
|
|
||||||
const SAVE_IDLE_TIMEOUT_MS = 2000
|
|
||||||
|
|
||||||
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
|
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
|
||||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
||||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||||
const { nodes, edges } = result
|
const { nodes, edges } = result
|
||||||
|
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const nodesRef = useRef(nodes)
|
||||||
const idleCallbackRef = useRef<number | null>(null)
|
const edgesRef = useRef(edges)
|
||||||
const pendingSaveRef = useRef<{ projectId: string; nodes: AppNode[]; edges: AppEdge[] } | null>(
|
nodesRef.current = nodes
|
||||||
null
|
edgesRef.current = edges
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const save = useCallback(() => {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
|
saveGraphToStorage(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,
|
version: PROJECT_VERSION,
|
||||||
nodes: pending.nodes,
|
nodes: nodesRef.current,
|
||||||
edges: pending.edges,
|
edges: edgesRef.current,
|
||||||
})
|
})
|
||||||
}
|
}, [projectId])
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof requestIdleCallback !== 'undefined') {
|
return { ...result, save }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
import { useCanvasStore } from '@/app/canvas/canvasStore'
|
||||||
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
import { selectConnectionStatusForEdge } from '@/app/canvas/canvasStore.selectors'
|
||||||
|
import { CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
||||||
|
|
||||||
const EDGE_STROKE_WIDTH = 2
|
const EDGE_STROKE_WIDTH = 2
|
||||||
const DOT_MARKER_R = 1.5
|
const DOT_MARKER_R = 1.5
|
||||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
|
||||||
|
|
||||||
function AnimatedEdgeInner({
|
function AnimatedEdgeInner({
|
||||||
id,
|
id,
|
||||||
@@ -26,38 +26,13 @@ function AnimatedEdgeInner({
|
|||||||
target,
|
target,
|
||||||
data,
|
data,
|
||||||
}: EdgeProps) {
|
}: EdgeProps) {
|
||||||
const ctx = useContext(ConnectionPathContext)
|
const connectionStatus = useCanvasStore((s) =>
|
||||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
selectConnectionStatusForEdge(s, source, target)
|
||||||
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 statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
|
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
|
||||||
|
|
||||||
|
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
|
||||||
|
|
||||||
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { ComponentProps, ReactNode } from "react";
|
|||||||
import { NodeResizer } from "@xyflow/react";
|
import { NodeResizer } from "@xyflow/react";
|
||||||
import { useContext } from "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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||||
@@ -38,7 +39,7 @@ export function BaseNode({
|
|||||||
}: BaseNodeProps) {
|
}: BaseNodeProps) {
|
||||||
const flowUIContext = useContext(FlowUIContext);
|
const flowUIContext = useContext(FlowUIContext);
|
||||||
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
||||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
|
||||||
const hasSize =
|
const hasSize =
|
||||||
dimensions &&
|
dimensions &&
|
||||||
dimensions.width > 0 &&
|
dimensions.width > 0 &&
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export function FlowKeyboardShortcuts() {
|
|||||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const uiCtx = useContext(FlowUIContext)
|
const uiCtx = useContext(FlowUIContext)
|
||||||
const nodes = graphCtx?.nodes ?? []
|
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
const setConnectionFrom = uiCtx?.setConnectionFrom
|
const setConnectionFrom = uiCtx?.setConnectionFrom
|
||||||
const flowActionsRef = uiCtx?.flowActionsRef
|
const flowActionsRef = uiCtx?.flowActionsRef
|
||||||
@@ -80,6 +79,7 @@ export function FlowKeyboardShortcuts() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
const openDialog = document.querySelector('[role="dialog"]')
|
const openDialog = document.querySelector('[role="dialog"]')
|
||||||
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
|
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
|
||||||
@@ -152,7 +152,7 @@ export function FlowKeyboardShortcuts() {
|
|||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
}, [
|
}, [
|
||||||
nodes,
|
graphCtx?.graphRef,
|
||||||
setNodes,
|
setNodes,
|
||||||
setConnectionFrom,
|
setConnectionFrom,
|
||||||
pasteAtViewportCenter,
|
pasteAtViewportCenter,
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ type Props = {
|
|||||||
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const uiCtx = useContext(FlowUIContext)
|
const uiCtx = useContext(FlowUIContext)
|
||||||
const nodes = graphCtx?.nodes ?? []
|
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
const edges = graphCtx?.edges ?? []
|
|
||||||
const setEdges = graphCtx?.setEdges
|
const setEdges = graphCtx?.setEdges
|
||||||
const renamingNodeId = uiCtx?.renamingNodeId ?? null
|
const renamingNodeId = uiCtx?.renamingNodeId ?? null
|
||||||
const setRenamingNodeId = uiCtx?.setRenamingNodeId
|
const setRenamingNodeId = uiCtx?.setRenamingNodeId
|
||||||
@@ -32,7 +30,9 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
|||||||
}, [isRenaming, nodeId])
|
}, [isRenaming, nodeId])
|
||||||
|
|
||||||
const applyRename = useCallback(() => {
|
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()
|
const newId = inputValue.trim()
|
||||||
if (!newId || newId === nodeId) {
|
if (!newId || newId === nodeId) {
|
||||||
setRenamingNodeId(null)
|
setRenamingNodeId(null)
|
||||||
@@ -46,7 +46,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
|||||||
setNodes(nextNodes as AppNode[])
|
setNodes(nextNodes as AppNode[])
|
||||||
setEdges(nextEdges)
|
setEdges(nextEdges)
|
||||||
setRenamingNodeId(null)
|
setRenamingNodeId(null)
|
||||||
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
|
}, [nodeId, inputValue, graphCtx?.graphRef, setNodes, setEdges, setRenamingNodeId])
|
||||||
|
|
||||||
const cancelRename = useCallback(() => {
|
const cancelRename = useCallback(() => {
|
||||||
setRenamingNodeId?.(null)
|
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) {
|
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const uiCtx = useContext(FlowUIContext)
|
const uiCtx = useContext(FlowUIContext)
|
||||||
const nodes = graphCtx?.nodes ?? []
|
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
const setEdges = graphCtx?.setEdges
|
const setEdges = graphCtx?.setEdges
|
||||||
|
|
||||||
const edges = graphCtx?.edges ?? []
|
const edges = graphCtx?.edges ?? []
|
||||||
const node = nodes.find((n: any) => n.id === nodeId)
|
const node = nodes.find((n: any) => n.id === nodeId)
|
||||||
const nodeMenuExtraContent = useMemo(
|
const nodeMenuExtraContent = useMemo(
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ function BorderLoadingIndicator({
|
|||||||
container.firstElementChild instanceof HTMLElement
|
container.firstElementChild instanceof HTMLElement
|
||||||
? container.firstElementChild
|
? container.firstElementChild
|
||||||
: container
|
: container
|
||||||
const cw = (target as HTMLElement).offsetWidth
|
// Use ResizeObserver only to avoid forced synchronous layout (offsetWidth/offsetHeight).
|
||||||
const ch = (target as HTMLElement).offsetHeight
|
|
||||||
if (cw > 0 && ch > 0) setMeasured({ w: cw, h: ch })
|
|
||||||
const unObserve = observeResize(target, ({ width: w, height: h }) => {
|
const unObserve = observeResize(target, ({ width: w, height: h }) => {
|
||||||
if (w > 0 && h > 0) setMeasured({ w, h })
|
if (w > 0 && h > 0) setMeasured({ w, h })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
import React, { useCallback, useContext, useId, useMemo } 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 {
|
import {
|
||||||
AbstractNodeProps,
|
AbstractNodeProps,
|
||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
|
getConnectedNodesByType,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
type FlowNode,
|
|
||||||
} from '@/lib/graph/abstractNode'
|
} from '@/lib/graph/abstractNode'
|
||||||
|
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||||
|
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
|
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||||
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
|
||||||
import { useTheme } from '@/lib/themeContext'
|
|
||||||
import {
|
import {
|
||||||
getConfigTypes,
|
getConfigTypes,
|
||||||
getConfigContent,
|
getConfigContent,
|
||||||
@@ -58,32 +54,31 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
const configTypeId = getConfigTypeId(data ?? {})
|
const configTypeId = getConfigTypeId(data ?? {})
|
||||||
const configType = getConfigType(configTypeId)
|
const configType = getConfigType(configTypeId)
|
||||||
const content = getConfigContent(data ?? {})
|
const content = getConfigContent(data ?? {})
|
||||||
const { theme } = useTheme()
|
|
||||||
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||||
const editorRef = useRef<unknown>(null)
|
const editorId = useId()
|
||||||
|
|
||||||
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 onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||||
[updateData, 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(
|
const setConfigType = useCallback(
|
||||||
(newTypeId: ConfigTypeId) => {
|
(newTypeId: ConfigTypeId) => {
|
||||||
@@ -96,36 +91,6 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[configTypeId, data, updateData]
|
[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(
|
const insertExtendsFromNode = useCallback(
|
||||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||||
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
||||||
@@ -175,21 +140,8 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[connectedConfigNodes],
|
[connectedConfigNodes],
|
||||||
)
|
)
|
||||||
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
|
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
|
||||||
const extensions = useMemo(() => {
|
const highlightLang: HighlightLanguage =
|
||||||
const lang =
|
configTypeId === 'wireframe' ? 'javascript' : configType.language === 'plantuml' ? 'plantuml' : 'markdown'
|
||||||
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 [editorHeight, editorContainerRef] = useResizeHeight(180)
|
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||||
|
|
||||||
const insertBlocksContent = useMemo(() => {
|
const insertBlocksContent = useMemo(() => {
|
||||||
@@ -355,17 +307,17 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
<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 }}>
|
||||||
<CodeMirror
|
<CodeEditor
|
||||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
textareaId={editorId}
|
||||||
ref={editorRef}
|
|
||||||
value={content}
|
value={content}
|
||||||
height={`${editorHeight}px`}
|
onValueChange={onChange}
|
||||||
theme={theme}
|
language={highlightLang}
|
||||||
extensions={extensions}
|
minHeight={editorHeight}
|
||||||
onChange={onChange}
|
style={{ fontSize: 14 }}
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
textareaClassName="text-sm outline-none border-0 resize-none nodrag nopan"
|
||||||
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
preClassName="text-sm nodrag nopan"
|
||||||
|
className="min-h-0 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeContent>
|
</BaseNodeContent>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
import React, { useCallback, useContext, useId, useMemo } from 'react'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
|
||||||
import { javascript } from '@codemirror/lang-javascript'
|
|
||||||
import {
|
import {
|
||||||
AbstractNodeProps,
|
AbstractNodeProps,
|
||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
|
getConnectedNodesByType,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
type FlowNode,
|
|
||||||
} from '@/lib/graph/abstractNode'
|
} from '@/lib/graph/abstractNode'
|
||||||
|
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||||
|
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
@@ -29,50 +28,30 @@ export type FunctionNodeData = { body?: string }
|
|||||||
|
|
||||||
type Props = AbstractNodeProps<FunctionNodeData>
|
type Props = AbstractNodeProps<FunctionNodeData>
|
||||||
|
|
||||||
|
const LANGUAGE: HighlightLanguage = 'javascript'
|
||||||
|
|
||||||
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const flowUIContext = useContext(FlowUIContext)
|
const flowUIContext = useContext(FlowUIContext)
|
||||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||||
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
|
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
|
||||||
const bodyValue = data?.body ?? ''
|
const bodyValue = data?.body ?? ''
|
||||||
const { theme } = useTheme()
|
|
||||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||||
const editorRef = useRef<unknown>(null)
|
const editorId = useId()
|
||||||
|
|
||||||
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 onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(val: string) => updateData({ body: val }),
|
(val: string) => updateData({ body: val }),
|
||||||
[updateData]
|
[updateData]
|
||||||
)
|
)
|
||||||
|
const insertAt = useSimpleEditorInsert(editorId, bodyValue, onChange)
|
||||||
|
|
||||||
const insertAt = useCallback(
|
const connectedVariableNodes = useMemo(
|
||||||
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
|
||||||
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
|
[nodes, sourceIds]
|
||||||
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 connectedFunctionNodes = useMemo(
|
||||||
|
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
|
||||||
|
[nodes, sourceIds]
|
||||||
|
)
|
||||||
|
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||||
|
|
||||||
const insertVariableAtCursor = useCallback(
|
const insertVariableAtCursor = useCallback(
|
||||||
(variableNode: any) => {
|
(variableNode: any) => {
|
||||||
@@ -88,7 +67,6 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[insertAt]
|
[insertAt]
|
||||||
)
|
)
|
||||||
|
|
||||||
const extensions = useMemo(() => [javascript()], [])
|
|
||||||
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
||||||
const dimensions =
|
const dimensions =
|
||||||
width != null && height != null && width > 0 && height > 0
|
width != null && height != null && width > 0 && height > 0
|
||||||
@@ -140,17 +118,17 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
<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 }}>
|
||||||
<CodeMirror
|
<CodeEditor
|
||||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
textareaId={editorId}
|
||||||
ref={editorRef}
|
|
||||||
value={bodyValue}
|
value={bodyValue}
|
||||||
height={`${editorHeight}px`}
|
onValueChange={onChange}
|
||||||
theme={theme}
|
language="javascript"
|
||||||
extensions={extensions}
|
minHeight={editorHeight}
|
||||||
onChange={onChange}
|
style={{ fontSize: 12 }}
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
|
||||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
preClassName="text-xs nodrag nopan"
|
||||||
|
className="min-h-0 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeContent>
|
</BaseNodeContent>
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ import {
|
|||||||
useRenderingNodeState,
|
useRenderingNodeState,
|
||||||
type RenderingNodeData,
|
type RenderingNodeData,
|
||||||
} from './useRenderingNodeState'
|
} from './useRenderingNodeState'
|
||||||
import { ImageOutputView, MarkdownOutputView, RawOutputView } from './views'
|
import {
|
||||||
|
ImageOutputView,
|
||||||
|
MarkdownJsxView,
|
||||||
|
RawOutputView,
|
||||||
|
StaticMarkdownHtmlView,
|
||||||
|
StreamingMarkdownView,
|
||||||
|
} from './views'
|
||||||
|
|
||||||
export type { RenderingNodeData }
|
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) {
|
if (state.loading && state.streamingMarkdown !== null) {
|
||||||
return <MarkdownOutputView state={state} streaming={true} />
|
return <StreamingMarkdownView state={state} />
|
||||||
}
|
}
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
* pipeline interface.
|
* 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 { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
|
||||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||||
|
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||||
@@ -118,6 +118,8 @@ export function useRenderingNodeState(
|
|||||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||||
|
|
||||||
|
const deferredNodes = useDeferredValue(nodes)
|
||||||
|
const deferredEdges = useDeferredValue(edges)
|
||||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||||
const srcNode = useMemo(
|
const srcNode = useMemo(
|
||||||
() => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null),
|
() => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null),
|
||||||
@@ -150,8 +152,8 @@ export function useRenderingNodeState(
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
const signatures = useMemo(
|
const signatures = useMemo(
|
||||||
() => buildSourceSignatures(nodes as NodeLike[], edges as EdgeLike[], id, incomingIds),
|
() => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds),
|
||||||
[nodes, edges, id, incomingIds]
|
[deferredNodes, deferredEdges, id, incomingIds]
|
||||||
)
|
)
|
||||||
const {
|
const {
|
||||||
connectedNodeIds,
|
connectedNodeIds,
|
||||||
@@ -187,8 +189,14 @@ export function useRenderingNodeState(
|
|||||||
const lastManualRunTriggerRef = useRef(0)
|
const lastManualRunTriggerRef = useRef(0)
|
||||||
const manualRunTriggerSyncedRef = useRef(false)
|
const manualRunTriggerSyncedRef = useRef(false)
|
||||||
|
|
||||||
const pathCtx = useContext(ConnectionPathContext)
|
const triggerNodeIds = useCanvasStore((s) => s.path.triggerNodeIds)
|
||||||
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
|
|
||||||
|
const updateDataRef = useRef(updateData)
|
||||||
|
updateDataRef.current = updateData
|
||||||
|
const setNodesRef = useRef(setNodes)
|
||||||
|
setNodesRef.current = setNodes
|
||||||
|
const aiConnectionRef = useRef(aiConnection)
|
||||||
|
aiConnectionRef.current = aiConnection
|
||||||
const hasPendingInputs =
|
const hasPendingInputs =
|
||||||
effectiveUpdateMode === 'manual' &&
|
effectiveUpdateMode === 'manual' &&
|
||||||
!loading &&
|
!loading &&
|
||||||
@@ -254,6 +262,7 @@ export function useRenderingNodeState(
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||||
loadingStartedAtRef.current = Date.now()
|
loadingStartedAtRef.current = Date.now()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -261,7 +270,7 @@ export function useRenderingNodeState(
|
|||||||
setResolvedContent(null)
|
setResolvedContent(null)
|
||||||
setStreamingMarkdown(null)
|
setStreamingMarkdown(null)
|
||||||
setReasoningContent('')
|
setReasoningContent('')
|
||||||
updateData({
|
updateDataRef.current({
|
||||||
cachedRenderedContent: undefined,
|
cachedRenderedContent: undefined,
|
||||||
cachedResolvedContent: undefined,
|
cachedResolvedContent: undefined,
|
||||||
cachedReasoningContent: undefined,
|
cachedReasoningContent: undefined,
|
||||||
@@ -276,8 +285,8 @@ export function useRenderingNodeState(
|
|||||||
renderNodeId: id,
|
renderNodeId: id,
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
setNodes: setNodes ?? undefined,
|
setNodes: setNodesRef.current ?? undefined,
|
||||||
aiConnection,
|
aiConnection: aiConnectionRef.current,
|
||||||
...(isAgentSource && {
|
...(isAgentSource && {
|
||||||
onStreamingStart: () => setStreamingMarkdown(''),
|
onStreamingStart: () => setStreamingMarkdown(''),
|
||||||
onStreamingChunk: (chunk: string) =>
|
onStreamingChunk: (chunk: string) =>
|
||||||
@@ -295,7 +304,7 @@ export function useRenderingNodeState(
|
|||||||
if (thisRunId !== runIdRef.current) return
|
if (thisRunId !== runIdRef.current) return
|
||||||
setRenderedContent(htmlOrSvg)
|
setRenderedContent(htmlOrSvg)
|
||||||
setError(null)
|
setError(null)
|
||||||
updateData({
|
updateDataRef.current({
|
||||||
cachedRenderedContent: htmlOrSvg,
|
cachedRenderedContent: htmlOrSvg,
|
||||||
cachedResolvedContent: resolved,
|
cachedResolvedContent: resolved,
|
||||||
cachedReasoningContent: reasoning ?? '',
|
cachedReasoningContent: reasoning ?? '',
|
||||||
@@ -349,26 +358,18 @@ export function useRenderingNodeState(
|
|||||||
minLoadingTimeoutRef.current = null
|
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,
|
id,
|
||||||
srcId,
|
srcId,
|
||||||
srcNode?.type,
|
srcNode?.type,
|
||||||
effectiveUpdateMode,
|
effectiveUpdateMode,
|
||||||
runTrigger,
|
runTrigger,
|
||||||
sourceContent,
|
|
||||||
sourceSignature,
|
sourceSignature,
|
||||||
configSignature,
|
|
||||||
edgesSignature,
|
|
||||||
variablesSignature,
|
|
||||||
functionsSignature,
|
|
||||||
dataSignature,
|
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
retryCount,
|
retryCount,
|
||||||
updateData,
|
|
||||||
setNodes,
|
|
||||||
aiConnection,
|
|
||||||
isAgentSource,
|
|
||||||
incomingIds.length,
|
incomingIds.length,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
|
|
||||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
|
||||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||||
|
|
||||||
export type ImageOutputViewProps = {
|
export type ImageOutputViewProps = {
|
||||||
@@ -13,8 +11,6 @@ export type ImageOutputViewProps = {
|
|||||||
|
|
||||||
export function ImageOutputView({
|
export function ImageOutputView({
|
||||||
state,
|
state,
|
||||||
selected,
|
|
||||||
viewportFocused,
|
|
||||||
onViewportFocus,
|
onViewportFocus,
|
||||||
onViewportBlur,
|
onViewportBlur,
|
||||||
}: ImageOutputViewProps) {
|
}: ImageOutputViewProps) {
|
||||||
@@ -24,60 +20,11 @@ export function ImageOutputView({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onFocus={onViewportFocus}
|
onFocus={onViewportFocus}
|
||||||
onBlur={onViewportBlur}
|
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
|
<div
|
||||||
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
|
className="absolute inset-0 nodrag nopan overflow-auto"
|
||||||
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||||
/>
|
/>
|
||||||
</TransformComponent>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TransformWrapper>
|
|
||||||
</div>
|
</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 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 { Copy } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
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 type { RenderingNodeState } from '../useRenderingNodeState'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -15,21 +13,19 @@ export type RawOutputViewProps = {
|
|||||||
theme: 'light' | 'dark'
|
theme: 'light' | 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RawOutputView({ state, height, containerRef, theme }: RawOutputViewProps) {
|
function languageForRaw(rawLanguage: string): HighlightLanguage {
|
||||||
const extensions = useMemo(() => {
|
if (rawLanguage === 'wireframe') return 'javascript'
|
||||||
const lang =
|
if (rawLanguage === 'plantuml') return 'plantuml'
|
||||||
state.rawLanguage === 'wireframe'
|
return 'markdown'
|
||||||
? javascript()
|
}
|
||||||
: state.rawLanguage === 'plantuml'
|
|
||||||
? plantumlLanguage.extension
|
export function RawOutputView({ state, height, containerRef }: RawOutputViewProps) {
|
||||||
: markdown()
|
const language = useMemo(() => languageForRaw(state.rawLanguage), [state.rawLanguage])
|
||||||
return [lang]
|
|
||||||
}, [state.rawLanguage])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -50,15 +46,16 @@ export function RawOutputView({ state, height, containerRef, theme }: RawOutputV
|
|||||||
>
|
>
|
||||||
<Copy className="size-3.5" />
|
<Copy className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<CodeMirror
|
<CodeEditor
|
||||||
value={state.rawDisplayContent}
|
value={state.rawDisplayContent}
|
||||||
height={`${height}px`}
|
onValueChange={() => {}}
|
||||||
theme={theme}
|
language={language}
|
||||||
extensions={extensions}
|
|
||||||
readOnly
|
readOnly
|
||||||
editable={false}
|
minHeight={height}
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
style={{ fontSize: 12 }}
|
||||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
|
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>
|
</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).
|
* 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').
|
* 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 { ImageOutputView } from './ImageOutputView'
|
||||||
export { MarkdownOutputView } from './MarkdownOutputView'
|
export { MarkdownJsxView } from './MarkdownJsxView'
|
||||||
export { RawOutputView } from './RawOutputView'
|
export { RawOutputView } from './RawOutputView'
|
||||||
|
export { StaticMarkdownHtmlView } from './StaticMarkdownHtmlView'
|
||||||
|
export { StreamingMarkdownView } from './StreamingMarkdownView'
|
||||||
|
|
||||||
export type { ImageOutputViewProps } from './ImageOutputView'
|
export type { ImageOutputViewProps } from './ImageOutputView'
|
||||||
export type { MarkdownOutputViewProps } from './MarkdownOutputView'
|
export type { MarkdownJsxViewProps } from './MarkdownJsxView'
|
||||||
export type { RawOutputViewProps } from './RawOutputView'
|
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) => {
|
const unobserve = observeResize(el, (size) => {
|
||||||
if (size.height > 0) setHeight(size.height)
|
if (size.height > 0) setHeight(size.height)
|
||||||
})
|
})
|
||||||
const initial = el.getBoundingClientRect().height
|
// Rely on ResizeObserver for initial size to avoid forced synchronous layout (getBoundingClientRect).
|
||||||
if (initial > 0) setHeight(initial)
|
|
||||||
return unobserve
|
return unobserve
|
||||||
}, deps ?? [])
|
}, 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.
|
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
||||||
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
||||||
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
|
* 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.
|
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
||||||
*
|
*
|
||||||
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
|
* **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 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 { nodePropsAreEqual } from './flowUtils'
|
||||||
import type { AppNode } from './nodeTypes'
|
import type { AppNode } from './nodeTypes'
|
||||||
|
|
||||||
@@ -59,6 +60,15 @@ export type AbstractNodeContext<TData = Record<string, unknown>> = {
|
|||||||
targetIds: string[]
|
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
|
// Hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -72,13 +82,11 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
|||||||
data: TData
|
data: TData
|
||||||
): AbstractNodeContext<TData> {
|
): AbstractNodeContext<TData> {
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const pathCtx = useContext(ConnectionPathContext)
|
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||||
const nodes = graphCtx?.nodes ?? []
|
|
||||||
const edges = graphCtx?.edges ?? []
|
const edges = graphCtx?.edges ?? []
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
const setEdges = graphCtx?.setEdges
|
const setEdges = graphCtx?.setEdges
|
||||||
|
|
||||||
const addConnectionPathTrigger = pathCtx?.addConnectionPathTrigger
|
|
||||||
const updateData = useCallback(
|
const updateData = useCallback(
|
||||||
(partial: Partial<TData>) => {
|
(partial: Partial<TData>) => {
|
||||||
if (!setNodes) return
|
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
|
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||||
) as AppNode[]
|
) as AppNode[]
|
||||||
)
|
)
|
||||||
addConnectionPathTrigger?.(id)
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||||
},
|
},
|
||||||
[id, setNodes, addConnectionPathTrigger]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const incomingEdges = useMemo(
|
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 = {
|
export type GraphContextValue = {
|
||||||
nodes: AppNode[]
|
|
||||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||||
edges: AppEdge[]
|
|
||||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
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)
|
const GraphContext = React.createContext<GraphContextValue | null>(null)
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
* ## State flow
|
* ## State flow
|
||||||
*
|
*
|
||||||
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
|
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
|
||||||
* This hook updates FlowContext sets; edges read them via getConnectionStatus() in connectionStatus.ts.
|
* This hook dispatches to the canvas store; edges read path state via selectors.
|
||||||
* See lib/graph/state.ts for the overall state flow (graph state, connection path state).
|
|
||||||
*
|
*
|
||||||
* ## Lifecycle phases (conceptual)
|
* ## Lifecycle phases (conceptual)
|
||||||
*
|
*
|
||||||
@@ -19,14 +18,14 @@
|
|||||||
* Priority for edge status: error > paused > updating > default.
|
* Priority for edge status: error > paused > updating > default.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useContext, useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { ConnectionPathContext } from './flowContext'
|
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
|
|
||||||
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State that drives connection status for this node.
|
* 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 = {
|
export type NodeConnectionStatusState = {
|
||||||
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
|
/** 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
|
* 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:
|
* Use in any node that can be updating, in error, or paused:
|
||||||
*
|
*
|
||||||
@@ -53,7 +52,6 @@ export function useSyncConnectionStatus(
|
|||||||
nodeId: string,
|
nodeId: string,
|
||||||
state: NodeConnectionStatusState
|
state: NodeConnectionStatusState
|
||||||
): void {
|
): void {
|
||||||
const ctx = useContext(ConnectionPathContext)
|
|
||||||
const { updating, error, paused } = state
|
const { updating, error, paused } = state
|
||||||
const prevRef = useRef({ updating: false, error: false, paused: false })
|
const prevRef = useRef({ updating: false, error: false, paused: false })
|
||||||
|
|
||||||
@@ -64,26 +62,24 @@ export function useSyncConnectionStatus(
|
|||||||
const nowPaused = Boolean(paused)
|
const nowPaused = Boolean(paused)
|
||||||
|
|
||||||
if (prev.updating !== nowUpdating) {
|
if (prev.updating !== nowUpdating) {
|
||||||
if (nowUpdating) ctx?.startConnectionPathUpdate?.(nodeId)
|
if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
||||||
else ctx?.endConnectionPathUpdate?.(nodeId)
|
else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||||
prev.updating = nowUpdating
|
prev.updating = nowUpdating
|
||||||
}
|
}
|
||||||
if (prev.error !== nowError) {
|
if (prev.error !== nowError) {
|
||||||
if (nowError) ctx?.addConnectionPathError?.(nodeId)
|
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
|
||||||
else ctx?.removeConnectionPathError?.(nodeId)
|
|
||||||
prev.error = nowError
|
prev.error = nowError
|
||||||
}
|
}
|
||||||
if (prev.paused !== nowPaused) {
|
if (prev.paused !== nowPaused) {
|
||||||
if (nowPaused) ctx?.addConnectionPathPausedNode?.(nodeId)
|
dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } })
|
||||||
else ctx?.removeConnectionPathPausedNode?.(nodeId)
|
|
||||||
prev.paused = nowPaused
|
prev.paused = nowPaused
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (prevRef.current.updating) ctx?.endConnectionPathUpdate?.(nodeId)
|
if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||||
if (prevRef.current.error) ctx?.removeConnectionPathError?.(nodeId)
|
if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
|
||||||
if (prevRef.current.paused) ctx?.removeConnectionPathPausedNode?.(nodeId)
|
if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } })
|
||||||
prevRef.current = { updating: false, error: false, 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 { ProjectsPage } from './app/pleroma/PleromaPage'
|
||||||
import { KeromaPage } from './app/keroma/KeromaPage'
|
import { KeromaPage } from './app/keroma/KeromaPage'
|
||||||
import { CanvasRoute } from './app/canvas/CanvasRoute'
|
import { CanvasRoute } from './app/canvas/CanvasRoute'
|
||||||
|
import './lib/prismSetup'
|
||||||
|
import 'prismjs/themes/prism.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
|
|||||||
@@ -143,11 +143,11 @@ body {
|
|||||||
fill: none;
|
fill: none;
|
||||||
stroke-dasharray: 8 6;
|
stroke-dasharray: 8 6;
|
||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
animation: edge-flow 1.2s linear infinite;
|
animation: edge-flow 0.5s linear infinite;
|
||||||
will-change: stroke-dashoffset;
|
will-change: stroke-dashoffset;
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
backface-visibility: hidden;
|
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,
|
.react-flow__edge path.animated-edge-path.animated-edge-path--updating,
|
||||||
@@ -367,3 +367,30 @@ pre {
|
|||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--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: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
// Backend API (dev): proxy to avoid CORS
|
// Backend API (dev): proxy to avoid CORS
|
||||||
'/api/todos': {
|
|
||||||
target: 'http://localhost:8080',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/api/todos/': {
|
|
||||||
target: 'http://localhost:8080',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/health': {
|
'/health': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
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