refactor nodes

This commit is contained in:
2026-03-09 14:04:13 +01:00
parent 76039bdd0e
commit 24efc597b2
19 changed files with 572 additions and 288 deletions

View File

@@ -5,16 +5,11 @@ import {
type EdgeProps,
} from '@xyflow/react'
import FlowContext from '../../lib/flowContext'
import { getConnectionLabelForTarget } from '../../lib/nodeRegistry'
const EDGE_STROKE_WIDTH = 2
const DOT_MARKER_R = 1.5
function getEdgeLabelByTargetType(targetType: string | undefined): string | undefined {
if (targetType === 'render') return 'render'
if (targetType === 'config' || targetType === 'function') return 'add input'
return undefined
}
export function AnimatedEdge({
id,
sourceX,
@@ -32,7 +27,7 @@ export function AnimatedEdge({
const nodes = ctx?.nodes ?? []
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
const derivedLabel = useMemo(
() => getEdgeLabelByTargetType(targetNode?.type),
() => getConnectionLabelForTarget(targetNode?.type),
[targetNode?.type]
)
const label = labelProp ?? derivedLabel

View File

@@ -2,23 +2,11 @@ import React, { useContext, useMemo } from 'react'
import FlowContext from '../../lib/flowContext'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import { NodeHelpPopover } from './NodeHelpPopover'
const NODE_HAS_INPUT: Record<string, boolean> = {
config: true,
function: true,
render: true,
variable: false,
}
const NODE_HAS_OUTPUT: Record<string, boolean> = {
config: true,
function: true,
render: true,
variable: true,
}
import { getNodeType } from '../../lib/nodeRegistry'
type Props = {
nodeId: string
nodeType: 'config' | 'function' | 'render' | 'variable'
nodeType: string
children?: React.ReactNode
}
@@ -36,8 +24,9 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
return { inputs, outputs }
}, [edges, nodeId])
const showInput = NODE_HAS_INPUT[nodeType] ?? false
const showOutput = NODE_HAS_OUTPUT[nodeType] ?? false
const descriptor = getNodeType(nodeType)
const showInput = descriptor?.hasInput ?? false
const showOutput = descriptor?.hasOutput ?? false
return (
<div className="flex items-center gap-2 w-full text-xs text-muted-foreground">

View File

@@ -1,11 +1,11 @@
import React from 'react'
import { HelpCircle } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { getNodeHelp, type NodeType } from '../../lib/nodeHelp'
import { getNodeHelp } from '../../lib/nodeRegistry'
import { cn } from '../../lib/utils'
type Props = {
nodeType: NodeType
nodeType: string
className?: string
}

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useContext } from 'react'
import FlowContext from '../../lib/flowContext'
import { DEFAULT_NODE_STYLE, getNextNodeId, getResetDataForType } from '../../lib/flowUtils'
import { getNextNodeId, getResetDataForType } from '../../lib/flowUtils'
import { getDefaultStyle } from '../../lib/nodeRegistry'
import {
Menubar,
MenubarContent,
@@ -15,11 +16,9 @@ import {
const DUPLICATE_OFFSET = { x: 30, y: 30 }
type NodeType = 'config' | 'render' | 'variable' | 'function'
type Props = {
nodeId: string
nodeType: NodeType
nodeType: string
/** Content for Insert → Inputs (function nodes); when inputsMenuContent is set, Inputs is a separate menu and this is not used in Insert */
editInputsContent?: React.ReactNode
/** When set, Inputs is rendered as its own top-level menu (config nodes); Insert then only shows markup-specific options */
@@ -57,7 +56,7 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
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,
style: getDefaultStyle(nodeType),
}
if (newNode.data?.title && nodeType === 'config') newNode.data.title = `${newId}`
return nds.concat(newNode)

View File

@@ -22,7 +22,7 @@ import {
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from './BaseNode'
} from '../base/BaseNode'
import { Code2, ScrollText, Variable } from 'lucide-react'
import {
MenubarItem,
@@ -34,10 +34,10 @@ import {
} from '../ui/menubar'
import { Kbd } from '../ui/kbd'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar'
import { InputHandle, OutputHandle } from '../base/NodeHandles'
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from '../base/NodeMenubar'
type Props = {
id: string
@@ -94,13 +94,13 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
configType: newTypeId,
content: getConfigContent(n.data) ?? '',
},
}
...n,
data: {
...n.data,
configType: newTypeId,
content: getConfigContent(n.data) ?? '',
},
}
: n
)
)
@@ -184,8 +184,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
configTypeId === 'wireframe'
? javascript()
: configType.language === 'plantuml'
? plantumlLanguage.extension
: markdown()
? plantumlLanguage.extension
: markdown()
return [
lang,
autocompletion({
@@ -205,25 +205,25 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
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={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>,
]
<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 }) => (

View File

@@ -10,11 +10,11 @@ import {
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from './BaseNode'
import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar'
} 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'

View File

@@ -8,15 +8,16 @@ import {
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from './BaseNode'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId, nodePropsAreEqual } from '../../lib/flowUtils'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar'
import { NodeStatusIndicator } from './NodeStatusIndicator'
} from '../base/BaseNode'
import { getDefaultDataForType, getNextNodeId, nodePropsAreEqual } 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 } from 'lucide-react'
import { InputHandle } from './NodeHandles'
import { InputHandle } from '../base/NodeHandles'
type Props = {
id: string
@@ -511,7 +512,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
a.download = `${id}.png`
a.click()
}
img.onerror = () => {}
img.onerror = () => { }
img.src = dataUrl
}, [id, renderedContent, isSvgOutput])
@@ -521,101 +522,101 @@ export const RenderingNode = memo(function RenderingNode({ id, width, 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} handles={<InputHandle id="ain" nodeId={id} />}>
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} 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={
<MenubarSub>
<MenubarSeparator></MenubarSeparator>
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
Export
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
PNG
</MenubarItem>
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
SVG
</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: DEFAULT_NODE_STYLE.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="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div>
)
) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : renderedContent ? (
isWireframeOutput ? (
<div className="rendering-wireframe min-h-0 flex-1 w-full min-w-0 flex flex-col overflow-hidden bg-background">
<div className="rendering-wireframe__viewport">
<div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} />
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar
nodeId={id}
nodeType="render"
nodeMenuExtraContent={
<MenubarSub>
<MenubarSeparator></MenubarSeparator>
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
Export
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
PNG
</MenubarItem>
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
SVG
</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="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div>
)
) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : renderedContent ? (
isWireframeOutput ? (
<div className="rendering-wireframe min-h-0 flex-1 w-full min-w-0 flex flex-col overflow-hidden bg-background">
<div className="rendering-wireframe__viewport">
<div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} />
</div>
</div>
</div>
) : (
<div
className={
isSvgOutput
? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary'
: '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>
) : (
<div
className={
isSvgOutput
? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary'
: '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>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{renderedContent
? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars`
: error
? 'Error'
: '—'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
</NodeStatusIndicator>
)
}, nodePropsAreEqual)

View File

@@ -6,14 +6,14 @@ import {
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from './BaseNode'
import { NodeMenubar } from './NodeMenubar'
} 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 './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { OutputHandle } from '../base/NodeHandles'
import { Variable } from 'lucide-react'
type ValueType = 'string' | 'number' | 'boolean'