From 24efc597b2486630ced8c6320a4e2de331deb07c Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 9 Mar 2026 14:04:13 +0100 Subject: [PATCH] refactor nodes --- docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md | 101 +++++++++ src/App.tsx | 174 +++++++-------- .../{graph => base}/AnimatedEdge.tsx | 9 +- src/components/{graph => base}/BaseHandle.tsx | 0 src/components/{graph => base}/BaseNode.tsx | 0 .../NodeFooterEdgeIndicators.tsx | 21 +- .../{graph => base}/NodeHandles.tsx | 0 .../{graph => base}/NodeHeaderTitle.tsx | 0 .../{graph => base}/NodeHelpPopover.tsx | 4 +- .../{graph => base}/NodeMenubar.tsx | 9 +- .../{graph => base}/NodeStatusIndicator.tsx | 0 .../{graph => nodes}/ConfigNode.tsx | 64 +++--- .../{graph => nodes}/FunctionNode.tsx | 10 +- .../{graph => nodes}/RenderingNode.tsx | 201 +++++++++--------- .../{graph => nodes}/VariableNode.tsx | 10 +- src/lib/flowUtils.ts | 45 ++-- src/lib/nodeRegistry.ts | 116 ++++++++++ src/lib/registerBuiltinNodes.tsx | 93 ++++++++ src/main.tsx | 3 + 19 files changed, 572 insertions(+), 288 deletions(-) create mode 100644 docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md rename src/components/{graph => base}/AnimatedEdge.tsx (90%) rename src/components/{graph => base}/BaseHandle.tsx (100%) rename src/components/{graph => base}/BaseNode.tsx (100%) rename src/components/{graph => base}/NodeFooterEdgeIndicators.tsx (80%) rename src/components/{graph => base}/NodeHandles.tsx (100%) rename src/components/{graph => base}/NodeHeaderTitle.tsx (100%) rename src/components/{graph => base}/NodeHelpPopover.tsx (93%) rename src/components/{graph => base}/NodeMenubar.tsx (96%) rename src/components/{graph => base}/NodeStatusIndicator.tsx (100%) rename src/components/{graph => nodes}/ConfigNode.tsx (91%) rename src/components/{graph => nodes}/FunctionNode.tsx (96%) rename src/components/{graph => nodes}/RenderingNode.tsx (80%) rename src/components/{graph => nodes}/VariableNode.tsx (95%) create mode 100644 src/lib/nodeRegistry.ts create mode 100644 src/lib/registerBuiltinNodes.tsx diff --git a/docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md b/docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md new file mode 100644 index 0000000..b2d3a65 --- /dev/null +++ b/docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md @@ -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//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. diff --git a/src/App.tsx b/src/App.tsx index d0fa0e0..b8a9b1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,11 +15,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 +31,14 @@ import { ContextMenuGroup } from "@/components/ui/context-menu" 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 { + getRegisteredNodeTypes, + getRegisteredNodeTypeIds, + getDefaultStyle, + isConnectionAllowed, +} from './lib/nodeRegistry' const SNAP_GRID: [number, number] = [15, 15] const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ @@ -125,7 +127,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 +155,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] ) @@ -294,22 +297,17 @@ export default function App() { (type: string) => { const position = getMenuPosition() if (position == null) return - const typeMap: Record = { - 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 +317,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 +324,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) }) @@ -387,75 +385,67 @@ export default function App() { />
- - - -
- - - - - - - -
-
+ + + +
+ + + + + + + +
+
- - - - Create Node - - - createNode('config')}> - - Config - - createNode('render')}> - - Renderer - - - createNode('variable')}> - - Variable - - createNode('function')}> - - Function - - - - - - pasteNode()}> - - Paste - - - -
-
+ + + + Create Node + + + {getRegisteredNodeTypes().map((desc, index) => ( + + {index === 2 ? : null} + createNode(desc.id)}> + {desc.menuIcon} + {desc.menuLabel} + + + ))} + + + + + pasteNode()}> + + Paste + + + +
+
diff --git a/src/components/graph/AnimatedEdge.tsx b/src/components/base/AnimatedEdge.tsx similarity index 90% rename from src/components/graph/AnimatedEdge.tsx rename to src/components/base/AnimatedEdge.tsx index 39b0ba6..9fa9cee 100644 --- a/src/components/graph/AnimatedEdge.tsx +++ b/src/components/base/AnimatedEdge.tsx @@ -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 diff --git a/src/components/graph/BaseHandle.tsx b/src/components/base/BaseHandle.tsx similarity index 100% rename from src/components/graph/BaseHandle.tsx rename to src/components/base/BaseHandle.tsx diff --git a/src/components/graph/BaseNode.tsx b/src/components/base/BaseNode.tsx similarity index 100% rename from src/components/graph/BaseNode.tsx rename to src/components/base/BaseNode.tsx diff --git a/src/components/graph/NodeFooterEdgeIndicators.tsx b/src/components/base/NodeFooterEdgeIndicators.tsx similarity index 80% rename from src/components/graph/NodeFooterEdgeIndicators.tsx rename to src/components/base/NodeFooterEdgeIndicators.tsx index 039603a..efd4194 100644 --- a/src/components/graph/NodeFooterEdgeIndicators.tsx +++ b/src/components/base/NodeFooterEdgeIndicators.tsx @@ -2,23 +2,11 @@ import React, { useContext, useMemo } from 'react' import FlowContext from '../../lib/flowContext' import { ArrowDownLeft, ArrowUpRight } from 'lucide-react' import { NodeHelpPopover } from './NodeHelpPopover' - -const NODE_HAS_INPUT: Record = { - config: true, - function: true, - render: true, - variable: false, -} -const NODE_HAS_OUTPUT: Record = { - config: true, - function: true, - render: true, - variable: true, -} +import { getNodeType } from '../../lib/nodeRegistry' type Props = { nodeId: string - nodeType: 'config' | 'function' | 'render' | 'variable' + nodeType: string children?: React.ReactNode } @@ -36,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 (
diff --git a/src/components/graph/NodeHandles.tsx b/src/components/base/NodeHandles.tsx similarity index 100% rename from src/components/graph/NodeHandles.tsx rename to src/components/base/NodeHandles.tsx diff --git a/src/components/graph/NodeHeaderTitle.tsx b/src/components/base/NodeHeaderTitle.tsx similarity index 100% rename from src/components/graph/NodeHeaderTitle.tsx rename to src/components/base/NodeHeaderTitle.tsx diff --git a/src/components/graph/NodeHelpPopover.tsx b/src/components/base/NodeHelpPopover.tsx similarity index 93% rename from src/components/graph/NodeHelpPopover.tsx rename to src/components/base/NodeHelpPopover.tsx index be49ceb..a4381fc 100644 --- a/src/components/graph/NodeHelpPopover.tsx +++ b/src/components/base/NodeHelpPopover.tsx @@ -1,11 +1,11 @@ import React from 'react' import { HelpCircle } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' -import { getNodeHelp, type NodeType } from '../../lib/nodeHelp' +import { getNodeHelp } from '../../lib/nodeRegistry' import { cn } from '../../lib/utils' type Props = { - nodeType: NodeType + nodeType: string className?: string } diff --git a/src/components/graph/NodeMenubar.tsx b/src/components/base/NodeMenubar.tsx similarity index 96% rename from src/components/graph/NodeMenubar.tsx rename to src/components/base/NodeMenubar.tsx index 70a586f..1421d34 100644 --- a/src/components/graph/NodeMenubar.tsx +++ b/src/components/base/NodeMenubar.tsx @@ -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) diff --git a/src/components/graph/NodeStatusIndicator.tsx b/src/components/base/NodeStatusIndicator.tsx similarity index 100% rename from src/components/graph/NodeStatusIndicator.tsx rename to src/components/base/NodeStatusIndicator.tsx diff --git a/src/components/graph/ConfigNode.tsx b/src/components/nodes/ConfigNode.tsx similarity index 91% rename from src/components/graph/ConfigNode.tsx rename to src/components/nodes/ConfigNode.tsx index 9a2cf38..4ecc552 100644 --- a/src/components/graph/ConfigNode.tsx +++ b/src/components/nodes/ConfigNode.tsx @@ -22,7 +22,7 @@ import { BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, -} from './BaseNode' +} from '../base/BaseNode' import { Code2, ScrollText, Variable } from 'lucide-react' import { MenubarItem, @@ -34,10 +34,10 @@ 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 @@ -94,13 +94,13 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: nds.map((n) => n.id === id ? { - ...n, - data: { - ...n.data, - configType: newTypeId, - content: getConfigContent(n.data) ?? '', - }, - } + ...n, + data: { + ...n.data, + configType: newTypeId, + content: getConfigContent(n.data) ?? '', + }, + } : n ) ) @@ -184,8 +184,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 +205,25 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: const typeItems = typeBlocks.flatMap((block) => isGroup(block) ? block.items.map(({ label, snippet }) => ( - insertAt(snippet, 'cursor')} - > - {label} - {insertShortcut} - - )) + insertAt(snippet, 'cursor')} + > + {label} + {insertShortcut} + + )) : [ - insertAt(block.snippet, 'cursor')} - > - {block.label} - {insertShortcut} - , - ] + insertAt(block.snippet, 'cursor')} + > + {block.label} + {insertShortcut} + , + ] ) const templatingItems = templatingGroup?.items.map(({ label, snippet }) => ( diff --git a/src/components/graph/FunctionNode.tsx b/src/components/nodes/FunctionNode.tsx similarity index 96% rename from src/components/graph/FunctionNode.tsx rename to src/components/nodes/FunctionNode.tsx index fd6ea8f..7b31950 100644 --- a/src/components/graph/FunctionNode.tsx +++ b/src/components/nodes/FunctionNode.tsx @@ -10,11 +10,11 @@ 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' diff --git a/src/components/graph/RenderingNode.tsx b/src/components/nodes/RenderingNode.tsx similarity index 80% rename from src/components/graph/RenderingNode.tsx rename to src/components/nodes/RenderingNode.tsx index 3371a4b..f3f7c69 100644 --- a/src/components/graph/RenderingNode.tsx +++ b/src/components/nodes/RenderingNode.tsx @@ -8,15 +8,16 @@ 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, nodePropsAreEqual } 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 { InputHandle } from '../base/NodeHandles' type Props = { id: string @@ -511,7 +512,7 @@ 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]) @@ -521,101 +522,101 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: return ( - }> - } title={} /> + }> + } title={} /> - -
- - - - Export - - - - PNG - - - SVG - - - - } - /> -
-
- {incomingIds.length === 0 ? ( - - - - - - No configuration connected - Connect a Configuration node or create one. The renderer will display the diagram or document. - - - - - - ) : error ? ( - srcData?.renderError ? ( - srcData.renderError(error) - ) : srcData?.errorHtml ? ( -
- ) : ( -
{error.message}
- ) - ) : loading ? ( -
Rendering…
- ) : renderedContent ? ( - isWireframeOutput ? ( -
-
-
+ +
+ + + + Export + + + + PNG + + + SVG + + + + } + /> +
+
+ {incomingIds.length === 0 ? ( + + + + + + No configuration connected + Connect a Configuration node or create one. The renderer will display the diagram or document. + + + + + + ) : error ? ( + srcData?.renderError ? ( + srcData.renderError(error) + ) : srcData?.errorHtml ? ( +
+ ) : ( +
{error.message}
+ ) + ) : loading ? ( +
Rendering…
+ ) : renderedContent ? ( + isWireframeOutput ? ( +
+
+
+
-
- ) : ( -
- ) - ) : null} -
- + ) : ( +
+ ) + ) : null} +
+ - - - {renderedContent - ? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars` - : error - ? 'Error' - : '—'} - - - + + + {renderedContent + ? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars` + : error + ? 'Error' + : '—'} + + + ) }, nodePropsAreEqual) diff --git a/src/components/graph/VariableNode.tsx b/src/components/nodes/VariableNode.tsx similarity index 95% rename from src/components/graph/VariableNode.tsx rename to src/components/nodes/VariableNode.tsx index ee02d0d..91e3ef4 100644 --- a/src/components/graph/VariableNode.tsx +++ b/src/components/nodes/VariableNode.tsx @@ -6,14 +6,14 @@ import { 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' diff --git a/src/lib/flowUtils.ts b/src/lib/flowUtils.ts index 936f704..60bc48d 100644 --- a/src/lib/flowUtils.ts +++ b/src/lib/flowUtils.ts @@ -3,6 +3,14 @@ * Skips re-render when only position (or other unrelated props) changed, * 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

( prev: P, next: P @@ -16,6 +24,7 @@ export function nodePropsAreEqual

= { config: 'cfg_', render: 'rnd_', @@ -23,9 +32,9 @@ export const PREFIX_BY_TYPE: Record = { 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) { @@ -64,6 +73,7 @@ export function replaceNodeIdInGraph( return { nodes: newNodes, edges: newEdges } } +/** @deprecated Use getDefaultStyle from nodeRegistry. Kept for compatibility. */ export const DEFAULT_NODE_STYLE: Record = { config: { width: 320, height: 320 }, render: { width: 384, height: 320 }, @@ -71,30 +81,17 @@ export const DEFAULT_NODE_STYLE: Record = { - 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): any { - const base = { ...DEFAULT_DATA[type] } - if (type === 'config' && newId) base.title = `${newId}` - return base + return getDefaultDataFromRegistry(type, newId) } -/** 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 { - if (type === 'config') { - return { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: nodeId ? `${nodeId}` : '' } - } - if (type === 'function') { - return { body: '' } - } - return getDefaultDataForType(type, nodeId) + return getResetDataFromRegistry(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) } diff --git a/src/lib/nodeRegistry.ts b/src/lib/nodeRegistry.ts new file mode 100644 index 0000000..cee556c --- /dev/null +++ b/src/lib/nodeRegistry.ts @@ -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 + defaultStyle: { width: number; height: number } + defaultData: Record + 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 + /** Optional: data for Reset action; if omitted, getDefaultData(nodeId) or defaultData is used. */ + getResetData?: (nodeId?: string) => Record + /** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */ + connectionLabel?: string +} + +const registry = new Map() + +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 { + 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 { + 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 } +} diff --git a/src/lib/registerBuiltinNodes.tsx b/src/lib/registerBuiltinNodes.tsx new file mode 100644 index 0000000..2ac7c16 --- /dev/null +++ b/src/lib/registerBuiltinNodes.tsx @@ -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: , + 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: , + 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: , + }) + + 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: , + getResetData: () => ({ body: '' }), + connectionLabel: 'add input', + }) +} diff --git a/src/main.tsx b/src/main.tsx index adac58b..28ae7c7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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(