Compare commits

...

6 Commits

Author SHA1 Message Date
33aa313f34 added wireframes 2026-03-08 23:23:11 +01:00
9ce0d55370 style 2026-03-08 22:42:31 +01:00
74fe1bed33 improved menus 2026-03-08 22:27:59 +01:00
4022975e4d add markdown support 2026-03-08 21:56:08 +01:00
fe37c9046d context 2026-03-08 21:44:44 +01:00
f21639693b theme 2026-03-08 21:23:07 +01:00
13 changed files with 678 additions and 205 deletions

98
package-lock.json generated
View File

@@ -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",
@@ -16,10 +17,12 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@uiw/react-codemirror": "^4.25.7", "@uiw/react-codemirror": "^4.25.7",
"@wireweave/core": "^2.6.0",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"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 +575,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 +620,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 +1492,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 +1512,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 +1543,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",
@@ -3366,6 +3446,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@wireweave/core": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@wireweave/core/-/core-2.6.0.tgz",
"integrity": "sha512-Cov/aVX/+bjoduUJTBxXuDxYOiQuNnlbjkX+pyVL9azoT7RNKwHLnQ3IXfhxeQzG/nfP17Mbon0Vmd5gsoz1lg==",
"license": "MIT"
},
"node_modules/@xyflow/react": { "node_modules/@xyflow/react": {
"version": "12.10.1", "version": "12.10.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
@@ -5764,6 +5850,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",

View File

@@ -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",
@@ -16,10 +17,12 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@uiw/react-codemirror": "^4.25.7", "@uiw/react-codemirror": "^4.25.7",
"@wireweave/core": "^2.6.0",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"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",

View File

@@ -35,8 +35,7 @@ import {
ContextMenuGroup ContextMenuGroup
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { AppMenubar } from '@/components/AppMenubar' import { AppMenubar } from '@/components/AppMenubar'
import { Button } from '@/components/ui/button' import { ClipboardPaste, Code2, ScrollText, Sparkles, Variable } from 'lucide-react'
import { ClipboardPaste, Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils' import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils'
const SNAP_GRID: [number, number] = [15, 15] const SNAP_GRID: [number, number] = [15, 15]
@@ -69,7 +68,7 @@ const initialEdges: Edge[] = [
const PROJECT_FILE_EXT = '.zui.json' const PROJECT_FILE_EXT = '.zui.json'
export default function App() { export default function App() {
const { theme, toggleTheme } = useTheme() const { theme } = useTheme()
const { const {
nodes, nodes,
edges, edges,
@@ -378,16 +377,6 @@ export default function App() {
canRedo={canRedo} canRedo={canRedo}
/> />
<div className="flex-1 min-h-0 relative flex flex-col"> <div className="flex-1 min-h-0 relative flex flex-col">
<Button
variant="outline"
size="icon"
className="absolute top-3 right-3 z-10 nodrag nopan"
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<FlowContext.Provider value={flowContextValue}> <FlowContext.Provider value={flowContextValue}>
<ContextMenu> <ContextMenu>
@@ -437,6 +426,7 @@ export default function App() {
<Sparkles className="mr-2 h-4 w-4" /> <Sparkles className="mr-2 h-4 w-4" />
Renderer Renderer
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator></ContextMenuSeparator>
<ContextMenuItem onSelect={() => createNode('variable')}> <ContextMenuItem onSelect={() => createNode('variable')}>
<Variable className="mr-2 h-4 w-4" /> <Variable className="mr-2 h-4 w-4" />
Variable Variable

View File

@@ -5,9 +5,14 @@ import {
MenubarItem, MenubarItem,
MenubarMenu, MenubarMenu,
MenubarTrigger, MenubarTrigger,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
MenubarCheckboxItem,
} from '@/components/ui/menubar' } from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd' import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { Download, FolderOpen, Redo2, Undo2 } from 'lucide-react' import { useTheme } from '@/lib/themeContext'
import { Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react'
type AppMenubarProps = { type AppMenubarProps = {
onImport: () => void onImport: () => void
@@ -31,6 +36,8 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
} }
export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }: AppMenubarProps) { export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }: AppMenubarProps) {
const { theme, setTheme } = useTheme()
useEffect(() => { useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => { const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, UNDO_KEYS)) { if (matchKey(ev, UNDO_KEYS)) {
@@ -92,6 +99,32 @@ export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }:
</MenubarItem> </MenubarItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-medium">View</MenubarTrigger>
<MenubarContent>
<MenubarSub>
<MenubarSubTrigger>Theme</MenubarSubTrigger>
<MenubarSubContent>
<MenubarCheckboxItem
checked={theme === 'light'}
onCheckedChange={() => setTheme('light')}
className="gap-2"
>
<Sun className="h-4 w-4" />
Light
</MenubarCheckboxItem>
<MenubarCheckboxItem
checked={theme === 'dark'}
onCheckedChange={() => setTheme('dark')}
className="gap-2"
>
<Moon className="h-4 w-4" />
Dark
</MenubarCheckboxItem>
</MenubarSubContent>
</MenubarSub>
</MenubarContent>
</MenubarMenu>
</Menubar> </Menubar>
) )
} }

View File

@@ -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>
); );
} }

View File

@@ -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 { javascript } from '@codemirror/lang-javascript'
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,
isGroup,
type ConfigTypeId,
} from '../../lib/configTypes'
import { import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
@@ -16,10 +26,14 @@ import {
import { Code2, ScrollText, Variable } from 'lucide-react' import { Code2, ScrollText, Variable } from 'lucide-react'
import { import {
MenubarItem, MenubarItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub, MenubarSub,
MenubarSubContent, MenubarSubContent,
MenubarSubTrigger, MenubarSubTrigger,
} from '../ui/menubar' } from '../ui/menubar'
import { Kbd } from '../ui/kbd'
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 +46,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)
const { theme } = useTheme() const { theme } = useTheme()
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes const setNodes = ctx?.setNodes
@@ -63,10 +77,35 @@ 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) ?? '',
},
}
: n
)
)
},
[id, setNodes, configTypeId]
) )
const insertAt = useCallback( const insertAt = useCallback(
@@ -91,12 +130,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(
@@ -140,32 +179,75 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
() => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id), () => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id),
[connectedConfigNodes], [connectedConfigNodes],
) )
const extensions = useMemo( const extensions = useMemo(() => {
() => [ const lang =
plantumlLanguage.extension, configTypeId === 'wireframe'
? javascript()
: configType.language === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [
lang,
autocompletion({ autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)], override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
activateOnTyping: true, activateOnTyping: true,
}), }),
], ]
[variableIds, functionIds, configTitles], }, [configTypeId, configType.language, variableIds, functionIds, configTitles])
)
const [editorHeight, editorContainerRef] = useResizeHeight(180) const [editorHeight, editorContainerRef] = useResizeHeight(180)
const nunjucksTagSnippets = useMemo( const insertBlocksContent = useMemo(() => {
() => [ const templatingGroup = configType.insertBlocks.find(
{ label: 'Variable {{ }}', snippet: '{{ }}' }, (b): b is import('../../lib/configTypes').InsertBlockGroup => isGroup(b) && b.label === 'Templating'
{ 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 typeBlocks = configType.insertBlocks.filter((b) => !(isGroup(b) && b.label === 'Templating'))
const insertShortcut = <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
const typeItems = typeBlocks.flatMap((block) =>
isGroup(block)
? block.items.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs flex items-center group"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
{insertShortcut}
</MenubarItem>
))
: [
<MenubarItem
key={block.label}
className="text-xs flex items-center group"
onClick={() => insertAt(block.snippet, 'cursor')}
>
{block.label}
{insertShortcut}
</MenubarItem>,
]
)
const templatingItems =
templatingGroup?.items.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs flex items-center group"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
)) ?? []
return (
<>
{typeItems}
{templatingItems.length > 0 && (
<>
<MenubarSeparator />
{templatingItems}
</>
)}
</>
)
}, [configType.insertBlocks, insertAt])
const dimensions = const dimensions =
width != null && height != null && width > 0 && height > 0 width != null && height != null && width > 0 && height > 0
@@ -174,14 +256,31 @@ 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">
<NodeMenubar <NodeMenubar
nodeId={id} nodeId={id}
nodeType="config" nodeType="config"
editInputsContent={ inputsMenuContent={
hasDependencies ? ( hasDependencies ? (
<> <>
{connectedConfigNodes.map((n: any) => ( {connectedConfigNodes.map((n: any) => (
@@ -192,74 +291,59 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</MenubarSubTrigger> </MenubarSubTrigger>
<MenubarSubContent> <MenubarSubContent>
<MenubarItem <MenubarItem
className="text-xs" className="text-xs flex items-center group"
onClick={() => insertExtendsFromNode(n, 'cursor')} onClick={() => insertExtendsFromNode(n, 'cursor')}
> >
Extend Extend
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem <MenubarItem
className="text-xs" className="text-xs flex items-center group"
onClick={() => insertIncludeFromNode(n, 'cursor')} onClick={() => insertIncludeFromNode(n, 'cursor')}
> >
Include Include
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem <MenubarItem
className="text-xs" className="text-xs flex items-center group"
onClick={() => insertImportFromNode(n, 'cursor')} onClick={() => insertImportFromNode(n, 'cursor')}
> >
Import Import
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </MenubarSub>
))} ))}
{connectedVariableNodes.map((n: any) => ( {connectedVariableNodes.map((n: any) => (
<MenubarSub key={`${n.id}`}>
<MenubarSubTrigger className="text-xs flex items-center gap-2">
<Variable className="size-3.5 shrink-0" />
{n.id}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem <MenubarItem
className="text-xs" key={n.id}
className="text-xs flex items-center gap-2 group"
onClick={() => insertVariableReference(n, 'cursor')} onClick={() => insertVariableReference(n, 'cursor')}
> >
Insert <Variable className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
</MenubarSubContent>
</MenubarSub>
))} ))}
{connectedFunctionNodes.map((n: any) => ( {connectedFunctionNodes.map((n: any) => (
<MenubarSub key={`${n.id}`}>
<MenubarSubTrigger className="text-xs flex items-center gap-2">
<Code2 className="size-3.5 shrink-0" />
{n.id}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem <MenubarItem
className="text-xs" key={n.id}
className="text-xs flex items-center gap-2 group"
onClick={() => insertFunctionCall(n, 'cursor')} onClick={() => insertFunctionCall(n, 'cursor')}
> >
Insert <Code2 className="size-3.5 shrink-0" />
</MenubarItem> {n.id}
</MenubarSubContent> <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarSub>
))}
</>
) : undefined
}
insertTagsContent={
<>
{nunjucksTagSnippets.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
</MenubarItem> </MenubarItem>
))} ))}
</> </>
) : (
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
)
} }
insertMenuLabel="Blocks"
insertContentDirect
insertTagsContent={insertBlocksContent}
/> />
</div> </div>
@@ -267,7 +351,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 +364,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'} {configType.label} · {content ? `${content.length} chars` : 'none'}
</NodeFooterEdgeIndicators> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>

View File

@@ -15,7 +15,8 @@ import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from './NodeMenubar'
import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { MenubarItem, MenubarShortcut } from '../ui/menubar'
import { Kbd } from '../ui/kbd'
import { Code2, Variable } from 'lucide-react' import { Code2, Variable } from 'lucide-react'
type Props = { type Props = {
@@ -110,43 +111,35 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
<NodeMenubar <NodeMenubar
nodeId={id} nodeId={id}
nodeType="function" nodeType="function"
editInputsContent={ inputsMenuContent={
hasConnectedInputs ? ( hasConnectedInputs ? (
<> <>
{connectedVariableNodes.map((n: any) => ( {connectedVariableNodes.map((n: any) => (
<MenubarSub key={n.id}>
<MenubarSubTrigger className="text-xs flex items-center gap-2">
<Variable className="size-3.5 shrink-0" />
{n.id}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem <MenubarItem
className="text-xs" key={n.id}
className="text-xs flex items-center gap-2 group"
onClick={() => insertVariableAtCursor(n)} onClick={() => insertVariableAtCursor(n)}
> >
Insert at cursor <Variable className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
</MenubarSubContent>
</MenubarSub>
))} ))}
{connectedFunctionNodes.map((n: any) => ( {connectedFunctionNodes.map((n: any) => (
<MenubarSub key={n.id}>
<MenubarSubTrigger className="text-xs flex items-center gap-2">
<Code2 className="size-3.5 shrink-0" />
{n.id}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem <MenubarItem
className="text-xs" key={n.id}
className="text-xs flex items-center gap-2 group"
onClick={() => insertFunctionAtCursor(n)} onClick={() => insertFunctionAtCursor(n)}
> >
Insert at cursor <Code2 className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
</MenubarSubContent>
</MenubarSub>
))} ))}
</> </>
) : undefined ) : (
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert at cursor</span>
)
} }
/> />
</div> </div>
@@ -167,7 +160,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="function"> <NodeFooterEdgeIndicators nodeId={id} nodeType="function">
{bodyValue ? `${bodyValue.length} chars` : 'none'} {bodyValue ? `JavaScript · ${bodyValue.length} chars` : 'JavaScript · none'}
</NodeFooterEdgeIndicators> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>

View File

@@ -20,15 +20,23 @@ type NodeType = 'config' | 'render' | 'variable' | 'function'
type Props = { type Props = {
nodeId: string nodeId: string
nodeType: NodeType nodeType: NodeType
/** Content for Insert → Inputs (config and function nodes only) */ /** Content for Insert → Inputs (function nodes); when inputsMenuContent is set, Inputs is a separate menu and this is not used in Insert */
editInputsContent?: React.ReactNode editInputsContent?: React.ReactNode
/** Content for Insert → Tags (e.g. Nunjucks tag snippets, config nodes) */ /** When set, Inputs is rendered as its own top-level menu (config nodes); Insert then only shows markup-specific options */
inputsMenuContent?: React.ReactNode
/** Content for Insert → [Blocks/Tags] (e.g. type-specific snippets, config nodes) */
insertTagsContent?: React.ReactNode insertTagsContent?: React.ReactNode
/** Label for the Insert/Blocks menu trigger (default "Insert") */
insertMenuLabel?: string
/** When true, render insertTagsContent directly in the menu (no submenu level). Use for flat Blocks list. */
insertContentDirect?: boolean
/** Label for Insert submenu that shows insertTagsContent when not insertContentDirect (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, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, 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
@@ -106,12 +114,23 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
</MenubarItem> </MenubarItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
{hasEdit && ( {inputsMenuContent != null && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs" disabled={!hasConnectedNodes}>
Inputs
</MenubarTrigger>
<MenubarContent className="min-w-[12rem]">
{inputsMenuContent}
</MenubarContent>
</MenubarMenu>
)}
{hasEdit && (insertTagsContent != null || (editInputsContent != null && inputsMenuContent == null)) && (
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs"> <MenubarTrigger className="px-1.5 py-0 text-xs">
Insert {insertMenuLabel}
</MenubarTrigger> </MenubarTrigger>
<MenubarContent className="min-w-[10rem]"> <MenubarContent className="min-w-[10rem]">
{inputsMenuContent == null && editInputsContent != null && (
<MenubarSub> <MenubarSub>
<MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}> <MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}>
Inputs Inputs
@@ -120,16 +139,20 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
{editInputsContent} {editInputsContent}
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </MenubarSub>
{insertTagsContent != null && ( )}
{insertTagsContent != null &&
(insertContentDirect ? (
insertTagsContent
) : (
<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}
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </MenubarSub>
)} ))}
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
)} )}

View File

@@ -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,
@@ -13,13 +14,10 @@ import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from './NodeMenubar'
import { NodeStatusIndicator } from './NodeStatusIndicator' import { NodeStatusIndicator } from './NodeStatusIndicator'
import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
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,13 +25,12 @@ 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)
const loadingStartedAtRef = useRef<number | null>(null) const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? [] const nodes = ctx?.nodes ?? []
const edges = ctx?.edges ?? [] const edges = ctx?.edges ?? []
@@ -44,7 +41,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 +84,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 +107,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 +144,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
} }
setSvgContent(null) if (incomingIds.length === 0) {
setError({ kind: 'no-plantuml', message: 'No PlantUML found on connected configuration node' }) setRenderedContent(null)
setError(null)
setLoading(false) setLoading(false)
return return
} }
@@ -213,7 +211,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 +226,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 +426,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 +457,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 +472,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. }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature])
}, [id, plantumlText, 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
// 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()))
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 +513,11 @@ 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'
const isWireframeOutput = configTypeId === 'wireframe' && renderedContent && !isSvgOutput
return ( return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}> <NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
@@ -541,14 +531,15 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
nodeType="render" nodeType="render"
nodeMenuExtraContent={ nodeMenuExtraContent={
<MenubarSub> <MenubarSub>
<MenubarSubTrigger className="text-xs" disabled={!svgContent}> <MenubarSeparator></MenubarSeparator>
<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>
@@ -564,7 +555,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
@@ -594,18 +585,34 @@ 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 ? (
isWireframeOutput ? (
<div className="rendering-wireframe min-h-0 flex-1 w-full min-w-0 flex flex-col overflow-hidden bg-background">
<div className="rendering-wireframe__viewport">
<div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} />
</div>
</div>
) : (
<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'
: '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>
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render"> <NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'} {renderedContent
? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars`
: error
? 'Error'
: '—'}
</NodeFooterEdgeIndicators> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>

View File

@@ -135,7 +135,9 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="variable" /> <NodeFooterEdgeIndicators nodeId={id} nodeType="variable">
{`${valueType.charAt(0).toUpperCase() + valueType.slice(1)} · ${displayValue.length ? `${displayValue.length} chars` : 'none'}`}
</NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )

206
src/lib/configTypes.ts Normal file
View File

@@ -0,0 +1,206 @@
/**
* 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' | 'wireframe'
/** 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)
}
/** Optional options passed to render (e.g. node size for wireframe SVG). */
export type RenderOptions = { width?: number; height?: number }
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, options?: RenderOptions) => 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: 'Templating',
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: 'Templating',
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 = %}' },
],
},
]
/** Wireweave DSL: https://github.com/wireweave/core */
const WIREFRAME_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Page', snippet: 'page "Title" {\n \n}' },
{ label: 'Card', snippet: 'card p=4 {\n \n}' },
{ label: 'Title', snippet: 'title "' },
{ label: 'Text', snippet: 'text "' },
{ label: 'Button', snippet: 'button "Label"' },
{ label: 'Primary button', snippet: 'button "Label" primary' },
{ label: 'Input', snippet: 'input placeholder=""' },
{ label: 'Row', snippet: 'row {\n col span=6 { }\n}' },
{ label: 'Col', snippet: 'col span=6 { }' },
{ label: 'Header / Main / Footer', snippet: 'header { }\nmain { }\nfooter { }' },
{ label: 'Image', snippet: 'image "" w=400 h=300' },
{
label: 'Templating',
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)
}
/** Wireweave DSL to HTML+CSS; theme from document dark mode. See https://www.wireweave.org/ */
async function renderWireframe(content: string): Promise<string> {
const { parse, render } = await import('@wireweave/core')
const doc = parse(content)
const isDark =
typeof document !== 'undefined' &&
document.documentElement?.classList?.contains('dark')
const { html, css } = render(doc, { theme: isDark ? 'dark' : 'light' })
return `<style>${css}</style>${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,
},
{
id: 'wireframe',
label: 'Wireframe',
language: 'markdown',
insertBlocks: WIREFRAME_INSERT_BLOCKS,
render: renderWireframe,
},
]
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 ''
if (configTypeId === 'wireframe') {
return `page "Hello" {
card p=4 {
title "Welcome"
text "Hello, wireweave!"
button "Get Started" primary
}
}`
}
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'
}

View File

@@ -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: '' }

View File

@@ -56,11 +56,41 @@ body {
pointer-events: none; pointer-events: none;
} }
/* Rendering node: diagram is fetched once (light). In dark mode, invert so it stays readable without re-fetching. */ /* Rendering node: diagram/wireframe SVG must fit inside the content area. */
.rendering-diagram {
display: flex;
align-items: center;
justify-content: center;
}
.rendering-diagram svg {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.dark .rendering-diagram svg { .dark .rendering-diagram svg {
filter: invert(0.92) hue-rotate(180deg); filter: invert(0.92) hue-rotate(180deg);
} }
/* Wireframe: fit HTML preview in viewport (inspired by https://www.wireweave.org/ ). */
.rendering-wireframe__viewport {
container-type: inline-size;
container-name: wireframe;
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
justify-content: center;
align-items: flex-start;
}
.rendering-wireframe__content {
width: 1200px;
/* Scale to fit container width so content stays within the node */
zoom: calc(100cqw / 1200px);
min-height: min-content;
}
/* Animated React Flow edges: thicker stroke + path animation */ /* Animated React Flow edges: thicker stroke + path animation */
.react-flow__edge path.animated-edge-path, .react-flow__edge path.animated-edge-path,
.animated-edge-path { .animated-edge-path {