add markdown support
This commit is contained in:
91
package-lock.json
generated
91
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"nunjucks": "^3.2.4",
|
"nunjucks": "^3.2.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
@@ -572,6 +574,36 @@
|
|||||||
"@lezer/common": "^1.1.0"
|
"@lezer/common": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/lang-css": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.0.2",
|
||||||
|
"@lezer/css": "^1.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-html": {
|
||||||
|
"version": "6.4.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||||
|
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
|
"@codemirror/lang-javascript": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.4.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@lezer/css": "^1.1.0",
|
||||||
|
"@lezer/html": "^1.3.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/lang-javascript": {
|
"node_modules/@codemirror/lang-javascript": {
|
||||||
"version": "6.2.5",
|
"version": "6.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||||
@@ -587,6 +619,21 @@
|
|||||||
"@lezer/javascript": "^1.0.0"
|
"@lezer/javascript": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/lang-markdown": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.7.1",
|
||||||
|
"@codemirror/lang-html": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.3.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.2.1",
|
||||||
|
"@lezer/markdown": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/language": {
|
"node_modules/@codemirror/language": {
|
||||||
"version": "6.12.2",
|
"version": "6.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
|
||||||
@@ -1444,6 +1491,17 @@
|
|||||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/css": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@lezer/highlight": {
|
"node_modules/@lezer/highlight": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
@@ -1453,6 +1511,17 @@
|
|||||||
"@lezer/common": "^1.3.0"
|
"@lezer/common": "^1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/html": {
|
||||||
|
"version": "1.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||||
|
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@lezer/javascript": {
|
"node_modules/@lezer/javascript": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||||
@@ -1473,6 +1542,16 @@
|
|||||||
"@lezer/common": "^1.0.0"
|
"@lezer/common": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/markdown": {
|
||||||
|
"version": "1.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
|
||||||
|
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@marijn/find-cluster-break": {
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
@@ -5764,6 +5843,18 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "17.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
|
||||||
|
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"nunjucks": "^3.2.4",
|
"nunjucks": "^3.2.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
|||||||
@@ -115,10 +115,13 @@ export function BaseNodeHeader({
|
|||||||
export function BaseNodeHeaderRow({
|
export function BaseNodeHeaderRow({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
|
right,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
|
/** Optional right-side content (e.g. type selector). */
|
||||||
|
right?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -131,10 +134,11 @@ export function BaseNodeHeaderRow({
|
|||||||
{icon}
|
{icon}
|
||||||
<h3
|
<h3
|
||||||
data-slot="base-node-title"
|
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}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
{right != null ? <div className="shrink-0 nodrag nopan">{right}</div> : null}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
|
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
|
||||||
import { autocompletion } from '@codemirror/autocomplete'
|
import { autocompletion } from '@codemirror/autocomplete'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '../../lib/flowContext'
|
||||||
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
import { nodePropsAreEqual } from '../../lib/flowUtils'
|
||||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||||
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
||||||
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
||||||
import { useTheme } from '../../lib/themeContext'
|
import { useTheme } from '../../lib/themeContext'
|
||||||
|
import {
|
||||||
|
CONFIG_TYPES,
|
||||||
|
getConfigContent,
|
||||||
|
getConfigType,
|
||||||
|
getConfigTypeId,
|
||||||
|
getDefaultContentForConfigType,
|
||||||
|
isGroup,
|
||||||
|
type ConfigTypeId,
|
||||||
|
} from '../../lib/configTypes'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
@@ -20,6 +30,7 @@ import {
|
|||||||
MenubarSubContent,
|
MenubarSubContent,
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
} from '../ui/menubar'
|
} from '../ui/menubar'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||||
import { InputHandle, OutputHandle } from './NodeHandles'
|
import { InputHandle, OutputHandle } from './NodeHandles'
|
||||||
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
||||||
@@ -32,10 +43,10 @@ type Props = {
|
|||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PLANTUML = '@startuml\n\n@enduml\n'
|
|
||||||
|
|
||||||
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
|
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 { theme } = useTheme()
|
||||||
const ctx = useContext(FlowContext)
|
const ctx = useContext(FlowContext)
|
||||||
const setNodes = ctx?.setNodes
|
const setNodes = ctx?.setNodes
|
||||||
@@ -63,10 +74,37 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(val: string) => {
|
(val: string) => {
|
||||||
if (setNodes) {
|
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(
|
const insertAt = useCallback(
|
||||||
@@ -91,12 +129,12 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mode === 'prepend') {
|
if (mode === 'prepend') {
|
||||||
onChange(insertText + plantumlValue)
|
onChange(insertText + content)
|
||||||
} else {
|
} else {
|
||||||
onChange(plantumlValue + insertText)
|
onChange(content + insertText)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange, plantumlValue]
|
[onChange, content]
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertExtendsFromNode = useCallback(
|
const insertExtendsFromNode = useCallback(
|
||||||
@@ -142,30 +180,44 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
)
|
)
|
||||||
const extensions = useMemo(
|
const extensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
plantumlLanguage.extension,
|
configType.language === 'plantuml' ? plantumlLanguage.extension : markdown(),
|
||||||
autocompletion({
|
autocompletion({
|
||||||
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
|
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
|
||||||
activateOnTyping: true,
|
activateOnTyping: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[variableIds, functionIds, configTitles],
|
[configType.language, variableIds, functionIds, configTitles],
|
||||||
)
|
)
|
||||||
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||||
|
|
||||||
const nunjucksTagSnippets = useMemo(
|
const insertBlocksContent = useMemo(() => {
|
||||||
() => [
|
return configType.insertBlocks.map((block, idx) =>
|
||||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
isGroup(block) ? (
|
||||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
<MenubarSub key={`${block.label}-${idx}`}>
|
||||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
<MenubarSubTrigger className="text-xs">{block.label}</MenubarSubTrigger>
|
||||||
{ label: 'set', snippet: '{% set = %}' },
|
<MenubarSubContent className="min-w-[12rem]">
|
||||||
{ label: 'block / endblock', snippet: '{% block %}\n \n{% endblock %}' },
|
{block.items.map(({ label, snippet }) => (
|
||||||
{ label: 'extends', snippet: '{% extends "" %}' },
|
<MenubarItem
|
||||||
{ label: 'include', snippet: '{% include "" %}' },
|
key={label}
|
||||||
{ label: 'import', snippet: '{% import "" as %}' },
|
className="text-xs"
|
||||||
{ label: 'raw / endraw', snippet: '{% raw %}\n \n{% endraw %}' },
|
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 =
|
const dimensions =
|
||||||
width != null && height != null && width > 0 && height > 0
|
width != null && height != null && width > 0 && height > 0
|
||||||
@@ -174,7 +226,24 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
|
<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>
|
<BaseNodeContent>
|
||||||
<div className="shrink-0 w-full">
|
<div className="shrink-0 w-full">
|
||||||
@@ -247,19 +316,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
insertTagsContent={
|
insertTagsContent={insertBlocksContent}
|
||||||
<>
|
insertTagsLabel={configType.label}
|
||||||
{nunjucksTagSnippets.map(({ label, snippet }) => (
|
|
||||||
<MenubarItem
|
|
||||||
key={label}
|
|
||||||
className="text-xs"
|
|
||||||
onClick={() => insertAt(snippet, 'cursor')}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</MenubarItem>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,7 +325,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
<CodeMirror
|
<CodeMirror
|
||||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={plantumlValue}
|
value={content}
|
||||||
height={`${editorHeight}px`}
|
height={`${editorHeight}px`}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
@@ -280,7 +338,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
|
|
||||||
<BaseNodeFooter>
|
<BaseNodeFooter>
|
||||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="config">
|
<NodeFooterEdgeIndicators nodeId={id} nodeType="config">
|
||||||
{plantumlValue ? `${plantumlValue.length} chars` : 'none'}
|
{content ? `${content.length} chars` : 'none'}
|
||||||
</NodeFooterEdgeIndicators>
|
</NodeFooterEdgeIndicators>
|
||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
|
|||||||
@@ -22,13 +22,15 @@ type Props = {
|
|||||||
nodeType: NodeType
|
nodeType: NodeType
|
||||||
/** Content for Insert → Inputs (config and function nodes only) */
|
/** Content for Insert → Inputs (config and function nodes only) */
|
||||||
editInputsContent?: React.ReactNode
|
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
|
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 */
|
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
|
||||||
nodeMenuExtraContent?: React.ReactNode
|
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 ctx = useContext(FlowContext)
|
||||||
const nodes = ctx?.nodes ?? []
|
const nodes = ctx?.nodes ?? []
|
||||||
const setNodes = ctx?.setNodes
|
const setNodes = ctx?.setNodes
|
||||||
@@ -123,7 +125,7 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
|
|||||||
{insertTagsContent != null && (
|
{insertTagsContent != null && (
|
||||||
<MenubarSub>
|
<MenubarSub>
|
||||||
<MenubarSubTrigger className="text-xs">
|
<MenubarSubTrigger className="text-xs">
|
||||||
Tags
|
{insertTagsLabel}
|
||||||
</MenubarSubTrigger>
|
</MenubarSubTrigger>
|
||||||
<MenubarSubContent className="min-w-[12rem]">
|
<MenubarSubContent className="min-w-[12rem]">
|
||||||
{insertTagsContent}
|
{insertTagsContent}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import nunjucks from 'nunjucks'
|
import nunjucks from 'nunjucks'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '../../lib/flowContext'
|
||||||
|
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
|
||||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
@@ -17,9 +18,6 @@ import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSu
|
|||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
import { InputHandle } from './NodeHandles'
|
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 = {
|
type Props = {
|
||||||
id: string
|
id: string
|
||||||
data?: any
|
data?: any
|
||||||
@@ -27,7 +25,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RenderingNode = memo(function RenderingNode({ id, width, height }: 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 [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const runIdRef = useRef(0)
|
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 incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
|
||||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||||
const srcNode = nodes.find((n: any) => n.id === srcId)
|
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 ?? {}
|
const srcData = srcNode?.data ?? {}
|
||||||
|
|
||||||
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
|
/** 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
|
if (!node) return
|
||||||
visited.add(nodeId)
|
visited.add(nodeId)
|
||||||
out.add(nodeId)
|
out.add(nodeId)
|
||||||
const content = String(node.data?.plantuml ?? '')
|
const content = getConfigContent(node.data)
|
||||||
for (const ref of getTemplateRefs(content)) {
|
for (const ref of getTemplateRefs(content)) {
|
||||||
const refId = resolveRef(ref)
|
const refId = resolveRef(ref)
|
||||||
if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id))
|
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
|
nodes
|
||||||
.filter((n: any) => n.type === 'config' && connectedNodeIds.has(n.id))
|
.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()
|
.sort()
|
||||||
.join('|'),
|
.join('|'),
|
||||||
[nodes, connectedNodeIds]
|
[nodes, connectedNodeIds]
|
||||||
@@ -146,15 +145,15 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!plantumlText) {
|
if (!sourceContent && incomingIds.length > 0) {
|
||||||
if (incomingIds.length === 0) {
|
setRenderedContent(null)
|
||||||
setSvgContent(null)
|
setError({ kind: 'no-content', message: 'No content on connected configuration node' })
|
||||||
setError(null)
|
setLoading(false)
|
||||||
setLoading(false)
|
return
|
||||||
return
|
}
|
||||||
}
|
if (incomingIds.length === 0) {
|
||||||
setSvgContent(null)
|
setRenderedContent(null)
|
||||||
setError({ kind: 'no-plantuml', message: 'No PlantUML found on connected configuration node' })
|
setError(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -213,7 +212,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
||||||
visited.add(refId)
|
visited.add(refId)
|
||||||
configIdsUsed.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)
|
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))
|
if (refId !== srcId && !isReachable(refId, id))
|
||||||
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
||||||
return {
|
return {
|
||||||
src: String(node.data?.plantuml ?? ''),
|
src: getConfigContent(node.data),
|
||||||
path: name,
|
path: name,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -428,30 +427,18 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPlantuml = afterNunjucks
|
const resolvedContent = afterNunjucks
|
||||||
|
const typeRenderer = getConfigType(configTypeId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
const htmlOrSvg = await typeRenderer.render(resolvedContent)
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
body: resolvedPlantuml,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
if (cancelled || thisRunId !== runIdRef.current) return
|
||||||
|
setRenderedContent(htmlOrSvg)
|
||||||
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)
|
setError(null)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
if (cancelled || thisRunId !== runIdRef.current) return
|
||||||
const msg = err?.message ?? 'PlantUML render error'
|
const msg = err?.message ?? 'Render error'
|
||||||
setSvgContent(null)
|
setRenderedContent(null)
|
||||||
setError({ kind: 'render', message: msg })
|
setError({ kind: 'render', message: msg })
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled && thisRunId === runIdRef.current) {
|
if (!cancelled && thisRunId === runIdRef.current) {
|
||||||
@@ -471,8 +458,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (!cancelled && thisRunId === runIdRef.current) {
|
if (!cancelled && thisRunId === runIdRef.current) {
|
||||||
setSvgContent(null)
|
setRenderedContent(null)
|
||||||
setError({ kind: 'render', message: err?.message ?? 'PlantUML render error' })
|
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,29 +473,31 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
minLoadingTimeoutRef.current = null
|
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.
|
// 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 =
|
const dimensions =
|
||||||
width != null && height != null && width > 0 && height > 0
|
width != null && height != null && width > 0 && height > 0
|
||||||
? { width, height }
|
? { width, height }
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const isSvgOutput = Boolean(renderedContent?.trim().startsWith('<svg'))
|
||||||
|
|
||||||
const downloadSvg = useCallback(() => {
|
const downloadSvg = useCallback(() => {
|
||||||
if (!svgContent) return
|
if (!renderedContent || !isSvgOutput) return
|
||||||
const blob = new Blob([svgContent], { type: 'image/svg+xml' })
|
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `${id}.svg`
|
a.download = `${id}.svg`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}, [id, svgContent])
|
}, [id, renderedContent, isSvgOutput])
|
||||||
|
|
||||||
const downloadPng = useCallback(() => {
|
const downloadPng = useCallback(() => {
|
||||||
if (!svgContent) return
|
if (!renderedContent || !isSvgOutput) return
|
||||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgContent)
|
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -525,9 +514,9 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
}
|
}
|
||||||
img.onerror = () => {}
|
img.onerror = () => {}
|
||||||
img.src = dataUrl
|
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 (
|
return (
|
||||||
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
|
<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={
|
nodeMenuExtraContent={
|
||||||
<MenubarSub>
|
<MenubarSub>
|
||||||
<MenubarSeparator></MenubarSeparator>
|
<MenubarSeparator></MenubarSeparator>
|
||||||
<MenubarSubTrigger className="text-xs" disabled={!svgContent}>
|
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
||||||
Export
|
Export
|
||||||
</MenubarSubTrigger>
|
</MenubarSubTrigger>
|
||||||
<MenubarSubContent className="min-w-[10rem]">
|
<MenubarSubContent className="min-w-[10rem]">
|
||||||
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!svgContent}>
|
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
|
||||||
PNG
|
PNG
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!svgContent}>
|
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
|
||||||
SVG
|
SVG
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
@@ -565,7 +554,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
<Sparkles className="size-6" />
|
<Sparkles className="size-6" />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>No configuration connected</EmptyTitle>
|
<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>
|
</EmptyHeader>
|
||||||
<EmptyContent>
|
<EmptyContent>
|
||||||
<button
|
<button
|
||||||
@@ -595,10 +584,14 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
)
|
)
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||||
) : svgContent ? (
|
) : renderedContent ? (
|
||||||
<div
|
<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"
|
className={
|
||||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -606,7 +599,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
|
|
||||||
<BaseNodeFooter>
|
<BaseNodeFooter>
|
||||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
||||||
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'}
|
{renderedContent ? (isSvgOutput ? 'Diagram' : 'Markdown') : error ? 'Error' : '—'}
|
||||||
</NodeFooterEdgeIndicators>
|
</NodeFooterEdgeIndicators>
|
||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
|
|||||||
152
src/lib/configTypes.ts
Normal file
152
src/lib/configTypes.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Registry of config node types. Each type defines syntax highlighting,
|
||||||
|
* insert-menu blocks, and how to render the content (after Nunjucks) for the renderer.
|
||||||
|
* Add a new entry here and wire its language in ConfigNode to add a new config type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ConfigTypeId = 'plantuml' | 'markdown'
|
||||||
|
|
||||||
|
/** A single insert option (label + snippet to insert at cursor). */
|
||||||
|
export type InsertBlock = { label: string; snippet: string }
|
||||||
|
|
||||||
|
/** A group of insert options (e.g. "Diagram" with startuml, actor, etc.). */
|
||||||
|
export type InsertBlockGroup = { label: string; items: InsertBlock[] }
|
||||||
|
|
||||||
|
export type InsertBlockOrGroup = InsertBlock | InsertBlockGroup
|
||||||
|
|
||||||
|
function isGroup(b: InsertBlockOrGroup): b is InsertBlockGroup {
|
||||||
|
return 'items' in b && Array.isArray((b as InsertBlockGroup).items)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigType = {
|
||||||
|
id: ConfigTypeId
|
||||||
|
label: string
|
||||||
|
/** CodeMirror language key; used to pick the extension in ConfigNode. */
|
||||||
|
language: ConfigTypeId
|
||||||
|
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */
|
||||||
|
insertBlocks: InsertBlockOrGroup[]
|
||||||
|
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
|
||||||
|
render: (content: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||||
|
|
||||||
|
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||||
|
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||||
|
{ label: 'Actor', snippet: 'actor ' },
|
||||||
|
{ label: 'Participant', snippet: 'participant "" as ' },
|
||||||
|
{ label: 'Arrow', snippet: ' -> ' },
|
||||||
|
{ label: 'Note', snippet: 'note right of ' },
|
||||||
|
{
|
||||||
|
label: 'Nunjucks',
|
||||||
|
items: [
|
||||||
|
{ 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: 'extends', snippet: '{% extends "" %}' },
|
||||||
|
{ label: 'include', snippet: '{% include "" %}' },
|
||||||
|
{ label: 'import', snippet: '{% import "" as %}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const MARKDOWN_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||||
|
{ label: 'Heading 1', snippet: '# ' },
|
||||||
|
{ label: 'Heading 2', snippet: '## ' },
|
||||||
|
{ label: 'Heading 3', snippet: '### ' },
|
||||||
|
{ label: 'Bold', snippet: '****' },
|
||||||
|
{ label: 'Italic', snippet: '**' },
|
||||||
|
{ label: 'Code inline', snippet: '``' },
|
||||||
|
{ label: 'Code block', snippet: '```\n\n```' },
|
||||||
|
{ label: 'Link', snippet: '[]()' },
|
||||||
|
{ label: 'Image', snippet: '![]()' },
|
||||||
|
{ label: 'List item', snippet: '- ' },
|
||||||
|
{ label: 'Blockquote', snippet: '> ' },
|
||||||
|
{
|
||||||
|
label: 'Nunjucks',
|
||||||
|
items: [
|
||||||
|
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||||
|
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||||
|
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||||
|
{ label: 'set', snippet: '{% set = %}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Markdown to HTML using dynamic import to avoid loading if only PlantUML is used. */
|
||||||
|
async function renderMarkdown(content: string): Promise<string> {
|
||||||
|
const { marked } = await import('marked')
|
||||||
|
const html = typeof marked.parse === 'function' ? await marked.parse(content) : (marked as (s: string) => string)(content)
|
||||||
|
return typeof html === 'string' ? html : String(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONFIG_TYPES: ConfigType[] = [
|
||||||
|
{
|
||||||
|
id: 'plantuml',
|
||||||
|
label: 'Diagram',
|
||||||
|
language: 'plantuml',
|
||||||
|
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||||
|
render: async (content: string) => {
|
||||||
|
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
body: content,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.text()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'markdown',
|
||||||
|
label: 'Markdown',
|
||||||
|
language: 'markdown',
|
||||||
|
insertBlocks: MARKDOWN_INSERT_BLOCKS,
|
||||||
|
render: renderMarkdown,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CONFIG_TYPE_IDS = CONFIG_TYPES.map((t) => t.id)
|
||||||
|
export const DEFAULT_CONFIG_TYPE_ID: ConfigTypeId = 'plantuml'
|
||||||
|
|
||||||
|
export function getConfigType(id: ConfigTypeId): ConfigType {
|
||||||
|
const t = CONFIG_TYPES.find((c) => c.id === id)
|
||||||
|
if (!t) throw new Error(`Unknown config type: ${id}`)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultContentForConfigType(configTypeId: ConfigTypeId): string {
|
||||||
|
if (configTypeId === 'plantuml') return '@startuml\n\n@enduml\n'
|
||||||
|
if (configTypeId === 'markdown') return ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten insert blocks for iteration: either a single block or a group's items. */
|
||||||
|
export function* iterateInsertBlocks(blocks: InsertBlockOrGroup[]): Generator<InsertBlock> {
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (isGroup(b)) {
|
||||||
|
for (const item of b.items) yield item
|
||||||
|
} else {
|
||||||
|
yield b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isGroup }
|
||||||
|
|
||||||
|
/** Content from config node data (backward compat: content ?? plantuml). */
|
||||||
|
export function getConfigContent(data: Record<string, unknown> | undefined): string {
|
||||||
|
if (!data) return ''
|
||||||
|
const content = data.content ?? data.plantuml
|
||||||
|
return typeof content === 'string' ? content : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Config type id from data (default plantuml). */
|
||||||
|
export function getConfigTypeId(data: Record<string, unknown> | undefined): ConfigTypeId {
|
||||||
|
if (!data || data.configType == null) return 'plantuml'
|
||||||
|
const id = data.configType
|
||||||
|
return CONFIG_TYPE_IDS.includes(id as ConfigTypeId) ? (id as ConfigTypeId) : 'plantuml'
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DATA: Record<string, any> = {
|
const DEFAULT_DATA: Record<string, any> = {
|
||||||
config: { plantuml: '@startuml\n\n@enduml\n', title: '' },
|
config: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' },
|
||||||
render: {},
|
render: {},
|
||||||
variable: { value: '', valueType: 'string' },
|
variable: { value: '', valueType: 'string' },
|
||||||
function: {
|
function: {
|
||||||
@@ -91,7 +91,7 @@ export function getDefaultDataForType(type: string, newId?: string): any {
|
|||||||
/** Data for Reset action: clears code completely for config/function; same as default for others */
|
/** Data for Reset action: clears code completely for config/function; same as default for others */
|
||||||
export function getResetDataForType(type: string, nodeId?: string): any {
|
export function getResetDataForType(type: string, nodeId?: string): any {
|
||||||
if (type === 'config') {
|
if (type === 'config') {
|
||||||
return { plantuml: '@startuml\n\n@enduml\n', title: nodeId ? `${nodeId}` : '' }
|
return { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: nodeId ? `${nodeId}` : '' }
|
||||||
}
|
}
|
||||||
if (type === 'function') {
|
if (type === 'function') {
|
||||||
return { body: '' }
|
return { body: '' }
|
||||||
|
|||||||
Reference in New Issue
Block a user