feat: variable node
This commit is contained in:
20
src/App.tsx
20
src/App.tsx
@@ -16,6 +16,7 @@ import ReactFlow, {
|
|||||||
} from 'reactflow'
|
} from 'reactflow'
|
||||||
import ConfigNode from './components/graph/ConfigNode'
|
import ConfigNode from './components/graph/ConfigNode'
|
||||||
import RenderingNode from './components/graph/RenderingNode'
|
import RenderingNode from './components/graph/RenderingNode'
|
||||||
|
import VariableNode from './components/graph/VariableNode'
|
||||||
import FlowContext from './lib/flowContext'
|
import FlowContext from './lib/flowContext'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -55,7 +56,7 @@ export default function App() {
|
|||||||
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas' | 'node'; nodeId?: string; clientX: number; clientY: number }>(null)
|
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas' | 'node'; nodeId?: string; clientX: number; clientY: number }>(null)
|
||||||
|
|
||||||
const nodeTypes = React.useMemo(
|
const nodeTypes = React.useMemo(
|
||||||
() => ({ config: ConfigNode, render: RenderingNode }),
|
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode }),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,11 +105,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = genId()
|
const id = genId()
|
||||||
|
const typeMap: Record<string, Node['type']> = {
|
||||||
|
config: 'config',
|
||||||
|
render: 'render',
|
||||||
|
variable: 'variable',
|
||||||
|
}
|
||||||
|
const dataMap: Record<string, any> = {
|
||||||
|
config: { yaml: '# Enter YAML here\n', title: `config-${id}` },
|
||||||
|
render: {},
|
||||||
|
variable: { value: '', valueType: 'string' },
|
||||||
|
}
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
id,
|
id,
|
||||||
type: type === 'config' ? 'config' : 'render',
|
type: typeMap[type] ?? 'config',
|
||||||
position: { x: position.x, y: position.y },
|
position: { x: position.x, y: position.y },
|
||||||
data: type === 'config' ? { yaml: '# Enter YAML here\n', title: `config-${id}` } : {},
|
data: dataMap[type] ?? {},
|
||||||
}
|
}
|
||||||
setNodes((nds) => nds.concat(newNode))
|
setNodes((nds) => nds.concat(newNode))
|
||||||
lastClickRef.current = null
|
lastClickRef.current = null
|
||||||
@@ -141,7 +152,7 @@ export default function App() {
|
|||||||
onInit={onInit}
|
onInit={onInit}
|
||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
<Controls />ß
|
<Controls />
|
||||||
<MiniMap />
|
<MiniMap />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,6 +167,7 @@ export default function App() {
|
|||||||
<ContextMenuLabel>New Node</ContextMenuLabel>
|
<ContextMenuLabel>New Node</ContextMenuLabel>
|
||||||
<ContextMenuItem onSelect={() => createNode('config')}>Config</ContextMenuItem>
|
<ContextMenuItem onSelect={() => createNode('config')}>Config</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={() => createNode('render')}>Renderer</ContextMenuItem>
|
<ContextMenuItem onSelect={() => createNode('render')}>Renderer</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => createNode('variable')}>Variable</ContextMenuItem>
|
||||||
</ContextMenuGroup>
|
</ContextMenuGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
|||||||
const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
|
const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
|
||||||
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
|
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
|
||||||
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
|
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
|
||||||
|
const connectedVariableNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable')
|
||||||
|
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (setNodes) {
|
if (setNodes) {
|
||||||
@@ -129,6 +131,48 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
|||||||
[onChange, value]
|
[onChange, value]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const insertVariableReference = useCallback(
|
||||||
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||||
|
const insertText = `\${${sourceNode.id}}`
|
||||||
|
const editor = editorRef.current
|
||||||
|
const monaco = monacoRef.current
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editor && monaco) {
|
||||||
|
const model = editor.getModel()
|
||||||
|
const lineCount = model.getLineCount()
|
||||||
|
const selection = editor.getSelection()
|
||||||
|
|
||||||
|
let range
|
||||||
|
if (mode === 'prepend') {
|
||||||
|
range = new monaco.Range(1, 1, 1, 1)
|
||||||
|
} else if (mode === 'append') {
|
||||||
|
range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1)
|
||||||
|
} else if (selection) {
|
||||||
|
range = new monaco.Range(selection.startLineNumber, selection.startColumn, selection.startLineNumber, selection.startColumn)
|
||||||
|
} else {
|
||||||
|
range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.executeEdits('insert-var', [{ range, text: insertText, forceMoveMarkers: true }])
|
||||||
|
const newVal = editor.getModel().getValue()
|
||||||
|
onChange(newVal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'prepend') {
|
||||||
|
onChange(insertText + value)
|
||||||
|
} else {
|
||||||
|
onChange(value + insertText)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Insert variable reference failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, value]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode className="w-80">
|
<BaseNode className="w-80">
|
||||||
<BaseNodeHeader className="border-b">
|
<BaseNodeHeader className="border-b">
|
||||||
@@ -137,7 +181,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
|||||||
</BaseNodeHeader>
|
</BaseNodeHeader>
|
||||||
|
|
||||||
<BaseNodeContent>
|
<BaseNodeContent>
|
||||||
{connectedConfigNodes.length > 0 && (
|
{hasDependencies && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Menubar className="h-auto bg-none p-1 border-none shadow-none">
|
<Menubar className="h-auto bg-none p-1 border-none shadow-none">
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
@@ -146,7 +190,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
|||||||
</MenubarTrigger>
|
</MenubarTrigger>
|
||||||
<MenubarContent className="min-w-[12rem]">
|
<MenubarContent className="min-w-[12rem]">
|
||||||
{connectedConfigNodes.map((n: any) => (
|
{connectedConfigNodes.map((n: any) => (
|
||||||
<MenubarSub key={n.id}>
|
<MenubarSub key={`config-${n.id}`}>
|
||||||
<MenubarSubTrigger className="text-xs">
|
<MenubarSubTrigger className="text-xs">
|
||||||
{n.data?.title ?? n.id}
|
{n.data?.title ?? n.id}
|
||||||
</MenubarSubTrigger>
|
</MenubarSubTrigger>
|
||||||
@@ -172,6 +216,21 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
|||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
</MenubarSub>
|
</MenubarSub>
|
||||||
))}
|
))}
|
||||||
|
{connectedVariableNodes.map((n: any) => (
|
||||||
|
<MenubarSub key={`var-${n.id}`}>
|
||||||
|
<MenubarSubTrigger className="text-xs">
|
||||||
|
{n.id}
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent>
|
||||||
|
<MenubarItem
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => insertVariableReference(n, 'cursor')}
|
||||||
|
>
|
||||||
|
Insert at cursor
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarSubContent>
|
||||||
|
</MenubarSub>
|
||||||
|
))}
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
</Menubar>
|
</Menubar>
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
[edges]
|
[edges]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const variablesSignature = useMemo(
|
||||||
|
() =>
|
||||||
|
nodes
|
||||||
|
.filter((n: any) => n.type === 'variable')
|
||||||
|
.map((n: any) => `${n.id}:${n.data?.value}`)
|
||||||
|
.join('|'),
|
||||||
|
[nodes]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// debug
|
// debug
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -73,6 +82,8 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const configIdsUsed = new Set<string>()
|
||||||
|
|
||||||
const resolveIncludes = (text: string, visited = new Set<string>()): string => {
|
const resolveIncludes = (text: string, visited = new Set<string>()): string => {
|
||||||
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
|
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
|
||||||
return text.replace(includeRegex, (match: string, indent: string, ref: string) => {
|
return text.replace(includeRegex, (match: string, indent: string, ref: string) => {
|
||||||
@@ -100,6 +111,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
if (!isReachable(refNode.id, id)) throw new Error(`Included node not connected to renderer: ${ref}`)
|
if (!isReachable(refNode.id, id)) throw new Error(`Included node not connected to renderer: ${ref}`)
|
||||||
|
|
||||||
visited.add(refNode.id)
|
visited.add(refNode.id)
|
||||||
|
if (refNode.type === 'config') configIdsUsed.add(refNode.id)
|
||||||
const includedRaw = String(refNode.data?.yaml ?? '')
|
const includedRaw = String(refNode.data?.yaml ?? '')
|
||||||
const resolved = resolveIncludes(includedRaw, visited)
|
const resolved = resolveIncludes(includedRaw, visited)
|
||||||
visited.delete(refNode.id)
|
visited.delete(refNode.id)
|
||||||
@@ -112,8 +124,26 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId)
|
||||||
|
|
||||||
const resolvedYaml = resolveIncludes(yamlText)
|
const resolvedYaml = resolveIncludes(yamlText)
|
||||||
const parsed = yaml.load(resolvedYaml)
|
|
||||||
|
const varMap: Record<string, string> = {}
|
||||||
|
for (const e of edges) {
|
||||||
|
if (configIdsUsed.has(e.target)) {
|
||||||
|
const src = nodes.find((n: any) => n.id === e.source)
|
||||||
|
if (src?.type === 'variable') {
|
||||||
|
const v = src.data?.value
|
||||||
|
varMap[src.id] = v === undefined || v === null ? '' : String(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveVariables = (text: string): string =>
|
||||||
|
text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '')
|
||||||
|
|
||||||
|
const resolvedWithVars = resolveVariables(resolvedYaml)
|
||||||
|
const parsed = yaml.load(resolvedWithVars)
|
||||||
const newOutput = JSON.stringify(parsed, null, 2)
|
const newOutput = JSON.stringify(parsed, null, 2)
|
||||||
setError(null)
|
setError(null)
|
||||||
setOutput((prev) => (prev === newOutput ? prev : newOutput))
|
setOutput((prev) => (prev === newOutput ? prev : newOutput))
|
||||||
@@ -122,7 +152,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
setOutput('')
|
setOutput('')
|
||||||
setError({ kind: 'parse', message: msg })
|
setError({ kind: 'parse', message: msg })
|
||||||
}
|
}
|
||||||
}, [id, incomingIds.join(','), yamlText, configSignature, edgesSignature])
|
}, [id, incomingIds.join(','), yamlText, configSignature, edgesSignature, variablesSignature])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode className="w-96">
|
<BaseNode className="w-96">
|
||||||
|
|||||||
133
src/components/graph/VariableNode.tsx
Normal file
133
src/components/graph/VariableNode.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { memo, useCallback, useContext } from 'react'
|
||||||
|
import FlowContext from '../../lib/flowContext'
|
||||||
|
import {
|
||||||
|
BaseNode,
|
||||||
|
BaseNodeContent,
|
||||||
|
BaseNodeHeader,
|
||||||
|
BaseNodeHeaderTitle,
|
||||||
|
} from './BaseNode'
|
||||||
|
import { OutputHandle } from './NodeHandles'
|
||||||
|
import { Variable } from 'lucide-react'
|
||||||
|
|
||||||
|
type ValueType = 'string' | 'number' | 'boolean'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string
|
||||||
|
data: {
|
||||||
|
value?: string | number | boolean
|
||||||
|
valueType?: ValueType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VariableNode = memo(function VariableNode({ id, data }: Props) {
|
||||||
|
const ctx = useContext(FlowContext)
|
||||||
|
const setNodes = ctx?.setNodes
|
||||||
|
|
||||||
|
const valueType: ValueType = data?.valueType ?? 'string'
|
||||||
|
const value = data?.value ?? DEFAULT_BY_TYPE[valueType]
|
||||||
|
const displayValue = typeof value === 'string' ? value : String(value)
|
||||||
|
|
||||||
|
const updateData = useCallback(
|
||||||
|
(updates: { value?: string | number | boolean; valueType?: ValueType }) => {
|
||||||
|
if (!setNodes) return
|
||||||
|
setNodes((nds: any[]) =>
|
||||||
|
nds.map((n) =>
|
||||||
|
n.id === id ? { ...n, data: { ...n.data, ...updates } } : n
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[id, setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onTypeChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const nextType = e.target.value as ValueType
|
||||||
|
const raw = typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value)
|
||||||
|
const nextValue = coerceValue(raw, nextType)
|
||||||
|
updateData({ valueType: nextType, value: nextValue })
|
||||||
|
},
|
||||||
|
[value, updateData]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onValueChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const raw = e.target.type === 'checkbox' ? (e.target.checked ? 'true' : 'false') : e.target.value
|
||||||
|
const nextValue = coerceValue(raw, valueType)
|
||||||
|
updateData({ value: nextValue })
|
||||||
|
},
|
||||||
|
[valueType, updateData]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseNode className="w-56">
|
||||||
|
<BaseNodeHeader className="border-b">
|
||||||
|
<Variable className="size-4" />
|
||||||
|
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
|
||||||
|
</BaseNodeHeader>
|
||||||
|
|
||||||
|
<BaseNodeContent className="gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Type</label>
|
||||||
|
<select
|
||||||
|
value={valueType}
|
||||||
|
onChange={onTypeChange}
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="string">String</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="boolean">Boolean</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Value</label>
|
||||||
|
{valueType === 'boolean' ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === true}
|
||||||
|
onChange={onValueChange}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
{value ? 'true' : 'false'}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={valueType === 'number' ? 'number' : 'text'}
|
||||||
|
value={valueType === 'number' ? (value as number) : displayValue}
|
||||||
|
onChange={onValueChange}
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Use in config: <code className="rounded bg-muted px-0.5">prop: ${{id}}</code>
|
||||||
|
</p>
|
||||||
|
</BaseNodeContent>
|
||||||
|
|
||||||
|
<OutputHandle id="out" />
|
||||||
|
</BaseNode>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
VariableNode.displayName = 'VariableNode'
|
||||||
|
|
||||||
|
export default VariableNode
|
||||||
Reference in New Issue
Block a user