Compare commits

...

10 Commits

Author SHA1 Message Date
fc265266c7 feat: introdce store 2026-03-13 00:32:19 +01:00
2d8f13aebb nit 2026-03-13 00:19:14 +01:00
2ff7e1f5f2 fix: classes 2026-03-12 23:38:13 +01:00
4dca9c6478 feat: replace codemirror and image viewr, opt for simplier approach 2026-03-12 23:07:51 +01:00
b71d32da5e fix: remove backend placeholders 2026-03-12 21:10:00 +01:00
0578b26241 feat: save button 2026-03-12 21:06:47 +01:00
029bab8917 refactor: performance 2026-03-12 21:01:43 +01:00
92fdba7eef fix: edge animation 2026-03-12 20:45:40 +01:00
fb1df18256 refactor: markdown split form agent 2026-03-12 20:37:16 +01:00
35b2e9d538 fix: codemirror util 2026-03-12 20:23:39 +01:00
53 changed files with 3760 additions and 1123 deletions

View File

@@ -36,7 +36,7 @@ cd frontend && npm install && npm run dev
# → http://localhost:3000
```
**Frontend + backend** (so the app can show “Backend API: N todos”):
**Frontend + backend** (for AI agent and health):
```bash
# Terminal 1 backend
@@ -45,7 +45,7 @@ cd backend && npm install && npm run dev
# Terminal 2 frontend
cd frontend && npm install && npm run dev
# → http://localhost:3000 (Vite proxies /api/todos to backend)
# → http://localhost:3000 (Vite proxies /api/* and /health to backend)
```
---
@@ -89,16 +89,9 @@ For self-hosting (e.g. Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL;
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/todos` | List all todos. |
| GET | `/api/todos/:id` | Get one todo. |
| POST | `/api/todos` | Create (`{ "title": "...", "completed": false }`). |
| PUT | `/api/todos/:id` | Update. |
| DELETE | `/api/todos/:id` | Delete. |
| GET | `/health` | Health check (e.g. for Docker). |
| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }``{ "markdown" }`. |
Data is in-memory (resets on restart). Add a JSON file or DB later if needed.
---
## Agent node (local LLM or OpenAI)

View File

@@ -2,7 +2,7 @@
"name": "zui-backend",
"version": "1.0.0",
"private": true,
"description": "Minimal Express API for Zui (todos CRUD, no DB)",
"description": "Minimal Express API for Zui (agent, health; no DB)",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",

View File

@@ -1,5 +1,5 @@
/**
* Minimal Express API: /api/todos CRUD (in-memory).
* Minimal Express API: /api/agent, /health.
* No DB, no auth. CORS allowed for frontend. Production-ready env (PORT, CORS_ORIGIN).
*/
@@ -14,72 +14,6 @@ const app = express()
app.use(cors({ origin: CORS_ORIGIN }))
app.use(express.json())
// In-memory store (replace with JSON file or DB later)
let todos = [
{ id: '1', title: 'Sample todo', completed: false },
{ id: '2', title: 'Another item', completed: true },
]
let nextId = 3
/** GET /api/todos — list all */
app.get('/api/todos', (req, res) => {
try {
res.json(todos)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
/** GET /api/todos/:id — get one */
app.get('/api/todos/:id', (req, res) => {
try {
const todo = todos.find((t) => t.id === req.params.id)
if (!todo) return res.status(404).json({ error: 'Not found' })
res.json(todo)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
/** POST /api/todos — create */
app.post('/api/todos', (req, res) => {
try {
const { title, completed } = req.body ?? {}
const id = String(nextId++)
const todo = { id, title: title ?? '', completed: Boolean(completed) }
todos.push(todo)
res.status(201).json(todo)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
/** PUT /api/todos/:id — update */
app.put('/api/todos/:id', (req, res) => {
try {
const idx = todos.findIndex((t) => t.id === req.params.id)
if (idx === -1) return res.status(404).json({ error: 'Not found' })
const { title, completed } = req.body ?? {}
if (title !== undefined) todos[idx].title = title
if (completed !== undefined) todos[idx].completed = Boolean(completed)
res.json(todos[idx])
} catch (err) {
res.status(500).json({ error: err.message })
}
})
/** DELETE /api/todos/:id — delete */
app.delete('/api/todos/:id', (req, res) => {
try {
const idx = todos.findIndex((t) => t.id === req.params.id)
if (idx === -1) return res.status(404).json({ error: 'Not found' })
const removed = todos.splice(idx, 1)[0]
res.json(removed)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
function buildAgentRequest(body) {
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}

View 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 hasnt 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 edges 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 edges 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. Zustands `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.

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,12 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.5.0",
"zustand": "^5.0.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@@ -25,24 +26,28 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@uiw/react-codemirror": "^4.25.7",
"@types/prismjs": "^1.26.6",
"@wireweave/core": "^2.6.0",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"markdown-to-jsx": "^9.7.9",
"marked": "^17.0.4",
"next-themes": "^0.4.6",
"nunjucks": "^3.2.4",
"prism-react-renderer": "^2.4.1",
"prismjs": "^1.30.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "^6.28.0",
"react-zoom-pan-pinch": "^3.7.0",
"react-simple-code-editor": "^0.14.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@testing-library/react": "^16.0.0",
"@types/node": "^25.3.3",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
@@ -52,6 +57,7 @@
"shadcn": "^4.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^2.1.6"
}
}

View File

@@ -14,12 +14,14 @@ import {
} from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Undo2 } from 'lucide-react'
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Save, Undo2 } from 'lucide-react'
import { Input } from '@/components/ui/input'
export type CanvasMenubarProps = {
onImport: () => void
onExport: () => void
onSave?: () => void
canSave?: boolean
undo: () => void
redo: () => void
canUndo: boolean
@@ -34,6 +36,7 @@ export type CanvasMenubarProps = {
const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true }
const SAVE_KEYS = { key: 's', shiftKey: false }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
@@ -43,6 +46,8 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
export function CanvasMenubar({
onImport,
onExport,
onSave,
canSave = true,
undo,
redo,
canUndo,
@@ -114,11 +119,19 @@ export function CanvasMenubar({
ev.stopPropagation()
redo()
}
return
}
if (matchKey(ev, SAVE_KEYS)) {
if (onSave && canSave) {
ev.preventDefault()
ev.stopPropagation()
onSave()
}
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [undo, redo, canUndo, canRedo])
}, [undo, redo, canUndo, canRedo, onSave, canSave])
return (
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
@@ -135,6 +148,20 @@ export function CanvasMenubar({
<MenubarContent>
{projectId && (
<>
{onSave != null && (
<>
<MenubarItem onClick={onSave} disabled={!canSave} className="gap-2">
<Save className="h-4 w-4" />
Save
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>S</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarSeparator />
</>
)}
<MenubarItem
onClick={() => setIsRenamingProject(true)}
className="gap-2"

View File

@@ -31,10 +31,11 @@ import {
import { useTheme } from '@/lib/themeContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
import { getExampleGraph } from '@/app/canvas/canvasGraphUtils'
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
@@ -169,6 +170,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
canUndo,
canRedo,
setStateImmediate,
save,
} = useCanvasGraph(projectId)
const importInputRef = useRef<HTMLInputElement | null>(null)
@@ -185,20 +187,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const [isSelecting, setIsSelecting] = React.useState(false)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
const connectionPath = useCanvasConnectionPath(edges)
useEffect(() => {
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
}, [nodes, edges])
const connectionPath = useCanvasConnectionPathFromStore()
const nodesRef = useRef(nodes)
nodesRef.current = nodes
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
React.useEffect(() => {
fetch('/api/todos')
.then((r) => r.json())
.then((data: unknown) => {
if (Array.isArray(data)) setApiTodosCount(data.length)
})
.catch(() => setApiTodosCount(-1))
}, [])
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
graphRef.current.nodes = nodes
graphRef.current.edges = edges
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
const rafRef = useRef<number | null>(null)
@@ -244,7 +243,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
)
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
(params: Connection) => {
const targetType =
nodesRef.current.find((n) => n.id === params.target)?.type ?? ''
const conn = { ...params, data: { targetType } as Record<string, unknown> }
setEdges((eds) => addEdge(conn, eds))
},
[setEdges]
)
@@ -324,7 +328,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
toast.error('Invalid file: expected nodes and edges arrays')
return
}
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
const nodes = state.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
setStateImmediate({ nodes, edges })
if (state.version != null && state.version > PROJECT_VERSION) {
toast.error('Project was created with a newer app version')
} else {
@@ -393,8 +399,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
}, [])
const graphContextValue = useMemo(
() => ({ nodes, setNodes, edges, setEdges }),
[nodes, setNodes, edges, setEdges]
() => ({ setNodes, setEdges, graphRef, edges }),
[setNodes, setEdges, edges]
)
const connectionPathContextValue = useMemo(
() => ({
@@ -438,18 +444,36 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
]
)
const nodesForFlow = useMemo(
() =>
nodes.map((n) => ({
const prevNodesRef = useRef<AppNode[]>([])
const prevNodesForFlowRef = useRef<Node[]>([])
const nodesForFlow = useMemo(() => {
const prev = prevNodesRef.current
if (nodes === prev) return prevNodesForFlowRef.current
const prevById = new Map(prev.map((n) => [n.id, n]))
const prevWrappedById = new Map(
prevNodesForFlowRef.current.map((w, i) => [prev[i]?.id, w])
)
const result = nodes.map((n) => {
const prevNode = prevById.get(n.id)
if (prevNode === n && prevWrappedById.has(n.id)) {
return prevWrappedById.get(n.id)!
}
return {
...n,
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
})),
[nodes]
)
}
})
prevNodesRef.current = nodes
prevNodesForFlowRef.current = result
return result
}, [nodes])
const edgesForFlow = useMemo(
() =>
edges.map((e) => {
const targetType = nodes.find((nd) => nd.id === e.target)?.type ?? ''
const targetType =
(typeof e.data === 'object' && e.data !== null && (e.data as Record<string, unknown>).targetType != null
? (e.data as Record<string, unknown>).targetType
: '') as string
const connectionLabel = getConnectionLabelForTarget(targetType)
const baseData =
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
@@ -458,7 +482,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
data: { ...baseData, connectionLabel },
}
}),
[edges, nodes]
[edges]
)
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
@@ -595,6 +619,15 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<CanvasMenubar
onImport={handleImportProject}
onExport={handleExportProject}
onSave={
projectId
? () => {
save()
toast.success('Saved')
}
: undefined
}
canSave={Boolean(projectId)}
undo={undo}
redo={redo}
canUndo={canUndo}
@@ -606,11 +639,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
canCopy={selectedNodes.length === 1}
onFitView={() => flowActionsRef.current?.fitView?.()}
/>
{apiTodosCount !== null && (
<div className="shrink-0 px-3 py-1 text-xs text-muted-foreground border-b border-border/50">
Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'}
</div>
)}
<div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
<GraphContext.Provider value={graphContextValue}>

View File

@@ -2,7 +2,7 @@
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
*/
import React, { useEffect } from 'react'
import React, { useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { CanvasPage } from './CanvasPage'
import { usePlatform } from '@/app/kosmos/KosmosContext'
@@ -10,12 +10,14 @@ import { usePlatform } from '@/app/kosmos/KosmosContext'
export function CanvasRoute() {
const { projectId } = useParams<{ projectId: string }>()
const { projects, updateLastEdited } = usePlatform()
const updateLastEditedRef = useRef(updateLastEdited)
updateLastEditedRef.current = updateLastEdited
const project = projects.find((p) => p.id === projectId)
useEffect(() => {
if (projectId) updateLastEdited(projectId)
}, [projectId, updateLastEdited])
if (projectId) updateLastEditedRef.current(projectId)
}, [projectId])
if (!projectId) return null
if (!project) {

View File

@@ -17,25 +17,34 @@ export { ViewportDisplayContext }
/**
* Must be rendered inside ReactFlowProvider. Subscribes to viewport once,
* maps zoom to displayMode with hysteresis, and provides it to descendants.
* maps zoom to displayMode with hysteresis. Throttles updates via rAF to avoid
* re-rendering all contextual nodes on every zoom tick.
*/
export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) {
const { zoom } = useViewport()
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full'
)
const lastRef = useRef(displayMode)
const lastModeRef = useRef(displayMode)
const zoomRef = useRef(zoom)
const rafRef = useRef<number | null>(null)
zoomRef.current = zoom
useLayoutEffect(() => {
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
let next: ViewportDisplayMode = lastRef.current
if (zoom <= low) next = 'compact'
else if (zoom >= high) next = 'full'
if (next !== lastRef.current) {
lastRef.current = next
setDisplayMode(next)
}
if (rafRef.current !== null) return
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
const z = zoomRef.current
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
let next: ViewportDisplayMode = lastModeRef.current
if (z <= low) next = 'compact'
else if (z >= high) next = 'full'
if (next !== lastModeRef.current) {
lastModeRef.current = next
setDisplayMode(next)
}
})
}, [zoom])
return (

View File

@@ -7,6 +7,20 @@ import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage'
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
export function backfillEdgeTargetTypes(
nodes: AppNode[],
edges: AppEdge[]
): AppEdge[] {
const typeById = new Map(nodes.map((n) => [n.id, n.type ?? '']))
return edges.map((e) => {
const targetType = typeById.get(e.target) ?? (e.data as Record<string, unknown>)?.targetType ?? ''
const data = typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
if (data.targetType === targetType) return e
return { ...e, data: { ...data, targetType } }
})
}
const NODE_GAP = 150
const EXAMPLE_NODES: AppNode[] = [
@@ -45,20 +59,24 @@ const EXAMPLE_EDGES: AppEdge[] = [
]
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
return {
nodes: EXAMPLE_NODES.map((n) => ({
...n,
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
})),
edges: EXAMPLE_EDGES.map((e) => ({ ...e })),
}
const nodes = EXAMPLE_NODES.map((n) => ({
...n,
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
}))
const edges = backfillEdgeTargetTypes(
nodes,
EXAMPLE_EDGES.map((e) => ({ ...e }))
)
return { nodes, edges }
}
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
if (projectId) {
const stored = loadGraphFromStorage(projectId)
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] }
const nodes = stored.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
return { nodes, edges }
}
return { nodes: [], edges: [] }
}

View 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'

View 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 }
}

View 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
}

View 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')
})
})

View 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 }

View 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 }

View File

@@ -14,8 +14,8 @@ function setToStableKey(s: Set<string>): string {
export type EdgeLike = { source: string; target: string }
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
/** Short tail (ms) after last updating node ends so the path doesn't vanish instantly. */
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
export type UseCanvasConnectionPathResult = {
connectionPathUpdatingNodeIds: string[]
@@ -41,7 +41,6 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
const pathUpdateStartTimeRef = useRef<number | null>(null)
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
@@ -61,7 +60,6 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
const ref = pathUpdateNodeIdsRef.current
ref.add(nodeId)
if (ref.size === 1) {
pathUpdateStartTimeRef.current = Date.now()
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
pathUpdateEndTimeoutRef.current = null
@@ -77,17 +75,10 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
setConnectionPathUpdatingNodeIds(Array.from(ref))
return
}
const startedAt = pathUpdateStartTimeRef.current ?? 0
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed)
if (remaining === 0) {
pathUpdateEndTimeoutRef.current = setTimeout(() => {
pathUpdateEndTimeoutRef.current = null
clearPathUpdateSession()
} else {
pathUpdateEndTimeoutRef.current = setTimeout(() => {
pathUpdateEndTimeoutRef.current = null
clearPathUpdateSession()
}, remaining)
}
}, CONNECTION_PATH_UPDATE_TAIL_MS)
}, [clearPathUpdateSession])
const addConnectionPathTrigger = useCallback((nodeId: string) => {

View 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
)
}

View File

@@ -1,78 +1,38 @@
/**
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
* with initial graph from project storage (or example) and debounced + idle-based save.
* with initial graph from project storage (or example). Save is explicit via save().
* Keeps CanvasPage focused on composition and layout.
*/
import { useEffect, useMemo, useRef } from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory>
/** Debounce delay (ms) before we schedule a save. */
const SAVE_DEBOUNCE_MS = 800
/** Max wait (ms) for requestIdleCallback before falling back to setTimeout. */
const SAVE_IDLE_TIMEOUT_MS = 2000
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
/** Persist current nodes/edges to storage. No-op when projectId is missing. */
save: () => void
}
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
const { nodes, edges } = result
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const idleCallbackRef = useRef<number | null>(null)
const pendingSaveRef = useRef<{ projectId: string; nodes: AppNode[]; edges: AppEdge[] } | null>(
null
)
const nodesRef = useRef(nodes)
const edgesRef = useRef(edges)
nodesRef.current = nodes
edgesRef.current = edges
useEffect(() => {
const save = useCallback(() => {
if (!projectId) return
saveGraphToStorage(projectId, {
version: PROJECT_VERSION,
nodes: nodesRef.current,
edges: edgesRef.current,
})
}, [projectId])
const scheduleSave = () => {
pendingSaveRef.current = { projectId, nodes, edges }
const doSave = () => {
const pending = pendingSaveRef.current
pendingSaveRef.current = null
if (pending && pending.projectId === projectId) {
saveGraphToStorage(pending.projectId, {
version: PROJECT_VERSION,
nodes: pending.nodes,
edges: pending.edges,
})
}
}
if (typeof requestIdleCallback !== 'undefined') {
idleCallbackRef.current = requestIdleCallback(doSave, {
timeout: SAVE_IDLE_TIMEOUT_MS,
})
} else {
idleCallbackRef.current = window.setTimeout(doSave, 0) as unknown as number
}
}
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(scheduleSave, SAVE_DEBOUNCE_MS)
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = null
}
if (idleCallbackRef.current != null) {
if (typeof cancelIdleCallback !== 'undefined') {
cancelIdleCallback(idleCallbackRef.current)
} else {
clearTimeout(idleCallbackRef.current)
}
idleCallbackRef.current = null
}
}
}, [projectId, nodes, edges])
return result
return { ...result, save }
}

View 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>
)
}

View File

@@ -1,15 +1,15 @@
import React, { useContext, useMemo, memo } from 'react'
import React, { memo } from 'react'
import {
BaseEdge,
getBezierPath,
type EdgeProps,
} from '@xyflow/react'
import { ConnectionPathContext } from '@/lib/graph/flowContext'
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
import { useCanvasStore } from '@/app/canvas/canvasStore'
import { selectConnectionStatusForEdge } from '@/app/canvas/canvasStore.selectors'
import { CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
const EDGE_STROKE_WIDTH = 2
const DOT_MARKER_R = 1.5
const EMPTY_PATH_NODE_IDS = new Set<string>()
function AnimatedEdgeInner({
id,
@@ -26,38 +26,13 @@ function AnimatedEdgeInner({
target,
data,
}: EdgeProps) {
const ctx = useContext(ConnectionPathContext)
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
const errorTargetNodeIds = useMemo(
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
[ctx?.connectionPathErrorNodeIds]
)
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
const connectionStatus = useMemo(
() =>
getConnectionStatus({
source,
target,
pathNodeIds,
pausedSegmentNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
}),
[
source,
target,
pathNodeIds,
pausedSegmentNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
]
const connectionStatus = useCanvasStore((s) =>
selectConnectionStatusForEdge(s, source, target)
)
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
sourceX,
sourceY,

View File

@@ -2,7 +2,8 @@ import type { ComponentProps, ReactNode } from "react";
import { NodeResizer } from "@xyflow/react";
import { useContext } from "react";
import { FlowUIContext, useConnectionPathRole } from "@/lib/graph/flowContext";
import { FlowUIContext } from "@/lib/graph/flowContext";
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
import { cn } from "@/lib/utils";
/** Default min size for resizable nodes (used by NodeResizer). */
@@ -38,7 +39,7 @@ export function BaseNode({
}: BaseNodeProps) {
const flowUIContext = useContext(FlowUIContext);
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
const connectionPathRole = useConnectionPathRole(nodeId);
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
const hasSize =
dimensions &&
dimensions.width > 0 &&

View File

@@ -16,7 +16,6 @@ export function FlowKeyboardShortcuts() {
const { fitView, screenToFlowPosition } = useReactFlow()
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const nodes = graphCtx?.nodes ?? []
const setNodes = graphCtx?.setNodes
const setConnectionFrom = uiCtx?.setConnectionFrom
const flowActionsRef = uiCtx?.flowActionsRef
@@ -80,6 +79,7 @@ export function FlowKeyboardShortcuts() {
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
if (ev.key === 'Escape') {
const openDialog = document.querySelector('[role="dialog"]')
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
@@ -152,7 +152,7 @@ export function FlowKeyboardShortcuts() {
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [
nodes,
graphCtx?.graphRef,
setNodes,
setConnectionFrom,
pasteAtViewportCenter,

View File

@@ -11,9 +11,7 @@ type Props = {
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const nodes = graphCtx?.nodes ?? []
const setNodes = graphCtx?.setNodes
const edges = graphCtx?.edges ?? []
const setEdges = graphCtx?.setEdges
const renamingNodeId = uiCtx?.renamingNodeId ?? null
const setRenamingNodeId = uiCtx?.setRenamingNodeId
@@ -32,7 +30,9 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
}, [isRenaming, nodeId])
const applyRename = useCallback(() => {
if (!setNodes || !setEdges || !setRenamingNodeId) return
if (!setNodes || !setEdges || !setRenamingNodeId || !graphCtx?.graphRef) return
const nodes = graphCtx.graphRef.current.nodes
const edges = graphCtx.graphRef.current.edges
const newId = inputValue.trim()
if (!newId || newId === nodeId) {
setRenamingNodeId(null)
@@ -46,7 +46,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
setNodes(nextNodes as AppNode[])
setEdges(nextEdges)
setRenamingNodeId(null)
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
}, [nodeId, inputValue, graphCtx?.graphRef, setNodes, setEdges, setRenamingNodeId])
const cancelRename = useCallback(() => {
setRenamingNodeId?.(null)

View File

@@ -45,10 +45,9 @@ type Props = {
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const nodes = graphCtx?.nodes ?? []
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
const setNodes = graphCtx?.setNodes
const setEdges = graphCtx?.setEdges
const edges = graphCtx?.edges ?? []
const node = nodes.find((n: any) => n.id === nodeId)
const nodeMenuExtraContent = useMemo(

View File

@@ -63,9 +63,7 @@ function BorderLoadingIndicator({
container.firstElementChild instanceof HTMLElement
? container.firstElementChild
: container
const cw = (target as HTMLElement).offsetWidth
const ch = (target as HTMLElement).offsetHeight
if (cw > 0 && ch > 0) setMeasured({ w: cw, h: ch })
// Use ResizeObserver only to avoid forced synchronous layout (offsetWidth/offsetHeight).
const unObserve = observeResize(target, ({ width: w, height: h }) => {
if (w > 0 && h > 0) setMeasured({ w, h })
})

View File

@@ -1,18 +1,14 @@
import React, { useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import React, { useCallback, useContext, useId, useMemo } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
getConnectedNodesByType,
useAbstractNode,
type FlowNode,
} from '@/lib/graph/abstractNode'
import { CodeEditor } from '@/components/editor/CodeEditor'
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
import { plantumlLanguage } from '@/lib/plantumlLanguage'
import { useTheme } from '@/lib/themeContext'
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
import {
getConfigTypes,
getConfigContent,
@@ -58,32 +54,31 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
const configTypeId = getConfigTypeId(data ?? {})
const configType = getConfigType(configTypeId)
const content = getConfigContent(data ?? {})
const { theme } = useTheme()
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
const editorRef = useRef<unknown>(null)
const connectedConfigNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
[nodes, sourceIds]
)
const connectedVariableNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
[nodes, sourceIds]
)
const connectedDataNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'data'),
[nodes, sourceIds]
)
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
const editorId = useId()
const onChange = useCallback(
(val: string) => updateData({ content: val, configType: configTypeId }),
[updateData, configTypeId]
)
const insertAt = useSimpleEditorInsert(editorId, content, onChange)
const connectedConfigNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'config'),
[nodes, sourceIds]
)
const connectedVariableNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
[nodes, sourceIds]
)
const connectedDataNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'data'),
[nodes, sourceIds]
)
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
const setConfigType = useCallback(
(newTypeId: ConfigTypeId) => {
@@ -96,36 +91,6 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
[configTypeId, data, updateData]
)
const insertAt = useCallback(
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
const ref = editorRef.current as { view: { state: { doc: { length: number; toString(): string }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
if (ref?.view) {
const view = ref.view
const doc = view.state.doc
const len = doc.length
let from: number
if (mode === 'prepend') {
from = 0
} else if (mode === 'append') {
from = len
} else {
const main = view.state.selection.main
from = main.from
}
view.dispatch({ changes: { from, to: from, insert: insertText } })
const newVal = view.state.doc.toString()
onChange(newVal)
return
}
if (mode === 'prepend') {
onChange(insertText + content)
} else {
onChange(content + insertText)
}
},
[onChange, content]
)
const insertExtendsFromNode = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
@@ -175,21 +140,8 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
[connectedConfigNodes],
)
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
const extensions = useMemo(() => {
const lang =
configTypeId === 'wireframe'
? javascript()
: configType.language === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [
lang,
autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds, dataIds)],
activateOnTyping: true,
}),
]
}, [configTypeId, configType.language, variableIds, functionIds, configTitles, dataIds])
const highlightLang: HighlightLanguage =
configTypeId === 'wireframe' ? 'javascript' : configType.language === 'plantuml' ? 'plantuml' : 'markdown'
const [editorHeight, editorContainerRef] = useResizeHeight(180)
const insertBlocksContent = useMemo(() => {
@@ -355,17 +307,17 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
/>
</div>
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<CodeMirror
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef}
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
<CodeEditor
textareaId={editorId}
value={content}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
onChange={onChange}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
onValueChange={onChange}
language={highlightLang}
minHeight={editorHeight}
style={{ fontSize: 14 }}
textareaClassName="text-sm outline-none border-0 resize-none nodrag nopan"
preClassName="text-sm nodrag nopan"
className="min-h-0 w-full"
/>
</div>
</BaseNodeContent>

View File

@@ -1,14 +1,13 @@
import React, { useCallback, useContext, useMemo, useRef } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import React, { useCallback, useContext, useId, useMemo } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
getConnectedNodesByType,
useAbstractNode,
type FlowNode,
} from '@/lib/graph/abstractNode'
import { CodeEditor } from '@/components/editor/CodeEditor'
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { useTheme } from '@/lib/themeContext'
import {
BaseNode,
BaseNodeContent,
@@ -29,50 +28,30 @@ export type FunctionNodeData = { body?: string }
type Props = AbstractNodeProps<FunctionNodeData>
const LANGUAGE: HighlightLanguage = 'javascript'
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
const bodyValue = data?.body ?? ''
const { theme } = useTheme()
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
const editorRef = useRef<unknown>(null)
const connectedVariableNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
[nodes, sourceIds]
)
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
const editorId = useId()
const onChange = useCallback(
(val: string) => updateData({ body: val }),
[updateData]
)
const insertAt = useSimpleEditorInsert(editorId, bodyValue, onChange)
const insertAt = useCallback(
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
const ref = editorRef.current as { view: { state: { doc: { length: number }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
if (ref?.view) {
const view = ref.view
const doc = view.state.doc
const len = doc.length
let from: number
if (mode === 'prepend') from = 0
else if (mode === 'append') from = len
else from = view.state.selection.main.from
view.dispatch({ changes: { from, to: from, insert: insertText } })
onChange(view.state.doc.toString())
return
}
if (mode === 'prepend') onChange(insertText + bodyValue)
else onChange(bodyValue + insertText)
},
[onChange, bodyValue]
const connectedVariableNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
[nodes, sourceIds]
)
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
const insertVariableAtCursor = useCallback(
(variableNode: any) => {
@@ -88,7 +67,6 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
[insertAt]
)
const extensions = useMemo(() => [javascript()], [])
const [editorHeight, editorContainerRef] = useResizeHeight(120)
const dimensions =
width != null && height != null && width > 0 && height > 0
@@ -140,17 +118,17 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
}
/>
</div>
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<CodeMirror
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef}
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
<CodeEditor
textareaId={editorId}
value={bodyValue}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
onChange={onChange}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
onValueChange={onChange}
language="javascript"
minHeight={editorHeight}
style={{ fontSize: 12 }}
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
preClassName="text-xs nodrag nopan"
className="min-h-0 w-full"
/>
</div>
</BaseNodeContent>

View File

@@ -39,7 +39,13 @@ import {
useRenderingNodeState,
type RenderingNodeData,
} from './useRenderingNodeState'
import { ImageOutputView, MarkdownOutputView, RawOutputView } from './views'
import {
ImageOutputView,
MarkdownJsxView,
RawOutputView,
StaticMarkdownHtmlView,
StreamingMarkdownView,
} from './views'
export type { RenderingNodeData }
@@ -114,10 +120,19 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
/>
)
}
return <MarkdownOutputView state={state} streaming={false} />
const isConfigMarkdown =
state.sourceNodeType === 'config' && state.rawLanguage === 'markdown'
if (isConfigMarkdown) {
return (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<MarkdownJsxView markdown={state.resolvedContent ?? ''} />
</div>
)
}
return <StaticMarkdownHtmlView state={state} />
}
if (state.loading && state.streamingMarkdown !== null) {
return <MarkdownOutputView state={state} streaming={true} />
return <StreamingMarkdownView state={state} />
}
if (state.loading) {
return (

View File

@@ -4,10 +4,10 @@
* pipeline interface.
*/
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useAbstractNode } from '@/lib/graph/abstractNode'
import { ConnectionPathContext } from '@/lib/graph/flowContext'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
@@ -118,6 +118,8 @@ export function useRenderingNodeState(
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
const deferredNodes = useDeferredValue(nodes)
const deferredEdges = useDeferredValue(edges)
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
const srcNode = useMemo(
() => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null),
@@ -150,8 +152,8 @@ export function useRenderingNodeState(
: ''
const signatures = useMemo(
() => buildSourceSignatures(nodes as NodeLike[], edges as EdgeLike[], id, incomingIds),
[nodes, edges, id, incomingIds]
() => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds),
[deferredNodes, deferredEdges, id, incomingIds]
)
const {
connectedNodeIds,
@@ -187,8 +189,14 @@ export function useRenderingNodeState(
const lastManualRunTriggerRef = useRef(0)
const manualRunTriggerSyncedRef = useRef(false)
const pathCtx = useContext(ConnectionPathContext)
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
const triggerNodeIds = useCanvasStore((s) => s.path.triggerNodeIds)
const updateDataRef = useRef(updateData)
updateDataRef.current = updateData
const setNodesRef = useRef(setNodes)
setNodesRef.current = setNodes
const aiConnectionRef = useRef(aiConnection)
aiConnectionRef.current = aiConnection
const hasPendingInputs =
effectiveUpdateMode === 'manual' &&
!loading &&
@@ -254,6 +262,7 @@ export function useRenderingNodeState(
let cancelled = false
const run = async () => {
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
loadingStartedAtRef.current = Date.now()
setLoading(true)
setError(null)
@@ -261,7 +270,7 @@ export function useRenderingNodeState(
setResolvedContent(null)
setStreamingMarkdown(null)
setReasoningContent('')
updateData({
updateDataRef.current({
cachedRenderedContent: undefined,
cachedResolvedContent: undefined,
cachedReasoningContent: undefined,
@@ -276,8 +285,8 @@ export function useRenderingNodeState(
renderNodeId: id,
viewportWidth,
viewportHeight,
setNodes: setNodes ?? undefined,
aiConnection,
setNodes: setNodesRef.current ?? undefined,
aiConnection: aiConnectionRef.current,
...(isAgentSource && {
onStreamingStart: () => setStreamingMarkdown(''),
onStreamingChunk: (chunk: string) =>
@@ -295,7 +304,7 @@ export function useRenderingNodeState(
if (thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg)
setError(null)
updateData({
updateDataRef.current({
cachedRenderedContent: htmlOrSvg,
cachedResolvedContent: resolved,
cachedReasoningContent: reasoning ?? '',
@@ -349,26 +358,18 @@ export function useRenderingNodeState(
minLoadingTimeoutRef.current = null
}
}
// Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport.
// React Flow updates (position, selection, context ref churn) do not trigger re-runs.
}, [
id,
srcId,
srcNode?.type,
effectiveUpdateMode,
runTrigger,
sourceContent,
sourceSignature,
configSignature,
edgesSignature,
variablesSignature,
functionsSignature,
dataSignature,
viewportWidth,
viewportHeight,
retryCount,
updateData,
setNodes,
aiConnection,
isAgentSource,
incomingIds.length,
])

View File

@@ -1,6 +1,4 @@
import React from 'react'
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
import type { RenderingNodeState } from '../useRenderingNodeState'
export type ImageOutputViewProps = {
@@ -13,8 +11,6 @@ export type ImageOutputViewProps = {
export function ImageOutputView({
state,
selected,
viewportFocused,
onViewportFocus,
onViewportBlur,
}: ImageOutputViewProps) {
@@ -25,59 +21,10 @@ export function ImageOutputView({
onFocus={onViewportFocus}
onBlur={onViewportBlur}
>
<TransformWrapper
initialScale={1}
initialPositionX={0}
initialPositionY={0}
minScale={0.2}
maxScale={4}
centerOnInit={false}
panning={{ disabled: !selected && !viewportFocused }}
wheel={{ disabled: !selected && !viewportFocused }}
doubleClick={{ disabled: !selected && !viewportFocused }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
<button
type="button"
onClick={() => zoomIn()}
className="react-flow__controls-button"
title="Zoom in"
>
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => zoomOut()}
className="react-flow__controls-button"
title="Zoom out"
>
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => resetTransform()}
className="react-flow__controls-button"
title="Reset view (fit all)"
>
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
</button>
</div>
<div className="absolute inset-0 nodrag nopan overflow-hidden [&_.react-transform-component]:!w-full [&_.react-transform-component]:!h-full [&_.react-transform-wrapper]:!w-full [&_.react-transform-wrapper]:!h-full">
<TransformComponent
wrapperClass="!w-full !h-full"
contentClass="nodrag nopan !w-full !h-full !block !min-h-0"
>
<div
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
/>
</TransformComponent>
</div>
</>
)}
</TransformWrapper>
<div
className="absolute inset-0 nodrag nopan overflow-auto"
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
/>
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,10 +1,8 @@
import React, { useMemo } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import { Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { plantumlLanguage } from '@/lib/plantumlLanguage'
import { CodeEditor } from '@/components/editor/CodeEditor'
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
import type { RenderingNodeState } from '../useRenderingNodeState'
import { toast } from 'sonner'
@@ -15,21 +13,19 @@ export type RawOutputViewProps = {
theme: 'light' | 'dark'
}
export function RawOutputView({ state, height, containerRef, theme }: RawOutputViewProps) {
const extensions = useMemo(() => {
const lang =
state.rawLanguage === 'wireframe'
? javascript()
: state.rawLanguage === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [lang]
}, [state.rawLanguage])
function languageForRaw(rawLanguage: string): HighlightLanguage {
if (rawLanguage === 'wireframe') return 'javascript'
if (rawLanguage === 'plantuml') return 'plantuml'
return 'markdown'
}
export function RawOutputView({ state, height, containerRef }: RawOutputViewProps) {
const language = useMemo(() => languageForRaw(state.rawLanguage), [state.rawLanguage])
return (
<div
ref={containerRef}
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input"
>
<Button
type="button"
@@ -50,15 +46,16 @@ export function RawOutputView({ state, height, containerRef, theme }: RawOutputV
>
<Copy className="size-3.5" />
</Button>
<CodeMirror
<CodeEditor
value={state.rawDisplayContent}
height={`${height}px`}
theme={theme}
extensions={extensions}
onValueChange={() => {}}
language={language}
readOnly
editable={false}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
minHeight={height}
style={{ fontSize: 12 }}
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
preClassName="text-xs nodrag nopan min-h-0"
className="min-h-0 w-full"
/>
</div>
)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,12 +1,21 @@
/**
* Output view components: "Display" step of the pipeline (see lib/graph/rendering.ts).
* Which view is used comes from the source's outputType ('image' | 'html').
*
* - Streaming (agent): StreamingMarkdownView
* - Config markdown preview: MarkdownJsxView (markdown-to-jsx)
* - Agent final / other HTML: StaticMarkdownHtmlView
* RenderingNode chooses the view directly; no shared dispatcher.
*/
export { ImageOutputView } from './ImageOutputView'
export { MarkdownOutputView } from './MarkdownOutputView'
export { MarkdownJsxView } from './MarkdownJsxView'
export { RawOutputView } from './RawOutputView'
export { StaticMarkdownHtmlView } from './StaticMarkdownHtmlView'
export { StreamingMarkdownView } from './StreamingMarkdownView'
export type { ImageOutputViewProps } from './ImageOutputView'
export type { MarkdownOutputViewProps } from './MarkdownOutputView'
export type { MarkdownJsxViewProps } from './MarkdownJsxView'
export type { RawOutputViewProps } from './RawOutputView'
export type { StaticMarkdownHtmlViewProps } from './StaticMarkdownHtmlView'
export type { StreamingMarkdownViewProps } from './StreamingMarkdownView'

View File

@@ -21,8 +21,7 @@ export function useResizeHeight(
const unobserve = observeResize(el, (size) => {
if (size.height > 0) setHeight(size.height)
})
const initial = el.getBoundingClientRect().height
if (initial > 0) setHeight(initial)
// Rely on ResizeObserver for initial size to avoid forced synchronous layout (getBoundingClientRect).
return unobserve
}, deps ?? [])

View 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
}

View File

@@ -4,7 +4,7 @@
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
* also reports this node as a trigger for connection path (lifecycle "trigger").
* also marks this node as a connection-path trigger so edges update on data changes.
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
*
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
@@ -16,7 +16,8 @@
*/
import React, { useCallback, useContext, useMemo } from 'react'
import { GraphContext, ConnectionPathContext } from './flowContext'
import { GraphContext } from './flowContext'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { nodePropsAreEqual } from './flowUtils'
import type { AppNode } from './nodeTypes'
@@ -59,6 +60,15 @@ export type AbstractNodeContext<TData = Record<string, unknown>> = {
targetIds: string[]
}
/** Returns nodes that are connected to this node (in sourceIds) and have the given type. */
export function getConnectedNodesByType<T extends FlowNode = FlowNode>(
nodes: FlowNode[],
sourceIds: string[],
type: string
): T[] {
return nodes.filter((n) => sourceIds.includes(n.id) && n.type === type) as T[]
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
@@ -72,13 +82,11 @@ export function useAbstractNode<TData = Record<string, unknown>>(
data: TData
): AbstractNodeContext<TData> {
const graphCtx = useContext(GraphContext)
const pathCtx = useContext(ConnectionPathContext)
const nodes = graphCtx?.nodes ?? []
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
const edges = graphCtx?.edges ?? []
const setNodes = graphCtx?.setNodes
const setEdges = graphCtx?.setEdges
const addConnectionPathTrigger = pathCtx?.addConnectionPathTrigger
const updateData = useCallback(
(partial: Partial<TData>) => {
if (!setNodes) return
@@ -87,9 +95,9 @@ export function useAbstractNode<TData = Record<string, unknown>>(
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
) as AppNode[]
)
addConnectionPathTrigger?.(id)
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
},
[id, setNodes, addConnectionPathTrigger]
[id, setNodes]
)
const incomingEdges = useMemo(

View File

@@ -25,14 +25,20 @@ export type FlowActions = {
}
// ---------------------------------------------------------------------------
// Graph context (nodes, edges, setters)
// Graph context (setters + graphRef for reads; edges in context so edge changes trigger re-renders)
// ---------------------------------------------------------------------------
export type GraphContextRef = {
current: { nodes: AppNode[]; edges: AppEdge[] }
}
export type GraphContextValue = {
nodes: AppNode[]
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
edges: AppEdge[]
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
/** Current nodes/edges; updated every render. Read from here to avoid re-rendering on position-only changes. */
graphRef: GraphContextRef
/** Edges in context so consumers (e.g. edge indicators, useAbstractNode) re-render when edges change. */
edges: AppEdge[]
}
const GraphContext = React.createContext<GraphContextValue | null>(null)

View File

@@ -5,8 +5,7 @@
* ## State flow
*
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
* This hook updates FlowContext sets; edges read them via getConnectionStatus() in connectionStatus.ts.
* See lib/graph/state.ts for the overall state flow (graph state, connection path state).
* This hook dispatches to the canvas store; edges read path state via selectors.
*
* ## Lifecycle phases (conceptual)
*
@@ -19,14 +18,14 @@
* Priority for edge status: error > paused > updating > default.
*/
import { useContext, useEffect, useRef } from 'react'
import { ConnectionPathContext } from './flowContext'
import { useEffect, useRef } from 'react'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
/**
* State that drives connection status for this node.
* Pass the current values from your node; the hook syncs them to FlowContext.
* Pass the current values from your node; the hook syncs them to the canvas store.
*/
export type NodeConnectionStatusState = {
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
@@ -38,9 +37,9 @@ export type NodeConnectionStatusState = {
}
/**
* Syncs this node's lifecycle state to FlowContext so connection status (edge colors)
* Syncs this node's lifecycle state to the canvas store so connection status (edge colors)
* and path animation are correct. Call once per node with the current updating/error/paused
* state; the hook will add/remove this node from the appropriate sets.
* state; the hook will dispatch path commands to add/remove this node from the appropriate sets.
*
* Use in any node that can be updating, in error, or paused:
*
@@ -53,7 +52,6 @@ export function useSyncConnectionStatus(
nodeId: string,
state: NodeConnectionStatusState
): void {
const ctx = useContext(ConnectionPathContext)
const { updating, error, paused } = state
const prevRef = useRef({ updating: false, error: false, paused: false })
@@ -64,26 +62,24 @@ export function useSyncConnectionStatus(
const nowPaused = Boolean(paused)
if (prev.updating !== nowUpdating) {
if (nowUpdating) ctx?.startConnectionPathUpdate?.(nodeId)
else ctx?.endConnectionPathUpdate?.(nodeId)
if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
prev.updating = nowUpdating
}
if (prev.error !== nowError) {
if (nowError) ctx?.addConnectionPathError?.(nodeId)
else ctx?.removeConnectionPathError?.(nodeId)
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
prev.error = nowError
}
if (prev.paused !== nowPaused) {
if (nowPaused) ctx?.addConnectionPathPausedNode?.(nodeId)
else ctx?.removeConnectionPathPausedNode?.(nodeId)
dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } })
prev.paused = nowPaused
}
return () => {
if (prevRef.current.updating) ctx?.endConnectionPathUpdate?.(nodeId)
if (prevRef.current.error) ctx?.removeConnectionPathError?.(nodeId)
if (prevRef.current.paused) ctx?.removeConnectionPathPausedNode?.(nodeId)
if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } })
prevRef.current = { updating: false, error: false, paused: false }
}
}, [nodeId, updating, error, paused, ctx])
}, [nodeId, updating, error, paused])
}

View File

@@ -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.-]*$/,
}
}
}

View 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))
}

View File

@@ -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

View 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'

View 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 {}

View 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">&#8203;</span>
)}
</div>
))}
</>
)
}

View File

@@ -9,6 +9,8 @@ import { KosmosPage } from './app/kosmos/KosmosPage'
import { ProjectsPage } from './app/pleroma/PleromaPage'
import { KeromaPage } from './app/keroma/KeromaPage'
import { CanvasRoute } from './app/canvas/CanvasRoute'
import './lib/prismSetup'
import 'prismjs/themes/prism.css'
import './styles.css'
import '@xyflow/react/dist/style.css'

View File

@@ -143,11 +143,11 @@ body {
fill: none;
stroke-dasharray: 8 6;
stroke-dashoffset: 0;
animation: edge-flow 1.2s linear infinite;
animation: edge-flow 0.5s linear infinite;
will-change: stroke-dashoffset;
transform: translateZ(0);
backface-visibility: hidden;
transition: stroke 0.4s ease-out;
transition: stroke 0.2s ease-out;
}
.react-flow__edge path.animated-edge-path.animated-edge-path--updating,
@@ -366,4 +366,31 @@ pre {
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
/* Nunjucks tokens in code editor: distinct from main language (Prism) */
.token.nunjucks-var {
color: #7c3aed;
}
.token.nunjucks-tag {
color: #c2410c;
}
.token.nunjucks-comment {
color: #0d9488;
}
.dark .token.nunjucks-var {
color: #a78bfa;
}
.dark .token.nunjucks-tag {
color: #fb923c;
}
.dark .token.nunjucks-comment {
color: #2dd4bf;
}
/* Prism default theme adds a background to .token.operator (e.g. "="); remove it in our code editor */
.code-editor .token.operator,
.code-editor .token.entity,
.code-editor .token.url {
background: none;
}

View File

@@ -8,14 +8,6 @@ export default defineConfig({
server: {
proxy: {
// Backend API (dev): proxy to avoid CORS
'/api/todos': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/api/todos/': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8080',
changeOrigin: true,

15
frontend/vitest.config.ts Normal file
View 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'),
},
},
})