368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
import nunjucks from 'nunjucks'
|
|
import FlowContext from '../../lib/flowContext'
|
|
import { useTheme } from '../../lib/themeContext'
|
|
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
|
import {
|
|
BaseNode,
|
|
BaseNodeContent,
|
|
BaseNodeFooter,
|
|
BaseNodeFooterText,
|
|
BaseNodeHeaderRow,
|
|
} from './BaseNode'
|
|
import { Sparkles } from 'lucide-react'
|
|
import { InputHandle, OutputHandle } from './NodeHandles'
|
|
|
|
// Use relative URL so Vite dev proxy (and optional prod proxy) avoids CORS
|
|
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
|
|
|
type Props = {
|
|
id: string
|
|
data?: any
|
|
style?: React.CSSProperties
|
|
}
|
|
|
|
const DEFAULT_CONFIG_NODE_STYLE = { width: 320, height: 320 }
|
|
|
|
const DARK_SKINPARAMS = `
|
|
skinparam backgroundColor #1e1e1e
|
|
skinparam defaultFontColor #e0e0e0
|
|
skinparam shadowing false
|
|
skinparam ArrowColor #b0b0b0
|
|
skinparam ArrowFontColor #e0e0e0
|
|
skinparam ActivityBackgroundColor #2d2d2d
|
|
skinparam ActivityBorderColor #6b6b6b
|
|
skinparam ActivityDiamondBackgroundColor #2d2d2d
|
|
skinparam ActivityDiamondBorderColor #6b6b6b
|
|
skinparam SequenceParticipantBackgroundColor #2d2d2d
|
|
skinparam SequenceParticipantBorderColor #6b6b6b
|
|
skinparam SequenceLifeLineBorderColor #6b6b6b
|
|
skinparam SequenceBoxBackgroundColor #252525
|
|
skinparam SequenceBoxBorderColor #6b6b6b
|
|
skinparam SequenceActorBackgroundColor #2d2d2d
|
|
skinparam SequenceActorBorderColor #6b6b6b
|
|
skinparam ClassBackgroundColor #2d2d2d
|
|
skinparam ClassBorderColor #6b6b6b
|
|
skinparam ComponentBackgroundColor #2d2d2d
|
|
skinparam ComponentBorderColor #6b6b6b
|
|
skinparam StateBackgroundColor #2d2d2d
|
|
skinparam StateBorderColor #6b6b6b
|
|
skinparam partitionBorderColor #6b6b6b
|
|
skinparam sequence {
|
|
ArrowColor #b0b0b0
|
|
LifeLineBorderColor #6b6b6b
|
|
LifeLineBackgroundColor #2d2d2d
|
|
ParticipantBorderColor #6b6b6b
|
|
ActorBorderColor #6b6b6b
|
|
BoxBorderColor #6b6b6b
|
|
}
|
|
skinparam activity {
|
|
ArrowColor #b0b0b0
|
|
BorderColor #6b6b6b
|
|
DiamondBorderColor #6b6b6b
|
|
}
|
|
skinparam class {
|
|
ArrowColor #b0b0b0
|
|
BorderColor #6b6b6b
|
|
}
|
|
`
|
|
|
|
export const RenderingNode = memo(function RenderingNode({ id, width, height }: Props) {
|
|
const [svgContent, setSvgContent] = useState<string | null>(null)
|
|
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const runIdRef = useRef(0)
|
|
const { theme } = useTheme()
|
|
|
|
const ctx = useContext(FlowContext)
|
|
const nodes = ctx?.nodes ?? []
|
|
const edges = ctx?.edges ?? []
|
|
const setNodes = ctx?.setNodes
|
|
const setEdges = ctx?.setEdges
|
|
|
|
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
|
|
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
|
|
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
|
const srcNode = nodes.find((n: any) => n.id === srcId)
|
|
const plantumlText = srcNode && srcNode.type === 'config' ? srcNode?.data?.plantuml ?? '' : ''
|
|
const srcData = srcNode?.data ?? {}
|
|
|
|
const configSignature = useMemo(
|
|
() =>
|
|
nodes
|
|
.filter((n: any) => n.type === 'config')
|
|
.map((n: any) => `${n.id}:${n.data?.title ?? ''}:${n.data?.plantuml ?? ''}`)
|
|
.join('|'),
|
|
[nodes]
|
|
)
|
|
|
|
const edgesSignature = useMemo(
|
|
() =>
|
|
edges
|
|
.map((e: any) => `${e.source}->${e.target}`)
|
|
.sort()
|
|
.join('|'),
|
|
[edges]
|
|
)
|
|
|
|
const variablesSignature = useMemo(
|
|
() =>
|
|
nodes
|
|
.filter((n: any) => n.type === 'variable')
|
|
.map((n: any) => `${n.id}:${n.data?.value}`)
|
|
.join('|'),
|
|
[nodes]
|
|
)
|
|
|
|
const functionsSignature = useMemo(
|
|
() =>
|
|
nodes
|
|
.filter((n: any) => n.type === 'function')
|
|
.map((n: any) => `${n.id}:${n.data?.body ?? ''}`)
|
|
.join('|'),
|
|
[nodes]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!plantumlText) {
|
|
if (incomingIds.length === 0) {
|
|
setSvgContent(null)
|
|
setError(null)
|
|
setLoading(false)
|
|
return
|
|
}
|
|
setSvgContent(null)
|
|
setError({ kind: 'no-plantuml', message: 'No PlantUML found on connected configuration node' })
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
runIdRef.current += 1
|
|
const thisRunId = runIdRef.current
|
|
let cancelled = false
|
|
|
|
const run = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const configIdsUsed = new Set<string>()
|
|
|
|
const resolveIncludes = (text: string, visited = new Set<string>()): string => {
|
|
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
|
|
return text.replace(includeRegex, (match: string, indent: string, ref: string) => {
|
|
const refName = ref.endsWith('.puml') ? ref.slice(0, -5) : ref
|
|
const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName)
|
|
if (!refNode) throw new Error(`Included node not found: ${ref}`)
|
|
if (visited.has(refNode.id)) throw new Error(`Circular include detected: ${ref}`)
|
|
|
|
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
|
|
}
|
|
|
|
if (!isReachable(refNode.id, id)) throw new Error(`Included node not connected to renderer: ${ref}`)
|
|
|
|
visited.add(refNode.id)
|
|
if (refNode.type === 'config') configIdsUsed.add(refNode.id)
|
|
const includedRaw = String(refNode.data?.plantuml ?? '')
|
|
const resolved = resolveIncludes(includedRaw, visited)
|
|
visited.delete(refNode.id)
|
|
|
|
const indented = resolved
|
|
.split('\n')
|
|
.map((line: string) => (line === '' ? '' : indent + line))
|
|
.join('\n')
|
|
return indented
|
|
})
|
|
}
|
|
|
|
if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId)
|
|
|
|
const resolvedIncludes = resolveIncludes(plantumlText)
|
|
|
|
const varMap: Record<string, string> = {}
|
|
// Use null prototype so keys like "var", "constructor", "toString" don't collide with Object.prototype
|
|
const nunjucksContext = Object.create(null) as Record<string, unknown>
|
|
|
|
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') {
|
|
const v = src.data?.value
|
|
const str = v === undefined || v === null ? '' : String(v)
|
|
varMap[src.id] = str
|
|
// Keep booleans/numbers for {% if %} etc.; Nunjucks treats "false" as truthy
|
|
nunjucksContext[src.id] =
|
|
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
|
} else if (src?.type === 'function') {
|
|
const body = src.data?.body ?? 'return args[0];'
|
|
nunjucksContext[src.id] = (...args: unknown[]) => {
|
|
try {
|
|
const fn = new Function('args', body)
|
|
const result = fn(args)
|
|
if (result === undefined || result === null) return ''
|
|
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return result
|
|
return String(result)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let afterNunjucks: string
|
|
try {
|
|
const env = new nunjucks.Environment([], { autoescape: false })
|
|
afterNunjucks = env.renderString(resolvedIncludes, nunjucksContext)
|
|
} catch (nunjucksErr: any) {
|
|
throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`)
|
|
}
|
|
|
|
const resolveFunctionCalls = (text: string): string => {
|
|
const fnCallRegex = /\$\{([\w-]+)\s*\(([^)]*)\)\}/g
|
|
return text.replace(fnCallRegex, (match, funcId: string, argsStr: string) => {
|
|
const fnNode = nodes.find((n: any) => n.id === funcId && n.type === 'function')
|
|
if (!fnNode) return match
|
|
const body = fnNode.data?.body ?? 'return args[0];'
|
|
const argIds = argsStr.split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
const argValues = argIds.map((argId: string) => varMap[argId] ?? '')
|
|
try {
|
|
const fn = new Function('args', body)
|
|
const result = fn(argValues)
|
|
if (result === undefined || result === null) return ''
|
|
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return String(result)
|
|
return String(result)
|
|
} catch {
|
|
return match
|
|
}
|
|
})
|
|
}
|
|
|
|
const resolveVariables = (text: string): string =>
|
|
text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '')
|
|
|
|
const afterFunctions = resolveFunctionCalls(afterNunjucks)
|
|
let resolvedWithVars = resolveVariables(afterFunctions)
|
|
if (theme === 'dark') {
|
|
resolvedWithVars = resolvedWithVars.replace(
|
|
/^(\s*@startuml\s*\n)/i,
|
|
`$1${DARK_SKINPARAMS}\n`
|
|
)
|
|
}
|
|
|
|
const res = await fetch(KROKI_PLANTUML_SVG, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
body: resolvedWithVars,
|
|
})
|
|
|
|
if (cancelled || thisRunId !== runIdRef.current) return
|
|
|
|
if (!res.ok) {
|
|
const errText = await res.text()
|
|
throw new Error(res.status === 400 ? errText || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
|
}
|
|
|
|
const svg = await res.text()
|
|
if (thisRunId !== runIdRef.current) return
|
|
setSvgContent(svg)
|
|
setError(null)
|
|
} catch (err: any) {
|
|
if (cancelled || thisRunId !== runIdRef.current) return
|
|
const msg = err?.message ?? 'PlantUML render error'
|
|
setSvgContent(null)
|
|
setError({ kind: 'render', message: msg })
|
|
} finally {
|
|
if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
|
|
}
|
|
}
|
|
|
|
run()
|
|
return () => { cancelled = true }
|
|
// Only re-run when inputs that affect the resolved diagram change (signatures + source + theme).
|
|
// Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders.
|
|
}, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature])
|
|
|
|
const dimensions =
|
|
width != null && height != null && width > 0 && height > 0
|
|
? { width, height }
|
|
: undefined
|
|
|
|
return (
|
|
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}>
|
|
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={id} />
|
|
|
|
<BaseNodeContent>
|
|
<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 PlantUML diagram.</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 genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
|
|
const nid = genId()
|
|
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: { plantuml: '@startuml\n\n@enduml\n', title: `config-${nid}` }, style: DEFAULT_CONFIG_NODE_STYLE }
|
|
setNodes((nds: any[]) => nds.concat(newNode))
|
|
const edgeId = `e-${nid}-${id}`
|
|
setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id }))
|
|
}}
|
|
>
|
|
Create Config
|
|
</button>
|
|
</EmptyContent>
|
|
</Empty>
|
|
) : error ? (
|
|
srcData?.renderError ? (
|
|
srcData.renderError(error)
|
|
) : srcData?.errorHtml ? (
|
|
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
|
|
) : (
|
|
<div className="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div>
|
|
)
|
|
) : loading ? (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
|
) : svgContent ? (
|
|
<div
|
|
className="min-h-0 flex-1 w-full overflow-auto rounded border border-input bg-white dark:bg-gray-900 [&_svg]:max-w-full [&_svg]:h-auto"
|
|
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</BaseNodeContent>
|
|
|
|
<BaseNodeFooter>
|
|
<BaseNodeFooterText>
|
|
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'}
|
|
</BaseNodeFooterText>
|
|
</BaseNodeFooter>
|
|
</BaseNode>
|
|
)
|
|
})
|
|
|
|
RenderingNode.displayName = 'RenderingNode'
|
|
|
|
export default RenderingNode
|