Files
zui/frontend/src/components/nodes/RenderingNode.tsx
2026-03-11 20:22:32 +01:00

992 lines
56 KiB
TypeScript

import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '../../lib/abstractNode'
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { plantumlLanguage } from '../../lib/plantumlLanguage'
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 FlowContext from '../../lib/flowContext'
import { getDefaultStyle, getNodeType } 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, Copy } 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'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import { useTheme } from '../../lib/themeContext'
import { toast } from 'sonner'
export type RenderingNodeData = {
viewportWidth?: number
viewportHeight?: number
}
const DEFAULT_VIEWPORT_WIDTH = 1200
const DEFAULT_VIEWPORT_HEIGHT = 800
type Props = AbstractNodeProps<RenderingNodeData>
type ViewMode = 'preview' | 'raw'
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const [viewMode, setViewMode] = useState<ViewMode>('preview')
const [viewportFocused, setViewportFocused] = useState(false)
const runIdRef = useRef(0)
const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(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 isAgentSource = srcNode?.type === 'agent'
const agentOutputMarkdown = isAgentSource ? ((srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : ''
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? 'markdown' : 'plantuml'
const configType = getConfigType(configTypeId)
const outputType = configType.outputType
const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? agentOutputMarkdown : ''
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<string>()
const isReachable = (startId: string, targetId: string) => {
const q: string[] = [startId]
const seen = new Set<string>([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<string>) => {
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 ?? undefined) as Record<string, unknown> | undefined)
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<string>()
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 dataSignature = useMemo(
() =>
nodes
.filter((n: any) => n.type === 'data' && connectedNodeIds.has(n.id))
.map((n: any) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`)
.sort()
.join('|'),
[nodes, connectedNodeIds]
)
const RENDER_DEBOUNCE_MS = 250
useEffect(() => {
if (!sourceContent && incomingIds.length > 0) {
setRenderedContent(null)
setResolvedContent(null)
setError({
kind: 'no-content',
message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node',
})
setLoading(false)
return
}
if (incomingIds.length === 0) {
setRenderedContent(null)
setResolvedContent(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 {
if (srcNode?.type === 'agent') {
const md = (srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? ''
setResolvedContent(md)
const markdownType = getConfigType('markdown')
const html = await markdownType.render(md)
if (cancelled || thisRunId !== runIdRef.current) return
setRenderedContent(html)
setError(null)
setLoading(false)
return
}
const configIdsUsed = new Set<string>()
const isReachable = (startId: string, targetId: string) => {
const q: string[] = [startId]
const seen = new Set<string>([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<string>()) => {
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 ?? undefined) as Record<string, unknown> | undefined)
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 ?? undefined) as Record<string, unknown> | undefined),
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<string, unknown>
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)
if (src?.type === 'data') {
const rows = (src.data?.rows as Record<string, string>[] | undefined) ?? []
const hidden = (src.data?.hiddenColumns as string[] | undefined) ?? []
const visibleCols = (src.data?.columns as string[] | undefined) ?? []
.filter((c) => !hidden.includes(c))
const filteredRows = rows.map((row) => {
const out: Record<string, string> = {}
for (const col of visibleCols) {
if (col in row) out[col] = row[col]
}
return out
})
nunjucksContext[src.id] = filteredRows
}
}
// All function node ids that feed (directly or transitively) into config — need to register them and collect their variables
const functionIdsToRegister = new Set<string>()
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<string, unknown> =>
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<string, string[]>
const functionConnectedFunctionIds = Object.create(null) as Record<string, string[]>
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 as { body?: string } | undefined)?.body ?? 'return args[0];'
const parsed = parseFunctionSignature(body)
const connectedVarIds = new Set<string>(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<string, unknown>) : 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<string, string>()
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<string>((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<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 (e: unknown) {
const s = e as { __suspend?: boolean; promise?: Promise<string>; 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) {
setRenderedContent(null)
setResolvedContent(null)
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
setLoading(false)
return
}
// Collapse runs of newlines so {% for %} / {{ }} on their own lines don't leave blank lines
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
setResolvedContent(resolved)
const typeRenderer = getConfigType(configTypeId)
try {
const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
const htmlOrSvg = await typeRenderer.render(resolved, 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, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource])
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
// Kroki and other SVG sources may prepend <?xml ... ?> so we detect by presence of <svg> tag
const isSvgOutput = Boolean(renderedContent?.trim() && /<svg[\s>]/i.test(renderedContent.trim()))
/** Strip remaining Nunjucks tags from resolved content for display in raw view (so tags don't show as literal lines). */
const rawDisplayContent = useMemo(() => {
if (resolvedContent == null) return ''
return resolvedContent
.replace(/\{%[\s\S]*?%\}/g, '')
.replace(/\{\{[\s\S]*?\}\}/g, '')
.replace(/\{#[\s\S]*?#\}/g, '')
.replace(/(\r?\n)\s*(\r?\n)/g, '$1$2')
.replace(/^\s*\n|\n\s*$/g, (m) => (m === '\n' ? '\n' : ''))
.trim()
}, [resolvedContent])
/** Process SVG HTML so it keeps aspect ratio and fills the viewport (used only for display, not download). */
const displayContent = useMemo(() => {
if (!renderedContent || !isSvgOutput) return renderedContent
let html = renderedContent
// Force preserve aspect ratio so the diagram is not stretched (Kroki often returns preserveAspectRatio="none")
html = html.replace(/\bpreserveAspectRatio\s*=\s*["']none["']/gi, 'preserveAspectRatio="xMidYMid meet"')
// Make root SVG fill container so it scales uniformly with meet
html = html.replace(/\bwidth\s*=\s*["'][^"']*["']/i, 'width="100%"')
html = html.replace(/\bheight\s*=\s*["'][^"']*["']/i, 'height="100%"')
// Override inline style width/height so they don't override the attributes
html = html.replace(/\bstyle\s*=\s*["']([^"']*)["']/i, (_, style) => {
const overridden = style.replace(/\b(width|height):[^;]+/gi, '$1:100%')
return `style="${overridden}"`
})
return html
}, [renderedContent, isSvgOutput])
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])
const { theme } = useTheme()
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
const rawExtensions = useMemo(() => {
const lang =
configTypeId === 'wireframe'
? javascript()
: configType.language === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [lang]
}, [configTypeId, configType.language])
return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<InputHandle id="ain" nodeId={id} />}>
<BaseNodeHeaderRow
icon={<Sparkles className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
right={
incomingIds.length > 0 ? (
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(v) => {
if (v === 'preview' || v === 'raw') setViewMode(v)
}}
aria-label="View mode"
variant="outline"
size="sm"
className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0"
>
<ToggleGroupItem
value="preview"
aria-label="Preview"
className="gap-1.5 px-2.5 h-7"
onClick={(e) => {
e.stopPropagation()
setViewMode('preview')
}}
>
Preview
</ToggleGroupItem>
<ToggleGroupItem
value="raw"
aria-label="Raw config"
className="gap-1.5 px-2.5 h-7"
onClick={(e) => {
e.stopPropagation()
setViewMode('raw')
}}
>
Raw
</ToggleGroupItem>
</ToggleGroup>
) : undefined
}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar
nodeId={id}
nodeType="render"
nodeMenuExtraContent={
<>
{outputType === 'image' && (
<MenubarSub>
<MenubarSubTrigger className="text-xs">Viewport</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem] p-2">
<div className="grid gap-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground shrink-0">Width</label>
<Input
type="number"
min={200}
max={4000}
value={viewportDraft.width}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!Number.isNaN(v)) onViewportDraftChange('width', v)
}}
className="h-7 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground shrink-0">Height</label>
<Input
type="number"
min={200}
max={4000}
value={viewportDraft.height}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!Number.isNaN(v)) onViewportDraftChange('height', v)
}}
className="h-7 text-xs"
/>
</div>
<button
type="button"
onClick={onViewportApply}
className="mt-1 w-full rounded bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Apply
</button>
</div>
</MenubarSubContent>
</MenubarSub>
)}
<MenubarSub>
<MenubarSeparator />
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
Export / Copy
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]" aria-label="Export or copy diagram">
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
Download SVG
</MenubarItem>
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
Download PNG
</MenubarItem>
<MenubarItem className="text-xs" onClick={copySvg} disabled={!isSvgOutput}>
Copy SVG
</MenubarItem>
<MenubarItem className="text-xs" onClick={copyPng} disabled={!isSvgOutput}>
Copy image
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
</>
}
/>
</div>
<div className="min-h-0 flex-1 flex flex-col">
{incomingIds.length === 0 ? (
<Empty className="min-h-0 flex-1">
<EmptyHeader>
<EmptyMedia variant="icon">
<Sparkles className="size-6" />
</EmptyMedia>
<EmptyTitle>No configuration connected</EmptyTitle>
<EmptyDescription>Connect a Configuration node or create one. The renderer will display the diagram or document.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<button
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
onClick={() => {
if (!setNodes || !setEdges) return
const nid = getNextNodeId('config', nodes.map((n: any) => n.id))
const thisNode = nodes.find((n: any) => n.id === id)
const pos = thisNode?.position ?? { x: 0, y: 0 }
const newPos = { x: pos.x - 220, y: pos.y }
const newNode = { id: nid, type: 'config', position: newPos, data: getDefaultDataForType('config', nid), style: getDefaultStyle('config') }
setNodes((nds: any[]) => nds.concat(newNode))
setEdges((eds: any[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }))
}}
>
Create Config
</button>
</EmptyContent>
</Empty>
) : error ? (
(srcData as { renderError?: (err: { kind: string; message: string }) => React.ReactNode; errorHtml?: string })?.renderError ? (
(srcData as { renderError: (err: { kind: string; message: string }) => React.ReactNode }).renderError(error)
) : (srcData as { errorHtml?: string })?.errorHtml ? (
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String((srcData as { errorHtml: string }).errorHtml) }} />
) : (
<div className="flex flex-col gap-2 p-3">
<p className="text-xs text-red-700 dark:text-red-400">{error.message}</p>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => setRetryCount((c) => c + 1)}
>
<RotateCw className="size-3 mr-1" />
Retry
</Button>
</div>
)
) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : viewMode === 'raw' ? (
<div ref={rawEditorContainerRef} className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<Button
type="button"
variant="secondary"
size="icon"
className="absolute top-2 right-2 z-10 h-7 w-7 shrink-0 rounded-md shadow-sm"
onClick={() => {
const text = rawDisplayContent
if (text) {
navigator.clipboard?.writeText(text).then(() => toast.success('Copied to clipboard')).catch(() => {})
}
}}
disabled={!rawDisplayContent}
title="Copy raw output"
>
<Copy className="size-3.5" />
</Button>
<CodeMirror
value={rawDisplayContent}
height={`${rawEditorHeight}px`}
theme={theme}
extensions={rawExtensions}
readOnly
editable={false}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
/>
</div>
) : renderedContent ? (
outputType === 'image' ? (
<div
className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary rounded outline-none"
tabIndex={0}
onFocus={() => setViewportFocused(true)}
onBlur={() => setViewportFocused(false)}
>
<TransformWrapper
initialScale={1}
initialPositionX={0}
initialPositionY={0}
minScale={0.2}
maxScale={4}
centerOnInit={true}
onInit={(ctx) => {
if (!ctx?.instance?.wrapperComponent || !ctx?.instance?.contentComponent) return
const fitToView = () => {
const wrapper = ctx.instance.wrapperComponent
const content = ctx.instance.contentComponent
if (!wrapper || !content) return
const wW = wrapper.clientWidth
const wH = wrapper.clientHeight
const cW = content.scrollWidth || content.clientWidth
const cH = content.scrollHeight || content.clientHeight
if (cW > 0 && cH > 0) {
const scale = Math.min(wW / cW, wH / cH, 1)
const posX = (wW - cW * scale) / 2
const posY = (wH - cH * scale) / 2
ctx.setTransform(posX, posY, scale, 0)
}
}
requestAnimationFrame(() => {
requestAnimationFrame(fitToView)
})
}}
panning={{ disabled: !selected && !viewportFocused }}
wheel={{ disabled: !selected && !viewportFocused }}
doubleClick={{ disabled: !selected && !viewportFocused }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
<button
type="button"
onClick={() => zoomIn()}
className="react-flow__controls-button"
title="Zoom in"
>
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => zoomOut()}
className="react-flow__controls-button"
title="Zoom out"
>
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => resetTransform()}
className="react-flow__controls-button"
title="Reset view (fit all)"
>
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
</button>
</div>
<div className="absolute inset-0 nodrag nopan overflow-hidden flex flex-col">
<TransformComponent
wrapperClass="!w-full !h-full"
contentClass="inline-flex flex-col nodrag nopan !w-full !min-h-0 !flex-1"
>
<div
className="rendering-diagram inline-flex w-full min-h-0 flex-1 p-4 nodrag nopan"
dangerouslySetInnerHTML={{ __html: displayContent ?? '' }}
/>
</TransformComponent>
</div>
</>
)}
</TransformWrapper>
</div>
) : (
<div
className="rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
)
) : null}
</div>
</BaseNodeContent>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{viewMode === 'raw'
? (rawDisplayContent ? `Raw · ${rawDisplayContent.length} chars` : '—')
: renderedContent
? `${configType.label} · ${renderedContent.length} chars`
: error
? 'Error'
: '—'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
</NodeStatusIndicator>
)
}
export const RenderingNode = createAbstractNodeComponent<RenderingNodeData>(
'RenderingNode',
RenderingNodeComponent
)
export default RenderingNode