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/graph/abstractNode' import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/configTypes' import { getSourceRenderingLogic } from '@/lib/sourceRenderingLogic' import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' import { useResizeHeight } from '@/hooks/useResizeHeight' import { plantumlLanguage } from '@/lib/plantumlLanguage' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '@/components/ui/empty' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, } from '@/components/graph/BaseNode' import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils' import FlowContext from '@/lib/graph/flowContext' import { getDefaultStyle, getNodeType } from '@/lib/graph/nodeRegistry' import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators' import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle' import { NodeMenubar } from '@/components/graph/NodeMenubar' import { NodeStatusIndicator } from '@/components/graph/NodeStatusIndicator' import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '@/components/ui/menubar' import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy, Play, ChevronDown } from 'lucide-react' import { InputHandle } from '@/components/graph/NodeHandles' import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { useTheme } from '@/lib/themeContext' import { toast } from 'sonner' export type RenderingNodeData = { viewportWidth?: number viewportHeight?: number /** When set, overrides the source node's default. 'auto' = re-render on upstream changes; 'manual' = only when user clicks Run. */ updateMode?: 'auto' | 'manual' /** Incremented when user clicks Run (manual mode). Effect runs when this changes. */ runTrigger?: number /** Signature of inputs used in the last successful render. Used in manual mode to show paused (yellow) when upstream changed. */ lastRunSourceSignature?: string } const DEFAULT_VIEWPORT_WIDTH = 1200 const DEFAULT_VIEWPORT_HEIGHT = 800 type Props = AbstractNodeProps 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(null) const [resolvedContent, setResolvedContent] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [retryCount, setRetryCount] = useState(0) const [viewMode, setViewMode] = useState('preview') const [viewportFocused, setViewportFocused] = useState(false) const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) const lastManualRunTriggerRef = useRef(0) 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 sourceLogic = useMemo(() => (srcNode?.type ? getSourceRenderingLogic(srcNode.type) : null), [srcNode?.type]) const effectiveUpdateMode = data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto' const runTrigger = data?.runTrigger ?? 0 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 | undefined) : isAgentSource ? 'markdown' : 'plantuml' const configType = getConfigType(configTypeId) const outputType = configType.outputType const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record | undefined) : isAgentSource ? agentOutputMarkdown : '' const srcData = srcNode?.data ?? {} const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? [] /** 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 ?? undefined) as Record | 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() 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] ) /** Single signature of all inputs that affect this render. Stored on successful render for manual-mode paused state. */ const sourceSignature = useMemo( () => JSON.stringify({ configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, }), [configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature] ) const lastRunSourceSignature = data?.lastRunSourceSignature /** * In manual mode, yellow = "dirty": inputs changed since the last manual run (or never run). * We report paused when dirty so we get added to connectionPathPausedNodeIds; the path is * then computed as downstream(trigger) ∩ upstream(paused), so we must not require * pathNodeIds.has(id) here (that would be a chicken-and-egg). */ const hasPendingInputs = effectiveUpdateMode === 'manual' && !loading && triggerNodeIds.length > 0 && incomingIds.length > 0 && sourceSignature !== lastRunSourceSignature useSyncConnectionStatus(id, { updating: loading, error: error != null, paused: hasPendingInputs }) const RENDER_DEBOUNCE_MS = 250 useEffect(() => { if (incomingIds.length === 0) { setRenderedContent(null) setResolvedContent(null) setError(null) setLoading(false) return } if (!srcId || !srcNode) { setRenderedContent(null) setResolvedContent(null) setError(null) setLoading(false) return } const logic = getSourceRenderingLogic(srcNode.type ?? '') if (!logic) { setRenderedContent(null) setResolvedContent(null) setError({ kind: 'render', message: `Unsupported source type: ${srcNode.type}` }) setLoading(false) return } if (effectiveUpdateMode === 'manual' && runTrigger === 0) { setRenderedContent(null) setResolvedContent(null) setError({ kind: 'no-content', message: isAgentSource ? 'Run the Agent node to generate output, then click Run here.' : 'Click Run to render.', }) setLoading(false) return } if (effectiveUpdateMode === 'manual' && runTrigger === lastManualRunTriggerRef.current) { return } if (effectiveUpdateMode === 'manual') lastManualRunTriggerRef.current = runTrigger runIdRef.current += 1 const thisRunId = runIdRef.current const signatureForThisRun = sourceSignature const isManualMode = effectiveUpdateMode === 'manual' let cancelled = false const run = async () => { loadingStartedAtRef.current = Date.now() setLoading(true) setError(null) try { const context = { nodes, edges, sourceNodeId: srcId, renderNodeId: id, viewportWidth, viewportHeight, } const { resolved, outputTypeId } = await logic.getResolvedContent(context) if (cancelled || thisRunId !== runIdRef.current) return setResolvedContent(resolved) const typeRenderer = getConfigType(outputTypeId) const renderOptions = outputTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined const htmlOrSvg = await typeRenderer.render(resolved, renderOptions) if (cancelled || thisRunId !== runIdRef.current) return setRenderedContent(htmlOrSvg) setError(null) if (isManualMode) { updateData({ lastRunSourceSignature: signatureForThisRun }) } } catch (err: any) { if (!cancelled && thisRunId === runIdRef.current) { setRenderedContent(null) setError({ kind: 'render', message: err?.message ?? 'Render error' }) } } 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) } } } } if (effectiveUpdateMode === 'auto') { const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS) return () => { cancelled = true clearTimeout(debounceTimer) if (minLoadingTimeoutRef.current != null) { clearTimeout(minLoadingTimeoutRef.current) minLoadingTimeoutRef.current = null } setLoading(false) } } run() return () => { cancelled = true if (minLoadingTimeoutRef.current != null) { clearTimeout(minLoadingTimeoutRef.current) minLoadingTimeoutRef.current = null } setLoading(false) } }, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, nodes, edges, updateData]) 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())) /** 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 ( }> } title={} onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} right={ incomingIds.length > 0 ? (
e.stopPropagation()}> When to re-render checked && updateData({ updateMode: 'auto' })} className="flex flex-col items-start gap-0.5 py-2" > Auto Re-renders when upstream content changes checked && updateData({ updateMode: 'manual' })} className="flex flex-col items-start gap-0.5 py-2" > Manual Re-renders only when you click Run { 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" > { e.stopPropagation() setViewMode('preview') }} > Preview { e.stopPropagation() setViewMode('raw') }} > Raw
) : undefined } />
{outputType === 'image' && ( 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 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 ? (
) : (

{error.message}

) ) : loading ? (
Rendering…
) : viewMode === 'raw' ? (
) : renderedContent ? ( outputType === 'image' ? (
setViewportFocused(true)} onBlur={() => setViewportFocused(false)} > { 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 }) => ( <>
)}
) : (
) ) : null}
{viewMode === 'raw' ? (rawDisplayContent ? `Raw · ${rawDisplayContent.length} chars` : '—') : renderedContent ? `${configType.label} · ${renderedContent.length} chars` : error ? 'Error' : '—'} ) } export const RenderingNode = createAbstractNodeComponent( 'RenderingNode', RenderingNodeComponent ) export default RenderingNode