Compare commits
8 Commits
8556a80868
...
c55bde60e6
| Author | SHA1 | Date | |
|---|---|---|---|
| c55bde60e6 | |||
| 88629e6dcc | |||
| 50d34bfb00 | |||
| 2d6687b4ad | |||
| 84e3647fb5 | |||
| 9565a425b6 | |||
| 24efc597b2 | |||
| 76039bdd0e |
42
README.md
42
README.md
@@ -0,0 +1,42 @@
|
||||
# ZUI
|
||||
|
||||
A node-based editor for configuration-driven content. Connect **Config** nodes (PlantUML, Markdown, or Wireframe) to **Render** nodes to see live output. Use **Variable** and **Function** nodes to feed data and custom logic into configs via Nunjucks templating.
|
||||
|
||||
## Run the app
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the URL shown in the terminal (e.g. http://localhost:5173).
|
||||
|
||||
## Adding nodes
|
||||
|
||||
- **Right-click** on the canvas to open the context menu.
|
||||
- Choose **Create Node** → **Config**, **Render**, **Variable**, or **Function**.
|
||||
- Connect nodes by dragging from an output handle to an input handle.
|
||||
|
||||
## Saving and loading
|
||||
|
||||
- **Project → Export…** saves the current graph as a `.zui.json` file.
|
||||
- **Project → Import…** loads a previously saved project.
|
||||
|
||||
Projects are stored as JSON with a `version` field, plus `nodes` and `edges` arrays.
|
||||
|
||||
## Tech
|
||||
|
||||
- [React Flow](https://reactflow.dev/) for the graph canvas
|
||||
- [Nunjucks](https://mozilla.github.io/nunjucks/) for templating in configs
|
||||
- PlantUML diagrams via [Kroki](https://kroki.io/) (proxied in dev)
|
||||
- [Wireweave](https://github.com/wireweave/core) for wireframe UI → SVG
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
|----------------|--------------------------|
|
||||
| `npm run dev` | Start dev server |
|
||||
| `npm run build`| Production build |
|
||||
| `npm run preview` | Preview production build |
|
||||
| `npm run test` | Run tests in watch mode |
|
||||
| `npm run test:run` | Run tests once |
|
||||
|
||||
101
docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md
Normal file
101
docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Node Type Extensibility — Proposal
|
||||
|
||||
## Goal
|
||||
|
||||
Allow developers to add new node types and their rendering/logic without editing core app code. Each new type should provide: its React component, default data/style, connection rules, help text, and context-menu entry.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Registration:** In `App.tsx`, `nodeTypes` is a hardcoded map (config → ConfigNode, render → RenderingNode, etc.).
|
||||
- **Defaults & IDs:** `flowUtils.ts` has `PREFIX_BY_TYPE`, `DEFAULT_NODE_STYLE`, `DEFAULT_DATA`, `getDefaultDataForType`, `getResetDataForType` — all branch on type.
|
||||
- **Validation:** `isValidConnection` in `App.tsx` encodes rules (e.g. nothing → variable, only config → render) with explicit type checks.
|
||||
- **UI:** Context menu “Create node” items, paste allowlist (`VALID_NODE_TYPES`), node help (`nodeHelp.tsx`), footer indicators (`NODE_HAS_INPUT` / `NODE_HAS_OUTPUT`), and `NodeMenubar` / `AnimatedEdge` use type strings or fixed type unions.
|
||||
|
||||
Adding a fifth type today requires editing App, flowUtils, nodeHelp, NodeFooterEdgeIndicators, and possibly NodeMenubar/AnimatedEdge.
|
||||
|
||||
---
|
||||
|
||||
## Alternative 1: Central Node Type Registry (single module)
|
||||
|
||||
**Idea:** One module (e.g. `src/lib/nodeRegistry.ts` or `src/config/nodeTypes.ts`) holds a **single registry object** keyed by type id. Each entry describes the type:
|
||||
|
||||
- `component` — React component for the node
|
||||
- `defaultStyle`, `defaultData`, `idPrefix`
|
||||
- `hasInput` / `hasOutput` (for footer and validation)
|
||||
- `allowedSourceTypes` / `allowedTargetTypes` (for connection validation)
|
||||
- `help` (title + content for NodeHelpPopover)
|
||||
- `menuLabel`, `menuIcon` (for context menu “Create node”)
|
||||
|
||||
App and other consumers **import the registry** and iterate: build `nodeTypes` from registry, build context menu from registry, call `getDefaultDataForType(registry, type)`, and run connection validation using registry metadata.
|
||||
|
||||
**Pros:** Single source of truth; no new concepts (no plugins).
|
||||
**Cons:** Extensions still require editing that one file (or a dedicated “contrib” section inside it). Good for a small, curated set of types.
|
||||
|
||||
---
|
||||
|
||||
## Alternative 2: Plugin / Contribution API (mutable registry + `registerNodeType()`)
|
||||
|
||||
**Idea:** The registry is **mutable**. A bootstrap phase (e.g. in `main.tsx` or a dedicated `registerBuiltinNodes.ts`) calls `registerNodeType(descriptor)` for each built-in type. Third-party code (or feature modules) can call the same API to add types without touching the core registry module.
|
||||
|
||||
- Export `registerNodeType(descriptor: NodeTypeDescriptor)` and `getRegisteredNodeTypes()` (and optionally `getNodeType(id)`).
|
||||
- Descriptor shape: same as in Alternative 1 (component, defaults, connection rules, help, menu).
|
||||
- Built-in types are registered at app init; the registry is then read-only for the rest of the session (or remains open for dynamic plugins).
|
||||
|
||||
**Pros:** True extensibility: new types = call `registerNodeType` (e.g. from a separate package or an async-loaded chunk).
|
||||
**Cons:** Need a clear descriptor type and lifecycle (when registration runs, ordering). Slightly more indirection when reading “what types exist.”
|
||||
|
||||
---
|
||||
|
||||
## Alternative 3: Convention-based discovery (folder or config)
|
||||
|
||||
**Idea:** Node types are **discovered** from the filesystem or a config file.
|
||||
|
||||
- **Variant A:** Each type lives under a folder, e.g. `src/nodes/<typeId>/index.ts` exporting a descriptor. A loader (at build or runtime) imports all and registers them.
|
||||
- **Variant B:** A config file (e.g. `nodeTypes.config.ts`) lists type ids and module paths; the app dynamically imports and registers them.
|
||||
|
||||
**Pros:** “Add a folder” or “add a line” to add a type; no direct call to a central registry from feature code.
|
||||
**Cons:** Build/runtime setup (e.g. `import.meta.glob` or dynamic `import()`), ordering and error handling. May be overkill for an in-repo extension story.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Use Alternative 2 (Plugin / Contribution API)** with a single registry module:
|
||||
|
||||
1. **Define** a `NodeTypeDescriptor` type and a small API: `registerNodeType(descriptor)`, `getRegisteredNodeTypes()`, `getNodeType(id)`.
|
||||
2. **Move** all type-specific data (component, defaultStyle, defaultData, idPrefix, hasInput, hasOutput, connection rules, help, menu label/icon) into the descriptor. Connection rules can be expressed as `allowedSourceTypes` / `allowedTargetTypes` so validation is data-driven.
|
||||
3. **Register** the four built-in types (config, render, variable, function) at startup from a single “builtin” registration file (or from within the registry module). No change to the public shape of existing components; they are just passed to `registerNodeType`.
|
||||
4. **Refactor** App, flowUtils, nodeHelp, NodeFooterEdgeIndicators, and related UI to use the registry: `nodeTypes` and context menu from `getRegisteredNodeTypes()`, defaults and validation from descriptor, help from descriptor (or still from a function that reads descriptor.help).
|
||||
5. **Result:** Adding a fifth type = implement a component + call `registerNodeType({ id: 'mytype', component: MyNode, ... })` (e.g. in a feature module or a separate entry that runs before the app). No edits to App’s branching logic or to flowUtils/nodeHelp’s type unions.
|
||||
|
||||
Optional follow-ups: move help content into the descriptor (so `nodeHelp.tsx` only re-exports from registry), and add a small “node type list” config so the context menu order is explicit.
|
||||
|
||||
---
|
||||
|
||||
## Implementation outline (Alternative 2)
|
||||
|
||||
- **New:** `src/lib/nodeRegistry.ts` — `NodeTypeDescriptor` type, `registerNodeType()`, `getRegisteredNodeTypes()`, `getNodeType(id)`. Help content can live in the descriptor or be merged from current `nodeHelp.tsx` for built-ins.
|
||||
- **New (optional):** `src/lib/registerBuiltinNodes.ts` — imports ConfigNode, RenderingNode, VariableNode, FunctionNode and their help/defaults, calls `registerNodeType` for each; invoked from `main.tsx` before React render.
|
||||
- **Refactor:** `flowUtils.ts` — `getDefaultDataForType`, `getResetDataForType`, `DEFAULT_NODE_STYLE`, `PREFIX_BY_TYPE` (and `getNextNodeId`) take type and optionally the registry, or read from registry when called (registry imported inside flowUtils). Prefer: registry holds defaults/prefix; flowUtils exports thin wrappers that use registry.
|
||||
- **Refactor:** `App.tsx` — `nodeTypes` from `getRegisteredNodeTypes().map(...)`, context menu from same, `createNode`/paste use registry for valid types and defaults, `isValidConnection` uses descriptor’s `allowedSourceTypes`/`allowedTargetTypes`.
|
||||
- **Refactor:** `nodeHelp.tsx` — `getNodeHelp(type)` returns descriptor.help from registry (built-ins register help in descriptor).
|
||||
- **Refactor:** `NodeFooterEdgeIndicators.tsx` — `hasInput`/`hasOutput` from descriptor.
|
||||
- **Refactor:** `NodeMenubar` / `NodeHelpPopover` — keep `nodeType` as string; descriptor is looked up by id where needed.
|
||||
- **Refactor:** `AnimatedEdge` — edge label by target type can stay as a small map or move to descriptor (e.g. `connectionLabel?: string` when this type is target).
|
||||
|
||||
After this, a new node type is added by: (1) implementing a React component, (2) calling `registerNodeType({ id: 'mytype', component: MyNode, ... })` at app init.
|
||||
|
||||
---
|
||||
|
||||
## Implemented (Alternative 2)
|
||||
|
||||
- **`src/lib/nodeRegistry.ts`** — `NodeTypeDescriptor`, `registerNodeType()`, `getRegisteredNodeTypes()`, `getNodeType()`, `getDefaultDataForType()`, `getResetDataForType()`, `getIdPrefix()`, `getDefaultStyle()`, `isConnectionAllowed()`, `getConnectionLabelForTarget()`, `getNodeHelp()`.
|
||||
- **`src/lib/registerBuiltinNodes.tsx`** — Registers config, render, variable, function; invoked from `main.tsx` before render.
|
||||
- **`src/lib/flowUtils.ts`** — `getNextNodeId()` uses registry; default/reset data delegate to registry.
|
||||
- **App, NodeHelpPopover, NodeFooterEdgeIndicators, NodeMenubar, AnimatedEdge** — Use registry for types, validation, help, and UI.
|
||||
|
||||
### Adding a new node type
|
||||
|
||||
1. Implement a React component that accepts React Flow node props.
|
||||
2. Call `registerNodeType({ id, component, defaultStyle, defaultData, idPrefix, hasInput, hasOutput, allowedSourceTypes?, allowedTargetTypes?, help, menuLabel, menuIcon, ... })` from a module that runs at startup (e.g. imported and called from `main.tsx`).
|
||||
3. No edits to `App.tsx` or core flow/utils are required.
|
||||
2510
package-lock.json
generated
2510
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -5,13 +5,16 @@
|
||||
"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",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@@ -26,15 +29,18 @@
|
||||
"nunjucks": "^3.2.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"reactflow": "^11.0.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"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",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"jsdom": "^25.0.0",
|
||||
"vitest": "^2.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"shadcn": "^4.0.0",
|
||||
|
||||
312
src/App.tsx
312
src/App.tsx
@@ -8,6 +8,8 @@ import {
|
||||
addEdge,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
useNodesInitialized,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
@@ -15,11 +17,7 @@ import {
|
||||
type NodeChange,
|
||||
type EdgeChange,
|
||||
} from '@xyflow/react'
|
||||
import ConfigNode from './components/graph/ConfigNode'
|
||||
import FunctionNode from './components/graph/FunctionNode'
|
||||
import RenderingNode from './components/graph/RenderingNode'
|
||||
import VariableNode from './components/graph/VariableNode'
|
||||
import { AnimatedEdge } from './components/graph/AnimatedEdge'
|
||||
import { AnimatedEdge } from './components/base/AnimatedEdge'
|
||||
import FlowContext from './lib/flowContext'
|
||||
import { useTheme } from './lib/themeContext'
|
||||
import { useGraphStateWithHistory } from './hooks/useGraphStateWithHistory'
|
||||
@@ -35,8 +33,24 @@ import {
|
||||
ContextMenuGroup
|
||||
} from "@/components/ui/context-menu"
|
||||
import { AppMenubar } from '@/components/AppMenubar'
|
||||
import { ClipboardPaste, Code2, ScrollText, Sparkles, Variable } from 'lucide-react'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
|
||||
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils'
|
||||
import {
|
||||
getRegisteredNodeTypes,
|
||||
getRegisteredNodeTypeIds,
|
||||
getDefaultStyle,
|
||||
isConnectionAllowed,
|
||||
} from './lib/nodeRegistry'
|
||||
import type { AppNode, AppEdge } from './lib/nodeTypes'
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||
@@ -44,28 +58,68 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||
})
|
||||
|
||||
const initialNodes: Node[] = [
|
||||
const NODE_GAP = 150
|
||||
const initialNodes: AppNode[] = [
|
||||
{
|
||||
id: 'var_001',
|
||||
position: { x: 50, y: 100 },
|
||||
data: { value: 'Zoe', valueType: 'string' as const },
|
||||
type: 'variable',
|
||||
style: DEFAULT_NODE_STYLE.variable,
|
||||
},
|
||||
{
|
||||
id: 'cfg_001',
|
||||
position: { x: 50, y: 50 },
|
||||
data: { plantuml: '@startuml\nactor User\nparticipant "Render" as R\nUser -> R : config\n@enduml\n', title: 'config-cfg_001' },
|
||||
position: { x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP, y: 50 },
|
||||
data: {
|
||||
plantuml: '@startuml\nactor User\nparticipant "{{ var_001 }}" as R\nUser -> R : loves\n@enduml\n',
|
||||
title: 'config-cfg_001',
|
||||
},
|
||||
type: 'config',
|
||||
style: DEFAULT_NODE_STYLE.config,
|
||||
},
|
||||
{
|
||||
id: 'rnd_001',
|
||||
position: { x: 350, y: 80 },
|
||||
position: {
|
||||
x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP + DEFAULT_NODE_STYLE.config.width + NODE_GAP,
|
||||
y: 50,
|
||||
},
|
||||
data: {},
|
||||
type: 'render',
|
||||
style: DEFAULT_NODE_STYLE.render,
|
||||
},
|
||||
]
|
||||
|
||||
const initialEdges: Edge[] = [
|
||||
const initialEdges: AppEdge[] = [
|
||||
{ id: 'e-var_001-cfg_001', source: 'var_001', target: 'cfg_001', type: 'animated' },
|
||||
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' },
|
||||
]
|
||||
|
||||
function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
return {
|
||||
nodes: initialNodes.map((n) => ({
|
||||
...n,
|
||||
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
||||
})),
|
||||
edges: initialEdges.map((e) => ({ ...e })),
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_FILE_EXT = '.zui.json'
|
||||
const PROJECT_VERSION = 1
|
||||
|
||||
export type ProjectMessage = { type: 'success' | 'error'; text: string }
|
||||
|
||||
/** Calls fitView when nodes are initialized (e.g. after load/import). Must be rendered inside ReactFlowProvider. */
|
||||
function FlowFitViewOnLoad() {
|
||||
const nodesInitialized = useNodesInitialized()
|
||||
const { fitView } = useReactFlow()
|
||||
React.useEffect(() => {
|
||||
if (nodesInitialized) {
|
||||
fitView?.({ duration: 200 })
|
||||
}
|
||||
}, [nodesInitialized, fitView])
|
||||
return null
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { theme } = useTheme()
|
||||
@@ -83,12 +137,13 @@ export default function App() {
|
||||
canUndo,
|
||||
canRedo,
|
||||
setStateImmediate,
|
||||
} = useGraphStateWithHistory(initialNodes, initialEdges)
|
||||
} = useGraphStateWithHistory(getExampleGraph().nodes, getExampleGraph().edges)
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
|
||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
|
||||
const [projectMessage, setProjectMessage] = React.useState<ProjectMessage | null>(null)
|
||||
|
||||
const wrapperRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
|
||||
@@ -125,7 +180,7 @@ export default function App() {
|
||||
)
|
||||
|
||||
const nodeTypes = React.useMemo(
|
||||
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }),
|
||||
() => Object.fromEntries(getRegisteredNodeTypes().map((r) => [r.id, r.component])),
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -153,11 +208,12 @@ export default function App() {
|
||||
const sourceType = sourceNode?.type
|
||||
const targetType = targetNode?.type
|
||||
if (!sourceType || !targetType) return false
|
||||
if (connection.source === connection.target) return false
|
||||
if (targetType === 'variable') return false
|
||||
if (targetType === 'render' && (sourceType === 'variable' || sourceType === 'function')) return false
|
||||
if (sourceType === 'render') return false
|
||||
return true
|
||||
return isConnectionAllowed(
|
||||
sourceType,
|
||||
targetType,
|
||||
connection.source,
|
||||
connection.target
|
||||
)
|
||||
},
|
||||
[nodes]
|
||||
)
|
||||
@@ -181,6 +237,12 @@ export default function App() {
|
||||
setRfInstance(instance)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectMessage) return
|
||||
const t = setTimeout(() => setProjectMessage(null), 3000)
|
||||
return () => clearTimeout(t)
|
||||
}, [projectMessage])
|
||||
|
||||
const onNodeDragStart = useCallback(() => {
|
||||
saveForDragEnd()
|
||||
}, [saveForDragEnd])
|
||||
@@ -190,7 +252,7 @@ export default function App() {
|
||||
}, [commitDragEnd])
|
||||
|
||||
const handleExportProject = useCallback(() => {
|
||||
const state = { nodes, edges }
|
||||
const state = { version: PROJECT_VERSION, nodes, edges }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
@@ -198,12 +260,19 @@ export default function App() {
|
||||
a.download = `project${PROJECT_FILE_EXT}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setProjectMessage({ type: 'success', text: 'Project exported' })
|
||||
}, [nodes, edges])
|
||||
|
||||
const handleImportProject = useCallback(() => {
|
||||
importInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleLoadExample = useCallback(() => {
|
||||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||||
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
||||
setProjectMessage({ type: 'success', text: 'Example loaded' })
|
||||
}, [setStateImmediate])
|
||||
|
||||
const onImportFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -213,11 +282,19 @@ export default function App() {
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const text = reader.result as string
|
||||
const state = JSON.parse(text) as { nodes?: Node[]; edges?: Edge[] }
|
||||
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) return
|
||||
setStateImmediate({ nodes: state.nodes, edges: state.edges })
|
||||
const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
|
||||
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
|
||||
setProjectMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' })
|
||||
return
|
||||
}
|
||||
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
|
||||
setProjectMessage(
|
||||
state.version != null && state.version > PROJECT_VERSION
|
||||
? { type: 'error', text: 'Project was created with a newer app version' }
|
||||
: { type: 'success', text: 'Project loaded' }
|
||||
)
|
||||
} catch {
|
||||
// Invalid JSON
|
||||
setProjectMessage({ type: 'error', text: 'Invalid file: not valid JSON' })
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
@@ -294,22 +371,17 @@ export default function App() {
|
||||
(type: string) => {
|
||||
const position = getMenuPosition()
|
||||
if (position == null) return
|
||||
const typeMap: Record<string, Node['type']> = {
|
||||
config: 'config',
|
||||
render: 'render',
|
||||
variable: 'variable',
|
||||
function: 'function',
|
||||
}
|
||||
const nodeType = typeMap[type] ?? 'config'
|
||||
const nodeType = type as Node['type']
|
||||
setNodes((nds) => {
|
||||
const newId = getNextNodeId(nodeType, nds.map((n) => n.id))
|
||||
const dataMap = getDefaultDataForType(nodeType, newId)
|
||||
const style = getDefaultStyle(nodeType)
|
||||
const newNode: Node = {
|
||||
id: newId,
|
||||
type: nodeType,
|
||||
position: { x: position.x, y: position.y },
|
||||
data: dataMap,
|
||||
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
|
||||
style,
|
||||
}
|
||||
return nds.concat(newNode)
|
||||
})
|
||||
@@ -319,8 +391,6 @@ export default function App() {
|
||||
[getMenuPosition, setNodes]
|
||||
)
|
||||
|
||||
const VALID_NODE_TYPES = ['config', 'render', 'variable', 'function'] as const
|
||||
|
||||
const pasteNode = React.useCallback(async () => {
|
||||
const position = getMenuPosition()
|
||||
if (position == null) return
|
||||
@@ -328,17 +398,19 @@ export default function App() {
|
||||
const text = await navigator.clipboard?.readText()
|
||||
if (!text) return
|
||||
const raw = JSON.parse(text) as { id?: string; type?: string; data?: any; position?: { x: number; y: number }; style?: any }
|
||||
if (!raw || typeof raw.type !== 'string' || !VALID_NODE_TYPES.includes(raw.type as any)) return
|
||||
const validIds = getRegisteredNodeTypeIds()
|
||||
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
||||
setNodes((nds) => {
|
||||
const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id))
|
||||
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
|
||||
if (raw.type === 'config' && data.title != null) data.title = `config-${newId}`
|
||||
const style = getDefaultStyle(raw.type)
|
||||
const newNode: Node = {
|
||||
id: newId,
|
||||
type: raw.type as Node['type'],
|
||||
position: { x: position.x, y: position.y },
|
||||
data,
|
||||
style: DEFAULT_NODE_STYLE[raw.type] ?? DEFAULT_NODE_STYLE.config,
|
||||
style,
|
||||
}
|
||||
return nds.concat(newNode)
|
||||
})
|
||||
@@ -385,77 +457,111 @@ export default function App() {
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
/>
|
||||
{projectMessage && (
|
||||
<div
|
||||
role="status"
|
||||
className={`shrink-0 px-3 py-2 text-sm ${projectMessage.type === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{projectMessage.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<FlowContext.Provider value={flowContextValue}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex-1 min-h-0 w-full" onWheelCapture={onWheelCapture}>
|
||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
colorMode={theme as ColorMode}
|
||||
snapToGrid
|
||||
snapGrid={SNAP_GRID}
|
||||
fitView
|
||||
onInit={onInit}
|
||||
nodeDragThreshold={1}
|
||||
>
|
||||
<Background variant="dots" gap={20} />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<FlowContext.Provider value={flowContextValue}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex-1 min-h-0 w-full relative" onWheelCapture={onWheelCapture}>
|
||||
{nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="pointer-events-auto rounded-lg border border-border bg-background/95 dark:bg-background/90 shadow-sm">
|
||||
<Empty className="p-6 md:p-8">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<CircleDotDashed className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Start adding a new node!</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Right‑click to add nodes. <br />Import a project or paste a node.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
||||
<Button onClick={handleImportProject} variant="outline" size="sm">
|
||||
<FolderOpen className="size-4" />
|
||||
Import…
|
||||
</Button>
|
||||
<Button onClick={handleLoadExample} variant="outline" size="sm">
|
||||
<FileStack className="size-4" />
|
||||
Load example
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||
<FlowFitViewOnLoad />
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
colorMode={theme as ColorMode}
|
||||
snapToGrid
|
||||
snapGrid={SNAP_GRID}
|
||||
fitView
|
||||
onInit={onInit}
|
||||
nodeDragThreshold={1}
|
||||
nodesDraggable
|
||||
nodesConnectable
|
||||
elementsSelectable
|
||||
>
|
||||
<Background variant="dots" gap={20} />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem onSelect={() => createNode('config')}>
|
||||
<ScrollText className="mr-2 h-4 w-4" />
|
||||
Config
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => createNode('render')}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Renderer
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator></ContextMenuSeparator>
|
||||
<ContextMenuItem onSelect={() => createNode('variable')}>
|
||||
<Variable className="mr-2 h-4 w-4" />
|
||||
Variable
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => createNode('function')}>
|
||||
<Code2 className="mr-2 h-4 w-4" />
|
||||
Function
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => pasteNode()}>
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</FlowContext.Provider>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
<ContextMenuGroup>
|
||||
{getRegisteredNodeTypes().map((desc, index) => (
|
||||
<React.Fragment key={desc.id}>
|
||||
{index === 2 ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem onSelect={() => createNode(desc.id)}>
|
||||
{desc.menuIcon}
|
||||
{desc.menuLabel}
|
||||
</ContextMenuItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => pasteNode()}>
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</FlowContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
@@ -5,16 +5,11 @@ import {
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { getConnectionLabelForTarget } from '../../lib/nodeRegistry'
|
||||
|
||||
const EDGE_STROKE_WIDTH = 2
|
||||
const DOT_MARKER_R = 1.5
|
||||
|
||||
function getEdgeLabelByTargetType(targetType: string | undefined): string | undefined {
|
||||
if (targetType === 'render') return 'render'
|
||||
if (targetType === 'config' || targetType === 'function') return 'add input'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function AnimatedEdge({
|
||||
id,
|
||||
sourceX,
|
||||
@@ -32,7 +27,7 @@ export function AnimatedEdge({
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
|
||||
const derivedLabel = useMemo(
|
||||
() => getEdgeLabelByTargetType(targetNode?.type),
|
||||
() => getConnectionLabelForTarget(targetNode?.type),
|
||||
[targetNode?.type]
|
||||
)
|
||||
const label = labelProp ?? derivedLabel
|
||||
@@ -1,23 +1,12 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
|
||||
const NODE_HAS_INPUT: Record<string, boolean> = {
|
||||
config: true,
|
||||
function: true,
|
||||
render: true,
|
||||
variable: false,
|
||||
}
|
||||
const NODE_HAS_OUTPUT: Record<string, boolean> = {
|
||||
config: true,
|
||||
function: true,
|
||||
render: true,
|
||||
variable: true,
|
||||
}
|
||||
import { NodeHelpPopover } from './NodeHelpPopover'
|
||||
import { getNodeType } from '../../lib/nodeRegistry'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
nodeType: 'config' | 'function' | 'render' | 'variable'
|
||||
nodeType: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -35,8 +24,9 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
|
||||
return { inputs, outputs }
|
||||
}, [edges, nodeId])
|
||||
|
||||
const showInput = NODE_HAS_INPUT[nodeType] ?? false
|
||||
const showOutput = NODE_HAS_OUTPUT[nodeType] ?? false
|
||||
const descriptor = getNodeType(nodeType)
|
||||
const showInput = descriptor?.hasInput ?? false
|
||||
const showOutput = descriptor?.hasOutput ?? false
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full text-xs text-muted-foreground">
|
||||
@@ -58,6 +48,9 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0 ml-auto">
|
||||
<NodeHelpPopover nodeType={nodeType} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/components/base/NodeHelpPopover.tsx
Normal file
35
src/components/base/NodeHelpPopover.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
||||
import { getNodeHelp } from '../../lib/nodeRegistry'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
type Props = {
|
||||
nodeType: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NodeHelpPopover({ nodeType, className }: Props) {
|
||||
const { title, content } = getNodeHelp(nodeType)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
'shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
className
|
||||
)}
|
||||
aria-label={`Help: ${title}`}
|
||||
>
|
||||
<button type="button">
|
||||
<HelpCircle className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-80 max-h-[70vh] overflow-y-auto">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
<div className="mt-2">{content}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useContext } from 'react'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { DEFAULT_NODE_STYLE, getNextNodeId, getResetDataForType } from '../../lib/flowUtils'
|
||||
import { getNextNodeId, getResetDataForType } from '../../lib/flowUtils'
|
||||
import { getDefaultStyle } from '../../lib/nodeRegistry'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
@@ -15,11 +16,9 @@ import {
|
||||
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
|
||||
type NodeType = 'config' | 'render' | 'variable' | 'function'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
nodeType: NodeType
|
||||
nodeType: string
|
||||
/** Content for Insert → Inputs (function nodes); when inputsMenuContent is set, Inputs is a separate menu and this is not used in Insert */
|
||||
editInputsContent?: React.ReactNode
|
||||
/** When set, Inputs is rendered as its own top-level menu (config nodes); Insert then only shows markup-specific options */
|
||||
@@ -57,7 +56,7 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
||||
type: node.type,
|
||||
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
|
||||
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
|
||||
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
|
||||
style: getDefaultStyle(nodeType),
|
||||
}
|
||||
if (newNode.data?.title && nodeType === 'config') newNode.data.title = `${newId}`
|
||||
return nds.concat(newNode)
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import React, { useCallback, 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 FlowContext from '../../lib/flowContext'
|
||||
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
||||
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
||||
@@ -22,7 +26,7 @@ import {
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from './BaseNode'
|
||||
} from '../base/BaseNode'
|
||||
import { Code2, ScrollText, Variable } from 'lucide-react'
|
||||
import {
|
||||
MenubarItem,
|
||||
@@ -34,78 +38,51 @@ import {
|
||||
} from '../ui/menubar'
|
||||
import { Kbd } from '../ui/kbd'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { InputHandle, OutputHandle } from './NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
||||
import { NodeMenubar } from './NodeMenubar'
|
||||
import { InputHandle, OutputHandle } from '../base/NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
data: any
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string }
|
||||
|
||||
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
|
||||
const configTypeId = getConfigTypeId(data)
|
||||
type Props = AbstractNodeProps<ConfigNodeData>
|
||||
|
||||
function ConfigNodeComponent({ id, data, width, height }: Props) {
|
||||
const configTypeId = getConfigTypeId(data ?? {})
|
||||
const configType = getConfigType(configTypeId)
|
||||
const content = getConfigContent(data)
|
||||
const content = getConfigContent(data ?? {})
|
||||
const { theme } = useTheme()
|
||||
const ctx = useContext(FlowContext)
|
||||
const setNodes = ctx?.setNodes
|
||||
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const edges = ctx?.edges ?? []
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
|
||||
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
|
||||
const connectedConfigNodes = useMemo(
|
||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config'),
|
||||
[nodes, incomingIds]
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, incomingIds]
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, incomingIds]
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string) => {
|
||||
if (setNodes) {
|
||||
setNodes((nds: any[]) =>
|
||||
nds.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, content: val, configType: configTypeId } } : n
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
[id, setNodes, configTypeId]
|
||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||
[updateData, configTypeId]
|
||||
)
|
||||
|
||||
const setConfigType = useCallback(
|
||||
(newTypeId: ConfigTypeId) => {
|
||||
if (!setNodes || newTypeId === configTypeId) return
|
||||
setNodes((nds: any[]) =>
|
||||
nds.map((n) =>
|
||||
n.id === id
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
configType: newTypeId,
|
||||
content: getConfigContent(n.data) ?? '',
|
||||
},
|
||||
}
|
||||
: n
|
||||
)
|
||||
)
|
||||
if (newTypeId === configTypeId) return
|
||||
updateData({
|
||||
configType: newTypeId,
|
||||
content: getConfigContent({ ...data, configType: newTypeId }) ?? '',
|
||||
})
|
||||
},
|
||||
[id, setNodes, configTypeId]
|
||||
[configTypeId, data, updateData]
|
||||
)
|
||||
|
||||
const insertAt = useCallback(
|
||||
@@ -184,8 +161,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
||||
configTypeId === 'wireframe'
|
||||
? javascript()
|
||||
: configType.language === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [
|
||||
lang,
|
||||
autocompletion({
|
||||
@@ -205,25 +182,25 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
||||
const typeItems = typeBlocks.flatMap((block) =>
|
||||
isGroup(block)
|
||||
? block.items.map(({ label, snippet }) => (
|
||||
<MenubarItem
|
||||
key={label}
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertAt(snippet, 'cursor')}
|
||||
>
|
||||
{label}
|
||||
{insertShortcut}
|
||||
</MenubarItem>
|
||||
))
|
||||
<MenubarItem
|
||||
key={label}
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertAt(snippet, 'cursor')}
|
||||
>
|
||||
{label}
|
||||
{insertShortcut}
|
||||
</MenubarItem>
|
||||
))
|
||||
: [
|
||||
<MenubarItem
|
||||
key={block.label}
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertAt(block.snippet, 'cursor')}
|
||||
>
|
||||
{block.label}
|
||||
{insertShortcut}
|
||||
</MenubarItem>,
|
||||
]
|
||||
<MenubarItem
|
||||
key={block.label}
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertAt(block.snippet, 'cursor')}
|
||||
>
|
||||
{block.label}
|
||||
{insertShortcut}
|
||||
</MenubarItem>,
|
||||
]
|
||||
)
|
||||
const templatingItems =
|
||||
templatingGroup?.items.map(({ label, snippet }) => (
|
||||
@@ -369,8 +346,11 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}, nodePropsAreEqual)
|
||||
}
|
||||
|
||||
ConfigNode.displayName = 'ConfigNode'
|
||||
export const ConfigNode = createAbstractNodeComponent<ConfigNodeData>(
|
||||
'ConfigNode',
|
||||
ConfigNodeComponent
|
||||
)
|
||||
|
||||
export default ConfigNode
|
||||
@@ -1,8 +1,12 @@
|
||||
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||
import { useTheme } from '../../lib/themeContext'
|
||||
import {
|
||||
@@ -10,54 +14,38 @@ import {
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from './BaseNode'
|
||||
import { InputHandle, OutputHandle } from './NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
||||
import { NodeMenubar } from './NodeMenubar'
|
||||
} from '../base/BaseNode'
|
||||
import { InputHandle, OutputHandle } from '../base/NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { MenubarItem, MenubarShortcut } from '../ui/menubar'
|
||||
import { Kbd } from '../ui/kbd'
|
||||
import { Code2, Variable } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
data: { body?: string }
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
export type FunctionNodeData = { body?: string }
|
||||
|
||||
export const FunctionNode = memo(function FunctionNode({ id, data, width, height }: Props) {
|
||||
type Props = AbstractNodeProps<FunctionNodeData>
|
||||
|
||||
function FunctionNodeComponent({ id, data, width, height }: Props) {
|
||||
const bodyValue = data?.body ?? ''
|
||||
const { theme } = useTheme()
|
||||
const ctx = useContext(FlowContext)
|
||||
const setNodes = ctx?.setNodes
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const edges = ctx?.edges ?? []
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
|
||||
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, incomingIds]
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, incomingIds]
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasConnectedVariables = connectedVariableNodes.length > 0
|
||||
const hasConnectedFunctions = connectedFunctionNodes.length > 0
|
||||
const hasConnectedInputs = hasConnectedVariables || hasConnectedFunctions
|
||||
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string) => {
|
||||
if (setNodes) {
|
||||
setNodes((nds: any[]) =>
|
||||
nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, body: val } } : n))
|
||||
)
|
||||
}
|
||||
},
|
||||
[id, setNodes]
|
||||
(val: string) => updateData({ body: val }),
|
||||
[updateData]
|
||||
)
|
||||
|
||||
const insertAt = useCallback(
|
||||
@@ -165,8 +153,11 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}, nodePropsAreEqual)
|
||||
}
|
||||
|
||||
FunctionNode.displayName = 'FunctionNode'
|
||||
export const FunctionNode = createAbstractNodeComponent<FunctionNodeData>(
|
||||
'FunctionNode',
|
||||
FunctionNodeComponent
|
||||
)
|
||||
|
||||
export default FunctionNode
|
||||
@@ -1,6 +1,10 @@
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import nunjucks from 'nunjucks'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
|
||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
||||
import {
|
||||
@@ -8,37 +12,43 @@ import {
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from './BaseNode'
|
||||
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId, nodePropsAreEqual } from '../../lib/flowUtils'
|
||||
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
||||
import { NodeMenubar } from './NodeMenubar'
|
||||
import { NodeStatusIndicator } from './NodeStatusIndicator'
|
||||
} from '../base/BaseNode'
|
||||
import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
|
||||
import { getDefaultStyle } from '../../lib/nodeRegistry'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { NodeStatusIndicator } from '../base/NodeStatusIndicator'
|
||||
import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { InputHandle } from './NodeHandles'
|
||||
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw } from 'lucide-react'
|
||||
import { InputHandle } from '../base/NodeHandles'
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||
import { Input } from '../ui/input'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
data?: any
|
||||
style?: React.CSSProperties
|
||||
export type RenderingNodeData = {
|
||||
viewportWidth?: number
|
||||
viewportHeight?: number
|
||||
}
|
||||
|
||||
export const RenderingNode = memo(function RenderingNode({ id, width, height }: Props) {
|
||||
const DEFAULT_VIEWPORT_WIDTH = 1200
|
||||
const DEFAULT_VIEWPORT_HEIGHT = 800
|
||||
|
||||
type Props = AbstractNodeProps<RenderingNodeData>
|
||||
|
||||
function RenderingNodeComponent({ id, data, width, height }: Props) {
|
||||
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
||||
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const runIdRef = useRef(0)
|
||||
const loadingStartedAtRef = useRef<number | null>(null)
|
||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const edges = ctx?.edges ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||
|
||||
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
|
||||
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
|
||||
const incomingIds = sourceIds
|
||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||
const srcNode = nodes.find((n: any) => n.id === srcId)
|
||||
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
|
||||
@@ -143,6 +153,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const RENDER_DEBOUNCE_MS = 250
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceContent && incomingIds.length > 0) {
|
||||
setRenderedContent(null)
|
||||
@@ -430,7 +442,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
||||
const typeRenderer = getConfigType(configTypeId)
|
||||
|
||||
try {
|
||||
const htmlOrSvg = await typeRenderer.render(resolvedContent)
|
||||
const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
|
||||
const htmlOrSvg = await typeRenderer.render(resolvedContent, renderOptions)
|
||||
if (cancelled || thisRunId !== runIdRef.current) return
|
||||
setRenderedContent(htmlOrSvg)
|
||||
setError(null)
|
||||
@@ -464,16 +477,17 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(debounceTimer)
|
||||
if (minLoadingTimeoutRef.current != null) {
|
||||
clearTimeout(minLoadingTimeoutRef.current)
|
||||
minLoadingTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
// Only re-run when inputs that affect the resolved output change (signatures + source).
|
||||
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature])
|
||||
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
|
||||
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight, retryCount])
|
||||
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
@@ -511,115 +525,231 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
||||
a.download = `${id}.png`
|
||||
a.click()
|
||||
}
|
||||
img.onerror = () => {}
|
||||
img.onerror = () => { }
|
||||
img.src = dataUrl
|
||||
}, [id, renderedContent, isSvgOutput])
|
||||
|
||||
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
|
||||
|
||||
const isWireframeOutput = configTypeId === 'wireframe' && renderedContent && !isSvgOutput
|
||||
const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight })
|
||||
useEffect(() => {
|
||||
setViewportDraft({ width: viewportWidth, height: viewportHeight })
|
||||
}, [viewportWidth, viewportHeight])
|
||||
|
||||
const onViewportDraftChange = useCallback((field: 'width' | 'height', value: number) => {
|
||||
setViewportDraft((prev) => ({ ...prev, [field]: Math.min(4000, Math.max(200, value)) }))
|
||||
}, [])
|
||||
const onViewportApply = useCallback(() => {
|
||||
updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height })
|
||||
}, [updateData, viewportDraft.width, viewportDraft.height])
|
||||
|
||||
return (
|
||||
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
|
||||
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<InputHandle id="ain" nodeId={id} />}>
|
||||
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<InputHandle id="ain" nodeId={id} />}>
|
||||
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="render"
|
||||
nodeMenuExtraContent={
|
||||
<MenubarSub>
|
||||
<MenubarSeparator></MenubarSeparator>
|
||||
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
||||
Export
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[10rem]">
|
||||
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
|
||||
PNG
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
|
||||
SVG
|
||||
</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 flex flex-col">
|
||||
{incomingIds.length === 0 ? (
|
||||
<Empty className="min-h-0 flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Sparkles className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No configuration connected</EmptyTitle>
|
||||
<EmptyDescription>Connect a Configuration node or create one. The renderer will display the diagram or document.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<button
|
||||
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
|
||||
onClick={() => {
|
||||
if (!setNodes || !setEdges) return
|
||||
const nid = getNextNodeId('config', nodes.map((n: any) => n.id))
|
||||
const thisNode = nodes.find((n: any) => n.id === id)
|
||||
const pos = thisNode?.position ?? { x: 0, y: 0 }
|
||||
const newPos = { x: pos.x - 220, y: pos.y }
|
||||
const newNode = { id: nid, type: 'config', position: newPos, data: getDefaultDataForType('config', nid), style: DEFAULT_NODE_STYLE.config }
|
||||
setNodes((nds: any[]) => nds.concat(newNode))
|
||||
setEdges((eds: any[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }))
|
||||
}}
|
||||
>
|
||||
Create Config
|
||||
</button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : error ? (
|
||||
srcData?.renderError ? (
|
||||
srcData.renderError(error)
|
||||
) : srcData?.errorHtml ? (
|
||||
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
|
||||
) : (
|
||||
<div className="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div>
|
||||
)
|
||||
) : loading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||
) : renderedContent ? (
|
||||
isWireframeOutput ? (
|
||||
<div className="rendering-wireframe min-h-0 flex-1 w-full min-w-0 flex flex-col overflow-hidden bg-background">
|
||||
<div className="rendering-wireframe__viewport">
|
||||
<div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} />
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="render"
|
||||
nodeMenuExtraContent={
|
||||
<>
|
||||
{isSvgOutput && (
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger className="text-xs">Viewport</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[12rem] p-2">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground shrink-0">Width</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={200}
|
||||
max={4000}
|
||||
value={viewportDraft.width}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(v)) onViewportDraftChange('width', v)
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground shrink-0">Height</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={200}
|
||||
max={4000}
|
||||
value={viewportDraft.height}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(v)) onViewportDraftChange('height', v)
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewportApply}
|
||||
className="mt-1 w-full rounded bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
)}
|
||||
<MenubarSub>
|
||||
<MenubarSeparator />
|
||||
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
||||
Export
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[10rem]">
|
||||
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
|
||||
PNG
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
|
||||
SVG
|
||||
</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 flex flex-col">
|
||||
{incomingIds.length === 0 ? (
|
||||
<Empty className="min-h-0 flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Sparkles className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No configuration connected</EmptyTitle>
|
||||
<EmptyDescription>Connect a Configuration node or create one. The renderer will display the diagram or document.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<button
|
||||
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
|
||||
onClick={() => {
|
||||
if (!setNodes || !setEdges) return
|
||||
const nid = getNextNodeId('config', nodes.map((n: any) => n.id))
|
||||
const thisNode = nodes.find((n: any) => n.id === id)
|
||||
const pos = thisNode?.position ?? { x: 0, y: 0 }
|
||||
const newPos = { x: pos.x - 220, y: pos.y }
|
||||
const newNode = { id: nid, type: 'config', position: newPos, data: getDefaultDataForType('config', nid), style: getDefaultStyle('config') }
|
||||
setNodes((nds: any[]) => nds.concat(newNode))
|
||||
setEdges((eds: any[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }))
|
||||
}}
|
||||
>
|
||||
Create Config
|
||||
</button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : error ? (
|
||||
srcData?.renderError ? (
|
||||
srcData.renderError(error)
|
||||
) : srcData?.errorHtml ? (
|
||||
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<p className="text-xs text-red-700 dark:text-red-400">{error.message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
>
|
||||
<RotateCw className="size-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
isSvgOutput
|
||||
? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary'
|
||||
: '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'
|
||||
}
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
)
|
||||
) : loading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||
) : renderedContent ? (
|
||||
isSvgOutput ? (
|
||||
<div className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary">
|
||||
<TransformWrapper
|
||||
initialScale={1}
|
||||
minScale={0.2}
|
||||
maxScale={4}
|
||||
centerOnInit
|
||||
onInit={(ref) => ref?.centerView(1, 0, 0)}
|
||||
panning={{ disabled: true }}
|
||||
wheel={{ disabled: true }}
|
||||
doubleClick={{ disabled: true }}
|
||||
>
|
||||
{({ 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">
|
||||
<TransformComponent
|
||||
wrapperClass="!w-full !h-full"
|
||||
contentClass="!w-full !h-full flex items-center justify-center nodrag nopan"
|
||||
>
|
||||
<div
|
||||
className="rendering-diagram flex items-center justify-center min-h-full min-w-full p-4 nodrag nopan"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="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"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
||||
{renderedContent
|
||||
? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars`
|
||||
: error
|
||||
? 'Error'
|
||||
: '—'}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
||||
{renderedContent
|
||||
? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars`
|
||||
: error
|
||||
? 'Error'
|
||||
: '—'}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
</NodeStatusIndicator>
|
||||
)
|
||||
}, nodePropsAreEqual)
|
||||
}
|
||||
|
||||
RenderingNode.displayName = 'RenderingNode'
|
||||
export const RenderingNode = createAbstractNodeComponent<RenderingNodeData>(
|
||||
'RenderingNode',
|
||||
RenderingNodeComponent
|
||||
)
|
||||
|
||||
export default RenderingNode
|
||||
@@ -1,31 +1,33 @@
|
||||
import React, { memo, useCallback, useContext } from 'react'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
||||
import React, { useCallback } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from './BaseNode'
|
||||
import { NodeMenubar } from './NodeMenubar'
|
||||
} from '../base/BaseNode'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { Input } from '../ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { Switch } from '../ui/switch'
|
||||
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
||||
import { OutputHandle } from './NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { OutputHandle } from '../base/NodeHandles'
|
||||
import { Variable } from 'lucide-react'
|
||||
|
||||
type ValueType = 'string' | 'number' | 'boolean'
|
||||
export type ValueType = 'string' | 'number' | 'boolean'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
data: {
|
||||
value?: string | number | boolean
|
||||
valueType?: ValueType
|
||||
}
|
||||
export type VariableNodeData = {
|
||||
value?: string | number | boolean
|
||||
valueType?: ValueType
|
||||
}
|
||||
|
||||
type Props = AbstractNodeProps<VariableNodeData>
|
||||
|
||||
const DEFAULT_BY_TYPE: Record<ValueType, string | number | boolean> = {
|
||||
string: '',
|
||||
number: 0,
|
||||
@@ -45,26 +47,13 @@ function coerceValue(raw: string, valueType: ValueType): string | number | boole
|
||||
}
|
||||
}
|
||||
|
||||
export const VariableNode = memo(function VariableNode({ id, data }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const setNodes = ctx?.setNodes
|
||||
function VariableNodeComponent({ id, data }: Props) {
|
||||
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
|
||||
|
||||
const valueType: ValueType = data?.valueType ?? 'string'
|
||||
const value = data?.value ?? DEFAULT_BY_TYPE[valueType]
|
||||
const displayValue = typeof value === 'string' ? value : String(value)
|
||||
|
||||
const updateData = useCallback(
|
||||
(updates: { value?: string | number | boolean; valueType?: ValueType }) => {
|
||||
if (!setNodes) return
|
||||
setNodes((nds: any[]) =>
|
||||
nds.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, ...updates } } : n
|
||||
)
|
||||
)
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const onTypeChange = useCallback(
|
||||
(nextType: string) => {
|
||||
const type = nextType as ValueType
|
||||
@@ -85,9 +74,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
|
||||
)
|
||||
|
||||
const onBooleanChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
updateData({ value: checked })
|
||||
},
|
||||
(checked: boolean) => updateData({ value: checked }),
|
||||
[updateData]
|
||||
)
|
||||
|
||||
@@ -141,8 +128,11 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}, nodePropsAreEqual)
|
||||
}
|
||||
|
||||
VariableNode.displayName = 'VariableNode'
|
||||
export const VariableNode = createAbstractNodeComponent<VariableNodeData>(
|
||||
'VariableNode',
|
||||
VariableNodeComponent
|
||||
)
|
||||
|
||||
export default VariableNode
|
||||
31
src/components/ui/popover.tsx
Normal file
31
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
33
src/fixtures/sample.zui.json
Normal file
33
src/fixtures/sample.zui.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "var_001",
|
||||
"type": "variable",
|
||||
"position": { "x": 50, "y": 100 },
|
||||
"data": { "value": "Zoe", "valueType": "string" },
|
||||
"style": { "width": 224, "height": 180 }
|
||||
},
|
||||
{
|
||||
"id": "cfg_001",
|
||||
"type": "config",
|
||||
"position": { "x": 424, "y": 50 },
|
||||
"data": {
|
||||
"plantuml": "@startuml\nactor User\nparticipant \"{{ var_001 }}\" as R\nUser -> R : loves\n@enduml\n",
|
||||
"title": "config-cfg_001"
|
||||
},
|
||||
"style": { "width": 320, "height": 320 }
|
||||
},
|
||||
{
|
||||
"id": "rnd_001",
|
||||
"type": "render",
|
||||
"position": { "x": 894, "y": 50 },
|
||||
"data": {},
|
||||
"style": { "width": 384, "height": 320 }
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e-var_001-cfg_001", "source": "var_001", "target": "cfg_001", "type": "animated" },
|
||||
{ "id": "e-cfg_001-rnd_001", "source": "cfg_001", "target": "rnd_001", "type": "animated" }
|
||||
]
|
||||
}
|
||||
107
src/hooks/useGraphStateWithHistory.test.ts
Normal file
107
src/hooks/useGraphStateWithHistory.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useGraphStateWithHistory } from './useGraphStateWithHistory'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
|
||||
const emptyNodes: AppNode[] = []
|
||||
const emptyEdges: AppEdge[] = []
|
||||
|
||||
function makeNode(id: string, type: string, x: number, y: number): AppNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position: { x, y },
|
||||
data: {},
|
||||
style: { width: 200, height: 100 },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGraphStateWithHistory', () => {
|
||||
it('returns initial nodes and edges', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const initialEdges: AppEdge[] = [{ id: 'e1', source: 'a', target: 'b' }]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, initialEdges))
|
||||
expect(result.current.nodes).toHaveLength(1)
|
||||
expect(result.current.nodes[0].id).toBe('a')
|
||||
expect(result.current.edges).toHaveLength(1)
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
})
|
||||
|
||||
it('push to past on setNodes and allows undo', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
act(() => {
|
||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
||||
})
|
||||
expect(result.current.nodes).toHaveLength(2)
|
||||
expect(result.current.canUndo).toBe(true)
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
})
|
||||
expect(result.current.nodes).toHaveLength(1)
|
||||
expect(result.current.nodes[0].id).toBe('a')
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(true)
|
||||
})
|
||||
|
||||
it('redo restores state', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
||||
act(() => {
|
||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
||||
})
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
})
|
||||
act(() => {
|
||||
result.current.redo()
|
||||
})
|
||||
expect(result.current.nodes).toHaveLength(2)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
})
|
||||
|
||||
it('saveForDragEnd and commitDragEnd push drag snapshot to history', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
||||
act(() => {
|
||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
||||
})
|
||||
act(() => {
|
||||
result.current.saveForDragEnd()
|
||||
})
|
||||
act(() => {
|
||||
result.current.setNodes((prev) =>
|
||||
prev.map((n) => (n.id === 'b' ? { ...n, position: { x: 200, y: 50 } } : n))
|
||||
)
|
||||
})
|
||||
act(() => {
|
||||
result.current.commitDragEnd()
|
||||
})
|
||||
expect(result.current.nodes.find((n) => n.id === 'b')?.position).toEqual({ x: 200, y: 50 })
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
})
|
||||
expect(result.current.nodes.find((n) => n.id === 'b')?.position).toEqual({ x: 100, y: 0 })
|
||||
})
|
||||
|
||||
it('setStateImmediate clears history', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
||||
act(() => {
|
||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
||||
})
|
||||
expect(result.current.canUndo).toBe(true)
|
||||
act(() => {
|
||||
result.current.setStateImmediate({
|
||||
nodes: [makeNode('c', 'variable', 0, 0)],
|
||||
edges: [],
|
||||
})
|
||||
})
|
||||
expect(result.current.nodes).toHaveLength(1)
|
||||
expect(result.current.nodes[0].id).toBe('c')
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
|
||||
export type GraphState = { nodes: Node[]; edges: Edge[] }
|
||||
export type GraphState = { nodes: AppNode[]; edges: AppEdge[] }
|
||||
|
||||
function cloneState(state: GraphState): GraphState {
|
||||
return {
|
||||
@@ -12,9 +12,9 @@ function cloneState(state: GraphState): GraphState {
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
export function useGraphStateWithHistory(initialNodes: Node[], initialEdges: Edge[]) {
|
||||
const [nodes, setNodesState] = useState<Node[]>(initialNodes)
|
||||
const [edges, setEdgesState] = useState<Edge[]>(initialEdges)
|
||||
export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) {
|
||||
const [nodes, setNodesState] = useState<AppNode[]>(initialNodes)
|
||||
const [edges, setEdgesState] = useState<AppEdge[]>(initialEdges)
|
||||
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
|
||||
|
||||
const pastRef = useRef<GraphState[]>([])
|
||||
@@ -32,21 +32,21 @@ export function useGraphStateWithHistory(initialNodes: Node[], initialEdges: Edg
|
||||
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||
}, [])
|
||||
|
||||
const setNodes = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => {
|
||||
const setNodes = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [pushToPast])
|
||||
|
||||
const setEdges = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => {
|
||||
const setEdges = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [pushToPast])
|
||||
|
||||
const setNodesSilent = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => {
|
||||
const setNodesSilent = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [])
|
||||
|
||||
const setEdgesSilent = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => {
|
||||
const setEdgesSilent = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [])
|
||||
|
||||
|
||||
135
src/lib/abstractNode.ts
Normal file
135
src/lib/abstractNode.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Abstract node layer: shared types, hook, and factory for flow node components.
|
||||
*
|
||||
* - **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.
|
||||
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
||||
*
|
||||
* Example: define NodeData type, Props = AbstractNodeProps<NodeData>, use useAbstractNode in the
|
||||
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import FlowContext from './flowContext'
|
||||
import { nodePropsAreEqual } from './flowUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Props passed by React Flow to custom node components. Extend data with your node's shape. */
|
||||
export type AbstractNodeProps<TData = Record<string, unknown>> = {
|
||||
id: string
|
||||
data: TData
|
||||
width?: number
|
||||
height?: number
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/** Edge shape used in flow context (minimal for connection logic). */
|
||||
export type FlowEdge = { id: string; source: string; target: string; [k: string]: unknown }
|
||||
|
||||
/** Node shape used in flow context (minimal for reading graph). */
|
||||
export type FlowNode = { id: string; type?: string; data?: unknown; position?: { x: number; y: number }; [k: string]: unknown }
|
||||
|
||||
/** Result of useAbstractNode: flow context plus helpers scoped to this node. */
|
||||
export type AbstractNodeContext<TData = Record<string, unknown>> = {
|
||||
id: string
|
||||
data: TData
|
||||
nodes: FlowNode[]
|
||||
edges: FlowEdge[]
|
||||
setNodes: (updater: (nodes: FlowNode[]) => FlowNode[]) => void
|
||||
setEdges: (updater: (edges: FlowEdge[]) => FlowEdge[]) => void
|
||||
/** Merge partial data into this node's data. Stable reference. */
|
||||
updateData: (partial: Partial<TData>) => void
|
||||
/** Incoming edge IDs (edges whose target is this node). */
|
||||
incomingEdges: FlowEdge[]
|
||||
/** Outgoing edge IDs (edges whose source is this node). */
|
||||
outgoingEdges: FlowEdge[]
|
||||
/** Source node IDs connected to this node (incoming). */
|
||||
sourceIds: string[]
|
||||
/** Target node IDs this node connects to (outgoing). */
|
||||
targetIds: string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provides flow context and helpers for the current node. Use in any node component
|
||||
* that receives id and data; updateData(partial) merges into this node's data.
|
||||
*/
|
||||
export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
id: string,
|
||||
data: TData
|
||||
): AbstractNodeContext<TData> {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const edges = ctx?.edges ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<TData>) => {
|
||||
if (!setNodes) return
|
||||
setNodes((nds: FlowNode[]) =>
|
||||
nds.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||
)
|
||||
)
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const incomingEdges = useMemo(
|
||||
() => (edges as FlowEdge[]).filter((e) => e.target === id),
|
||||
[edges, id]
|
||||
)
|
||||
const outgoingEdges = useMemo(
|
||||
() => (edges as FlowEdge[]).filter((e) => e.source === id),
|
||||
[edges, id]
|
||||
)
|
||||
const sourceIds = useMemo(
|
||||
() => incomingEdges.map((e) => e.source).sort(),
|
||||
[incomingEdges]
|
||||
)
|
||||
const targetIds = useMemo(
|
||||
() => outgoingEdges.map((e) => e.target).sort(),
|
||||
[outgoingEdges]
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
data,
|
||||
nodes,
|
||||
edges,
|
||||
setNodes: setNodes ?? (() => {}),
|
||||
setEdges: setEdges ?? (() => {}),
|
||||
updateData,
|
||||
incomingEdges,
|
||||
outgoingEdges,
|
||||
sourceIds,
|
||||
targetIds,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps a node component with React.memo and nodePropsAreEqual so only id/data/width/height/selected
|
||||
* changes trigger re-renders. Use with AbstractNodeProps<TData> for typed props.
|
||||
*/
|
||||
export function createAbstractNodeComponent<TData = Record<string, unknown>>(
|
||||
displayName: string,
|
||||
Component: React.ComponentType<AbstractNodeProps<TData>>
|
||||
): React.MemoExoticComponent<React.ComponentType<AbstractNodeProps<TData>>> {
|
||||
const Wrapped = React.memo(Component, nodePropsAreEqual) as React.MemoExoticComponent<
|
||||
React.ComponentType<AbstractNodeProps<TData>>
|
||||
>
|
||||
Wrapped.displayName = displayName
|
||||
return Wrapped
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export type ConfigType = {
|
||||
}
|
||||
|
||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||
const KROKI_TIMEOUT_MS = 15000
|
||||
|
||||
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||
@@ -108,15 +109,20 @@ async function renderMarkdown(content: string): Promise<string> {
|
||||
return typeof html === 'string' ? html : String(html)
|
||||
}
|
||||
|
||||
/** Wireweave DSL to HTML+CSS; theme from document dark mode. See https://www.wireweave.org/ */
|
||||
async function renderWireframe(content: string): Promise<string> {
|
||||
const { parse, render } = await import('@wireweave/core')
|
||||
/** Wireweave DSL to SVG; theme from document dark mode. See https://www.wireweave.org/ */
|
||||
async function renderWireframe(content: string, options?: RenderOptions): Promise<string> {
|
||||
const { parse, renderToSvg } = await import('@wireweave/core')
|
||||
const doc = parse(content)
|
||||
const isDark =
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement?.classList?.contains('dark')
|
||||
const { html, css } = render(doc, { theme: isDark ? 'dark' : 'light' })
|
||||
return `<style>${css}</style>${html}`
|
||||
const { svg } = renderToSvg(doc, {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
width: options?.width ?? 1200,
|
||||
height: options?.height,
|
||||
padding: 24,
|
||||
})
|
||||
return svg
|
||||
}
|
||||
|
||||
export const CONFIG_TYPES: ConfigType[] = [
|
||||
@@ -126,16 +132,36 @@ export const CONFIG_TYPES: ConfigType[] = [
|
||||
language: 'plantuml',
|
||||
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||
render: async (content: string) => {
|
||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content,
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
if (res.status >= 500) {
|
||||
throw new Error('Diagram service unavailable. Try again later.')
|
||||
}
|
||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||
}
|
||||
return res.text()
|
||||
} catch (err: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
|
||||
}
|
||||
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
|
||||
throw new Error('Diagram service unavailable. Check your connection or try again later.')
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
return res.text()
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react'
|
||||
import type { Connection } from '@xyflow/react'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
|
||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||
|
||||
export type FlowContextValue = {
|
||||
nodes: any[]
|
||||
setNodes: (updater: any) => void
|
||||
edges: any[]
|
||||
setEdges: (updater: any) => void
|
||||
nodes: AppNode[]
|
||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||
edges: AppEdge[]
|
||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
||||
renamingNodeId: string | null
|
||||
setRenamingNodeId: (id: string | null) => void
|
||||
/** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */
|
||||
|
||||
52
src/lib/flowUtils.test.ts
Normal file
52
src/lib/flowUtils.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { getNextNodeId, replaceNodeIdInGraph, DEFAULT_NODE_STYLE } from './flowUtils'
|
||||
import { registerBuiltinNodes } from './registerBuiltinNodes'
|
||||
|
||||
beforeAll(() => {
|
||||
registerBuiltinNodes()
|
||||
})
|
||||
|
||||
describe('getNextNodeId', () => {
|
||||
it('returns prefix + 001 when no existing ids', () => {
|
||||
expect(getNextNodeId('config', [])).toBe('cfg_001')
|
||||
expect(getNextNodeId('render', [])).toBe('rnd_001')
|
||||
expect(getNextNodeId('variable', [])).toBe('var_001')
|
||||
expect(getNextNodeId('function', [])).toBe('fn_001')
|
||||
})
|
||||
|
||||
it('increments after existing ids', () => {
|
||||
expect(getNextNodeId('config', ['cfg_001'])).toBe('cfg_002')
|
||||
expect(getNextNodeId('config', ['cfg_001', 'cfg_002', 'cfg_003'])).toBe('cfg_004')
|
||||
})
|
||||
|
||||
it('ignores ids of other types', () => {
|
||||
expect(getNextNodeId('config', ['rnd_001', 'var_001'])).toBe('cfg_001')
|
||||
expect(getNextNodeId('config', ['cfg_002'])).toBe('cfg_003')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceNodeIdInGraph', () => {
|
||||
it('renames node and updates edges and references in data', () => {
|
||||
const nodes = [
|
||||
{ id: 'cfg_001', data: { title: 'cfg_001', plantuml: 'x' }, type: 'config', position: { x: 0, y: 0 } },
|
||||
{ id: 'rnd_001', data: {}, type: 'render', position: { x: 100, y: 0 } },
|
||||
]
|
||||
const edges = [
|
||||
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001' },
|
||||
]
|
||||
const result = replaceNodeIdInGraph(nodes, edges, 'cfg_001', 'cfg_002')
|
||||
expect(result.nodes[0].id).toBe('cfg_002')
|
||||
expect(result.nodes[0].data).toEqual(expect.objectContaining({ title: 'cfg_002' }))
|
||||
expect(result.edges[0].source).toBe('cfg_002')
|
||||
expect(result.edges[0].id).toBe('e-cfg_002-rnd_001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_NODE_STYLE', () => {
|
||||
it('has styles for config, render, variable, function', () => {
|
||||
expect(DEFAULT_NODE_STYLE.config).toEqual({ width: 320, height: 320 })
|
||||
expect(DEFAULT_NODE_STYLE.render).toEqual({ width: 384, height: 320 })
|
||||
expect(DEFAULT_NODE_STYLE.variable).toEqual({ width: 224, height: 180 })
|
||||
expect(DEFAULT_NODE_STYLE.function).toEqual({ width: 288, height: 260 })
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,19 @@
|
||||
* Skips re-render when only position (or other unrelated props) changed,
|
||||
* so dragging one node doesn't force other nodes to re-render.
|
||||
*/
|
||||
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
||||
|
||||
import {
|
||||
getIdPrefix,
|
||||
getDefaultDataForType as getDefaultDataFromRegistry,
|
||||
getResetDataForType as getResetDataFromRegistry,
|
||||
getDefaultStyle,
|
||||
} from './nodeRegistry'
|
||||
|
||||
/** Minimal node/edge shape for replaceNodeIdInGraph (avoids circular dependency on nodeTypes). */
|
||||
type GraphNode = { id: string; data?: unknown; [k: string]: unknown }
|
||||
type GraphEdge = { id: string; source: string; target: string; [k: string]: unknown }
|
||||
|
||||
export function nodePropsAreEqual<P extends { id?: string; data?: unknown; width?: number; height?: number; selected?: boolean }>(
|
||||
prev: P,
|
||||
next: P
|
||||
): boolean {
|
||||
@@ -16,6 +28,7 @@ export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: n
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use getIdPrefix from nodeRegistry for new code. Kept for compatibility. */
|
||||
export const PREFIX_BY_TYPE: Record<string, string> = {
|
||||
config: 'cfg_',
|
||||
render: 'rnd_',
|
||||
@@ -23,9 +36,9 @@ export const PREFIX_BY_TYPE: Record<string, string> = {
|
||||
function: 'fn_',
|
||||
}
|
||||
|
||||
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …) */
|
||||
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …). Uses nodeRegistry for prefix when available. */
|
||||
export function getNextNodeId(type: string, existingIds: string[]): string {
|
||||
const prefix = PREFIX_BY_TYPE[type] ?? 'node_'
|
||||
const prefix = getIdPrefix(type)
|
||||
const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)
|
||||
let max = 0
|
||||
for (const id of existingIds) {
|
||||
@@ -47,13 +60,15 @@ function replaceInData(value: unknown, oldId: string, newId: string): unknown {
|
||||
|
||||
/** Update graph after renaming a node: change node id and all references in data and edges */
|
||||
export function replaceNodeIdInGraph(
|
||||
nodes: Array<{ id: string; data?: any; [k: string]: any }>,
|
||||
edges: Array<{ id: string; source: string; target: string; [k: string]: any }>,
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
oldId: string,
|
||||
newId: string
|
||||
): { nodes: typeof nodes; edges: typeof edges } {
|
||||
): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
const newNodes = nodes.map((n) =>
|
||||
n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any }
|
||||
n.id === oldId
|
||||
? { ...n, id: newId, data: replaceInData(n.data, oldId, newId) }
|
||||
: { ...n, data: replaceInData(n.data, oldId, newId) }
|
||||
)
|
||||
const newEdges = edges.map((e) => ({
|
||||
...e,
|
||||
@@ -64,6 +79,7 @@ export function replaceNodeIdInGraph(
|
||||
return { nodes: newNodes, edges: newEdges }
|
||||
}
|
||||
|
||||
/** @deprecated Use getDefaultStyle from nodeRegistry. Kept for compatibility. */
|
||||
export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
|
||||
config: { width: 320, height: 320 },
|
||||
render: { width: 384, height: 320 },
|
||||
@@ -71,30 +87,17 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
|
||||
function: { width: 288, height: 260 },
|
||||
}
|
||||
|
||||
const DEFAULT_DATA: Record<string, any> = {
|
||||
config: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' },
|
||||
render: {},
|
||||
variable: { value: '', valueType: 'string' },
|
||||
function: {
|
||||
body: `function(num, kwargs) {
|
||||
return num + (kwargs.bar || 0);
|
||||
}`,
|
||||
},
|
||||
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
||||
export function getDefaultDataForType(type: string, newId?: string): Record<string, unknown> {
|
||||
return getDefaultDataFromRegistry(type, newId)
|
||||
}
|
||||
|
||||
export function getDefaultDataForType(type: string, newId?: string): any {
|
||||
const base = { ...DEFAULT_DATA[type] }
|
||||
if (type === 'config' && newId) base.title = `${newId}`
|
||||
return base
|
||||
/** Data for Reset action. Uses nodeRegistry when type is registered. */
|
||||
export function getResetDataForType(type: string, nodeId?: string): Record<string, unknown> {
|
||||
return getResetDataFromRegistry(type, nodeId)
|
||||
}
|
||||
|
||||
/** Data for Reset action: clears code completely for config/function; same as default for others */
|
||||
export function getResetDataForType(type: string, nodeId?: string): any {
|
||||
if (type === 'config') {
|
||||
return { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: nodeId ? `${nodeId}` : '' }
|
||||
}
|
||||
if (type === 'function') {
|
||||
return { body: '' }
|
||||
}
|
||||
return getDefaultDataForType(type, nodeId)
|
||||
/** Default style for a type. Uses nodeRegistry when type is registered. */
|
||||
export function getDefaultStyleForType(type: string): { width: number; height: number } {
|
||||
return getDefaultStyle(type)
|
||||
}
|
||||
|
||||
84
src/lib/nodeHelp.tsx
Normal file
84
src/lib/nodeHelp.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type React from 'react'
|
||||
|
||||
export type NodeType = 'config' | 'render' | 'variable' | 'function'
|
||||
|
||||
export type NodeHelpEntry = {
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
const Code = ({ children }: { children: React.ReactNode }) => (
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">{children}</code>
|
||||
)
|
||||
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div className="mt-3 first:mt-0">
|
||||
<h4 className="text-xs font-semibold text-foreground">{title}</h4>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const NODE_HELP: Record<NodeType, NodeHelpEntry> = {
|
||||
config: {
|
||||
title: 'Config node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Config nodes hold PlantUML + Nunjucks template content. Connect one config to a Render node to display the diagram. Use the editor to write <Code>@startuml</Code> blocks and Nunjucks tags (<Code>{'{{ }}'}</Code>, <Code>{'{% %}'}</Code>).</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>From another config, reference this template:</p>
|
||||
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
||||
<li><Code>{'{% extends "configId" %}'}</Code> — inherit layout</li>
|
||||
<li><Code>{'{% include "configId" %}'}</Code> — inline content</li>
|
||||
<li><Code>{'{% import "configId" as alias %}'}</Code> — use as macro namespace</li>
|
||||
</ul>
|
||||
<p className="mt-2">Replace <Code>configId</Code> with this node’s id or its title. Connect variables/functions to this config; they are available as <Code>{'{{ varId }}'}</Code> and <Code>{'{{ x | fnId }}'}</Code> in the template.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
render: {
|
||||
title: 'Renderer node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Connect a single Config node (input) to this Render node. It resolves the config’s PlantUML + Nunjucks (variables, function filters, extends/include), sends the result to the diagram service, and shows the SVG here.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>Renderer nodes are terminal: they only consume configs. They are not referenced from other nodes. To reuse a diagram, reference the Config node from another Config (extends/include), then connect that config to a Render node.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
variable: {
|
||||
title: 'Variable node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Variables hold a value (string, number, or boolean). Connect a Variable node to a Config node to expose it in that config’s template.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>In a Config template connected to this variable, use <Code>{'{{ '}<em>nodeId</em>{' }}'}</Code> where <em>nodeId</em> is this node’s id. Example: if the variable node id is <Code>var_001</Code>, write <Code>{'{{ var_001 }}'}</Code> in the config.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
function: {
|
||||
title: 'Function node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Functions are Nunjucks custom filters. Write a body using either named parameters (<Code>function(num, x, kwargs) { ... }</Code>) or the <Code>args</Code> array. Connect this node to a Config to use the filter in that config’s template.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>In a Config template, use the filter syntax: <Code>{'{{ value | '}<em>nodeId</em>{' }}'}</Code> or <Code>{'{{ value | '}<em>nodeId</em>{'(arg1, key=val) }}'}</Code>. The first argument is the value before <Code>|</Code>; extra arguments and keyword args are passed as in Nunjucks. Return a value or a Promise for async filters.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function getNodeHelp(nodeType: NodeType): NodeHelpEntry {
|
||||
return NODE_HELP[nodeType] ?? { title: nodeType, content: null }
|
||||
}
|
||||
125
src/lib/nodeRegistry.ts
Normal file
125
src/lib/nodeRegistry.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Extensible node type registry. Register node types with registerNodeType();
|
||||
* built-in types are registered in registerBuiltinNodes.ts.
|
||||
* Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI.
|
||||
*/
|
||||
|
||||
import type React from 'react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
export type NodeHelpEntry = {
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
/** Props passed to registered node components: id, data, and optional dimensions/selection. */
|
||||
export type NodeComponentProps = {
|
||||
id: string
|
||||
data?: Record<string, unknown>
|
||||
width?: number
|
||||
height?: number
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export type NodeTypeDescriptor = {
|
||||
id: string
|
||||
component: React.ComponentType<NodeComponentProps>
|
||||
defaultStyle: { width: number; height: number }
|
||||
defaultData: Record<string, unknown>
|
||||
idPrefix: string
|
||||
hasInput: boolean
|
||||
hasOutput: boolean
|
||||
/** When this type is the connection target, which source types are allowed. Omit = all. */
|
||||
allowedSourceTypes?: string[]
|
||||
/** When this type is the connection source, which target types are allowed. Omit = all. */
|
||||
allowedTargetTypes?: string[]
|
||||
help: NodeHelpEntry
|
||||
menuLabel: string
|
||||
menuIcon: React.ReactNode
|
||||
/** Optional: override default data for new nodes (e.g. set title from newId). */
|
||||
getDefaultData?: (newId?: string) => Record<string, unknown>
|
||||
/** Optional: data for Reset action; if omitted, getDefaultData(nodeId) or defaultData is used. */
|
||||
getResetData?: (nodeId?: string) => Record<string, unknown>
|
||||
/** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */
|
||||
connectionLabel?: string
|
||||
}
|
||||
|
||||
const registry = new Map<string, NodeTypeDescriptor>()
|
||||
|
||||
export function registerNodeType(descriptor: NodeTypeDescriptor): void {
|
||||
if (registry.has(descriptor.id)) {
|
||||
console.warn(`[nodeRegistry] Overwriting existing node type: ${descriptor.id}`)
|
||||
}
|
||||
registry.set(descriptor.id, descriptor)
|
||||
}
|
||||
|
||||
export function getNodeType(id: string): NodeTypeDescriptor | undefined {
|
||||
return registry.get(id)
|
||||
}
|
||||
|
||||
export function getRegisteredNodeTypes(): NodeTypeDescriptor[] {
|
||||
return Array.from(registry.values())
|
||||
}
|
||||
|
||||
export function getRegisteredNodeTypeIds(): string[] {
|
||||
return Array.from(registry.keys())
|
||||
}
|
||||
|
||||
/** Default data for a new node of this type. Uses getDefaultData from descriptor if provided. */
|
||||
export function getDefaultDataForType(type: string, newId?: string): Record<string, unknown> {
|
||||
const desc = registry.get(type)
|
||||
if (!desc) return {}
|
||||
if (desc.getDefaultData) return { ...desc.getDefaultData(newId) }
|
||||
const base: Record<string, unknown> = { ...desc.defaultData }
|
||||
if (type === 'config' && newId) base.title = `${newId}`
|
||||
return base
|
||||
}
|
||||
|
||||
/** Data for Reset action. Uses getResetData from descriptor if provided. */
|
||||
export function getResetDataForType(type: string, nodeId?: string): Record<string, unknown> {
|
||||
const desc = registry.get(type)
|
||||
if (!desc) return getDefaultDataForType(type, nodeId)
|
||||
if (desc.getResetData) return { ...desc.getResetData(nodeId) }
|
||||
return getDefaultDataForType(type, nodeId)
|
||||
}
|
||||
|
||||
/** Id prefix for this type (e.g. cfg_, rnd_). Used by getNextNodeId. */
|
||||
export function getIdPrefix(type: string): string {
|
||||
return getNodeType(type)?.idPrefix ?? 'node_'
|
||||
}
|
||||
|
||||
/** Default style for this type. */
|
||||
export function getDefaultStyle(type: string): { width: number; height: number } {
|
||||
const desc = getNodeType(type)
|
||||
if (desc) return desc.defaultStyle
|
||||
return { width: 320, height: 320 }
|
||||
}
|
||||
|
||||
/** Whether a connection from source to target is allowed based on registered types. */
|
||||
export function isConnectionAllowed(
|
||||
sourceType: string,
|
||||
targetType: string,
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string
|
||||
): boolean {
|
||||
if (sourceNodeId === targetNodeId) return false
|
||||
const sourceDesc = getNodeType(sourceType)
|
||||
const targetDesc = getNodeType(targetType)
|
||||
if (!sourceDesc || !targetDesc) return false
|
||||
if (!targetDesc.hasInput) return false
|
||||
if (!sourceDesc.hasOutput) return false
|
||||
if (targetDesc.allowedSourceTypes != null && !targetDesc.allowedSourceTypes.includes(sourceType)) return false
|
||||
if (sourceDesc.allowedTargetTypes != null && !sourceDesc.allowedTargetTypes.includes(targetType)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Edge label when target is of this type (for AnimatedEdge). */
|
||||
export function getConnectionLabelForTarget(targetType: string): string | undefined {
|
||||
return getNodeType(targetType)?.connectionLabel
|
||||
}
|
||||
|
||||
/** Help entry for a node type. Use in NodeHelpPopover. */
|
||||
export function getNodeHelp(nodeType: string): NodeHelpEntry {
|
||||
const desc = getNodeType(nodeType)
|
||||
return desc?.help ?? { title: nodeType, content: null }
|
||||
}
|
||||
13
src/lib/nodeTypes.ts
Normal file
13
src/lib/nodeTypes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Central node and edge types for the app. Use AppNode / AppEdge in graph state and context.
|
||||
*/
|
||||
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { ConfigNodeData } from '@/components/nodes/ConfigNode'
|
||||
import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
|
||||
import type { VariableNodeData } from '@/components/nodes/VariableNode'
|
||||
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
|
||||
|
||||
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData
|
||||
export type AppNode = Node<AppNodeData>
|
||||
export type AppEdge = Edge
|
||||
93
src/lib/registerBuiltinNodes.tsx
Normal file
93
src/lib/registerBuiltinNodes.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Registers built-in node types (config, render, variable, function).
|
||||
* Import this once at app startup (e.g. in main.tsx) so the registry is populated.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ScrollText, Sparkles, Variable, Code2 } from 'lucide-react'
|
||||
import { registerNodeType } from './nodeRegistry'
|
||||
import { NODE_HELP } from './nodeHelp'
|
||||
import ConfigNode from '../components/nodes/ConfigNode'
|
||||
import RenderingNode from '../components/nodes/RenderingNode'
|
||||
import VariableNode from '../components/nodes/VariableNode'
|
||||
import FunctionNode from '../components/nodes/FunctionNode'
|
||||
|
||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||
|
||||
export function registerBuiltinNodes(): void {
|
||||
registerNodeType({
|
||||
id: 'config',
|
||||
component: ConfigNode,
|
||||
defaultStyle: { width: 320, height: 320 },
|
||||
defaultData: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' },
|
||||
idPrefix: 'cfg_',
|
||||
hasInput: true,
|
||||
hasOutput: true,
|
||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
||||
allowedTargetTypes: ['config', 'render'],
|
||||
help: NODE_HELP.config,
|
||||
menuLabel: 'Config',
|
||||
menuIcon: <ScrollText className={ICON_CLASS} />,
|
||||
getDefaultData: (newId) => ({
|
||||
configType: 'plantuml',
|
||||
content: '@startuml\n\n@enduml\n',
|
||||
title: newId ?? '',
|
||||
}),
|
||||
getResetData: (nodeId) => ({
|
||||
configType: 'plantuml',
|
||||
content: '@startuml\n\n@enduml\n',
|
||||
title: nodeId ?? '',
|
||||
}),
|
||||
connectionLabel: 'add input',
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'render',
|
||||
component: RenderingNode,
|
||||
defaultStyle: { width: 384, height: 320 },
|
||||
defaultData: { viewportWidth: 1200, viewportHeight: 800 },
|
||||
idPrefix: 'rnd_',
|
||||
hasInput: true,
|
||||
hasOutput: false,
|
||||
allowedSourceTypes: ['config'],
|
||||
help: NODE_HELP.render,
|
||||
menuLabel: 'Renderer',
|
||||
menuIcon: <Sparkles className={ICON_CLASS} />,
|
||||
connectionLabel: 'render',
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'variable',
|
||||
component: VariableNode,
|
||||
defaultStyle: { width: 224, height: 180 },
|
||||
defaultData: { value: '', valueType: 'string' },
|
||||
idPrefix: 'var_',
|
||||
hasInput: false,
|
||||
hasOutput: true,
|
||||
allowedTargetTypes: ['config', 'function'],
|
||||
help: NODE_HELP.variable,
|
||||
menuLabel: 'Variable',
|
||||
menuIcon: <Variable className={ICON_CLASS} />,
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'function',
|
||||
component: FunctionNode,
|
||||
defaultStyle: { width: 288, height: 260 },
|
||||
defaultData: {
|
||||
body: `function(num, kwargs) {
|
||||
return num + (kwargs.bar || 0);
|
||||
}`,
|
||||
},
|
||||
idPrefix: 'fn_',
|
||||
hasInput: true,
|
||||
hasOutput: true,
|
||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
||||
allowedTargetTypes: ['config', 'function'],
|
||||
help: NODE_HELP.function,
|
||||
menuLabel: 'Function',
|
||||
menuIcon: <Code2 className={ICON_CLASS} />,
|
||||
getResetData: () => ({ body: '' }),
|
||||
connectionLabel: 'add input',
|
||||
})
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ThemeProvider } from './lib/themeContext'
|
||||
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
||||
import './styles.css'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
registerBuiltinNodes()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
|
||||
45
src/projectLoad.test.ts
Normal file
45
src/projectLoad.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { getRegisteredNodeTypeIds } from './lib/nodeRegistry'
|
||||
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
||||
import sample from './fixtures/sample.zui.json'
|
||||
|
||||
beforeAll(() => {
|
||||
registerBuiltinNodes()
|
||||
})
|
||||
|
||||
/** Minimal type for parsed .zui.json project file */
|
||||
type ProjectFile = {
|
||||
version?: number
|
||||
nodes: Array<{ id: string; type: string; data?: unknown; position?: { x: number; y: number }; style?: unknown }>
|
||||
edges: Array<{ id: string; source: string; target: string; type?: string }>
|
||||
}
|
||||
|
||||
describe('project load (.zui.json)', () => {
|
||||
it('parses sample project and asserts node/edge count and types', () => {
|
||||
const project = sample as ProjectFile
|
||||
expect(project).toBeDefined()
|
||||
expect(Array.isArray(project.nodes)).toBe(true)
|
||||
expect(Array.isArray(project.edges)).toBe(true)
|
||||
expect(project.nodes).toHaveLength(3)
|
||||
expect(project.edges).toHaveLength(2)
|
||||
const nodeTypes = project.nodes.map((n) => n.type)
|
||||
expect(nodeTypes).toContain('variable')
|
||||
expect(nodeTypes).toContain('config')
|
||||
expect(nodeTypes).toContain('render')
|
||||
const validTypeIds = getRegisteredNodeTypeIds()
|
||||
for (const n of project.nodes) {
|
||||
expect(validTypeIds).toContain(n.type)
|
||||
}
|
||||
expect(project.nodes[0].id).toBe('var_001')
|
||||
expect(project.nodes[1].id).toBe('cfg_001')
|
||||
expect(project.nodes[2].id).toBe('rnd_001')
|
||||
expect(project.edges[0].source).toBe('var_001')
|
||||
expect(project.edges[0].target).toBe('cfg_001')
|
||||
expect(project.edges[1].source).toBe('cfg_001')
|
||||
expect(project.edges[1].target).toBe('rnd_001')
|
||||
if (project.version != null) {
|
||||
expect(typeof project.version).toBe('number')
|
||||
expect(project.version).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,15 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Viewport control buttons: same look as React Flow controls; Lucide icons use stroke. */
|
||||
.rendering-viewport .react-flow__controls-button svg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Rendering node: diagram/wireframe SVG must fit inside the content area. */
|
||||
.rendering-diagram {
|
||||
display: flex;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user