From 49a6e5b4a33bc962d6cff2c69087c74d0d4943fc Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 6 Mar 2026 23:03:42 +0100 Subject: [PATCH] basic function --- src/App.tsx | 6 +- src/components/graph/ConfigNode.tsx | 60 ++++++++++++- src/components/graph/FunctionNode.tsx | 111 +++++++++++++++++++++++++ src/components/graph/RenderingNode.tsx | 40 +++++++-- 4 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 src/components/graph/FunctionNode.tsx diff --git a/src/App.tsx b/src/App.tsx index 9504f15..6ef2695 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import ReactFlow, { EdgeChange, } from 'reactflow' 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 FlowContext from './lib/flowContext' @@ -56,7 +57,7 @@ export default function App() { const [contextTarget, setContextTarget] = React.useState(null) const nodeTypes = React.useMemo( - () => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode }), + () => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }), [] ) @@ -109,11 +110,13 @@ export default function App() { config: 'config', render: 'render', variable: 'variable', + function: 'function', } const dataMap: Record = { config: { yaml: '# Enter YAML here\n', title: `config-${id}` }, render: {}, variable: { value: '', valueType: 'string' }, + function: { body: '// args[0], args[1], ...\nreturn args[0];' }, } const newNode: Node = { id, @@ -168,6 +171,7 @@ export default function App() { createNode('config')}>Config createNode('render')}>Renderer createNode('variable')}>Variable + createNode('function')}>Function )} diff --git a/src/components/graph/ConfigNode.tsx b/src/components/graph/ConfigNode.tsx index 380a798..75804dc 100644 --- a/src/components/graph/ConfigNode.tsx +++ b/src/components/graph/ConfigNode.tsx @@ -45,7 +45,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { const incomingIds = incomingEdges.map((e: any) => e.source).sort() const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config') const connectedVariableNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable') - const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 + const connectedFunctionNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function') + const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 useEffect(() => { if (setNodes) { @@ -173,6 +174,48 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { [onChange, value] ) + const insertFunctionCall = useCallback( + (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { + const insertText = `\${${sourceNode.id}()}` // user can add variable ids inside: ${funcId(var1, var2)} + const editor = editorRef.current + const monaco = monacoRef.current + + try { + if (editor && monaco) { + const model = editor.getModel() + const lineCount = model.getLineCount() + const selection = editor.getSelection() + + let range + if (mode === 'prepend') { + range = new monaco.Range(1, 1, 1, 1) + } else if (mode === 'append') { + range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1) + } else if (selection) { + range = new monaco.Range(selection.startLineNumber, selection.startColumn, selection.startLineNumber, selection.startColumn) + } else { + range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1) + } + + editor.executeEdits('insert-fn', [{ range, text: insertText, forceMoveMarkers: true }]) + const newVal = editor.getModel().getValue() + onChange(newVal) + return + } + + if (mode === 'prepend') { + onChange(insertText + value) + } else { + onChange(value + insertText) + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Insert function call failed', err) + } + }, + [onChange, value] + ) + return ( @@ -231,6 +274,21 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { ))} + {connectedFunctionNodes.map((n: any) => ( + + + {n.id} + + + insertFunctionCall(n, 'cursor')} + > + Insert at cursor + + + + ))} diff --git a/src/components/graph/FunctionNode.tsx b/src/components/graph/FunctionNode.tsx new file mode 100644 index 0000000..6bbc2fd --- /dev/null +++ b/src/components/graph/FunctionNode.tsx @@ -0,0 +1,111 @@ +import React, { memo, useCallback, useContext, useEffect, useRef, useState } from 'react' +import Editor, { loader } from '@monaco-editor/react' +import FlowContext from '../../lib/flowContext' +import { + BaseNode, + BaseNodeContent, + BaseNodeFooter, + BaseNodeHeader, + BaseNodeHeaderTitle, +} from './BaseNode' +import { InputHandle, OutputHandle } from './NodeHandles' +import { Code2 } from 'lucide-react' + +loader.config({ paths: { vs: 'https://unpkg.com/monaco-editor@latest/min/vs' } }) + +type Props = { + id: string + data: { body?: string } +} + +const DEFAULT_BODY = `// args[0], args[1], ... are the variable values (in order) +return args[0]; +` + +export const FunctionNode = memo(function FunctionNode({ id, data }: Props) { + const [value, setValue] = useState(data?.body ?? DEFAULT_BODY) + const ctx = useContext(FlowContext) + const setNodes = ctx?.setNodes + const editorRef = useRef(null) + const monacoRef = useRef(null) + + useEffect(() => { + if (setNodes) { + setNodes((nds: any[]) => + nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, body: value } } : n)) + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const onChange = useCallback( + (val?: string) => { + const v = val ?? '' + setValue(v) + if (setNodes) { + setNodes((nds: any[]) => + nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, body: v } } : n)) + ) + } + }, + [id, setNodes] + ) + + const handleEditorMount = useCallback((editor: any, monaco: any) => { + editorRef.current = editor + monacoRef.current = monaco + try { + editor.onKeyDown((e: any) => e?.browserEvent?.stopPropagation()) + editor.onMouseDown((e: any) => e?.event?.stopPropagation()) + } catch {} + }, []) + + const storedBody = ctx?.nodes?.find((n: any) => n.id === id)?.data?.body ?? '' + + return ( + + + + {id} + + + +

+ Call in config: ${{id}(var1, var2)} +

+
+ +
+
+ + +
+ Body: {storedBody ? `${storedBody.length} chars` : 'none'} +
+
+ + + +
+ ) +}) + +FunctionNode.displayName = 'FunctionNode' + +export default FunctionNode diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx index fa51656..3d74074 100644 --- a/src/components/graph/RenderingNode.tsx +++ b/src/components/graph/RenderingNode.tsx @@ -9,7 +9,7 @@ import { BaseNodeHeader, BaseNodeHeaderTitle, } from './BaseNode' -import { Rocket } from 'lucide-react' +import { Sparkles } from 'lucide-react' import { InputHandle, OutputHandle } from './NodeHandles' type Props = { @@ -61,6 +61,15 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { [nodes] ) + const functionsSignature = useMemo( + () => + nodes + .filter((n: any) => n.type === 'function') + .map((n: any) => `${n.id}:${n.data?.body ?? ''}`) + .join('|'), + [nodes] + ) + useEffect(() => { // debug // eslint-disable-next-line no-console @@ -139,10 +148,31 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { } } + const resolveFunctionCalls = (text: string): string => { + const fnCallRegex = /\$\{([\w-]+)\s*\(([^)]*)\)\}/g + return text.replace(fnCallRegex, (match, funcId: string, argsStr: string) => { + const fnNode = nodes.find((n: any) => n.id === funcId && n.type === 'function') + if (!fnNode) return match + const body = fnNode.data?.body ?? 'return args[0];' + const argIds = argsStr.split(',').map((s: string) => s.trim()).filter(Boolean) + const argValues = argIds.map((argId: string) => varMap[argId] ?? '') + try { + const fn = new Function('args', body) + const result = fn(argValues) + if (result === undefined || result === null) return '' + if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return String(result) + return String(result) + } catch (err) { + return match + } + }) + } + const resolveVariables = (text: string): string => text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '') - const resolvedWithVars = resolveVariables(resolvedYaml) + const afterFunctions = resolveFunctionCalls(resolvedYaml) + const resolvedWithVars = resolveVariables(afterFunctions) const parsed = yaml.load(resolvedWithVars) const newOutput = JSON.stringify(parsed, null, 2) setError(null) @@ -152,12 +182,12 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { setOutput('') setError({ kind: 'parse', message: msg }) } - }, [id, incomingIds.join(','), yamlText, configSignature, edgesSignature, variablesSignature]) + }, [id, incomingIds.join(','), yamlText, configSignature, edgesSignature, variablesSignature, functionsSignature]) return ( - + {id} @@ -166,7 +196,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { - + No configuration connected Connect a Configuration node or create one. The renderer will display parsed YAML.