add indicators
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
63
src/components/graph/NodeFooterEdgeIndicators.tsx
Normal file
63
src/components/graph/NodeFooterEdgeIndicators.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user