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

@@ -33,7 +33,7 @@ import {
} from "@/components/ui/context-menu"
import { Button } from '@/components/ui/button'
import { ClipboardPaste, Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react'
import { DEFAULT_NODE_STYLE, genId, getDefaultDataForType } from './lib/flowUtils'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils'
const SNAP_GRID: [number, number] = [15, 15]
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
@@ -43,23 +43,23 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
const initialNodes: Node[] = [
{
id: 'config-1',
id: 'cfg_001',
position: { x: 50, y: 50 },
data: { plantuml: '@startuml\nactor User\nparticipant "Render" as R\nUser -> R : config\n@enduml\n' },
data: { plantuml: '@startuml\nactor User\nparticipant "Render" as R\nUser -> R : config\n@enduml\n', title: 'config-cfg_001' },
type: 'config',
style: DEFAULT_NODE_STYLE.config,
},
{
id: 'render-1',
id: 'rnd_001',
position: { x: 350, y: 80 },
data: {},
type: 'render',
style: DEFAULT_NODE_STYLE.render,
},
].map((n) => ({ ...n, id: n.id ?? genId() }))
]
const initialEdges: Edge[] = [
{ id: 'e-config-render', source: 'config-1', target: 'render-1', type: 'animated' },
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' },
]
export default function App() {
@@ -67,6 +67,7 @@ export default function App() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const wrapperRef = React.useRef<HTMLDivElement | null>(null)
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
@@ -123,7 +124,6 @@ export default function App() {
(type: string) => {
const position = getMenuPosition()
if (position == null) return
const newId = genId()
const typeMap: Record<string, Node['type']> = {
config: 'config',
render: 'render',
@@ -131,15 +131,18 @@ export default function App() {
function: 'function',
}
const nodeType = typeMap[type] ?? 'config'
const dataMap = getDefaultDataForType(nodeType, newId)
const newNode: Node = {
id: newId,
type: nodeType,
position: { x: position.x, y: position.y },
data: dataMap,
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
}
setNodes((nds) => nds.concat(newNode))
setNodes((nds) => {
const newId = getNextNodeId(nodeType, nds.map((n) => n.id))
const dataMap = getDefaultDataForType(nodeType, newId)
const newNode: Node = {
id: newId,
type: nodeType,
position: { x: position.x, y: position.y },
data: dataMap,
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
}
return nds.concat(newNode)
})
lastClickRef.current = null
setContextTarget(null)
},
@@ -156,17 +159,19 @@ export default function App() {
if (!text) return
const raw = JSON.parse(text) as { id?: string; type?: string; data?: any; position?: { x: number; y: number }; style?: any }
if (!raw || typeof raw.type !== 'string' || !VALID_NODE_TYPES.includes(raw.type as any)) return
const newId = genId()
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
if (raw.type === 'config' && data.title != null) data.title = `config-${newId}`
const newNode: Node = {
id: newId,
type: raw.type as Node['type'],
position: { x: position.x, y: position.y },
data,
style: DEFAULT_NODE_STYLE[raw.type] ?? DEFAULT_NODE_STYLE.config,
}
setNodes((nds) => nds.concat(newNode))
setNodes((nds) => {
const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id))
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
if (raw.type === 'config' && data.title != null) data.title = `config-${newId}`
const newNode: Node = {
id: newId,
type: raw.type as Node['type'],
position: { x: position.x, y: position.y },
data,
style: DEFAULT_NODE_STYLE[raw.type] ?? DEFAULT_NODE_STYLE.config,
}
return nds.concat(newNode)
})
lastClickRef.current = null
setContextTarget(null)
} catch {
@@ -199,7 +204,7 @@ export default function App() {
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges }}>
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges, renamingNodeId, setRenamingNodeId }}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div style={{ width: '100%', height: '100%' }}>

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">

View File

@@ -5,6 +5,8 @@ export type FlowContextValue = {
setNodes: (updater: any) => void
edges: any[]
setEdges: (updater: any) => void
renamingNodeId: string | null
setRenamingNodeId: (id: string | null) => void
}
const FlowContext = React.createContext<FlowContextValue | null>(null)

View File

@@ -1,5 +1,50 @@
/** Generate short unique node id */
export const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
export const PREFIX_BY_TYPE: Record<string, string> = {
config: 'cfg_',
render: 'rnd_',
variable: 'var_',
function: 'fn_',
}
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …) */
export function getNextNodeId(type: string, existingIds: string[]): string {
const prefix = PREFIX_BY_TYPE[type] ?? 'node_'
const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)
let max = 0
for (const id of existingIds) {
const m = id.match(re)
if (m) max = Math.max(max, parseInt(m[1], 10))
}
return `${prefix}${String(max + 1).padStart(3, '0')}`
}
/** Recursively replace oldId with newId in string values (for rename propagation) */
function replaceInData(value: unknown, oldId: string, newId: string): unknown {
if (typeof value === 'string') return value.split(oldId).join(newId)
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) return value.map((v) => replaceInData(v, oldId, newId))
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = replaceInData(v, oldId, newId)
return out
}
/** Update graph after renaming a node: change node id and all references in data and edges */
export function replaceNodeIdInGraph(
nodes: Array<{ id: string; data?: any; [k: string]: any }>,
edges: Array<{ id: string; source: string; target: string; [k: string]: any }>,
oldId: string,
newId: string
): { nodes: typeof nodes; edges: typeof edges } {
const newNodes = nodes.map((n) =>
n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any }
)
const newEdges = edges.map((e) => ({
...e,
id: e.id.includes(oldId) ? e.id.split(oldId).join(newId) : e.id,
source: e.source === oldId ? newId : e.source,
target: e.target === oldId ? newId : e.target,
}))
return { nodes: newNodes, edges: newEdges }
}
export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
config: { width: 320, height: 320 },
@@ -20,3 +65,14 @@ export function getDefaultDataForType(type: string, newId?: string): any {
if (type === 'config' && newId) base.title = `config-${newId}`
return base
}
/** Data for Reset action: clears code completely for config/function; same as default for others */
export function getResetDataForType(type: string, nodeId?: string): any {
if (type === 'config') {
return { plantuml: '@startuml\n\n@enduml\n', title: nodeId ? `config-${nodeId}` : '' }
}
if (type === 'function') {
return { body: '' }
}
return getDefaultDataForType(type, nodeId)
}