add markdown support

This commit is contained in:
2026-03-08 21:56:08 +01:00
parent fe37c9046d
commit 4022975e4d
8 changed files with 403 additions and 101 deletions

View File

@@ -115,10 +115,13 @@ export function BaseNodeHeader({
export function BaseNodeHeaderRow({
icon,
title,
right,
className,
}: {
icon: ReactNode;
title: ReactNode;
/** Optional right-side content (e.g. type selector). */
right?: ReactNode;
className?: string;
}) {
return (
@@ -131,10 +134,11 @@ export function BaseNodeHeaderRow({
{icon}
<h3
data-slot="base-node-title"
className="user-select-none flex-1 font-mono"
className="user-select-none flex-1 font-mono min-w-0 truncate"
>
{title}
</h3>
{right != null ? <div className="shrink-0 nodrag nopan">{right}</div> : null}
</header>
);
}

View File

@@ -1,12 +1,22 @@
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror'
import { markdown } from '@codemirror/lang-markdown'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
import { plantumlLanguage } from '../../lib/plantumlLanguage'
import { useTheme } from '../../lib/themeContext'
import {
CONFIG_TYPES,
getConfigContent,
getConfigType,
getConfigTypeId,
getDefaultContentForConfigType,
isGroup,
type ConfigTypeId,
} from '../../lib/configTypes'
import {
BaseNode,
BaseNodeContent,
@@ -20,6 +30,7 @@ import {
MenubarSubContent,
MenubarSubTrigger,
} from '../ui/menubar'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle'
@@ -32,10 +43,10 @@ type Props = {
height?: number
}
const DEFAULT_PLANTUML = '@startuml\n\n@enduml\n'
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
const plantumlValue = data?.plantuml ?? DEFAULT_PLANTUML
const configTypeId = getConfigTypeId(data)
const configType = getConfigType(configTypeId)
const content = getConfigContent(data) || getDefaultContentForConfigType(configTypeId)
const { theme } = useTheme()
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
@@ -63,10 +74,37 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
const onChange = useCallback(
(val: string) => {
if (setNodes) {
setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, plantuml: val } } : n)))
setNodes((nds: any[]) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, content: val, configType: configTypeId } } : n
)
)
}
},
[id, setNodes]
[id, setNodes, configTypeId]
)
const setConfigType = useCallback(
(newTypeId: ConfigTypeId) => {
if (!setNodes || newTypeId === configTypeId) return
setNodes((nds: any[]) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
configType: newTypeId,
content:
getConfigContent(n.data) ||
getDefaultContentForConfigType(newTypeId),
},
}
: n
)
)
},
[id, setNodes, configTypeId]
)
const insertAt = useCallback(
@@ -91,12 +129,12 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
return
}
if (mode === 'prepend') {
onChange(insertText + plantumlValue)
onChange(insertText + content)
} else {
onChange(plantumlValue + insertText)
onChange(content + insertText)
}
},
[onChange, plantumlValue]
[onChange, content]
)
const insertExtendsFromNode = useCallback(
@@ -142,30 +180,44 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
)
const extensions = useMemo(
() => [
plantumlLanguage.extension,
configType.language === 'plantuml' ? plantumlLanguage.extension : markdown(),
autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
activateOnTyping: true,
}),
],
[variableIds, functionIds, configTitles],
[configType.language, variableIds, functionIds, configTitles],
)
const [editorHeight, editorContainerRef] = useResizeHeight(180)
const nunjucksTagSnippets = useMemo(
() => [
{ label: 'Variable {{ }}', snippet: '{{ }}' },
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
{ label: 'set', snippet: '{% set = %}' },
{ label: 'block / endblock', snippet: '{% block %}\n \n{% endblock %}' },
{ label: 'extends', snippet: '{% extends "" %}' },
{ label: 'include', snippet: '{% include "" %}' },
{ label: 'import', snippet: '{% import "" as %}' },
{ label: 'raw / endraw', snippet: '{% raw %}\n \n{% endraw %}' },
],
[]
)
const insertBlocksContent = useMemo(() => {
return configType.insertBlocks.map((block, idx) =>
isGroup(block) ? (
<MenubarSub key={`${block.label}-${idx}`}>
<MenubarSubTrigger className="text-xs">{block.label}</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{block.items.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
</MenubarItem>
))}
</MenubarSubContent>
</MenubarSub>
) : (
<MenubarItem
key={block.label}
className="text-xs"
onClick={() => insertAt(block.snippet, 'cursor')}
>
{block.label}
</MenubarItem>
)
)
}, [configType.insertBlocks, insertAt])
const dimensions =
width != null && height != null && width > 0 && height > 0
@@ -174,7 +226,24 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
return (
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow icon={<ScrollText className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />} />
<BaseNodeHeaderRow
icon={<ScrollText className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />}
right={
<Select value={configTypeId} onValueChange={(v) => setConfigType(v as ConfigTypeId)}>
<SelectTrigger className="h-7 w-[7rem] text-xs font-normal">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONFIG_TYPES.map((t) => (
<SelectItem key={t.id} value={t.id} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
@@ -247,19 +316,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</>
) : undefined
}
insertTagsContent={
<>
{nunjucksTagSnippets.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
</MenubarItem>
))}
</>
}
insertTagsContent={insertBlocksContent}
insertTagsLabel={configType.label}
/>
</div>
@@ -267,7 +325,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
<CodeMirror
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef}
value={plantumlValue}
value={content}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
@@ -280,7 +338,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="config">
{plantumlValue ? `${plantumlValue.length} chars` : 'none'}
{content ? `${content.length} chars` : 'none'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>

View File

@@ -22,13 +22,15 @@ type Props = {
nodeType: NodeType
/** Content for Insert → Inputs (config and function nodes only) */
editInputsContent?: React.ReactNode
/** Content for Insert → Tags (e.g. Nunjucks tag snippets, config nodes) */
/** Content for Insert → [Blocks/Tags] (e.g. type-specific snippets, config nodes) */
insertTagsContent?: React.ReactNode
/** Label for Insert submenu that shows insertTagsContent (default "Tags") */
insertTagsLabel?: string
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
nodeMenuExtraContent?: React.ReactNode
}
export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsContent, nodeMenuExtraContent }: Props) {
export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsContent, insertTagsLabel = 'Tags', nodeMenuExtraContent }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
@@ -123,7 +125,7 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
{insertTagsContent != null && (
<MenubarSub>
<MenubarSubTrigger className="text-xs">
Tags
{insertTagsLabel}
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{insertTagsContent}

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks'
import FlowContext from '../../lib/flowContext'
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
import {
BaseNode,
@@ -17,9 +18,6 @@ import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSu
import { Sparkles } from 'lucide-react'
import { InputHandle } 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
@@ -27,7 +25,7 @@ type Props = {
}
export const RenderingNode = memo(function RenderingNode({ id, width, height }: Props) {
const [svgContent, setSvgContent] = useState<string | null>(null)
const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false)
const runIdRef = useRef(0)
@@ -44,7 +42,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
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 configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
const sourceContent = srcNode?.type === 'config' ? getConfigContent(srcNode.data) : ''
const srcData = srcNode?.data ?? {}
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
@@ -86,7 +85,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
if (!node) return
visited.add(nodeId)
out.add(nodeId)
const content = String(node.data?.plantuml ?? '')
const content = getConfigContent(node.data)
for (const ref of getTemplateRefs(content)) {
const refId = resolveRef(ref)
if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id))
@@ -109,7 +108,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
() =>
nodes
.filter((n: any) => n.type === 'config' && connectedNodeIds.has(n.id))
.map((n: any) => `${n.id}:${n.data?.title ?? ''}:${n.data?.plantuml ?? ''}`)
.map((n: any) => `${n.id}:${n.data?.title ?? ''}:${getConfigContent(n.data)}`)
.sort()
.join('|'),
[nodes, connectedNodeIds]
@@ -146,15 +145,15 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
)
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' })
if (!sourceContent && incomingIds.length > 0) {
setRenderedContent(null)
setError({ kind: 'no-content', message: 'No content on connected configuration node' })
setLoading(false)
return
}
if (incomingIds.length === 0) {
setRenderedContent(null)
setError(null)
setLoading(false)
return
}
@@ -213,7 +212,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
visited.add(refId)
configIdsUsed.add(refId)
const content = String(node.data?.plantuml ?? '')
const content = getConfigContent(node.data)
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
}
@@ -228,7 +227,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
if (refId !== srcId && !isReachable(refId, id))
throw new Error(`Referenced config not connected to renderer: ${name}`)
return {
src: String(node.data?.plantuml ?? ''),
src: getConfigContent(node.data),
path: name,
}
},
@@ -428,30 +427,18 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
return
}
const resolvedPlantuml = afterNunjucks
const resolvedContent = afterNunjucks
const typeRenderer = getConfigType(configTypeId)
try {
const res = await fetch(KROKI_PLANTUML_SVG, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: resolvedPlantuml,
})
const htmlOrSvg = await typeRenderer.render(resolvedContent)
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)
setRenderedContent(htmlOrSvg)
setError(null)
} catch (err: any) {
if (cancelled || thisRunId !== runIdRef.current) return
const msg = err?.message ?? 'PlantUML render error'
setSvgContent(null)
const msg = err?.message ?? 'Render error'
setRenderedContent(null)
setError({ kind: 'render', message: msg })
} finally {
if (!cancelled && thisRunId === runIdRef.current) {
@@ -471,8 +458,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
})
} catch (err: any) {
if (!cancelled && thisRunId === runIdRef.current) {
setSvgContent(null)
setError({ kind: 'render', message: err?.message ?? 'PlantUML render error' })
setRenderedContent(null)
setError({ kind: 'render', message: err?.message ?? 'Render error' })
setLoading(false)
}
}
@@ -486,29 +473,31 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
minLoadingTimeoutRef.current = null
}
}
// Only re-run when inputs that affect the resolved diagram change (signatures + source).
// Only re-run when inputs that affect the resolved output change (signatures + source).
// Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders.
}, [id, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature])
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature])
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
const isSvgOutput = Boolean(renderedContent?.trim().startsWith('<svg'))
const downloadSvg = useCallback(() => {
if (!svgContent) return
const blob = new Blob([svgContent], { type: 'image/svg+xml' })
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, svgContent])
}, [id, renderedContent, isSvgOutput])
const downloadPng = useCallback(() => {
if (!svgContent) return
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgContent)
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')
@@ -525,9 +514,9 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
}
img.onerror = () => {}
img.src = dataUrl
}, [id, svgContent])
}, [id, renderedContent, isSvgOutput])
const status = loading ? 'loading' : error ? 'error' : svgContent ? 'success' : 'initial'
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
@@ -542,14 +531,14 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
nodeMenuExtraContent={
<MenubarSub>
<MenubarSeparator></MenubarSeparator>
<MenubarSubTrigger className="text-xs" disabled={!svgContent}>
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
Export
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!svgContent}>
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
PNG
</MenubarItem>
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!svgContent}>
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
SVG
</MenubarItem>
</MenubarSubContent>
@@ -565,7 +554,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
<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>
<EmptyDescription>Connect a Configuration node or create one. The renderer will display the diagram or document.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<button
@@ -595,10 +584,14 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
)
) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : svgContent ? (
) : renderedContent ? (
<div
className="rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary [&_svg]:max-w-full [&_svg]:h-auto"
dangerouslySetInnerHTML={{ __html: svgContent }}
className={
isSvgOutput
? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary [&_svg]:max-w-full [&_svg]:h-auto'
: '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>
@@ -606,7 +599,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'}
{renderedContent ? (isSvgOutput ? 'Diagram' : 'Markdown') : error ? 'Error' : '—'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>