refactor nodes
This commit is contained in:
@@ -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<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
||||
prev: 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> = {
|
||||
config: 'cfg_',
|
||||
render: 'rnd_',
|
||||
@@ -23,9 +32,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) {
|
||||
@@ -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<string, { width: number; height: number }> = {
|
||||
config: { width: 320, 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 },
|
||||
}
|
||||
|
||||
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): 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)
|
||||
}
|
||||
|
||||
116
src/lib/nodeRegistry.ts
Normal file
116
src/lib/nodeRegistry.ts
Normal 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 }
|
||||
}
|
||||
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: {},
|
||||
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',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user