import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import nunjucks from 'nunjucks' import { AbstractNodeProps, createAbstractNodeComponent, useAbstractNode, } from '../../lib/abstractNode' import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, } from '../base/BaseNode' import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils' import { getDefaultStyle } from '../../lib/nodeRegistry' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeMenubar } from '../base/NodeMenubar' import { NodeStatusIndicator } from '../base/NodeStatusIndicator' import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw } from 'lucide-react' import { InputHandle } from '../base/NodeHandles' import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' import { Input } from '../ui/input' import { Button } from '../ui/button' export type RenderingNodeData = { viewportWidth?: number viewportHeight?: number } const DEFAULT_VIEWPORT_WIDTH = 1200 const DEFAULT_VIEWPORT_HEIGHT = 800 type Props = AbstractNodeProps function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const [renderedContent, setRenderedContent] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [retryCount, setRetryCount] = useState(0) const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode(id, data ?? {}) const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT const incomingIds = sourceIds const srcId = incomingIds.length > 0 ? incomingIds[0] : null const srcNode = nodes.find((n: any) => n.id === srcId) const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml' const sourceContent = srcNode?.type === 'config' ? getConfigContent(srcNode.data) : '' const srcData = srcNode?.data ?? {} /** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */ const connectedNodeIds = useMemo(() => { const out = new Set() const isReachable = (startId: string, targetId: string) => { const q: string[] = [startId] const seen = new Set([startId]) while (q.length) { const cur = q.shift()! if (cur === targetId) return true for (const e of edges) { if (e.source === cur && !seen.has(e.target)) { seen.add(e.target) q.push(e.target) } } } return false } const resolveRef = (name: string) => { const refName = name.replace(/\.(puml|html)$/, '').trim() return nodes.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName } const getTemplateRefs = (content: string): string[] => { const refs: string[] = [] const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/) if (extendMatch) refs.push(extendMatch[1].trim()) const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g let m while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim()) const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim()) return refs } const addConfigRefs = (nodeId: string, visited: Set) => { if (visited.has(nodeId)) return const node = nodes.find((n: any) => n.id === nodeId && n.type === 'config') if (!node) return visited.add(nodeId) out.add(nodeId) const content = getConfigContent(node.data) for (const ref of getTemplateRefs(content)) { const refId = resolveRef(ref) if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id)) addConfigRefs(refId, visited) } } const configVisited = new Set() for (const nid of incomingIds) { const node = nodes.find((n: any) => n.id === nid) if (node?.type === 'config') addConfigRefs(nid, configVisited) else out.add(nid) } for (const e of edges) { if (out.has(e.target)) out.add(e.source) } return out }, [nodes, edges, id, incomingIds]) const configSignature = useMemo( () => nodes .filter((n: any) => n.type === 'config' && connectedNodeIds.has(n.id)) .map((n: any) => `${n.id}:${n.data?.title ?? ''}:${getConfigContent(n.data)}`) .sort() .join('|'), [nodes, connectedNodeIds] ) const edgesSignature = useMemo( () => edges .filter((e: any) => connectedNodeIds.has(e.source) && (connectedNodeIds.has(e.target) || e.target === id)) .map((e: any) => `${e.source}->${e.target}`) .sort() .join('|'), [edges, connectedNodeIds, id] ) const variablesSignature = useMemo( () => nodes .filter((n: any) => n.type === 'variable' && connectedNodeIds.has(n.id)) .map((n: any) => `${n.id}:${n.data?.value}`) .sort() .join('|'), [nodes, connectedNodeIds] ) const functionsSignature = useMemo( () => nodes .filter((n: any) => n.type === 'function' && connectedNodeIds.has(n.id)) .map((n: any) => `${n.id}:${n.data?.body ?? ''}`) .sort() .join('|'), [nodes, connectedNodeIds] ) const RENDER_DEBOUNCE_MS = 250 useEffect(() => { if (!sourceContent && incomingIds.length > 0) { setRenderedContent(null) setError({ kind: 'no-content', message: 'No content on connected configuration node' }) setLoading(false) return } if (incomingIds.length === 0) { setRenderedContent(null) setError(null) setLoading(false) return } runIdRef.current += 1 const thisRunId = runIdRef.current let cancelled = false const run = async () => { loadingStartedAtRef.current = Date.now() setLoading(true) setError(null) try { const configIdsUsed = new Set() const isReachable = (startId: string, targetId: string) => { const q: string[] = [startId] const seen = new Set([startId]) while (q.length) { const cur = q.shift()! if (cur === targetId) return true for (const e of edges) { if (e.source === cur && !seen.has(e.target)) { seen.add(e.target) q.push(e.target) } } } return false } const resolveExtendsRef = (name: string): string => { const refName = name.replace(/\.(puml|html)$/, '').trim() return nodes.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName } /** Collect refs from {% extends %}, {% include %}, {% import %} in template content */ const getTemplateRefs = (content: string): string[] => { const refs: string[] = [] const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/) if (extendMatch) refs.push(extendMatch[1].trim()) const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g let m while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim()) const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim()) return refs } const addConfigAndRefs = (templateName: string, visited = new Set()) => { const refId = resolveExtendsRef(templateName) if (visited.has(refId)) throw new Error(`Circular reference detected: ${templateName}`) const node = nodes.find((n: any) => n.id === refId && n.type === 'config') if (!node) throw new Error(`Config not found: ${templateName}`) if (refId !== srcId && !isReachable(refId, id)) throw new Error(`Referenced config not connected to renderer: ${templateName}`) visited.add(refId) configIdsUsed.add(refId) const content = getConfigContent(node.data) for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited) } if (srcId && srcNode?.type === 'config') addConfigAndRefs(srcId) // Loader for Nunjucks {% extends %}, {% include %}, {% import %}: resolve template name to config node's plantuml const configLoader = { getSource: (name: string): { src: string; path: string } | null => { const refId = resolveExtendsRef(name) const node = nodes.find((n: any) => n.id === refId && n.type === 'config') if (!node) return null if (refId !== srcId && !isReachable(refId, id)) throw new Error(`Referenced config not connected to renderer: ${name}`) return { src: getConfigContent(node.data), path: name, } }, } // Context: variables connected to configs, plus variables connected to functions that feed configs (so they can be injected as constants). const nunjucksContext = Object.create(null) as Record const setVarInContext = (src: any) => { const v = src.data?.value const str = v === undefined || v === null ? '' : String(v) nunjucksContext[src.id] = v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str } for (const e of edges) { if (!configIdsUsed.has(e.target)) continue const src = nodes.find((n: any) => n.id === e.source) if (src?.type === 'variable') setVarInContext(src) } // All function node ids that feed (directly or transitively) into config — need to register them and collect their variables const functionIdsToRegister = new Set() let added = true while (added) { added = false for (const e of edges) { const src = nodes.find((n: any) => n.id === e.source) if (src?.type !== 'function') continue const targetInScope = configIdsUsed.has(e.target) || functionIdsToRegister.has(e.target) if (!targetInScope) continue if (!functionIdsToRegister.has(src.id)) { functionIdsToRegister.add(src.id) added = true } } } for (const e of edges) { if (!configIdsUsed.has(e.target) && !functionIdsToRegister.has(e.target)) continue const src = nodes.find((n: any) => n.id === e.source) if (src?.type === 'function') { for (const e2 of edges) { if (e2.target !== src.id) continue const vNode = nodes.find((n: any) => n.id === e2.source) if (vNode?.type === 'variable') setVarInContext(vNode) } } } 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 => typeof v === 'object' && v !== null && !Array.isArray(v) // For each registered function: which variable/function node ids are connected to it? const functionConnectedVariableIds = Object.create(null) as Record const functionConnectedFunctionIds = Object.create(null) as Record for (const fid of functionIdsToRegister) { for (const e of edges) { if (e.target !== fid) continue const src = nodes.find((n: any) => n.id === e.source) if (src?.type === 'variable') { if (!functionConnectedVariableIds[fid]) functionConnectedVariableIds[fid] = [] functionConnectedVariableIds[fid].push(src.id) } else if (src?.type === 'function') { if (!functionConnectedFunctionIds[fid]) functionConnectedFunctionIds[fid] = [] functionConnectedFunctionIds[fid].push(src.id) } } } for (const fid of functionIdsToRegister) { const src = nodes.find((n: any) => n.id === fid) if (!src || src.type !== 'function') continue const body = src.data?.body ?? 'return args[0];' const parsed = parseFunctionSignature(body) const connectedVarIds = new Set(functionConnectedVariableIds[fid] ?? []) const connectedFuncIds = functionConnectedFunctionIds[fid] ?? [] 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) : Object.create(null) // Cache for nested filter results so sync-looking code like `num + fn_003(4)` works: // callable throws Suspend when result isn't ready; we await, cache, then re-run. // Cached values are strings (Nunjucks); coerce to number when numeric so 2 + fn_003(4) => 10 not "28". const nestedCache = new Map() const coerceCached = (s: string): string | number => { const n = Number(s) return s.trim() !== '' && !Number.isNaN(n) ? n : s } const makeCallable = (filterId: string) => (input: unknown) => { const key = `${filterId}::${JSON.stringify(input)}` if (nestedCache.has(key)) return coerceCached(nestedCache.get(key)!) const p = new Promise((resolve, reject) => { env.getFilter(filterId)(input, (err: Error | null, res: string) => err ? reject(err) : resolve(res) ) }) p.then((res) => nestedCache.set(key, res)) const suspend = { __suspend: true as const, promise: p, key } throw suspend } 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 if (connectedVarIds.has(name) && name in nunjucksContext) return nunjucksContext[name] if (connectedFuncIds.includes(name)) return makeCallable(name) return positionals[i] }) const extraVarIds = [...connectedVarIds].filter((vid) => !paramNames.includes(vid)) const extraFuncIds = connectedFuncIds.filter((fid2) => !paramNames.includes(fid2)) const allParamNames = [...paramNames, ...extraVarIds, ...extraFuncIds] const allArgs = [ ...invocationArgs, ...extraVarIds.map((vid) => nunjucksContext[vid]), ...extraFuncIds.map((fid2) => makeCallable(fid2)), ] const fn = new Function(...allParamNames, innerBody) invoke = () => fn(...allArgs) } else { const fn = new Function('args', body) invoke = () => fn(positionals) } const done = (err: Error | null, res: string) => { callback(err, res) } const runInvoke = () => { try { const result = invoke() if (result != null && typeof (result as Promise).then === 'function') { (result as Promise).then( (r) => done(null, formatFilterResult(r)), (err) => done(err instanceof Error ? err : new Error(String(err)), '') ) } else { done(null, formatFilterResult(result)) } } catch (e: unknown) { const s = e as { __suspend?: boolean; promise?: Promise; key?: string } if (s?.__suspend && s.promise) { s.promise.then(() => runInvoke(), (err) => done(err instanceof Error ? err : new Error(String(err)), '') ) } else { done(e instanceof Error ? e : new Error(String(e)), '') } } } runInvoke() }, 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 resolvedContent = afterNunjucks const typeRenderer = getConfigType(configTypeId) try { const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined const htmlOrSvg = await typeRenderer.render(resolvedContent, renderOptions) if (cancelled || thisRunId !== runIdRef.current) return setRenderedContent(htmlOrSvg) setError(null) } catch (err: any) { if (cancelled || thisRunId !== runIdRef.current) return const msg = err?.message ?? 'Render error' setRenderedContent(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) } } } }) } catch (err: any) { if (!cancelled && thisRunId === runIdRef.current) { setRenderedContent(null) setError({ kind: 'render', message: err?.message ?? 'Render error' }) setLoading(false) } } } const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS) return () => { cancelled = true clearTimeout(debounceTimer) if (minLoadingTimeoutRef.current != null) { clearTimeout(minLoadingTimeoutRef.current) minLoadingTimeoutRef.current = null } } // Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry. }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight, retryCount]) const dimensions = width != null && height != null && width > 0 && height > 0 ? { width, height } : undefined // Kroki and other SVG sources may prepend so we detect by presence of tag const isSvgOutput = Boolean(renderedContent?.trim() && /]/i.test(renderedContent.trim())) const downloadSvg = useCallback(() => { if (!renderedContent || !isSvgOutput) return const blob = new Blob([renderedContent], { type: 'image/svg+xml' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${id}.svg` a.click() URL.revokeObjectURL(url) }, [id, renderedContent, isSvgOutput]) const downloadPng = useCallback(() => { if (!renderedContent || !isSvgOutput) return const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent) const img = new Image() img.onload = () => { const canvas = document.createElement('canvas') canvas.width = img.naturalWidth canvas.height = img.naturalHeight const ctx = canvas.getContext('2d') if (!ctx) return ctx.drawImage(img, 0, 0) const pngUrl = canvas.toDataURL('image/png') const a = document.createElement('a') a.href = pngUrl a.download = `${id}.png` a.click() } img.onerror = () => { } img.src = dataUrl }, [id, renderedContent, isSvgOutput]) const copyPng = useCallback(() => { if (!renderedContent || !isSvgOutput) return const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent) const img = new Image() img.onload = () => { const canvas = document.createElement('canvas') canvas.width = img.naturalWidth canvas.height = img.naturalHeight const ctx = canvas.getContext('2d') if (!ctx) return ctx.drawImage(img, 0, 0) canvas.toBlob((blob) => { if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {}) }, 'image/png') } img.onerror = () => {} img.src = dataUrl }, [renderedContent, isSvgOutput]) const copySvg = useCallback(() => { if (!renderedContent || !isSvgOutput) return const blob = new Blob([renderedContent], { type: 'image/svg+xml' }) navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {}) }, [renderedContent, isSvgOutput]) const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial' const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight }) useEffect(() => { setViewportDraft({ width: viewportWidth, height: viewportHeight }) }, [viewportWidth, viewportHeight]) const onViewportDraftChange = useCallback((field: 'width' | 'height', value: number) => { setViewportDraft((prev) => ({ ...prev, [field]: Math.min(4000, Math.max(200, value)) })) }, []) const onViewportApply = useCallback(() => { updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height }) }, [updateData, viewportDraft.width, viewportDraft.height]) return ( }> } title={} />
{isSvgOutput && ( Viewport
{ const v = parseInt(e.target.value, 10) if (!Number.isNaN(v)) onViewportDraftChange('width', v) }} className="h-7 text-xs" />
{ const v = parseInt(e.target.value, 10) if (!Number.isNaN(v)) onViewportDraftChange('height', v) }} className="h-7 text-xs" />
)} Export / Copy Download SVG Download PNG Copy SVG Copy image } />
{incomingIds.length === 0 ? ( No configuration connected Connect a Configuration node or create one. The renderer will display the diagram or document. ) : error ? ( srcData?.renderError ? ( srcData.renderError(error) ) : srcData?.errorHtml ? (
) : (

{error.message}

) ) : loading ? (
Rendering…
) : renderedContent ? ( isSvgOutput ? (
ref?.centerView(1, 0, 0)} panning={{ disabled: true }} wheel={{ disabled: true }} doubleClick={{ disabled: true }} > {({ zoomIn, zoomOut, resetTransform }) => ( <>
)}
) : (
) ) : null}
{renderedContent ? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars` : error ? 'Error' : '—'} ) } export const RenderingNode = createAbstractNodeComponent( 'RenderingNode', RenderingNodeComponent ) export default RenderingNode