refactor node
This commit is contained in:
@@ -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 { autocompletion } from '@codemirror/autocomplete'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
import { javascript } from '@codemirror/lang-javascript'
|
import { javascript } from '@codemirror/lang-javascript'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import {
|
||||||
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
AbstractNodeProps,
|
||||||
|
createAbstractNodeComponent,
|
||||||
|
useAbstractNode,
|
||||||
|
type FlowNode,
|
||||||
|
} from '../../lib/abstractNode'
|
||||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||||
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
||||||
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
||||||
@@ -39,73 +43,46 @@ import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
|||||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||||
import { NodeMenubar } from '../base/NodeMenubar'
|
import { NodeMenubar } from '../base/NodeMenubar'
|
||||||
|
|
||||||
type Props = {
|
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string }
|
||||||
id: string
|
|
||||||
data: any
|
|
||||||
width?: number
|
|
||||||
height?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
|
type Props = AbstractNodeProps<ConfigNodeData>
|
||||||
const configTypeId = getConfigTypeId(data)
|
|
||||||
|
function ConfigNodeComponent({ id, data, width, height }: Props) {
|
||||||
|
const configTypeId = getConfigTypeId(data ?? {})
|
||||||
const configType = getConfigType(configTypeId)
|
const configType = getConfigType(configTypeId)
|
||||||
const content = getConfigContent(data)
|
const content = getConfigContent(data ?? {})
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const ctx = useContext(FlowContext)
|
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||||
const setNodes = ctx?.setNodes
|
|
||||||
|
|
||||||
const editorRef = useRef<unknown>(null)
|
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(
|
const connectedConfigNodes = useMemo(
|
||||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config'),
|
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
|
||||||
[nodes, incomingIds]
|
[nodes, sourceIds]
|
||||||
)
|
)
|
||||||
const connectedVariableNodes = useMemo(
|
const connectedVariableNodes = useMemo(
|
||||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
|
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||||
[nodes, incomingIds]
|
[nodes, sourceIds]
|
||||||
)
|
)
|
||||||
const connectedFunctionNodes = useMemo(
|
const connectedFunctionNodes = useMemo(
|
||||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
|
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||||
[nodes, incomingIds]
|
[nodes, sourceIds]
|
||||||
)
|
)
|
||||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(val: string) => {
|
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||||
if (setNodes) {
|
[updateData, configTypeId]
|
||||||
setNodes((nds: any[]) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, content: val, configType: configTypeId } } : n
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[id, setNodes, configTypeId]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const setConfigType = useCallback(
|
const setConfigType = useCallback(
|
||||||
(newTypeId: ConfigTypeId) => {
|
(newTypeId: ConfigTypeId) => {
|
||||||
if (!setNodes || newTypeId === configTypeId) return
|
if (newTypeId === configTypeId) return
|
||||||
setNodes((nds: any[]) =>
|
updateData({
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
configType: newTypeId,
|
configType: newTypeId,
|
||||||
content: getConfigContent(n.data) ?? '',
|
content: getConfigContent({ ...data, configType: newTypeId }) ?? '',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
[configTypeId, data, updateData]
|
||||||
: n
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[id, setNodes, configTypeId]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertAt = useCallback(
|
const insertAt = useCallback(
|
||||||
@@ -369,8 +346,11 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
}, nodePropsAreEqual)
|
}
|
||||||
|
|
||||||
ConfigNode.displayName = 'ConfigNode'
|
export const ConfigNode = createAbstractNodeComponent<ConfigNodeData>(
|
||||||
|
'ConfigNode',
|
||||||
|
ConfigNodeComponent
|
||||||
|
)
|
||||||
|
|
||||||
export default ConfigNode
|
export default ConfigNode
|
||||||
|
|||||||
@@ -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 CodeMirror from '@uiw/react-codemirror'
|
||||||
import { javascript } from '@codemirror/lang-javascript'
|
import { javascript } from '@codemirror/lang-javascript'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import {
|
||||||
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
AbstractNodeProps,
|
||||||
|
createAbstractNodeComponent,
|
||||||
|
useAbstractNode,
|
||||||
|
type FlowNode,
|
||||||
|
} from '../../lib/abstractNode'
|
||||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||||
import { useTheme } from '../../lib/themeContext'
|
import { useTheme } from '../../lib/themeContext'
|
||||||
import {
|
import {
|
||||||
@@ -19,45 +23,29 @@ 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'
|
||||||
|
|
||||||
type Props = {
|
export type FunctionNodeData = { body?: string }
|
||||||
id: string
|
|
||||||
data: { body?: string }
|
|
||||||
width?: number
|
|
||||||
height?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
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 bodyValue = data?.body ?? ''
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const ctx = useContext(FlowContext)
|
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||||
const setNodes = ctx?.setNodes
|
|
||||||
const editorRef = useRef<unknown>(null)
|
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(
|
const connectedVariableNodes = useMemo(
|
||||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
|
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||||
[nodes, incomingIds]
|
[nodes, sourceIds]
|
||||||
)
|
)
|
||||||
const connectedFunctionNodes = useMemo(
|
const connectedFunctionNodes = useMemo(
|
||||||
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
|
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||||
[nodes, incomingIds]
|
[nodes, sourceIds]
|
||||||
)
|
)
|
||||||
const hasConnectedVariables = connectedVariableNodes.length > 0
|
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||||
const hasConnectedFunctions = connectedFunctionNodes.length > 0
|
|
||||||
const hasConnectedInputs = hasConnectedVariables || hasConnectedFunctions
|
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(val: string) => {
|
(val: string) => updateData({ body: val }),
|
||||||
if (setNodes) {
|
[updateData]
|
||||||
setNodes((nds: any[]) =>
|
|
||||||
nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, body: val } } : n))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[id, setNodes]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertAt = useCallback(
|
const insertAt = useCallback(
|
||||||
@@ -165,8 +153,11 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
|
|||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
}, nodePropsAreEqual)
|
}
|
||||||
|
|
||||||
FunctionNode.displayName = 'FunctionNode'
|
export const FunctionNode = createAbstractNodeComponent<FunctionNodeData>(
|
||||||
|
'FunctionNode',
|
||||||
|
FunctionNodeComponent
|
||||||
|
)
|
||||||
|
|
||||||
export default FunctionNode
|
export default FunctionNode
|
||||||
|
|||||||
@@ -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 nunjucks from 'nunjucks'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import {
|
||||||
|
AbstractNodeProps,
|
||||||
|
createAbstractNodeComponent,
|
||||||
|
useAbstractNode,
|
||||||
|
} from '../../lib/abstractNode'
|
||||||
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
|
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
|
||||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +13,7 @@ import {
|
|||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '../base/BaseNode'
|
} from '../base/BaseNode'
|
||||||
import { getDefaultDataForType, getNextNodeId, nodePropsAreEqual } from '../../lib/flowUtils'
|
import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
|
||||||
import { getDefaultStyle } from '../../lib/nodeRegistry'
|
import { getDefaultStyle } from '../../lib/nodeRegistry'
|
||||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||||
@@ -19,27 +23,20 @@ import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSu
|
|||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
import { InputHandle } from '../base/NodeHandles'
|
import { InputHandle } from '../base/NodeHandles'
|
||||||
|
|
||||||
type Props = {
|
export type RenderingNodeData = Record<string, unknown>
|
||||||
id: string
|
|
||||||
data?: any
|
|
||||||
style?: React.CSSProperties
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [renderedContent, setRenderedContent] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const runIdRef = useRef(0)
|
const runIdRef = useRef(0)
|
||||||
const loadingStartedAtRef = useRef<number | null>(null)
|
const loadingStartedAtRef = useRef<number | null>(null)
|
||||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const ctx = useContext(FlowContext)
|
const { nodes, edges, setNodes, setEdges, sourceIds } = useAbstractNode<RenderingNodeData>(id, {})
|
||||||
const nodes = ctx?.nodes ?? []
|
|
||||||
const edges = ctx?.edges ?? []
|
|
||||||
const setNodes = ctx?.setNodes
|
|
||||||
const setEdges = ctx?.setEdges
|
|
||||||
|
|
||||||
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
|
const incomingIds = sourceIds
|
||||||
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
|
|
||||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||||
const srcNode = nodes.find((n: any) => n.id === srcId)
|
const srcNode = nodes.find((n: any) => n.id === srcId)
|
||||||
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
|
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
|
||||||
@@ -619,8 +616,11 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
</BaseNode>
|
</BaseNode>
|
||||||
</NodeStatusIndicator>
|
</NodeStatusIndicator>
|
||||||
)
|
)
|
||||||
}, nodePropsAreEqual)
|
}
|
||||||
|
|
||||||
RenderingNode.displayName = 'RenderingNode'
|
export const RenderingNode = createAbstractNodeComponent<RenderingNodeData>(
|
||||||
|
'RenderingNode',
|
||||||
|
RenderingNodeComponent
|
||||||
|
)
|
||||||
|
|
||||||
export default RenderingNode
|
export default RenderingNode
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { memo, useCallback, useContext } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import {
|
||||||
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
AbstractNodeProps,
|
||||||
|
createAbstractNodeComponent,
|
||||||
|
useAbstractNode,
|
||||||
|
} from '../../lib/abstractNode'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
@@ -16,15 +19,14 @@ import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
|||||||
import { OutputHandle } from '../base/NodeHandles'
|
import { OutputHandle } from '../base/NodeHandles'
|
||||||
import { Variable } from 'lucide-react'
|
import { Variable } from 'lucide-react'
|
||||||
|
|
||||||
type ValueType = 'string' | 'number' | 'boolean'
|
export type ValueType = 'string' | 'number' | 'boolean'
|
||||||
|
|
||||||
type Props = {
|
export type VariableNodeData = {
|
||||||
id: string
|
|
||||||
data: {
|
|
||||||
value?: string | number | boolean
|
value?: string | number | boolean
|
||||||
valueType?: ValueType
|
valueType?: ValueType
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
type Props = AbstractNodeProps<VariableNodeData>
|
||||||
|
|
||||||
const DEFAULT_BY_TYPE: Record<ValueType, string | number | boolean> = {
|
const DEFAULT_BY_TYPE: Record<ValueType, string | number | boolean> = {
|
||||||
string: '',
|
string: '',
|
||||||
@@ -45,26 +47,13 @@ function coerceValue(raw: string, valueType: ValueType): string | number | boole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VariableNode = memo(function VariableNode({ id, data }: Props) {
|
function VariableNodeComponent({ id, data }: Props) {
|
||||||
const ctx = useContext(FlowContext)
|
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
|
||||||
const setNodes = ctx?.setNodes
|
|
||||||
|
|
||||||
const valueType: ValueType = data?.valueType ?? 'string'
|
const valueType: ValueType = data?.valueType ?? 'string'
|
||||||
const value = data?.value ?? DEFAULT_BY_TYPE[valueType]
|
const value = data?.value ?? DEFAULT_BY_TYPE[valueType]
|
||||||
const displayValue = typeof value === 'string' ? value : String(value)
|
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(
|
const onTypeChange = useCallback(
|
||||||
(nextType: string) => {
|
(nextType: string) => {
|
||||||
const type = nextType as ValueType
|
const type = nextType as ValueType
|
||||||
@@ -85,9 +74,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onBooleanChange = useCallback(
|
const onBooleanChange = useCallback(
|
||||||
(checked: boolean) => {
|
(checked: boolean) => updateData({ value: checked }),
|
||||||
updateData({ value: checked })
|
|
||||||
},
|
|
||||||
[updateData]
|
[updateData]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,8 +128,11 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
|
|||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
}, nodePropsAreEqual)
|
}
|
||||||
|
|
||||||
VariableNode.displayName = 'VariableNode'
|
export const VariableNode = createAbstractNodeComponent<VariableNodeData>(
|
||||||
|
'VariableNode',
|
||||||
|
VariableNodeComponent
|
||||||
|
)
|
||||||
|
|
||||||
export default VariableNode
|
export default VariableNode
|
||||||
|
|||||||
135
src/lib/abstractNode.ts
Normal file
135
src/lib/abstractNode.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user