245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { autocompletion } from '@codemirror/autocomplete'
|
|
import CodeMirror from '@uiw/react-codemirror'
|
|
import FlowContext from '../../lib/flowContext'
|
|
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
|
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
|
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
|
import { useTheme } from '../../lib/themeContext'
|
|
import {
|
|
BaseNode,
|
|
BaseNodeContent,
|
|
BaseNodeFooter,
|
|
BaseNodeFooterText,
|
|
BaseNodeHeaderRow,
|
|
} from './BaseNode'
|
|
import { GitBranchPlus, ScrollText } from 'lucide-react'
|
|
import {
|
|
Menubar,
|
|
MenubarContent,
|
|
MenubarItem,
|
|
MenubarMenu,
|
|
MenubarSub,
|
|
MenubarSubContent,
|
|
MenubarSubTrigger,
|
|
MenubarTrigger,
|
|
} from '../ui/menubar'
|
|
import { InputHandle, OutputHandle } from './NodeHandles'
|
|
|
|
type Props = {
|
|
id: string
|
|
data: any
|
|
width?: number
|
|
height?: number
|
|
}
|
|
|
|
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
|
|
const [value, setValue] = useState<string>(data?.plantuml ?? '@startuml\n\n@enduml\n')
|
|
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)
|
|
|
|
const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
|
|
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
|
|
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
|
|
const connectedVariableNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable')
|
|
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)))
|
|
}
|
|
},
|
|
[id, setNodes]
|
|
)
|
|
|
|
const insertAt = useCallback(
|
|
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
|
const ref = editorRef.current as { view: { state: { doc: { length: number; toString(): string }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
|
|
if (ref?.view) {
|
|
const view = ref.view
|
|
const doc = view.state.doc
|
|
const len = doc.length
|
|
let from: number
|
|
if (mode === 'prepend') {
|
|
from = 0
|
|
} else if (mode === 'append') {
|
|
from = len
|
|
} else {
|
|
const main = view.state.selection.main
|
|
from = main.from
|
|
}
|
|
view.dispatch({ changes: { from, to: from, insert: insertText } })
|
|
const newVal = view.state.doc.toString()
|
|
onChange(newVal)
|
|
return
|
|
}
|
|
if (mode === 'prepend') {
|
|
onChange(insertText + value)
|
|
} else {
|
|
onChange(value + insertText)
|
|
}
|
|
},
|
|
[onChange, value]
|
|
)
|
|
|
|
const insertIncludeFromNode = useCallback(
|
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
|
const ref = `${sourceNode.id}.puml`
|
|
insertAt(`!include ${ref}\n`, mode)
|
|
},
|
|
[insertAt]
|
|
)
|
|
|
|
const insertVariableReference = useCallback(
|
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
|
insertAt(`\${${sourceNode.id}}`, mode)
|
|
},
|
|
[insertAt]
|
|
)
|
|
|
|
const insertFunctionCall = useCallback(
|
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
|
insertAt(`\${${sourceNode.id}()}`, mode)
|
|
},
|
|
[insertAt]
|
|
)
|
|
|
|
const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
|
|
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
|
|
const configTitles = useMemo(
|
|
() => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id),
|
|
[connectedConfigNodes],
|
|
)
|
|
const extensions = useMemo(
|
|
() => [
|
|
plantumlLanguage.extension,
|
|
autocompletion({
|
|
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
|
|
activateOnTyping: true,
|
|
}),
|
|
],
|
|
[variableIds, functionIds, configTitles],
|
|
)
|
|
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
|
|
|
const dimensions =
|
|
width != null && height != null && width > 0 && height > 0
|
|
? { width, height }
|
|
: undefined
|
|
|
|
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`} />
|
|
|
|
<BaseNodeContent>
|
|
{hasDependencies && (
|
|
<div className="shrink-0 w-full">
|
|
<Menubar className="h-auto bg-none p-1 border-none shadow-none">
|
|
<MenubarMenu>
|
|
<MenubarTrigger className="px-1.5 py-0 text-xs">
|
|
<GitBranchPlus className="size-3.5" />
|
|
</MenubarTrigger>
|
|
<MenubarContent className="min-w-[12rem]">
|
|
{connectedConfigNodes.map((n: any) => (
|
|
<MenubarSub key={`config-${n.id}`}>
|
|
<MenubarSubTrigger className="text-xs">
|
|
{n.data?.title ?? n.id}
|
|
</MenubarSubTrigger>
|
|
<MenubarSubContent>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertIncludeFromNode(n, 'prepend')}
|
|
>
|
|
Prepend include
|
|
</MenubarItem>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertIncludeFromNode(n, 'append')}
|
|
>
|
|
Append include
|
|
</MenubarItem>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertIncludeFromNode(n, 'cursor')}
|
|
>
|
|
Insert at cursor
|
|
</MenubarItem>
|
|
</MenubarSubContent>
|
|
</MenubarSub>
|
|
))}
|
|
{connectedVariableNodes.map((n: any) => (
|
|
<MenubarSub key={`var-${n.id}`}>
|
|
<MenubarSubTrigger className="text-xs">
|
|
{n.id}
|
|
</MenubarSubTrigger>
|
|
<MenubarSubContent>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertVariableReference(n, 'cursor')}
|
|
>
|
|
Insert at cursor
|
|
</MenubarItem>
|
|
</MenubarSubContent>
|
|
</MenubarSub>
|
|
))}
|
|
{connectedFunctionNodes.map((n: any) => (
|
|
<MenubarSub key={`fn-${n.id}`}>
|
|
<MenubarSubTrigger className="text-xs">
|
|
{n.id}
|
|
</MenubarSubTrigger>
|
|
<MenubarSubContent>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertFunctionCall(n, 'cursor')}
|
|
>
|
|
Insert at cursor
|
|
</MenubarItem>
|
|
</MenubarSubContent>
|
|
</MenubarSub>
|
|
))}
|
|
</MenubarContent>
|
|
</MenubarMenu>
|
|
</Menubar>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden rounded border border-input">
|
|
<CodeMirror
|
|
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
|
ref={editorRef}
|
|
value={value}
|
|
height={`${editorHeight}px`}
|
|
theme={theme}
|
|
extensions={extensions}
|
|
onChange={onChange}
|
|
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
|
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
|
/>
|
|
</div>
|
|
</BaseNodeContent>
|
|
|
|
<BaseNodeFooter>
|
|
<BaseNodeFooterText>{storedPlantuml ? `${storedPlantuml.length} chars` : 'none'}</BaseNodeFooterText>
|
|
</BaseNodeFooter>
|
|
</BaseNode>
|
|
)
|
|
})
|
|
|
|
ConfigNode.displayName = 'ConfigNode'
|
|
|
|
export default ConfigNode
|