improve fn node
This commit is contained in:
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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) { ... }</code> — kwargs has keyword args.
|
||||
</p>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user