feat: add node help system and registry for extensible node types
- Implemented a help system for different node types (config, render, variable, function) with detailed usage instructions. - Created a node registry to manage node types, including registration, retrieval, and validation of connections between nodes. - Defined central node and edge types for the application to streamline state management. - Added Nunjucks autocomplete functionality to enhance user experience in template editing. - Developed a syntax highlighting parser for PlantUML and Nunjucks within the CodeMirror editor. - Registered built-in node types at application startup, including their default configurations and help entries. - Introduced a theme context provider to manage light/dark mode preferences across the application. - Created utility functions for class name management using clsx and tailwind-merge. - Set up Tailwind CSS for styling with custom themes and responsive design. - Configured Vite for development with proxy settings for backend API calls and Kroki diagram service.
This commit is contained in:
356
frontend/src/components/nodes/ConfigNode.tsx
Normal file
356
frontend/src/components/nodes/ConfigNode.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
||||
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
||||
import { useTheme } from '../../lib/themeContext'
|
||||
import {
|
||||
CONFIG_TYPES,
|
||||
getConfigContent,
|
||||
getConfigType,
|
||||
getConfigTypeId,
|
||||
isGroup,
|
||||
type ConfigTypeId,
|
||||
} from '../../lib/configTypes'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '../base/BaseNode'
|
||||
import { Code2, ScrollText, Variable } from 'lucide-react'
|
||||
import {
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
} from '../ui/menubar'
|
||||
import { Kbd } from '../ui/kbd'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { InputHandle, OutputHandle } from '../base/NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
|
||||
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string }
|
||||
|
||||
type Props = AbstractNodeProps<ConfigNodeData>
|
||||
|
||||
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const configTypeId = getConfigTypeId(data ?? {})
|
||||
const configType = getConfigType(configTypeId)
|
||||
const content = getConfigContent(data ?? {})
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const connectedConfigNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||
[updateData, configTypeId]
|
||||
)
|
||||
|
||||
const setConfigType = useCallback(
|
||||
(newTypeId: ConfigTypeId) => {
|
||||
if (newTypeId === configTypeId) return
|
||||
updateData({
|
||||
configType: newTypeId,
|
||||
content: getConfigContent({ ...data, configType: newTypeId }) ?? '',
|
||||
})
|
||||
},
|
||||
[configTypeId, data, updateData]
|
||||
)
|
||||
|
||||
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 + content)
|
||||
} else {
|
||||
onChange(content + insertText)
|
||||
}
|
||||
},
|
||||
[onChange, content]
|
||||
)
|
||||
|
||||
const insertExtendsFromNode = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertIncludeFromNode = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{% include "${sourceNode.id}" %}\n`, mode)
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertImportFromNode = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{% import "${sourceNode.id}" as ${sourceNode.id} %}\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(() => {
|
||||
const lang =
|
||||
configTypeId === 'wireframe'
|
||||
? javascript()
|
||||
: configType.language === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [
|
||||
lang,
|
||||
autocompletion({
|
||||
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
]
|
||||
}, [configTypeId, configType.language, variableIds, functionIds, configTitles])
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||
|
||||
const insertBlocksContent = useMemo(() => {
|
||||
const templatingGroup = configType.insertBlocks.find(
|
||||
(b): b is import('../../lib/configTypes').InsertBlockGroup => isGroup(b) && b.label === 'Templating'
|
||||
)
|
||||
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 =
|
||||
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} selected={selected} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
|
||||
<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>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="config"
|
||||
inputsMenuContent={
|
||||
hasDependencies ? (
|
||||
<>
|
||||
{connectedConfigNodes.map((n: any) => (
|
||||
<MenubarSub key={`${n.id}`}>
|
||||
<MenubarSubTrigger className="text-xs flex items-center gap-2">
|
||||
<ScrollText className="size-3.5 shrink-0" />
|
||||
{n.data?.title ?? n.id}
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarItem
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertExtendsFromNode(n, 'cursor')}
|
||||
>
|
||||
Extend
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertIncludeFromNode(n, 'cursor')}
|
||||
>
|
||||
Include
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertImportFromNode(n, 'cursor')}
|
||||
>
|
||||
Import
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
))}
|
||||
{connectedVariableNodes.map((n: any) => (
|
||||
<MenubarItem
|
||||
key={n.id}
|
||||
className="text-xs flex items-center gap-2 group"
|
||||
onClick={() => insertVariableReference(n, '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>
|
||||
))}
|
||||
{connectedFunctionNodes.map((n: any) => (
|
||||
<MenubarItem
|
||||
key={n.id}
|
||||
className="text-xs flex items-center gap-2 group"
|
||||
onClick={() => insertFunctionCall(n, '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>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
|
||||
)
|
||||
}
|
||||
insertMenuLabel="Blocks"
|
||||
insertContentDirect
|
||||
insertTagsContent={insertBlocksContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<CodeMirror
|
||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
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>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="config">
|
||||
{configType.label} · {content ? `${content.length} chars` : 'none'}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConfigNode = createAbstractNodeComponent<ConfigNodeData>(
|
||||
'ConfigNode',
|
||||
ConfigNodeComponent
|
||||
)
|
||||
|
||||
export default ConfigNode
|
||||
163
frontend/src/components/nodes/FunctionNode.tsx
Normal file
163
frontend/src/components/nodes/FunctionNode.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||
import { useTheme } from '../../lib/themeContext'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '../base/BaseNode'
|
||||
import { InputHandle, OutputHandle } from '../base/NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { MenubarItem, MenubarShortcut } from '../ui/menubar'
|
||||
import { Kbd } from '../ui/kbd'
|
||||
import { Code2, Variable } from 'lucide-react'
|
||||
|
||||
export type FunctionNodeData = { body?: string }
|
||||
|
||||
type Props = AbstractNodeProps<FunctionNodeData>
|
||||
|
||||
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const bodyValue = data?.body ?? ''
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ body: val }),
|
||||
[updateData]
|
||||
)
|
||||
|
||||
const insertAt = useCallback(
|
||||
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
const ref = editorRef.current as { view: { state: { doc: { length: number }; 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 from = view.state.selection.main.from
|
||||
view.dispatch({ changes: { from, to: from, insert: insertText } })
|
||||
onChange(view.state.doc.toString())
|
||||
return
|
||||
}
|
||||
if (mode === 'prepend') onChange(insertText + bodyValue)
|
||||
else onChange(bodyValue + insertText)
|
||||
},
|
||||
[onChange, bodyValue]
|
||||
)
|
||||
|
||||
const insertVariableAtCursor = useCallback(
|
||||
(variableNode: any) => {
|
||||
insertAt(variableNode.id, 'cursor')
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertFunctionAtCursor = useCallback(
|
||||
(functionNode: any) => {
|
||||
insertAt(functionNode.id, 'cursor')
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const extensions = useMemo(() => [javascript()], [])
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
? { width, height }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}>
|
||||
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="function"
|
||||
inputsMenuContent={
|
||||
hasConnectedInputs ? (
|
||||
<>
|
||||
{connectedVariableNodes.map((n: any) => (
|
||||
<MenubarItem
|
||||
key={n.id}
|
||||
className="text-xs flex items-center gap-2 group"
|
||||
onClick={() => insertVariableAtCursor(n)}
|
||||
>
|
||||
<Variable className="size-3.5 shrink-0" />
|
||||
{n.id}
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
))}
|
||||
{connectedFunctionNodes.map((n: any) => (
|
||||
<MenubarItem
|
||||
key={n.id}
|
||||
className="text-xs flex items-center gap-2 group"
|
||||
onClick={() => insertFunctionAtCursor(n)}
|
||||
>
|
||||
<Code2 className="size-3.5 shrink-0" />
|
||||
{n.id}
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert at cursor</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<CodeMirror
|
||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||
ref={editorRef}
|
||||
value={bodyValue}
|
||||
height={`${editorHeight}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
||||
/>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="function">
|
||||
{bodyValue ? `JavaScript · ${bodyValue.length} chars` : 'JavaScript · none'}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}
|
||||
|
||||
export const FunctionNode = createAbstractNodeComponent<FunctionNodeData>(
|
||||
'FunctionNode',
|
||||
FunctionNodeComponent
|
||||
)
|
||||
|
||||
export default FunctionNode
|
||||
786
frontend/src/components/nodes/RenderingNode.tsx
Normal file
786
frontend/src/components/nodes/RenderingNode.tsx
Normal file
@@ -0,0 +1,786 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import nunjucks from 'nunjucks'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
|
||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '../base/BaseNode'
|
||||
import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
|
||||
import { getDefaultStyle } from '../../lib/nodeRegistry'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { NodeStatusIndicator } from '../base/NodeStatusIndicator'
|
||||
import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
|
||||
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw } from 'lucide-react'
|
||||
import { InputHandle } from '../base/NodeHandles'
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||
import { Input } from '../ui/input'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export type RenderingNodeData = {
|
||||
viewportWidth?: number
|
||||
viewportHeight?: number
|
||||
}
|
||||
|
||||
const DEFAULT_VIEWPORT_WIDTH = 1200
|
||||
const DEFAULT_VIEWPORT_HEIGHT = 800
|
||||
|
||||
type Props = AbstractNodeProps<RenderingNodeData>
|
||||
|
||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
||||
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const runIdRef = useRef(0)
|
||||
const loadingStartedAtRef = useRef<number | null>(null)
|
||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||
|
||||
const incomingIds = sourceIds
|
||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||
const srcNode = nodes.find((n: any) => n.id === srcId)
|
||||
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
|
||||
const sourceContent = srcNode?.type === 'config' ? getConfigContent(srcNode.data) : ''
|
||||
const srcData = srcNode?.data ?? {}
|
||||
|
||||
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
const out = new Set<string>()
|
||||
const isReachable = (startId: string, targetId: string) => {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const resolveRef = (name: string) => {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return nodes.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName
|
||||
}
|
||||
const getTemplateRefs = (content: string): string[] => {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
const addConfigRefs = (nodeId: string, visited: Set<string>) => {
|
||||
if (visited.has(nodeId)) return
|
||||
const node = nodes.find((n: any) => n.id === nodeId && n.type === 'config')
|
||||
if (!node) return
|
||||
visited.add(nodeId)
|
||||
out.add(nodeId)
|
||||
const content = getConfigContent(node.data)
|
||||
for (const ref of getTemplateRefs(content)) {
|
||||
const refId = resolveRef(ref)
|
||||
if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id))
|
||||
addConfigRefs(refId, visited)
|
||||
}
|
||||
}
|
||||
const configVisited = new Set<string>()
|
||||
for (const nid of incomingIds) {
|
||||
const node = nodes.find((n: any) => n.id === nid)
|
||||
if (node?.type === 'config') addConfigRefs(nid, configVisited)
|
||||
else out.add(nid)
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (out.has(e.target)) out.add(e.source)
|
||||
}
|
||||
return out
|
||||
}, [nodes, edges, id, incomingIds])
|
||||
|
||||
const configSignature = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.filter((n: any) => n.type === 'config' && connectedNodeIds.has(n.id))
|
||||
.map((n: any) => `${n.id}:${n.data?.title ?? ''}:${getConfigContent(n.data)}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const edgesSignature = useMemo(
|
||||
() =>
|
||||
edges
|
||||
.filter((e: any) => connectedNodeIds.has(e.source) && (connectedNodeIds.has(e.target) || e.target === id))
|
||||
.map((e: any) => `${e.source}->${e.target}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[edges, connectedNodeIds, id]
|
||||
)
|
||||
|
||||
const variablesSignature = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.filter((n: any) => n.type === 'variable' && connectedNodeIds.has(n.id))
|
||||
.map((n: any) => `${n.id}:${n.data?.value}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const functionsSignature = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.filter((n: any) => n.type === 'function' && connectedNodeIds.has(n.id))
|
||||
.map((n: any) => `${n.id}:${n.data?.body ?? ''}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const RENDER_DEBOUNCE_MS = 250
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceContent && incomingIds.length > 0) {
|
||||
setRenderedContent(null)
|
||||
setError({ kind: 'no-content', message: 'No content on connected configuration node' })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (incomingIds.length === 0) {
|
||||
setRenderedContent(null)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
runIdRef.current += 1
|
||||
const thisRunId = runIdRef.current
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
loadingStartedAtRef.current = Date.now()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const configIdsUsed = new Set<string>()
|
||||
|
||||
const isReachable = (startId: string, targetId: string) => {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const resolveExtendsRef = (name: string): string => {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return nodes.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName
|
||||
}
|
||||
|
||||
/** Collect refs from {% extends %}, {% include %}, {% import %} in template content */
|
||||
const getTemplateRefs = (content: string): string[] => {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
|
||||
const addConfigAndRefs = (templateName: string, visited = new Set<string>()) => {
|
||||
const refId = resolveExtendsRef(templateName)
|
||||
if (visited.has(refId)) throw new Error(`Circular reference detected: ${templateName}`)
|
||||
const node = nodes.find((n: any) => n.id === refId && n.type === 'config')
|
||||
if (!node) throw new Error(`Config not found: ${templateName}`)
|
||||
if (refId !== srcId && !isReachable(refId, id))
|
||||
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
||||
visited.add(refId)
|
||||
configIdsUsed.add(refId)
|
||||
const content = getConfigContent(node.data)
|
||||
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
|
||||
}
|
||||
|
||||
if (srcId && srcNode?.type === 'config') addConfigAndRefs(srcId)
|
||||
|
||||
// Loader for Nunjucks {% extends %}, {% include %}, {% import %}: resolve template name to config node's plantuml
|
||||
const configLoader = {
|
||||
getSource: (name: string): { src: string; path: string } | null => {
|
||||
const refId = resolveExtendsRef(name)
|
||||
const node = nodes.find((n: any) => n.id === refId && n.type === 'config')
|
||||
if (!node) return null
|
||||
if (refId !== srcId && !isReachable(refId, id))
|
||||
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
||||
return {
|
||||
src: getConfigContent(node.data),
|
||||
path: name,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Context: variables connected to configs, plus variables connected to functions that feed configs (so they can be injected as constants).
|
||||
const nunjucksContext = Object.create(null) as Record<string, unknown>
|
||||
const setVarInContext = (src: any) => {
|
||||
const v = src.data?.value
|
||||
const str = v === undefined || v === null ? '' : String(v)
|
||||
nunjucksContext[src.id] =
|
||||
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (!configIdsUsed.has(e.target)) continue
|
||||
const src = nodes.find((n: any) => n.id === e.source)
|
||||
if (src?.type === 'variable') setVarInContext(src)
|
||||
}
|
||||
// All function node ids that feed (directly or transitively) into config — need to register them and collect their variables
|
||||
const functionIdsToRegister = new Set<string>()
|
||||
let added = true
|
||||
while (added) {
|
||||
added = false
|
||||
for (const e of edges) {
|
||||
const src = nodes.find((n: any) => n.id === e.source)
|
||||
if (src?.type !== 'function') continue
|
||||
const targetInScope = configIdsUsed.has(e.target) || functionIdsToRegister.has(e.target)
|
||||
if (!targetInScope) continue
|
||||
if (!functionIdsToRegister.has(src.id)) {
|
||||
functionIdsToRegister.add(src.id)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (!configIdsUsed.has(e.target) && !functionIdsToRegister.has(e.target)) continue
|
||||
const src = nodes.find((n: any) => n.id === e.source)
|
||||
if (src?.type === 'function') {
|
||||
for (const e2 of edges) {
|
||||
if (e2.target !== src.id) continue
|
||||
const vNode = nodes.find((n: any) => n.id === e2.source)
|
||||
if (vNode?.type === 'variable') setVarInContext(vNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||
|
||||
// Register each connected function node as a Nunjucks custom filter (async so sync and async user code both work).
|
||||
// Supports either named params: function(num, x, y, kwargs) { return num + (kwargs.bar || 10); } or legacy: args array.
|
||||
const formatFilterResult = (r: unknown): string => {
|
||||
if (r === undefined || r === null) return ''
|
||||
if (typeof r === 'string' || typeof r === 'number' || typeof r === 'boolean') return String(r)
|
||||
return String(r)
|
||||
}
|
||||
/** Parse function(num, x, y, kwargs) { body } or (num, x, y, kwargs) => body to get param names and inner body. */
|
||||
const parseFunctionSignature = (body: string): { paramNames: string[]; innerBody: string } | null => {
|
||||
const withCommentsStripped = body.replace(/^\s*\/\/[^\n]*\n?/gm, '').trim()
|
||||
const trimmed = withCommentsStripped.trim()
|
||||
const fnMatch = trimmed.match(/^function\s*\(([^)]*)\)\s*\{([\s\S]*)\}\s*$/)
|
||||
if (fnMatch) {
|
||||
const paramNames = fnMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||
return { paramNames, innerBody: fnMatch[2].trim() }
|
||||
}
|
||||
const arrowBlockMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*\{([\s\S]*)\}\s*$/)
|
||||
if (arrowBlockMatch) {
|
||||
const paramNames = arrowBlockMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||
return { paramNames, innerBody: arrowBlockMatch[2].trim() }
|
||||
}
|
||||
const arrowExprMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*(.+)\s*$/s)
|
||||
if (arrowExprMatch) {
|
||||
const paramNames = arrowExprMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||
return { paramNames, innerBody: 'return ' + arrowExprMatch[2].trim() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
|
||||
// For each registered function: which variable/function node ids are connected to it?
|
||||
const functionConnectedVariableIds = Object.create(null) as Record<string, string[]>
|
||||
const functionConnectedFunctionIds = Object.create(null) as Record<string, string[]>
|
||||
for (const fid of functionIdsToRegister) {
|
||||
for (const e of edges) {
|
||||
if (e.target !== fid) continue
|
||||
const src = nodes.find((n: any) => n.id === e.source)
|
||||
if (src?.type === 'variable') {
|
||||
if (!functionConnectedVariableIds[fid]) functionConnectedVariableIds[fid] = []
|
||||
functionConnectedVariableIds[fid].push(src.id)
|
||||
} else if (src?.type === 'function') {
|
||||
if (!functionConnectedFunctionIds[fid]) functionConnectedFunctionIds[fid] = []
|
||||
functionConnectedFunctionIds[fid].push(src.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fid of functionIdsToRegister) {
|
||||
const src = nodes.find((n: any) => n.id === fid)
|
||||
if (!src || src.type !== 'function') continue
|
||||
const body = src.data?.body ?? 'return args[0];'
|
||||
const parsed = parseFunctionSignature(body)
|
||||
const connectedVarIds = new Set<string>(functionConnectedVariableIds[fid] ?? [])
|
||||
const connectedFuncIds = functionConnectedFunctionIds[fid] ?? []
|
||||
env.addFilter(
|
||||
src.id,
|
||||
(value: unknown, ...args: unknown[]) => {
|
||||
const callback = args[args.length - 1] as (err: Error | null, res: string) => void
|
||||
const raw = [value, ...args.slice(0, -1)]
|
||||
const hasKwargs = raw.length > 0 && isPlainObject(raw[raw.length - 1])
|
||||
const positionals = hasKwargs ? raw.slice(0, -1) : raw
|
||||
const kwargs = hasKwargs ? (raw[raw.length - 1] as Record<string, unknown>) : Object.create(null)
|
||||
|
||||
// Cache for nested filter results so sync-looking code like `num + fn_003(4)` works:
|
||||
// callable throws Suspend when result isn't ready; we await, cache, then re-run.
|
||||
// Cached values are strings (Nunjucks); coerce to number when numeric so 2 + fn_003(4) => 10 not "28".
|
||||
const nestedCache = new Map<string, string>()
|
||||
const coerceCached = (s: string): string | number => {
|
||||
const n = Number(s)
|
||||
return s.trim() !== '' && !Number.isNaN(n) ? n : s
|
||||
}
|
||||
const makeCallable = (filterId: string) => (input: unknown) => {
|
||||
const key = `${filterId}::${JSON.stringify(input)}`
|
||||
if (nestedCache.has(key)) return coerceCached(nestedCache.get(key)!)
|
||||
const p = new Promise<string>((resolve, reject) => {
|
||||
env.getFilter(filterId)(input, (err: Error | null, res: string) =>
|
||||
err ? reject(err) : resolve(res)
|
||||
)
|
||||
})
|
||||
p.then((res) => nestedCache.set(key, res))
|
||||
const suspend = { __suspend: true as const, promise: p, key }
|
||||
throw suspend
|
||||
}
|
||||
|
||||
let invoke: () => unknown
|
||||
if (parsed) {
|
||||
const { paramNames, innerBody } = parsed
|
||||
const lastParam = paramNames[paramNames.length - 1]
|
||||
const invocationArgs = paramNames.map((name, i) => {
|
||||
if (name === lastParam && lastParam === 'kwargs') return kwargs
|
||||
if (connectedVarIds.has(name) && name in nunjucksContext)
|
||||
return nunjucksContext[name]
|
||||
if (connectedFuncIds.includes(name)) return makeCallable(name)
|
||||
return positionals[i]
|
||||
})
|
||||
const extraVarIds = [...connectedVarIds].filter((vid) => !paramNames.includes(vid))
|
||||
const extraFuncIds = connectedFuncIds.filter((fid2) => !paramNames.includes(fid2))
|
||||
const allParamNames = [...paramNames, ...extraVarIds, ...extraFuncIds]
|
||||
const allArgs = [
|
||||
...invocationArgs,
|
||||
...extraVarIds.map((vid) => nunjucksContext[vid]),
|
||||
...extraFuncIds.map((fid2) => makeCallable(fid2)),
|
||||
]
|
||||
const fn = new Function(...allParamNames, innerBody)
|
||||
invoke = () => fn(...allArgs)
|
||||
} else {
|
||||
const fn = new Function('args', body)
|
||||
invoke = () => fn(positionals)
|
||||
}
|
||||
|
||||
const done = (err: Error | null, res: string) => {
|
||||
callback(err, res)
|
||||
}
|
||||
const runInvoke = () => {
|
||||
try {
|
||||
const result = invoke()
|
||||
if (result != null && typeof (result as Promise<unknown>).then === 'function') {
|
||||
(result as Promise<unknown>).then(
|
||||
(r) => done(null, formatFilterResult(r)),
|
||||
(err) => done(err instanceof Error ? err : new Error(String(err)), '')
|
||||
)
|
||||
} else {
|
||||
done(null, formatFilterResult(result))
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const s = e as { __suspend?: boolean; promise?: Promise<string>; key?: string }
|
||||
if (s?.__suspend && s.promise) {
|
||||
s.promise.then(() => runInvoke(), (err) =>
|
||||
done(err instanceof Error ? err : new Error(String(err)), '')
|
||||
)
|
||||
} else {
|
||||
done(e instanceof Error ? e : new Error(String(e)), '')
|
||||
}
|
||||
}
|
||||
}
|
||||
runInvoke()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => {
|
||||
if (cancelled || thisRunId !== runIdRef.current) return
|
||||
if (nunjucksErr) {
|
||||
setSvgContent(null)
|
||||
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedContent = afterNunjucks
|
||||
const typeRenderer = getConfigType(configTypeId)
|
||||
|
||||
try {
|
||||
const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
|
||||
const htmlOrSvg = await typeRenderer.render(resolvedContent, renderOptions)
|
||||
if (cancelled || thisRunId !== runIdRef.current) return
|
||||
setRenderedContent(htmlOrSvg)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
if (cancelled || thisRunId !== runIdRef.current) return
|
||||
const msg = err?.message ?? 'Render error'
|
||||
setRenderedContent(null)
|
||||
setError({ kind: 'render', message: msg })
|
||||
} finally {
|
||||
if (!cancelled && thisRunId === runIdRef.current) {
|
||||
const startedAt = loadingStartedAtRef.current ?? 0
|
||||
const elapsed = Date.now() - startedAt
|
||||
const remaining = Math.max(0, 1000 - elapsed)
|
||||
if (remaining > 0) {
|
||||
minLoadingTimeoutRef.current = setTimeout(() => {
|
||||
minLoadingTimeoutRef.current = null
|
||||
if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
|
||||
}, remaining)
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (!cancelled && thisRunId === runIdRef.current) {
|
||||
setRenderedContent(null)
|
||||
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(debounceTimer)
|
||||
if (minLoadingTimeoutRef.current != null) {
|
||||
clearTimeout(minLoadingTimeoutRef.current)
|
||||
minLoadingTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
|
||||
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight, retryCount])
|
||||
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
? { width, height }
|
||||
: 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(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${id}.svg`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [id, renderedContent, isSvgOutput])
|
||||
|
||||
const downloadPng = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const pngUrl = canvas.toDataURL('image/png')
|
||||
const a = document.createElement('a')
|
||||
a.href = pngUrl
|
||||
a.download = `${id}.png`
|
||||
a.click()
|
||||
}
|
||||
img.onerror = () => { }
|
||||
img.src = dataUrl
|
||||
}, [id, renderedContent, isSvgOutput])
|
||||
|
||||
const copyPng = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {})
|
||||
}, 'image/png')
|
||||
}
|
||||
img.onerror = () => {}
|
||||
img.src = dataUrl
|
||||
}, [renderedContent, isSvgOutput])
|
||||
|
||||
const copySvg = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||
navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {})
|
||||
}, [renderedContent, isSvgOutput])
|
||||
|
||||
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
|
||||
|
||||
const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight })
|
||||
useEffect(() => {
|
||||
setViewportDraft({ width: viewportWidth, height: viewportHeight })
|
||||
}, [viewportWidth, viewportHeight])
|
||||
|
||||
const onViewportDraftChange = useCallback((field: 'width' | 'height', value: number) => {
|
||||
setViewportDraft((prev) => ({ ...prev, [field]: Math.min(4000, Math.max(200, value)) }))
|
||||
}, [])
|
||||
const onViewportApply = useCallback(() => {
|
||||
updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height })
|
||||
}, [updateData, viewportDraft.width, viewportDraft.height])
|
||||
|
||||
return (
|
||||
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
|
||||
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<InputHandle id="ain" nodeId={id} />}>
|
||||
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="render"
|
||||
nodeMenuExtraContent={
|
||||
<>
|
||||
{isSvgOutput && (
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger className="text-xs">Viewport</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[12rem] p-2">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground shrink-0">Width</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={200}
|
||||
max={4000}
|
||||
value={viewportDraft.width}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(v)) onViewportDraftChange('width', v)
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground shrink-0">Height</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={200}
|
||||
max={4000}
|
||||
value={viewportDraft.height}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(v)) onViewportDraftChange('height', v)
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewportApply}
|
||||
className="mt-1 w-full rounded bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
)}
|
||||
<MenubarSub>
|
||||
<MenubarSeparator />
|
||||
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
||||
Export / Copy
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[10rem]" aria-label="Export or copy diagram">
|
||||
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
|
||||
Download SVG
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
|
||||
Download PNG
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={copySvg} disabled={!isSvgOutput}>
|
||||
Copy SVG
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={copyPng} disabled={!isSvgOutput}>
|
||||
Copy image
|
||||
</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 flex flex-col">
|
||||
{incomingIds.length === 0 ? (
|
||||
<Empty className="min-h-0 flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Sparkles className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No configuration connected</EmptyTitle>
|
||||
<EmptyDescription>Connect a Configuration node or create one. The renderer will display the diagram or document.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<button
|
||||
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
|
||||
onClick={() => {
|
||||
if (!setNodes || !setEdges) return
|
||||
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: getDefaultDataForType('config', nid), style: getDefaultStyle('config') }
|
||||
setNodes((nds: any[]) => nds.concat(newNode))
|
||||
setEdges((eds: any[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }))
|
||||
}}
|
||||
>
|
||||
Create Config
|
||||
</button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : error ? (
|
||||
srcData?.renderError ? (
|
||||
srcData.renderError(error)
|
||||
) : srcData?.errorHtml ? (
|
||||
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<p className="text-xs text-red-700 dark:text-red-400">{error.message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
>
|
||||
<RotateCw className="size-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : loading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||
) : renderedContent ? (
|
||||
isSvgOutput ? (
|
||||
<div className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary">
|
||||
<TransformWrapper
|
||||
initialScale={1}
|
||||
minScale={0.2}
|
||||
maxScale={4}
|
||||
centerOnInit
|
||||
onInit={(ref) => ref?.centerView(1, 0, 0)}
|
||||
panning={{ disabled: true }}
|
||||
wheel={{ disabled: true }}
|
||||
doubleClick={{ disabled: true }}
|
||||
>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomIn()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomOut()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetTransform()}
|
||||
className="react-flow__controls-button"
|
||||
title="Reset view (fit all)"
|
||||
>
|
||||
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute inset-0 nodrag nopan">
|
||||
<TransformComponent
|
||||
wrapperClass="!w-full !h-full"
|
||||
contentClass="!w-full !h-full flex items-center justify-center nodrag nopan"
|
||||
>
|
||||
<div
|
||||
className="rendering-diagram flex items-center justify-center min-h-full min-w-full p-4 nodrag nopan"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="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}
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
||||
{renderedContent
|
||||
? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars`
|
||||
: error
|
||||
? 'Error'
|
||||
: '—'}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
</NodeStatusIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
export const RenderingNode = createAbstractNodeComponent<RenderingNodeData>(
|
||||
'RenderingNode',
|
||||
RenderingNodeComponent
|
||||
)
|
||||
|
||||
export default RenderingNode
|
||||
138
frontend/src/components/nodes/VariableNode.tsx
Normal file
138
frontend/src/components/nodes/VariableNode.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '../base/BaseNode'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { Input } from '../ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { Switch } from '../ui/switch'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { OutputHandle } from '../base/NodeHandles'
|
||||
import { Variable } from 'lucide-react'
|
||||
|
||||
export type ValueType = 'string' | 'number' | 'boolean'
|
||||
|
||||
export type VariableNodeData = {
|
||||
value?: string | number | boolean
|
||||
valueType?: ValueType
|
||||
}
|
||||
|
||||
type Props = AbstractNodeProps<VariableNodeData>
|
||||
|
||||
const DEFAULT_BY_TYPE: Record<ValueType, string | number | boolean> = {
|
||||
string: '',
|
||||
number: 0,
|
||||
boolean: false,
|
||||
}
|
||||
|
||||
function coerceValue(raw: string, valueType: ValueType): string | number | boolean {
|
||||
switch (valueType) {
|
||||
case 'number': {
|
||||
const n = Number(raw)
|
||||
return Number.isNaN(n) ? 0 : n
|
||||
}
|
||||
case 'boolean':
|
||||
return /^(1|true|yes|on)$/i.test(raw.trim())
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function VariableNodeComponent({ id, data, selected }: Props) {
|
||||
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
|
||||
|
||||
const valueType: ValueType = data?.valueType ?? 'string'
|
||||
const value = data?.value ?? DEFAULT_BY_TYPE[valueType]
|
||||
const displayValue = typeof value === 'string' ? value : String(value)
|
||||
|
||||
const onTypeChange = useCallback(
|
||||
(nextType: string) => {
|
||||
const type = nextType as ValueType
|
||||
const raw = typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value)
|
||||
const nextValue = coerceValue(raw, type)
|
||||
updateData({ valueType: type, value: nextValue })
|
||||
},
|
||||
[value, updateData]
|
||||
)
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value
|
||||
const nextValue = coerceValue(raw, valueType)
|
||||
updateData({ value: nextValue })
|
||||
},
|
||||
[valueType, updateData]
|
||||
)
|
||||
|
||||
const onBooleanChange = useCallback(
|
||||
(checked: boolean) => updateData({ value: checked }),
|
||||
[updateData]
|
||||
)
|
||||
|
||||
return (
|
||||
<BaseNode className="min-w-56 min-h-[180px]" selected={selected} handles={<OutputHandle id="out" />}>
|
||||
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar nodeId={id} nodeType="variable" />
|
||||
</div>
|
||||
<div className="flex flex-col p-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Type</label>
|
||||
<Select value={valueType} onValueChange={onTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">String</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Value</label>
|
||||
{valueType === 'boolean' ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={value === true}
|
||||
onCheckedChange={onBooleanChange}
|
||||
/>
|
||||
<span className="text-muted-foreground">{value ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={valueType === 'number' ? 'number' : 'text'}
|
||||
value={valueType === 'number' ? (value as number) : displayValue}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="variable">
|
||||
{`${valueType.charAt(0).toUpperCase() + valueType.slice(1)} · ${displayValue.length ? `${displayValue.length} chars` : 'none'}`}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}
|
||||
|
||||
export const VariableNode = createAbstractNodeComponent<VariableNodeData>(
|
||||
'VariableNode',
|
||||
VariableNodeComponent
|
||||
)
|
||||
|
||||
export default VariableNode
|
||||
Reference in New Issue
Block a user