basic function

This commit is contained in:
2026-03-06 23:03:42 +01:00
parent d690c94369
commit 49a6e5b4a3
4 changed files with 210 additions and 7 deletions

View File

@@ -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 | { type: 'canvas' | 'node'; nodeId?: string; clientX: number; clientY: number }>(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<string, any> = {
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() {
<ContextMenuItem onSelect={() => createNode('config')}>Config</ContextMenuItem>
<ContextMenuItem onSelect={() => createNode('render')}>Renderer</ContextMenuItem>
<ContextMenuItem onSelect={() => createNode('variable')}>Variable</ContextMenuItem>
<ContextMenuItem onSelect={() => createNode('function')}>Function</ContextMenuItem>
</ContextMenuGroup>
</>
)}

View File

@@ -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 (
<BaseNode className="w-80">
<BaseNodeHeader className="border-b">
@@ -231,6 +274,21 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
</MenubarSubContent>
</MenubarSub>
))}
{connectedFunctionNodes.map((n: any) => (
<MenubarSub key={`fn-${n.id}`}>
<MenubarSubTrigger className="text-xs">
{n.id}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem
className="text-xs"
onClick={() => insertFunctionCall(n, 'cursor')}
>
Insert at cursor
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
))}
</MenubarContent>
</MenubarMenu>
</Menubar>

View File

@@ -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<string>(data?.body ?? DEFAULT_BODY)
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
const editorRef = useRef<any>(null)
const monacoRef = useRef<any>(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 (
<BaseNode className="w-72">
<BaseNodeHeader className="border-b">
<Code2 className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent>
<p className="text-[10px] text-muted-foreground mb-1">
Call in config: <code className="rounded bg-muted px-0.5">$&#123;{id}(var1, var2)&#125;</code>
</p>
<div style={{ height: 120 }} className="w-full nodrag nopan rounded border border-input overflow-hidden">
<Editor
height="100%"
defaultLanguage="javascript"
value={value}
theme="vs-light"
onMount={handleEditorMount}
onChange={onChange}
options={{
minimap: { enabled: false },
fontSize: 11,
lineHeight: 16,
lineNumbers: 'on',
lineDecorationsWidth: 2,
scrollBeyondLastLine: false,
padding: { top: 4, bottom: 4 },
}}
/>
</div>
</BaseNodeContent>
<BaseNodeFooter>
<div className="w-full text-xs text-muted-foreground">
Body: {storedBody ? `${storedBody.length} chars` : 'none'}
</div>
</BaseNodeFooter>
<InputHandle id="in" />
<OutputHandle id="out" />
</BaseNode>
)
})
FunctionNode.displayName = 'FunctionNode'
export default FunctionNode

View File

@@ -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 (
<BaseNode className="w-96">
<BaseNodeHeader className="border-b">
<Rocket className="size-4" />
<Sparkles className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
</BaseNodeHeader>
@@ -166,7 +196,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Rocket className="size-6" />
<Sparkles className="size-6" />
</EmptyMedia>
<EmptyTitle>No configuration connected</EmptyTitle>
<EmptyDescription>Connect a Configuration node or create one. The renderer will display parsed YAML.</EmptyDescription>