add indicators

This commit is contained in:
2026-03-07 23:31:44 +01:00
parent 640c8b7d98
commit cbd9ef6c53
6 changed files with 134 additions and 14 deletions

View File

@@ -10,7 +10,6 @@ import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeFooterText,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { ScrollText } from 'lucide-react' import { ScrollText } from 'lucide-react'
@@ -21,6 +20,7 @@ import {
MenubarSubTrigger, MenubarSubTrigger,
} from '../ui/menubar' } from '../ui/menubar'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from './NodeMenubar'
@@ -264,7 +264,9 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<BaseNodeFooterText>{plantumlValue ? `${plantumlValue.length} chars` : 'none'}</BaseNodeFooterText> <NodeFooterEdgeIndicators nodeId={id} nodeType="config">
{plantumlValue ? `${plantumlValue.length} chars` : 'none'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )

View File

@@ -8,10 +8,10 @@ import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeFooterText,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from './NodeMenubar'
import { Code2 } from 'lucide-react' import { Code2 } from 'lucide-react'
@@ -69,7 +69,9 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<BaseNodeFooterText>{bodyValue ? `${bodyValue.length} chars` : 'none'}</BaseNodeFooterText> <NodeFooterEdgeIndicators nodeId={id} nodeType="function">
{bodyValue ? `${bodyValue.length} chars` : 'none'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )

View File

@@ -0,0 +1,63 @@
import React, { useContext, useMemo } from 'react'
import FlowContext from '../../lib/flowContext'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
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,
}
type Props = {
nodeId: string
nodeType: 'config' | 'function' | 'render' | 'variable'
children?: React.ReactNode
}
export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props) {
const ctx = useContext(FlowContext)
const edges = ctx?.edges ?? []
const { inputs, outputs } = useMemo(() => {
let inputs = 0
let outputs = 0
for (const e of edges) {
if (e.target === nodeId) inputs += 1
if (e.source === nodeId) outputs += 1
}
return { inputs, outputs }
}, [edges, nodeId])
const showInput = NODE_HAS_INPUT[nodeType] ?? false
const showOutput = NODE_HAS_OUTPUT[nodeType] ?? false
return (
<div className="flex items-center gap-2 w-full text-xs text-muted-foreground">
{showInput && (
<span className="flex items-center gap-1 shrink-0" title="Input connections">
<ArrowDownLeft className="size-3.5" />
<span>{inputs}</span>
</span>
)}
{showOutput && (
<span className="flex items-center gap-1 shrink-0" title="Output connections">
<ArrowUpRight className="size-3.5" />
<span>{outputs}</span>
</span>
)}
{children != null && (
<>
{(showInput || showOutput) && <span className="shrink-0">|</span>}
<span className="min-w-0 truncate">{children}</span>
</>
)}
</div>
)
}

View File

@@ -24,9 +24,11 @@ type Props = {
editInputsContent?: React.ReactNode editInputsContent?: React.ReactNode
/** Content for Insert → Tags (e.g. Nunjucks tag snippets, config nodes) */ /** Content for Insert → Tags (e.g. Nunjucks tag snippets, config nodes) */
insertTagsContent?: React.ReactNode insertTagsContent?: React.ReactNode
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
nodeMenuExtraContent?: React.ReactNode
} }
export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsContent }: Props) { export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsContent, nodeMenuExtraContent }: Props) {
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? [] const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes const setNodes = ctx?.setNodes
@@ -79,7 +81,7 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
}, [nodeId, ctx]) }, [nodeId, ctx])
return ( return (
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b shadow-none rounded-none text-muted-foreground"> <Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs"> <MenubarTrigger className="px-1.5 py-0 text-xs">
Node Node
@@ -97,6 +99,7 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
<MenubarItem className="text-xs" onClick={onReset}> <MenubarItem className="text-xs" onClick={onReset}>
Reset Reset
</MenubarItem> </MenubarItem>
{nodeMenuExtraContent}
<MenubarSeparator /> <MenubarSeparator />
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}> <MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
Delete Delete

View File

@@ -1,4 +1,4 @@
import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks' import nunjucks from 'nunjucks'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { useTheme } from '../../lib/themeContext' import { useTheme } from '../../lib/themeContext'
@@ -7,12 +7,13 @@ import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeFooterText,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils' import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from './NodeMenubar'
import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
@@ -355,13 +356,63 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
? { width, height } ? { width, height }
: undefined : undefined
const downloadSvg = useCallback(() => {
if (!svgContent) return
const blob = new Blob([svgContent], { 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, svgContent])
const downloadPng = useCallback(() => {
if (!svgContent) return
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgContent)
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, svgContent])
return ( return (
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}> <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={<NodeHeaderTitle nodeId={id} displayTitle={id} />} /> <BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent> <BaseNodeContent>
<div className="shrink-0 w-full"> <div className="shrink-0 w-full">
<NodeMenubar nodeId={id} nodeType="render" /> <NodeMenubar
nodeId={id}
nodeType="render"
nodeMenuExtraContent={
<MenubarSub>
<MenubarSubTrigger className="text-xs" disabled={!svgContent}>
Export
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!svgContent}>
PNG
</MenubarItem>
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!svgContent}>
SVG
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
}
/>
</div> </div>
<div className="min-h-0 flex-1 flex flex-col"> <div className="min-h-0 flex-1 flex flex-col">
{incomingIds.length === 0 ? ( {incomingIds.length === 0 ? (
@@ -411,9 +462,9 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<BaseNodeFooterText> <NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'} {svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'}
</BaseNodeFooterText> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )

View File

@@ -4,13 +4,13 @@ import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeFooterText,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { NodeMenubar } from './NodeMenubar' import { NodeMenubar } from './NodeMenubar'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Switch } from '../ui/switch' import { Switch } from '../ui/switch'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeHeaderTitle } from './NodeHeaderTitle'
import { OutputHandle } from './NodeHandles' import { OutputHandle } from './NodeHandles'
import { Variable } from 'lucide-react' import { Variable } from 'lucide-react'
@@ -134,8 +134,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<BaseNodeFooterText> <NodeFooterEdgeIndicators nodeId={id} nodeType="variable" />
</BaseNodeFooterText>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )