From 9565a425b68d64f886f1f3326a87014ff2fc059c Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 9 Mar 2026 14:11:33 +0100 Subject: [PATCH] refactor node --- src/components/nodes/ConfigNode.tsx | 86 ++++++---------- src/components/nodes/FunctionNode.tsx | 57 +++++------ src/components/nodes/RenderingNode.tsx | 36 +++---- src/components/nodes/VariableNode.tsx | 50 ++++----- src/lib/abstractNode.ts | 135 +++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 134 deletions(-) create mode 100644 src/lib/abstractNode.ts diff --git a/src/components/nodes/ConfigNode.tsx b/src/components/nodes/ConfigNode.tsx index 4ecc552..6a90066 100644 --- a/src/components/nodes/ConfigNode.tsx +++ b/src/components/nodes/ConfigNode.tsx @@ -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 + +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(id, data ?? {}) const editorRef = useRef(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 }: ) -}, nodePropsAreEqual) +} -ConfigNode.displayName = 'ConfigNode' +export const ConfigNode = createAbstractNodeComponent( + 'ConfigNode', + ConfigNodeComponent +) export default ConfigNode diff --git a/src/components/nodes/FunctionNode.tsx b/src/components/nodes/FunctionNode.tsx index 7b31950..82c2599 100644 --- a/src/components/nodes/FunctionNode.tsx +++ b/src/components/nodes/FunctionNode.tsx @@ -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 + +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(id, data ?? {}) const editorRef = useRef(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 ) -}, nodePropsAreEqual) +} -FunctionNode.displayName = 'FunctionNode' +export const FunctionNode = createAbstractNodeComponent( + 'FunctionNode', + FunctionNodeComponent +) export default FunctionNode diff --git a/src/components/nodes/RenderingNode.tsx b/src/components/nodes/RenderingNode.tsx index f3f7c69..e2925e2 100644 --- a/src/components/nodes/RenderingNode.tsx +++ b/src/components/nodes/RenderingNode.tsx @@ -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 -export const RenderingNode = memo(function RenderingNode({ id, width, height }: Props) { +type Props = AbstractNodeProps + +function RenderingNodeComponent({ id, width, height }: Props) { const [renderedContent, setRenderedContent] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | 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(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 }: ) -}, nodePropsAreEqual) +} -RenderingNode.displayName = 'RenderingNode' +export const RenderingNode = createAbstractNodeComponent( + 'RenderingNode', + RenderingNodeComponent +) export default RenderingNode diff --git a/src/components/nodes/VariableNode.tsx b/src/components/nodes/VariableNode.tsx index 91e3ef4..51fc13c 100644 --- a/src/components/nodes/VariableNode.tsx +++ b/src/components/nodes/VariableNode.tsx @@ -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 + const DEFAULT_BY_TYPE: Record = { 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(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) { ) -}, nodePropsAreEqual) +} -VariableNode.displayName = 'VariableNode' +export const VariableNode = createAbstractNodeComponent( + 'VariableNode', + VariableNodeComponent +) export default VariableNode diff --git a/src/lib/abstractNode.ts b/src/lib/abstractNode.ts new file mode 100644 index 0000000..de49f55 --- /dev/null +++ b/src/lib/abstractNode.ts @@ -0,0 +1,135 @@ +/** + * Abstract node layer: shared types, hook, and factory for flow node components. + * + * - **AbstractNodeProps** — 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, 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> = { + 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> = { + 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) => 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>( + id: string, + data: TData +): AbstractNodeContext { + 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) => { + 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 for typed props. + */ +export function createAbstractNodeComponent>( + displayName: string, + Component: React.ComponentType> +): React.MemoExoticComponent>> { + const Wrapped = React.memo(Component, nodePropsAreEqual) as React.MemoExoticComponent< + React.ComponentType> + > + Wrapped.displayName = displayName + return Wrapped +}