improvements
This commit is contained in:
61
src/App.tsx
61
src/App.tsx
@@ -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%' }}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
82
src/components/graph/NodeHeaderTitle.tsx
Normal file
82
src/components/graph/NodeHeaderTitle.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user