basic function
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
111
src/components/graph/FunctionNode.tsx
Normal file
111
src/components/graph/FunctionNode.tsx
Normal 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">${{id}(var1, var2)}</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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user