improvements

This commit is contained in:
2026-03-07 22:37:11 +01:00
parent 5b07bd4887
commit 4c33d1095d
9 changed files with 268 additions and 94 deletions

View File

@@ -1,4 +1,4 @@
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror'
import FlowContext from '../../lib/flowContext'
@@ -21,6 +21,7 @@ import {
MenubarSubTrigger,
} from '../ui/menubar'
import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar'
type Props = {
@@ -30,12 +31,13 @@ type Props = {
height?: number
}
const DEFAULT_PLANTUML = '@startuml\n\n@enduml\n'
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
const [value, setValue] = useState<string>(data?.plantuml ?? '@startuml\n\n@enduml\n')
const plantumlValue = data?.plantuml ?? DEFAULT_PLANTUML
const { theme } = useTheme()
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
const storedPlantuml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.plantuml ?? ''
const editorRef = useRef<unknown>(null)
@@ -46,16 +48,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
const connectedFunctionNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function')
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
useEffect(() => {
if (setNodes) {
setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, plantuml: value } } : n)))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onChange = useCallback(
(val: string) => {
setValue(val)
if (setNodes) {
setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, plantuml: val } } : n)))
}
@@ -85,12 +79,12 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
return
}
if (mode === 'prepend') {
onChange(insertText + value)
onChange(insertText + plantumlValue)
} else {
onChange(value + insertText)
onChange(plantumlValue + insertText)
}
},
[onChange, value]
[onChange, plantumlValue]
)
const insertExtendsFromNode = useCallback(
@@ -146,6 +140,21 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
)
const [editorHeight, editorContainerRef] = useResizeHeight(180)
const nunjucksTagSnippets = useMemo(
() => [
{ label: 'Variable {{ }}', snippet: '{{ }}' },
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
{ label: 'set', snippet: '{% set = %}' },
{ label: 'block / endblock', snippet: '{% block %}\n \n{% endblock %}' },
{ label: 'extends', snippet: '{% extends "" %}' },
{ label: 'include', snippet: '{% include "" %}' },
{ label: 'import', snippet: '{% import "" as %}' },
{ label: 'raw / endraw', snippet: '{% raw %}\n \n{% endraw %}' },
],
[]
)
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
@@ -153,7 +162,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
return (
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow icon={<ScrollText className="size-4" />} title={`${id}.puml`} />
<BaseNodeHeaderRow icon={<ScrollText className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}.puml`} />} />
<BaseNodeContent>
<div className="shrink-0 w-full">
@@ -223,6 +232,19 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</>
) : undefined
}
insertTagsContent={
<>
{nunjucksTagSnippets.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
</MenubarItem>
))}
</>
}
/>
</div>
@@ -230,7 +252,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
<CodeMirror
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef}
value={value}
value={plantumlValue}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
@@ -242,7 +264,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</BaseNodeContent>
<BaseNodeFooter>
<BaseNodeFooterText>{storedPlantuml ? `${storedPlantuml.length} chars` : 'none'}</BaseNodeFooterText>
<BaseNodeFooterText>{plantumlValue ? `${plantumlValue.length} chars` : 'none'}</BaseNodeFooterText>
</BaseNodeFooter>
</BaseNode>
)

View File

@@ -1,4 +1,4 @@
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import React, { memo, useCallback, useContext, useMemo } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import FlowContext from '../../lib/flowContext'
@@ -12,6 +12,7 @@ import {
BaseNodeHeaderRow,
} from './BaseNode'
import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar'
import { Code2 } from 'lucide-react'
@@ -22,28 +23,14 @@ type Props = {
height?: number
}
const DEFAULT_BODY = `// args[0], args[1], ... are the variable values (in order)
return args[0];
`
export const FunctionNode = memo(function FunctionNode({ id, data, width, height }: Props) {
const [value, setValue] = useState<string>(data?.body ?? DEFAULT_BODY)
const bodyValue = data?.body ?? ''
const { theme } = useTheme()
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
useEffect(() => {
if (setNodes) {
setNodes((nds: any[]) =>
nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, body: value } } : n))
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onChange = useCallback(
(val: string) => {
setValue(val)
if (setNodes) {
setNodes((nds: any[]) =>
nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, body: val } } : n))
@@ -53,8 +40,6 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
[id, setNodes]
)
const storedBody = ctx?.nodes?.find((n: any) => n.id === id)?.data?.body ?? ''
const extensions = useMemo(() => [javascript()], [])
const [editorHeight, editorContainerRef] = useResizeHeight(120)
const dimensions =
@@ -64,7 +49,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
return (
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="in" /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={id} />
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent>
<div className="shrink-0 w-full mb-1">
@@ -75,7 +60,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
</p>
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden rounded border border-input">
<CodeMirror
value={value}
value={bodyValue}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
@@ -87,7 +72,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
</BaseNodeContent>
<BaseNodeFooter>
<BaseNodeFooterText>Body: {storedBody ? `${storedBody.length} chars` : 'none'}</BaseNodeFooterText>
<BaseNodeFooterText>Body: {bodyValue ? `${bodyValue.length} chars` : 'none'}</BaseNodeFooterText>
</BaseNodeFooter>
</BaseNode>
)

View File

@@ -0,0 +1,82 @@
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import FlowContext from '../../lib/flowContext'
import { replaceNodeIdInGraph } from '../../lib/flowUtils'
type Props = {
nodeId: string
displayTitle: string
}
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const edges = ctx?.edges ?? []
const setEdges = ctx?.setEdges
const renamingNodeId = ctx?.renamingNodeId ?? null
const setRenamingNodeId = ctx?.setRenamingNodeId
const [inputValue, setInputValue] = useState(nodeId)
const inputRef = useRef<HTMLInputElement>(null)
const isRenaming = renamingNodeId === nodeId
useEffect(() => {
if (isRenaming) {
setInputValue(nodeId)
inputRef.current?.focus()
inputRef.current?.select()
}
}, [isRenaming, nodeId])
const applyRename = useCallback(() => {
if (!setNodes || !setEdges || !setRenamingNodeId) return
const newId = inputValue.trim()
if (!newId || newId === nodeId) {
setRenamingNodeId(null)
return
}
const existingIds = nodes.map((n: any) => n.id)
if (existingIds.includes(newId)) {
return
}
const { nodes: nextNodes, edges: nextEdges } = replaceNodeIdInGraph(nodes, edges, nodeId, newId)
setNodes(nextNodes)
setEdges(nextEdges)
setRenamingNodeId(null)
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
const cancelRename = useCallback(() => {
setRenamingNodeId?.(null)
}, [setRenamingNodeId])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
applyRename()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
},
[applyRename, cancelRename]
)
if (!isRenaming) {
return <>{displayTitle}</>
}
return (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={cancelRename}
className="nodrag nopan flex-1 min-w-0 rounded border border-input bg-background px-1.5 py-0 text-sm font-semibold outline-none focus:ring-1 focus:ring-ring"
data-slot="base-node-title"
/>
)
}

View File

@@ -1,11 +1,12 @@
import React, { useCallback, useContext } from 'react'
import FlowContext from '../../lib/flowContext'
import { DEFAULT_NODE_STYLE, genId, getDefaultDataForType } from '../../lib/flowUtils'
import { DEFAULT_NODE_STYLE, getNextNodeId, getResetDataForType } from '../../lib/flowUtils'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
@@ -19,11 +20,13 @@ type NodeType = 'config' | 'render' | 'variable' | 'function'
type Props = {
nodeId: string
nodeType: NodeType
/** Content for Edit → Inputs (config and function nodes only) */
/** Content for Insert → Inputs (config and function nodes only) */
editInputsContent?: React.ReactNode
/** Content for Insert → Tags (e.g. Nunjucks tag snippets, config nodes) */
insertTagsContent?: React.ReactNode
}
export function NodeMenubar({ nodeId, nodeType, editInputsContent }: Props) {
export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsContent }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
@@ -36,17 +39,19 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent }: Props) {
const onDuplicate = useCallback(() => {
if (!setNodes || !node) return
const newId = genId()
const pos = node.position ?? { x: 0, y: 0 }
const newNode = {
id: newId,
type: node.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
}
if (newNode.data?.title && nodeType === 'config') newNode.data.title = `config-${newId}`
setNodes((nds: any[]) => nds.concat(newNode))
setNodes((nds: any[]) => {
const newId = getNextNodeId(nodeType, nds.map((n: any) => n.id))
const newNode = {
id: newId,
type: node.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
}
if (newNode.data?.title && nodeType === 'config') newNode.data.title = `config-${newId}`
return nds.concat(newNode)
})
}, [node, nodeType, setNodes])
const onCopy = useCallback(() => {
@@ -57,9 +62,9 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent }: Props) {
const onReset = useCallback(() => {
if (!setNodes) return
const defaultData = getDefaultDataForType(nodeType, nodeId)
const resetData = getResetDataForType(nodeType, nodeId)
setNodes((nds: any[]) =>
nds.map((n) => (n.id === nodeId ? { ...n, data: defaultData } : n))
nds.map((n) => (n.id === nodeId ? { ...n, data: resetData } : n))
)
}, [nodeId, nodeType, setNodes])
@@ -69,11 +74,15 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent }: Props) {
setEdges((eds: any[]) => eds.filter((e: any) => e.source !== nodeId && e.target !== nodeId))
}, [nodeId, setNodes, setEdges])
const onRename = useCallback(() => {
ctx?.setRenamingNodeId?.(nodeId)
}, [nodeId, ctx])
return (
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-none shadow-none">
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
File
Node
</MenubarTrigger>
<MenubarContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={onDuplicate}>
@@ -82,9 +91,13 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent }: Props) {
<MenubarItem className="text-xs" onClick={onCopy}>
Copy
</MenubarItem>
<MenubarItem className="text-xs" onClick={onRename}>
Rename
</MenubarItem>
<MenubarItem className="text-xs" onClick={onReset}>
Reset
</MenubarItem>
<MenubarSeparator />
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
Delete
</MenubarItem>
@@ -93,7 +106,7 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent }: Props) {
{hasEdit && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
Edit
Insert
</MenubarTrigger>
<MenubarContent className="min-w-[10rem]">
<MenubarSub>
@@ -104,6 +117,16 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent }: Props) {
{editInputsContent}
</MenubarSubContent>
</MenubarSub>
{insertTagsContent != null && (
<MenubarSub>
<MenubarSubTrigger className="text-xs">
Tags
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{insertTagsContent}
</MenubarSubContent>
</MenubarSub>
)}
</MenubarContent>
</MenubarMenu>
)}

View File

@@ -10,6 +10,8 @@ import {
BaseNodeFooterText,
BaseNodeHeaderRow,
} from './BaseNode'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar'
import { Sparkles } from 'lucide-react'
import { InputHandle, OutputHandle } from './NodeHandles'
@@ -23,8 +25,6 @@ type Props = {
style?: React.CSSProperties
}
const DEFAULT_CONFIG_NODE_STYLE = { width: 320, height: 320 }
const DARK_SKINPARAMS = `
skinparam backgroundColor #1e1e1e
skinparam defaultFontColor #e0e0e0
@@ -357,7 +357,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
return (
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={id} />
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent>
<div className="shrink-0 w-full">
@@ -378,15 +378,13 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
onClick={() => {
if (!setNodes || !setEdges) return
const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
const nid = genId()
const nid = getNextNodeId('config', nodes.map((n: any) => n.id))
const thisNode = nodes.find((n: any) => n.id === id)
const pos = thisNode?.position ?? { x: 0, y: 0 }
const newPos = { x: pos.x - 220, y: pos.y }
const newNode = { id: nid, type: 'config', position: newPos, data: { plantuml: '@startuml\n\n@enduml\n', title: `config-${nid}` }, style: DEFAULT_CONFIG_NODE_STYLE }
const newNode = { id: nid, type: 'config', position: newPos, data: getDefaultDataForType('config', nid), style: DEFAULT_NODE_STYLE.config }
setNodes((nds: any[]) => nds.concat(newNode))
const edgeId = `e-${nid}-${id}`
setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id }))
setEdges((eds: any[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }))
}}
>
Create Config

View File

@@ -11,6 +11,7 @@ import { NodeMenubar } from './NodeMenubar'
import { Input } from '../ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Switch } from '../ui/switch'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { OutputHandle } from './NodeHandles'
import { Variable } from 'lucide-react'
@@ -91,7 +92,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
return (
<BaseNode className="min-w-56 min-h-[180px]" handles={<OutputHandle id="out" />}>
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={id} />
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent className="gap-2 p-3">
<div className="shrink-0 w-full">