refactor nodes

This commit is contained in:
2026-03-09 14:04:13 +01:00
parent 76039bdd0e
commit 24efc597b2
19 changed files with 572 additions and 288 deletions

View 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 Apps branching logic or to flowUtils/nodeHelps 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 descriptors `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.

View File

@@ -15,11 +15,7 @@ import {
type NodeChange, type NodeChange,
type EdgeChange, type EdgeChange,
} from '@xyflow/react' } from '@xyflow/react'
import ConfigNode from './components/graph/ConfigNode' import { AnimatedEdge } from './components/base/AnimatedEdge'
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 FlowContext from './lib/flowContext' import FlowContext from './lib/flowContext'
import { useTheme } from './lib/themeContext' import { useTheme } from './lib/themeContext'
import { useGraphStateWithHistory } from './hooks/useGraphStateWithHistory' import { useGraphStateWithHistory } from './hooks/useGraphStateWithHistory'
@@ -35,8 +31,14 @@ import {
ContextMenuGroup ContextMenuGroup
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { AppMenubar } from '@/components/AppMenubar' import { AppMenubar } from '@/components/AppMenubar'
import { ClipboardPaste, Code2, ScrollText, Sparkles, Variable } from 'lucide-react' import { ClipboardPaste } from 'lucide-react'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils' import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils'
import {
getRegisteredNodeTypes,
getRegisteredNodeTypeIds,
getDefaultStyle,
isConnectionAllowed,
} from './lib/nodeRegistry'
const SNAP_GRID: [number, number] = [15, 15] const SNAP_GRID: [number, number] = [15, 15]
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
@@ -125,7 +127,7 @@ export default function App() {
) )
const nodeTypes = React.useMemo( const nodeTypes = React.useMemo(
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }), () => Object.fromEntries(getRegisteredNodeTypes().map((r) => [r.id, r.component])),
[] []
) )
@@ -153,11 +155,12 @@ export default function App() {
const sourceType = sourceNode?.type const sourceType = sourceNode?.type
const targetType = targetNode?.type const targetType = targetNode?.type
if (!sourceType || !targetType) return false if (!sourceType || !targetType) return false
if (connection.source === connection.target) return false return isConnectionAllowed(
if (targetType === 'variable') return false sourceType,
if (targetType === 'render' && (sourceType === 'variable' || sourceType === 'function')) return false targetType,
if (sourceType === 'render') return false connection.source,
return true connection.target
)
}, },
[nodes] [nodes]
) )
@@ -294,22 +297,17 @@ export default function App() {
(type: string) => { (type: string) => {
const position = getMenuPosition() const position = getMenuPosition()
if (position == null) return if (position == null) return
const typeMap: Record<string, Node['type']> = { const nodeType = type as Node['type']
config: 'config',
render: 'render',
variable: 'variable',
function: 'function',
}
const nodeType = typeMap[type] ?? 'config'
setNodes((nds) => { setNodes((nds) => {
const newId = getNextNodeId(nodeType, nds.map((n) => n.id)) const newId = getNextNodeId(nodeType, nds.map((n) => n.id))
const dataMap = getDefaultDataForType(nodeType, newId) const dataMap = getDefaultDataForType(nodeType, newId)
const style = getDefaultStyle(nodeType)
const newNode: Node = { const newNode: Node = {
id: newId, id: newId,
type: nodeType, type: nodeType,
position: { x: position.x, y: position.y }, position: { x: position.x, y: position.y },
data: dataMap, data: dataMap,
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config, style,
} }
return nds.concat(newNode) return nds.concat(newNode)
}) })
@@ -319,8 +317,6 @@ export default function App() {
[getMenuPosition, setNodes] [getMenuPosition, setNodes]
) )
const VALID_NODE_TYPES = ['config', 'render', 'variable', 'function'] as const
const pasteNode = React.useCallback(async () => { const pasteNode = React.useCallback(async () => {
const position = getMenuPosition() const position = getMenuPosition()
if (position == null) return if (position == null) return
@@ -328,17 +324,19 @@ export default function App() {
const text = await navigator.clipboard?.readText() const text = await navigator.clipboard?.readText()
if (!text) return if (!text) return
const raw = JSON.parse(text) as { id?: string; type?: string; data?: any; position?: { x: number; y: number }; style?: any } 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) => { setNodes((nds) => {
const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id)) const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id))
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {} const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
if (raw.type === 'config' && data.title != null) data.title = `config-${newId}` if (raw.type === 'config' && data.title != null) data.title = `config-${newId}`
const style = getDefaultStyle(raw.type)
const newNode: Node = { const newNode: Node = {
id: newId, id: newId,
type: raw.type as Node['type'], type: raw.type as Node['type'],
position: { x: position.x, y: position.y }, position: { x: position.x, y: position.y },
data, data,
style: DEFAULT_NODE_STYLE[raw.type] ?? DEFAULT_NODE_STYLE.config, style,
} }
return nds.concat(newNode) return nds.concat(newNode)
}) })
@@ -387,75 +385,67 @@ export default function App() {
/> />
<div className="flex-1 min-h-0 relative flex flex-col"> <div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<FlowContext.Provider value={flowContextValue}> <FlowContext.Provider value={flowContextValue}>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div className="flex-1 min-h-0 w-full" onWheelCapture={onWheelCapture}> <div className="flex-1 min-h-0 w-full" onWheelCapture={onWheelCapture}>
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView> <ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect} onConnect={onConnect}
onConnectStart={onConnectStart} onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd} onConnectEnd={onConnectEnd}
onNodeDragStart={onNodeDragStart} onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop} onNodeDragStop={onNodeDragStop}
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions} defaultEdgeOptions={defaultEdgeOptions}
colorMode={theme as ColorMode} colorMode={theme as ColorMode}
snapToGrid snapToGrid
snapGrid={SNAP_GRID} snapGrid={SNAP_GRID}
fitView fitView
onInit={onInit} onInit={onInit}
nodeDragThreshold={1} nodeDragThreshold={1}
> >
<Background variant="dots" gap={20} /> <Background variant="dots" gap={20} />
<Controls /> <Controls />
<MiniMap /> <MiniMap />
</ReactFlow> </ReactFlow>
</ReactFlowProvider> </ReactFlowProvider>
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="w-48"> <ContextMenuContent className="w-48">
<ContextMenuGroup> <ContextMenuGroup>
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger> <ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-44"> <ContextMenuSubContent className="w-44">
<ContextMenuGroup> <ContextMenuGroup>
<ContextMenuItem onSelect={() => createNode('config')}> {getRegisteredNodeTypes().map((desc, index) => (
<ScrollText className="mr-2 h-4 w-4" /> <React.Fragment key={desc.id}>
Config {index === 2 ? <ContextMenuSeparator /> : null}
</ContextMenuItem> <ContextMenuItem onSelect={() => createNode(desc.id)}>
<ContextMenuItem onSelect={() => createNode('render')}> {desc.menuIcon}
<Sparkles className="mr-2 h-4 w-4" /> {desc.menuLabel}
Renderer </ContextMenuItem>
</ContextMenuItem> </React.Fragment>
<ContextMenuSeparator></ContextMenuSeparator> ))}
<ContextMenuItem onSelect={() => createNode('variable')}> </ContextMenuGroup>
<Variable className="mr-2 h-4 w-4" /> </ContextMenuSubContent>
Variable </ContextMenuSub>
</ContextMenuItem> <ContextMenuSeparator />
<ContextMenuItem onSelect={() => createNode('function')}> <ContextMenuItem onSelect={() => pasteNode()}>
<Code2 className="mr-2 h-4 w-4" /> <ClipboardPaste className="mr-2 h-4 w-4" />
Function Paste
</ContextMenuItem> </ContextMenuItem>
</ContextMenuGroup> </ContextMenuGroup>
</ContextMenuSubContent> </ContextMenuContent>
</ContextMenuSub> </ContextMenu>
<ContextMenuSeparator /> </FlowContext.Provider>
<ContextMenuItem onSelect={() => pasteNode()}>
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
</FlowContext.Provider>
</div> </div>
</div> </div>
</div > </div >

View File

@@ -5,16 +5,11 @@ import {
type EdgeProps, type EdgeProps,
} from '@xyflow/react' } from '@xyflow/react'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { getConnectionLabelForTarget } from '../../lib/nodeRegistry'
const EDGE_STROKE_WIDTH = 2 const EDGE_STROKE_WIDTH = 2
const DOT_MARKER_R = 1.5 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({ export function AnimatedEdge({
id, id,
sourceX, sourceX,
@@ -32,7 +27,7 @@ export function AnimatedEdge({
const nodes = ctx?.nodes ?? [] const nodes = ctx?.nodes ?? []
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target]) const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
const derivedLabel = useMemo( const derivedLabel = useMemo(
() => getEdgeLabelByTargetType(targetNode?.type), () => getConnectionLabelForTarget(targetNode?.type),
[targetNode?.type] [targetNode?.type]
) )
const label = labelProp ?? derivedLabel const label = labelProp ?? derivedLabel

View File

@@ -2,23 +2,11 @@ import React, { useContext, useMemo } from 'react'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react' import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import { NodeHelpPopover } from './NodeHelpPopover' import { NodeHelpPopover } from './NodeHelpPopover'
import { getNodeType } from '../../lib/nodeRegistry'
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,
}
type Props = { type Props = {
nodeId: string nodeId: string
nodeType: 'config' | 'function' | 'render' | 'variable' nodeType: string
children?: React.ReactNode children?: React.ReactNode
} }
@@ -36,8 +24,9 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
return { inputs, outputs } return { inputs, outputs }
}, [edges, nodeId]) }, [edges, nodeId])
const showInput = NODE_HAS_INPUT[nodeType] ?? false const descriptor = getNodeType(nodeType)
const showOutput = NODE_HAS_OUTPUT[nodeType] ?? false const showInput = descriptor?.hasInput ?? false
const showOutput = descriptor?.hasOutput ?? false
return ( return (
<div className="flex items-center gap-2 w-full text-xs text-muted-foreground"> <div className="flex items-center gap-2 w-full text-xs text-muted-foreground">

View File

@@ -1,11 +1,11 @@
import React from 'react' import React from 'react'
import { HelpCircle } from 'lucide-react' import { HelpCircle } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { getNodeHelp, type NodeType } from '../../lib/nodeHelp' import { getNodeHelp } from '../../lib/nodeRegistry'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
type Props = { type Props = {
nodeType: NodeType nodeType: string
className?: string className?: string
} }

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useContext } from 'react' import React, { useCallback, useContext } from 'react'
import FlowContext from '../../lib/flowContext' 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 { import {
Menubar, Menubar,
MenubarContent, MenubarContent,
@@ -15,11 +16,9 @@ import {
const DUPLICATE_OFFSET = { x: 30, y: 30 } const DUPLICATE_OFFSET = { x: 30, y: 30 }
type NodeType = 'config' | 'render' | 'variable' | 'function'
type Props = { type Props = {
nodeId: string 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 */ /** 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 editInputsContent?: React.ReactNode
/** When set, Inputs is rendered as its own top-level menu (config nodes); Insert then only shows markup-specific options */ /** 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, type: node.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y }, 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, 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}` if (newNode.data?.title && nodeType === 'config') newNode.data.title = `${newId}`
return nds.concat(newNode) return nds.concat(newNode)

View File

@@ -22,7 +22,7 @@ import {
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from '../base/BaseNode'
import { Code2, ScrollText, Variable } from 'lucide-react' import { Code2, ScrollText, Variable } from 'lucide-react'
import { import {
MenubarItem, MenubarItem,
@@ -34,10 +34,10 @@ import {
} from '../ui/menubar' } from '../ui/menubar'
import { Kbd } from '../ui/kbd' import { Kbd } from '../ui/kbd'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from '../base/NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from '../base/NodeMenubar'
type Props = { type Props = {
id: string id: string
@@ -94,13 +94,13 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
nds.map((n) => nds.map((n) =>
n.id === id n.id === id
? { ? {
...n, ...n,
data: { data: {
...n.data, ...n.data,
configType: newTypeId, configType: newTypeId,
content: getConfigContent(n.data) ?? '', content: getConfigContent(n.data) ?? '',
}, },
} }
: n : n
) )
) )
@@ -184,8 +184,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
configTypeId === 'wireframe' configTypeId === 'wireframe'
? javascript() ? javascript()
: configType.language === 'plantuml' : configType.language === 'plantuml'
? plantumlLanguage.extension ? plantumlLanguage.extension
: markdown() : markdown()
return [ return [
lang, lang,
autocompletion({ autocompletion({
@@ -205,25 +205,25 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
const typeItems = typeBlocks.flatMap((block) => const typeItems = typeBlocks.flatMap((block) =>
isGroup(block) isGroup(block)
? block.items.map(({ label, snippet }) => ( ? block.items.map(({ label, snippet }) => (
<MenubarItem <MenubarItem
key={label} key={label}
className="text-xs flex items-center group" className="text-xs flex items-center group"
onClick={() => insertAt(snippet, 'cursor')} onClick={() => insertAt(snippet, 'cursor')}
> >
{label} {label}
{insertShortcut} {insertShortcut}
</MenubarItem> </MenubarItem>
)) ))
: [ : [
<MenubarItem <MenubarItem
key={block.label} key={block.label}
className="text-xs flex items-center group" className="text-xs flex items-center group"
onClick={() => insertAt(block.snippet, 'cursor')} onClick={() => insertAt(block.snippet, 'cursor')}
> >
{block.label} {block.label}
{insertShortcut} {insertShortcut}
</MenubarItem>, </MenubarItem>,
] ]
) )
const templatingItems = const templatingItems =
templatingGroup?.items.map(({ label, snippet }) => ( templatingGroup?.items.map(({ label, snippet }) => (

View File

@@ -10,11 +10,11 @@ import {
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from '../base/BaseNode'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from '../base/NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from '../base/NodeMenubar'
import { MenubarItem, MenubarShortcut } from '../ui/menubar' import { MenubarItem, MenubarShortcut } from '../ui/menubar'
import { Kbd } from '../ui/kbd' import { Kbd } from '../ui/kbd'
import { Code2, Variable } from 'lucide-react' import { Code2, Variable } from 'lucide-react'

View File

@@ -8,15 +8,16 @@ import {
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from '../base/BaseNode'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId, nodePropsAreEqual } from '../../lib/flowUtils' import { getDefaultDataForType, getNextNodeId, nodePropsAreEqual } from '../../lib/flowUtils'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators' import { getDefaultStyle } from '../../lib/nodeRegistry'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeMenubar } from './NodeMenubar' import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeStatusIndicator } from './NodeStatusIndicator' import { NodeMenubar } from '../base/NodeMenubar'
import { NodeStatusIndicator } from '../base/NodeStatusIndicator'
import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { InputHandle } from './NodeHandles' import { InputHandle } from '../base/NodeHandles'
type Props = { type Props = {
id: string id: string
@@ -511,7 +512,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
a.download = `${id}.png` a.download = `${id}.png`
a.click() a.click()
} }
img.onerror = () => {} img.onerror = () => { }
img.src = dataUrl img.src = dataUrl
}, [id, renderedContent, isSvgOutput]) }, [id, renderedContent, isSvgOutput])
@@ -521,101 +522,101 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
return ( return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}> <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} />}> <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} />} /> <BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent> <BaseNodeContent>
<div className="shrink-0 w-full"> <div className="shrink-0 w-full">
<NodeMenubar <NodeMenubar
nodeId={id} nodeId={id}
nodeType="render" nodeType="render"
nodeMenuExtraContent={ nodeMenuExtraContent={
<MenubarSub> <MenubarSub>
<MenubarSeparator></MenubarSeparator> <MenubarSeparator></MenubarSeparator>
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}> <MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
Export Export
</MenubarSubTrigger> </MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]"> <MenubarSubContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}> <MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
PNG PNG
</MenubarItem> </MenubarItem>
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}> <MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
SVG SVG
</MenubarItem> </MenubarItem>
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </MenubarSub>
} }
/> />
</div> </div>
<div className="min-h-0 flex-1 flex flex-col"> <div className="min-h-0 flex-1 flex flex-col">
{incomingIds.length === 0 ? ( {incomingIds.length === 0 ? (
<Empty className="min-h-0 flex-1"> <Empty className="min-h-0 flex-1">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<Sparkles className="size-6" /> <Sparkles className="size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>No configuration connected</EmptyTitle> <EmptyTitle>No configuration connected</EmptyTitle>
<EmptyDescription>Connect a Configuration node or create one. The renderer will display the diagram or document.</EmptyDescription> <EmptyDescription>Connect a Configuration node or create one. The renderer will display the diagram or document.</EmptyDescription>
</EmptyHeader> </EmptyHeader>
<EmptyContent> <EmptyContent>
<button <button
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground" className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
onClick={() => { onClick={() => {
if (!setNodes || !setEdges) return if (!setNodes || !setEdges) return
const nid = getNextNodeId('config', nodes.map((n: any) => n.id)) const nid = getNextNodeId('config', nodes.map((n: any) => n.id))
const thisNode = nodes.find((n: any) => n.id === id) const thisNode = nodes.find((n: any) => n.id === id)
const pos = thisNode?.position ?? { x: 0, y: 0 } const pos = thisNode?.position ?? { x: 0, y: 0 }
const newPos = { x: pos.x - 220, y: pos.y } 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 } const newNode = { id: nid, type: 'config', position: newPos, data: getDefaultDataForType('config', nid), style: getDefaultStyle('config') }
setNodes((nds: any[]) => nds.concat(newNode)) setNodes((nds: any[]) => nds.concat(newNode))
setEdges((eds: any[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id })) setEdges((eds: any[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }))
}} }}
> >
Create Config Create Config
</button> </button>
</EmptyContent> </EmptyContent>
</Empty> </Empty>
) : error ? ( ) : error ? (
srcData?.renderError ? ( srcData?.renderError ? (
srcData.renderError(error) srcData.renderError(error)
) : srcData?.errorHtml ? ( ) : 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" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
) : ( ) : (
<div className="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div> <div className="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div>
) )
) : loading ? ( ) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div> <div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : renderedContent ? ( ) : renderedContent ? (
isWireframeOutput ? ( 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 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__viewport">
<div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} /> <div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} />
</div>
</div> </div>
</div> ) : (
) : ( <div
<div className={
className={ isSvgOutput
isSvgOutput ? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary'
? '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'
: '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 }}
dangerouslySetInnerHTML={{ __html: renderedContent }} />
/> )
) ) : null}
) : null} </div>
</div> </BaseNodeContent>
</BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render"> <NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{renderedContent {renderedContent
? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars` ? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars`
: error : error
? 'Error' ? 'Error'
: '—'} : '—'}
</NodeFooterEdgeIndicators> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
</NodeStatusIndicator> </NodeStatusIndicator>
) )
}, nodePropsAreEqual) }, nodePropsAreEqual)

View File

@@ -6,14 +6,14 @@ import {
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from '../base/BaseNode'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from '../base/NodeMenubar'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Switch } from '../ui/switch' import { Switch } from '../ui/switch'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { OutputHandle } from './NodeHandles' import { OutputHandle } from '../base/NodeHandles'
import { Variable } from 'lucide-react' import { Variable } from 'lucide-react'
type ValueType = 'string' | 'number' | 'boolean' type ValueType = 'string' | 'number' | 'boolean'

View File

@@ -3,6 +3,14 @@
* Skips re-render when only position (or other unrelated props) changed, * Skips re-render when only position (or other unrelated props) changed,
* so dragging one node doesn't force other nodes to re-render. * so dragging one node doesn't force other nodes to re-render.
*/ */
import {
getIdPrefix,
getDefaultDataForType as getDefaultDataFromRegistry,
getResetDataForType as getResetDataFromRegistry,
getDefaultStyle,
} from './nodeRegistry'
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>( export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
prev: P, prev: P,
next: P next: P
@@ -16,6 +24,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> = { export const PREFIX_BY_TYPE: Record<string, string> = {
config: 'cfg_', config: 'cfg_',
render: 'rnd_', render: 'rnd_',
@@ -23,9 +32,9 @@ export const PREFIX_BY_TYPE: Record<string, string> = {
function: 'fn_', 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 { 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+)$`) const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)
let max = 0 let max = 0
for (const id of existingIds) { for (const id of existingIds) {
@@ -64,6 +73,7 @@ export function replaceNodeIdInGraph(
return { nodes: newNodes, edges: newEdges } return { nodes: newNodes, edges: newEdges }
} }
/** @deprecated Use getDefaultStyle from nodeRegistry. Kept for compatibility. */
export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = { export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
config: { width: 320, height: 320 }, config: { width: 320, height: 320 },
render: { width: 384, height: 320 }, render: { width: 384, height: 320 },
@@ -71,30 +81,17 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
function: { width: 288, height: 260 }, function: { width: 288, height: 260 },
} }
const DEFAULT_DATA: Record<string, any> = { /** Default data for a new node. Uses nodeRegistry when type is registered. */
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);
}`,
},
}
export function getDefaultDataForType(type: string, newId?: string): any { export function getDefaultDataForType(type: string, newId?: string): any {
const base = { ...DEFAULT_DATA[type] } return getDefaultDataFromRegistry(type, newId)
if (type === 'config' && newId) base.title = `${newId}`
return base
} }
/** Data for Reset action: clears code completely for config/function; same as default for others */ /** Data for Reset action. Uses nodeRegistry when type is registered. */
export function getResetDataForType(type: string, nodeId?: string): any { export function getResetDataForType(type: string, nodeId?: string): any {
if (type === 'config') { return getResetDataFromRegistry(type, nodeId)
return { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: nodeId ? `${nodeId}` : '' } }
}
if (type === 'function') { /** Default style for a type. Uses nodeRegistry when type is registered. */
return { body: '' } export function getDefaultStyleForType(type: string): { width: number; height: number } {
} return getDefaultStyle(type)
return getDefaultDataForType(type, nodeId)
} }

116
src/lib/nodeRegistry.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* 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
}
export type NodeTypeDescriptor = {
id: string
component: React.ComponentType<any>
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 = { ...desc.defaultData }
if (type === 'config' && newId) (base as any).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 }
}

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

View File

@@ -2,9 +2,12 @@ import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App' import App from './App'
import { ThemeProvider } from './lib/themeContext' import { ThemeProvider } from './lib/themeContext'
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
import './styles.css' import './styles.css'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
registerBuiltinNodes()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>