refactor node

This commit is contained in:
2026-03-09 14:11:33 +01:00
parent 24efc597b2
commit 9565a425b6
5 changed files with 230 additions and 134 deletions

View File

@@ -1,10 +1,14 @@
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
type FlowNode,
} from '../../lib/abstractNode'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
import { plantumlLanguage } from '../../lib/plantumlLanguage'
@@ -39,73 +43,46 @@ import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from '../base/NodeMenubar'
type Props = {
id: string
data: any
width?: number
height?: number
}
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string }
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
const configTypeId = getConfigTypeId(data)
type Props = AbstractNodeProps<ConfigNodeData>
function ConfigNodeComponent({ id, data, width, height }: Props) {
const configTypeId = getConfigTypeId(data ?? {})
const configType = getConfigType(configTypeId)
const content = getConfigContent(data)
const content = getConfigContent(data ?? {})
const { theme } = useTheme()
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
const editorRef = useRef<unknown>(null)
const edges = ctx?.edges ?? []
const nodes = ctx?.nodes ?? []
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
const connectedConfigNodes = useMemo(
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config'),
[nodes, incomingIds]
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
[nodes, sourceIds]
)
const connectedVariableNodes = useMemo(
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
[nodes, incomingIds]
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
[nodes, incomingIds]
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
[nodes, sourceIds]
)
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
const onChange = useCallback(
(val: string) => {
if (setNodes) {
setNodes((nds: any[]) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, content: val, configType: configTypeId } } : n
)
)
}
},
[id, setNodes, configTypeId]
(val: string) => updateData({ content: val, configType: configTypeId }),
[updateData, configTypeId]
)
const setConfigType = useCallback(
(newTypeId: ConfigTypeId) => {
if (!setNodes || newTypeId === configTypeId) return
setNodes((nds: any[]) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
configType: newTypeId,
content: getConfigContent(n.data) ?? '',
},
}
: n
)
)
if (newTypeId === configTypeId) return
updateData({
configType: newTypeId,
content: getConfigContent({ ...data, configType: newTypeId }) ?? '',
})
},
[id, setNodes, configTypeId]
[configTypeId, data, updateData]
)
const insertAt = useCallback(
@@ -369,8 +346,11 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</BaseNodeFooter>
</BaseNode>
)
}, nodePropsAreEqual)
}
ConfigNode.displayName = 'ConfigNode'
export const ConfigNode = createAbstractNodeComponent<ConfigNodeData>(
'ConfigNode',
ConfigNodeComponent
)
export default ConfigNode

View File

@@ -1,8 +1,12 @@
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
type FlowNode,
} from '../../lib/abstractNode'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { useTheme } from '../../lib/themeContext'
import {
@@ -19,45 +23,29 @@ import { MenubarItem, MenubarShortcut } from '../ui/menubar'
import { Kbd } from '../ui/kbd'
import { Code2, Variable } from 'lucide-react'
type Props = {
id: string
data: { body?: string }
width?: number
height?: number
}
export type FunctionNodeData = { body?: string }
export const FunctionNode = memo(function FunctionNode({ id, data, width, height }: Props) {
type Props = AbstractNodeProps<FunctionNodeData>
function FunctionNodeComponent({ id, data, width, height }: Props) {
const bodyValue = data?.body ?? ''
const { theme } = useTheme()
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
const editorRef = useRef<unknown>(null)
const edges = ctx?.edges ?? []
const nodes = ctx?.nodes ?? []
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
const connectedVariableNodes = useMemo(
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
[nodes, incomingIds]
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
[nodes, incomingIds]
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
[nodes, sourceIds]
)
const hasConnectedVariables = connectedVariableNodes.length > 0
const hasConnectedFunctions = connectedFunctionNodes.length > 0
const hasConnectedInputs = hasConnectedVariables || hasConnectedFunctions
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
const onChange = useCallback(
(val: string) => {
if (setNodes) {
setNodes((nds: any[]) =>
nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, body: val } } : n))
)
}
},
[id, setNodes]
(val: string) => updateData({ body: val }),
[updateData]
)
const insertAt = useCallback(
@@ -165,8 +153,11 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
</BaseNodeFooter>
</BaseNode>
)
}, nodePropsAreEqual)
}
FunctionNode.displayName = 'FunctionNode'
export const FunctionNode = createAbstractNodeComponent<FunctionNodeData>(
'FunctionNode',
FunctionNodeComponent
)
export default FunctionNode

View File

@@ -1,6 +1,10 @@
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks'
import FlowContext from '../../lib/flowContext'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '../../lib/abstractNode'
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
import {
@@ -9,7 +13,7 @@ import {
BaseNodeFooter,
BaseNodeHeaderRow,
} from '../base/BaseNode'
import { getDefaultDataForType, getNextNodeId, nodePropsAreEqual } from '../../lib/flowUtils'
import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
import { getDefaultStyle } from '../../lib/nodeRegistry'
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
@@ -19,27 +23,20 @@ import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSu
import { Sparkles } from 'lucide-react'
import { InputHandle } from '../base/NodeHandles'
type Props = {
id: string
data?: any
style?: React.CSSProperties
}
export type RenderingNodeData = Record<string, unknown>
export const RenderingNode = memo(function RenderingNode({ id, width, height }: Props) {
type Props = AbstractNodeProps<RenderingNodeData>
function RenderingNodeComponent({ id, width, height }: Props) {
const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false)
const runIdRef = useRef(0)
const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const edges = ctx?.edges ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const { nodes, edges, setNodes, setEdges, sourceIds } = useAbstractNode<RenderingNodeData>(id, {})
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
const incomingIds = sourceIds
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
const srcNode = nodes.find((n: any) => n.id === srcId)
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
@@ -619,8 +616,11 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
</BaseNode>
</NodeStatusIndicator>
)
}, nodePropsAreEqual)
}
RenderingNode.displayName = 'RenderingNode'
export const RenderingNode = createAbstractNodeComponent<RenderingNodeData>(
'RenderingNode',
RenderingNodeComponent
)
export default RenderingNode

View File

@@ -1,6 +1,9 @@
import React, { memo, useCallback, useContext } from 'react'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import React, { useCallback } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '../../lib/abstractNode'
import {
BaseNode,
BaseNodeContent,
@@ -16,16 +19,15 @@ import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { OutputHandle } from '../base/NodeHandles'
import { Variable } from 'lucide-react'
type ValueType = 'string' | 'number' | 'boolean'
export type ValueType = 'string' | 'number' | 'boolean'
type Props = {
id: string
data: {
value?: string | number | boolean
valueType?: ValueType
}
export type VariableNodeData = {
value?: string | number | boolean
valueType?: ValueType
}
type Props = AbstractNodeProps<VariableNodeData>
const DEFAULT_BY_TYPE: Record<ValueType, string | number | boolean> = {
string: '',
number: 0,
@@ -45,26 +47,13 @@ function coerceValue(raw: string, valueType: ValueType): string | number | boole
}
}
export const VariableNode = memo(function VariableNode({ id, data }: Props) {
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
function VariableNodeComponent({ id, data }: Props) {
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
const valueType: ValueType = data?.valueType ?? 'string'
const value = data?.value ?? DEFAULT_BY_TYPE[valueType]
const displayValue = typeof value === 'string' ? value : String(value)
const updateData = useCallback(
(updates: { value?: string | number | boolean; valueType?: ValueType }) => {
if (!setNodes) return
setNodes((nds: any[]) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, ...updates } } : n
)
)
},
[id, setNodes]
)
const onTypeChange = useCallback(
(nextType: string) => {
const type = nextType as ValueType
@@ -85,9 +74,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
)
const onBooleanChange = useCallback(
(checked: boolean) => {
updateData({ value: checked })
},
(checked: boolean) => updateData({ value: checked }),
[updateData]
)
@@ -141,8 +128,11 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
</BaseNodeFooter>
</BaseNode>
)
}, nodePropsAreEqual)
}
VariableNode.displayName = 'VariableNode'
export const VariableNode = createAbstractNodeComponent<VariableNodeData>(
'VariableNode',
VariableNodeComponent
)
export default VariableNode

135
src/lib/abstractNode.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Abstract node layer: shared types, hook, and factory for flow node components.
*
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds.
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
*
* Example: define NodeData type, Props = AbstractNodeProps<NodeData>, use useAbstractNode in the
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
*/
import React, { useCallback, useContext, useMemo } from 'react'
import FlowContext from './flowContext'
import { nodePropsAreEqual } from './flowUtils'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props passed by React Flow to custom node components. Extend data with your node's shape. */
export type AbstractNodeProps<TData = Record<string, unknown>> = {
id: string
data: TData
width?: number
height?: number
selected?: boolean
}
/** Edge shape used in flow context (minimal for connection logic). */
export type FlowEdge = { id: string; source: string; target: string; [k: string]: unknown }
/** Node shape used in flow context (minimal for reading graph). */
export type FlowNode = { id: string; type?: string; data?: unknown; position?: { x: number; y: number }; [k: string]: unknown }
/** Result of useAbstractNode: flow context plus helpers scoped to this node. */
export type AbstractNodeContext<TData = Record<string, unknown>> = {
id: string
data: TData
nodes: FlowNode[]
edges: FlowEdge[]
setNodes: (updater: (nodes: FlowNode[]) => FlowNode[]) => void
setEdges: (updater: (edges: FlowEdge[]) => FlowEdge[]) => void
/** Merge partial data into this node's data. Stable reference. */
updateData: (partial: Partial<TData>) => void
/** Incoming edge IDs (edges whose target is this node). */
incomingEdges: FlowEdge[]
/** Outgoing edge IDs (edges whose source is this node). */
outgoingEdges: FlowEdge[]
/** Source node IDs connected to this node (incoming). */
sourceIds: string[]
/** Target node IDs this node connects to (outgoing). */
targetIds: string[]
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Provides flow context and helpers for the current node. Use in any node component
* that receives id and data; updateData(partial) merges into this node's data.
*/
export function useAbstractNode<TData = Record<string, unknown>>(
id: string,
data: TData
): AbstractNodeContext<TData> {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const edges = ctx?.edges ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const updateData = useCallback(
(partial: Partial<TData>) => {
if (!setNodes) return
setNodes((nds: FlowNode[]) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
)
)
},
[id, setNodes]
)
const incomingEdges = useMemo(
() => (edges as FlowEdge[]).filter((e) => e.target === id),
[edges, id]
)
const outgoingEdges = useMemo(
() => (edges as FlowEdge[]).filter((e) => e.source === id),
[edges, id]
)
const sourceIds = useMemo(
() => incomingEdges.map((e) => e.source).sort(),
[incomingEdges]
)
const targetIds = useMemo(
() => outgoingEdges.map((e) => e.target).sort(),
[outgoingEdges]
)
return {
id,
data,
nodes,
edges,
setNodes: setNodes ?? (() => {}),
setEdges: setEdges ?? (() => {}),
updateData,
incomingEdges,
outgoingEdges,
sourceIds,
targetIds,
}
}
// ---------------------------------------------------------------------------
// Component factory
// ---------------------------------------------------------------------------
/**
* Wraps a node component with React.memo and nodePropsAreEqual so only id/data/width/height/selected
* changes trigger re-renders. Use with AbstractNodeProps<TData> for typed props.
*/
export function createAbstractNodeComponent<TData = Record<string, unknown>>(
displayName: string,
Component: React.ComponentType<AbstractNodeProps<TData>>
): React.MemoExoticComponent<React.ComponentType<AbstractNodeProps<TData>>> {
const Wrapped = React.memo(Component, nodePropsAreEqual) as React.MemoExoticComponent<
React.ComponentType<AbstractNodeProps<TData>>
>
Wrapped.displayName = displayName
return Wrapped
}