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(
|
const insertFunctionCall = useCallback(
|
||||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||||
insertAt(`{{ ${sourceNode.id}() }}`, mode)
|
insertAt(`{{ '' | ${sourceNode.id} }}`, mode)
|
||||||
},
|
},
|
||||||
[insertAt]
|
[insertAt]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
|
|||||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="function">
|
<NodeFooterEdgeIndicators nodeId={id} nodeType="function">
|
||||||
{bodyValue ? `${bodyValue.length} chars` : 'none'}
|
{bodyValue ? `${bodyValue.length} chars` : 'none'}
|
||||||
</NodeFooterEdgeIndicators>
|
</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>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
|
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
|
||||||
@@ -68,7 +67,7 @@ function BorderLoadingIndicator({
|
|||||||
const cw = (target as HTMLElement).offsetWidth
|
const cw = (target as HTMLElement).offsetWidth
|
||||||
const ch = (target as HTMLElement).offsetHeight
|
const ch = (target as HTMLElement).offsetHeight
|
||||||
if (cw > 0 && ch > 0) {
|
if (cw > 0 && ch > 0) {
|
||||||
flushSync(() => setMeasured({ w: cw, h: ch }))
|
queueMicrotask(() => setMeasured({ w: cw, h: ch }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
syncMeasure()
|
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>
|
const nunjucksContext = Object.create(null) as Record<string, unknown>
|
||||||
|
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
if (!configIdsUsed.has(e.target)) continue
|
if (!configIdsUsed.has(e.target)) continue
|
||||||
const src = nodes.find((n: any) => n.id === e.source)
|
const src = nodes.find((n: any) => n.id === e.source)
|
||||||
@@ -245,32 +244,106 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
const str = v === undefined || v === null ? '' : String(v)
|
const str = v === undefined || v === null ? '' : String(v)
|
||||||
nunjucksContext[src.id] =
|
nunjucksContext[src.id] =
|
||||||
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
||||||
} else 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 ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let afterNunjucks: string
|
|
||||||
try {
|
|
||||||
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||||
afterNunjucks = env.render(srcId!, nunjucksContext)
|
|
||||||
} catch (nunjucksErr: any) {
|
// Register each connected function node as a Nunjucks custom filter (async so sync and async user code both work).
|
||||||
throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`)
|
// 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];'
|
||||||
|
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
|
const resolvedPlantuml = afterNunjucks
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
@@ -308,6 +381,14 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled && thisRunId === runIdRef.current) {
|
||||||
|
setSvgContent(null)
|
||||||
|
setError({ kind: 'render', message: err?.message ?? 'PlantUML render error' })
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run()
|
run()
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ const DEFAULT_DATA: Record<string, any> = {
|
|||||||
config: { plantuml: '@startuml\n\n@enduml\n', title: '' },
|
config: { plantuml: '@startuml\n\n@enduml\n', title: '' },
|
||||||
render: {},
|
render: {},
|
||||||
variable: { value: '', valueType: 'string' },
|
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 {
|
export function getDefaultDataForType(type: string, newId?: string): any {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function nunjucksCompletionSource(
|
|||||||
}
|
}
|
||||||
for (const id of functionIds ?? []) {
|
for (const id of functionIds ?? []) {
|
||||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
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) {
|
for (const kw of NUNJUCKS_KEYWORDS) {
|
||||||
|
|||||||
Reference in New Issue
Block a user