refactor: nodes as builder pattern
This commit is contained in:
851
frontend/src/components/nodes/render/RenderingNode.tsx
Normal file
851
frontend/src/components/nodes/render/RenderingNode.tsx
Normal file
@@ -0,0 +1,851 @@
|
||||
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 { getSourceRenderingLogic } from '@/lib/sourceRenderingLogic'
|
||||
import { useSyncConnectionStatus } from '@/lib/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/base/BaseNode'
|
||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/flowUtils'
|
||||
import FlowContext from '@/lib/flowContext'
|
||||
import { getDefaultStyle, getNodeType } from '@/lib/nodeRegistry'
|
||||
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '@/components/base/NodeMenubar'
|
||||
import { NodeStatusIndicator } from '@/components/base/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/base/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<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 lastManualRunTriggerRef = useRef<number>(0)
|
||||
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 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<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 ?? {}
|
||||
|
||||
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<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]
|
||||
)
|
||||
|
||||
/** 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 <?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="overlay" 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 ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ButtonGroup className="nodrag nopan">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
updateData({ runTrigger: (data?.runTrigger ?? 0) + 1 })
|
||||
}}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
Run
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 min-w-[4.5rem] gap-1 rounded-l-none pl-2 pr-1.5 text-xs font-normal"
|
||||
aria-label="Update mode"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{effectiveUpdateMode === 'manual' ? 'Manual' : 'Auto'}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
When to re-render
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={effectiveUpdateMode === 'auto'}
|
||||
onCheckedChange={(checked) => checked && updateData({ updateMode: 'auto' })}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="font-medium">Auto</span>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
Re-renders when upstream content changes
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={effectiveUpdateMode === 'manual'}
|
||||
onCheckedChange={(checked) => checked && updateData({ updateMode: 'manual' })}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="font-medium">Manual</span>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
Re-renders only when you click Run
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
<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>
|
||||
</div>
|
||||
) : 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
|
||||
Reference in New Issue
Block a user