improve fn node

This commit is contained in:
2026-03-08 11:19:51 +01:00
parent 48a6917a81
commit 2407470c14
6 changed files with 148 additions and 61 deletions

View File

@@ -117,7 +117,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
const insertFunctionCall = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
insertAt(`{{ ${sourceNode.id}() }}`, mode)
insertAt(`{{ '' | ${sourceNode.id} }}`, mode)
},
[insertAt]
)

View File

@@ -72,6 +72,9 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
<NodeFooterEdgeIndicators nodeId={id} nodeType="function">
{bodyValue ? `${bodyValue.length} chars` : 'none'}
</NodeFooterEdgeIndicators>
<p className="text-[10px] text-muted-foreground mt-1">
In config: <code className="rounded bg-muted px-0.5">{'{{ x | '}{id}{' }}'}</code> or <code>{'{{ x | '}{id}{'(a, b, key=val) }}'}</code>. Use <code>function(num, x, y, kwargs) &#123; ... &#125;</code> kwargs has keyword args.
</p>
</BaseNodeFooter>
</BaseNode>
)

View File

@@ -1,6 +1,5 @@
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { flushSync } from 'react-dom'
import { cn } from '@/lib/utils'
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
@@ -68,7 +67,7 @@ function BorderLoadingIndicator({
const cw = (target as HTMLElement).offsetWidth
const ch = (target as HTMLElement).offsetHeight
if (cw > 0 && ch > 0) {
flushSync(() => setMeasured({ w: cw, h: ch }))
queueMicrotask(() => setMeasured({ w: cw, h: ch }))
}
}
syncMeasure()

View File

@@ -234,9 +234,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
},
}
// Use null prototype so keys like "var", "constructor", "toString" don't collide with Object.prototype.
// Context: only variables. Function nodes are registered as Nunjucks custom filters (see below).
const nunjucksContext = Object.create(null) as Record<string, unknown>
for (const e of edges) {
if (!configIdsUsed.has(e.target)) continue
const src = nodes.find((n: any) => n.id === e.source)
@@ -245,67 +244,149 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
const str = v === undefined || v === null ? '' : String(v)
nunjucksContext[src.id] =
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
} else if (src?.type === 'function') {
}
}
const env = new nunjucks.Environment([configLoader], { autoescape: false })
// Register each connected function node as a Nunjucks custom filter (async so sync and async user code both work).
// Supports either named params: function(num, x, y, kwargs) { return num + (kwargs.bar || 10); } or legacy: args array.
const formatFilterResult = (r: unknown): string => {
if (r === undefined || r === null) return ''
if (typeof r === 'string' || typeof r === 'number' || typeof r === 'boolean') return String(r)
return String(r)
}
/** Parse function(num, x, y, kwargs) { body } or (num, x, y, kwargs) => body to get param names and inner body. */
const parseFunctionSignature = (body: string): { paramNames: string[]; innerBody: string } | null => {
const withCommentsStripped = body.replace(/^\s*\/\/[^\n]*\n?/gm, '').trim()
const trimmed = withCommentsStripped.trim()
const fnMatch = trimmed.match(/^function\s*\(([^)]*)\)\s*\{([\s\S]*)\}\s*$/)
if (fnMatch) {
const paramNames = fnMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
return { paramNames, innerBody: fnMatch[2].trim() }
}
const arrowBlockMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*\{([\s\S]*)\}\s*$/)
if (arrowBlockMatch) {
const paramNames = arrowBlockMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
return { paramNames, innerBody: arrowBlockMatch[2].trim() }
}
const arrowExprMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*(.+)\s*$/s)
if (arrowExprMatch) {
const paramNames = arrowExprMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
return { paramNames, innerBody: 'return ' + arrowExprMatch[2].trim() }
}
return null
}
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v)
for (const e of edges) {
if (!configIdsUsed.has(e.target)) continue
const src = nodes.find((n: any) => n.id === e.source)
if (src?.type === 'function') {
const body = src.data?.body ?? 'return args[0];'
nunjucksContext[src.id] = (...args: unknown[]) => {
try {
const fn = new Function('args', body)
const result = fn(args)
if (result === undefined || result === null) return ''
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return result
return String(result)
} catch {
return ''
const parsed = parseFunctionSignature(body)
env.addFilter(
src.id,
(value: unknown, ...args: unknown[]) => {
const callback = args[args.length - 1] as (err: Error | null, res: string) => void
const raw = [value, ...args.slice(0, -1)]
const hasKwargs = raw.length > 0 && isPlainObject(raw[raw.length - 1])
const positionals = hasKwargs ? raw.slice(0, -1) : raw
const kwargs = hasKwargs ? (raw[raw.length - 1] as Record<string, unknown>) : Object.create(null)
let invoke: () => unknown
if (parsed) {
const { paramNames, innerBody } = parsed
const lastParam = paramNames[paramNames.length - 1]
const invocationArgs = paramNames.map((name, i) => {
if (name === lastParam && lastParam === 'kwargs') return kwargs
return positionals[i]
})
const fn = new Function(...paramNames, innerBody)
invoke = () => fn(...invocationArgs)
} else {
const fn = new Function('args', body)
invoke = () => fn(positionals)
}
const done = (err: Error | null, res: string) => {
callback(err, res)
}
try {
const result = invoke()
if (result != null && typeof (result as Promise<unknown>).then === 'function') {
(result as Promise<unknown>).then(
(r) => done(null, formatFilterResult(r)),
(err) => done(err instanceof Error ? err : new Error(String(err)), '')
)
} else {
done(null, formatFilterResult(result))
}
} catch (err) {
done(err instanceof Error ? err : new Error(String(err)), '')
}
},
true
)
}
}
env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => {
if (cancelled || thisRunId !== runIdRef.current) return
if (nunjucksErr) {
setSvgContent(null)
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
setLoading(false)
return
}
const resolvedPlantuml = afterNunjucks
try {
const res = await fetch(KROKI_PLANTUML_SVG, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: resolvedPlantuml,
})
if (cancelled || thisRunId !== runIdRef.current) return
if (!res.ok) {
const errText = await res.text()
throw new Error(res.status === 400 ? errText || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
}
const svg = await res.text()
if (thisRunId !== runIdRef.current) return
setSvgContent(svg)
setError(null)
} catch (err: any) {
if (cancelled || thisRunId !== runIdRef.current) return
const msg = err?.message ?? 'PlantUML render error'
setSvgContent(null)
setError({ kind: 'render', message: msg })
} finally {
if (!cancelled && thisRunId === runIdRef.current) {
const startedAt = loadingStartedAtRef.current ?? 0
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, 1000 - elapsed)
if (remaining > 0) {
minLoadingTimeoutRef.current = setTimeout(() => {
minLoadingTimeoutRef.current = null
if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
}, remaining)
} else {
setLoading(false)
}
}
}
}
let afterNunjucks: string
try {
const env = new nunjucks.Environment([configLoader], { autoescape: false })
afterNunjucks = env.render(srcId!, nunjucksContext)
} catch (nunjucksErr: any) {
throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`)
}
const resolvedPlantuml = afterNunjucks
const res = await fetch(KROKI_PLANTUML_SVG, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: resolvedPlantuml,
})
if (cancelled || thisRunId !== runIdRef.current) return
if (!res.ok) {
const errText = await res.text()
throw new Error(res.status === 400 ? errText || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
}
const svg = await res.text()
if (thisRunId !== runIdRef.current) return
setSvgContent(svg)
setError(null)
} catch (err: any) {
if (cancelled || thisRunId !== runIdRef.current) return
const msg = err?.message ?? 'PlantUML render error'
setSvgContent(null)
setError({ kind: 'render', message: msg })
} finally {
if (!cancelled && thisRunId === runIdRef.current) {
const startedAt = loadingStartedAtRef.current ?? 0
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, 1000 - elapsed)
if (remaining > 0) {
minLoadingTimeoutRef.current = setTimeout(() => {
minLoadingTimeoutRef.current = null
if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
}, remaining)
} else {
setLoading(false)
}
setSvgContent(null)
setError({ kind: 'render', message: err?.message ?? 'PlantUML render error' })
setLoading(false)
}
}
}

View File

@@ -57,7 +57,11 @@ const DEFAULT_DATA: Record<string, any> = {
config: { plantuml: '@startuml\n\n@enduml\n', title: '' },
render: {},
variable: { value: '', valueType: 'string' },
function: { body: '// args[0], args[1], ...\nreturn args[0];' },
function: {
body: `function(num, kwargs) {
return num + (kwargs.bar || 0);
}`,
},
}
export function getDefaultDataForType(type: string, newId?: string): any {

View File

@@ -61,7 +61,7 @@ export function nunjucksCompletionSource(
}
for (const id of functionIds ?? []) {
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: id, type: 'function', info: 'Function (e.g. ' + id + '(var, 4))' })
options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" })
}
}
for (const kw of NUNJUCKS_KEYWORDS) {