Files
zui/src/App.tsx
2026-03-06 23:03:42 +01:00

186 lines
6.2 KiB
TypeScript

import React from 'react'
import ReactFlow, {
Controls,
Background,
MiniMap,
addEdge,
applyEdgeChanges,
applyNodeChanges,
useNodesState,
useEdgesState,
Node,
Edge,
Connection,
NodeChange,
EdgeChange,
} from 'reactflow'
import ConfigNode from './components/graph/ConfigNode'
import FunctionNode from './components/graph/FunctionNode'
import RenderingNode from './components/graph/RenderingNode'
import VariableNode from './components/graph/VariableNode'
import FlowContext from './lib/flowContext'
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
// Generate short unique node ids when missing
const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
const initialNodes: Node[] = [
{
id: 'config-1',
position: { x: 50, y: 50 },
data: { yaml: "# example:\nmessage: Hello from config" },
type: 'config',
},
{
id: 'render-1',
position: { x: 350, y: 80 },
data: {},
type: 'render',
},
].map((n) => ({ ...n, id: n.id ?? genId() }))
const initialEdges: Edge[] = [{ id: 'e-config-render', source: 'config-1', target: 'render-1' }]
export default function App() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
const wrapperRef = React.useRef<HTMLDivElement | null>(null)
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas' | 'node'; nodeId?: string; clientX: number; clientY: number }>(null)
const nodeTypes = React.useMemo(
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }),
[]
)
const onConnect = React.useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
)
const onInit = React.useCallback((instance: any) => {
setRfInstance(instance)
}, [])
const onCanvasContextMenu = React.useCallback((ev: React.MouseEvent) => {
ev.preventDefault()
const target = ev.target as HTMLElement
const nodeEl = target.closest('.react-flow__node') as HTMLElement | null
const handleEl = target.closest('.react-flow__handle') as HTMLElement | null
const clientX = ev.clientX
const clientY = ev.clientY
lastClickRef.current = { clientX, clientY }
if (nodeEl && !handleEl) {
const nodeId = nodeEl.dataset?.id || nodeEl.getAttribute('data-id') || (nodeEl.id && nodeEl.id.startsWith('reactflow__node-') ? nodeEl.id.replace('reactflow__node-', '') : undefined)
setContextTarget({ type: 'node', nodeId, clientX, clientY })
return
}
setContextTarget({ type: 'canvas', clientX, clientY })
}, [])
const createNode = React.useCallback(
(type: string) => {
if (!rfInstance) return
const click = contextTarget ?? lastClickRef.current
const clientX = click?.clientX ?? window.innerWidth / 2
const clientY = click?.clientY ?? window.innerHeight / 2
const rect = wrapperRef.current?.getBoundingClientRect()
const point = rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY }
let position = point
try {
// project to flow coords when possible
position = rfInstance.project ? rfInstance.project(point) : point
} catch (e) {
// ignore and use raw point
}
const id = genId()
const typeMap: Record<string, Node['type']> = {
config: 'config',
render: 'render',
variable: 'variable',
function: 'function',
}
const dataMap: Record<string, any> = {
config: { yaml: '# Enter YAML here\n', title: `config-${id}` },
render: {},
variable: { value: '', valueType: 'string' },
function: { body: '// args[0], args[1], ...\nreturn args[0];' },
}
const newNode: Node = {
id,
type: typeMap[type] ?? 'config',
position: { x: position.x, y: position.y },
data: dataMap[type] ?? {},
}
setNodes((nds) => nds.concat(newNode))
lastClickRef.current = null
setContextTarget(null)
},
[rfInstance, setNodes]
)
const deleteNode = React.useCallback((id: string | undefined) => {
if (!id) return
setNodes((nds) => nds.filter((n) => n.id !== id))
setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id))
setContextTarget(null)
}, [setNodes, setEdges])
return (
<div className="reactflow-wrapper" ref={wrapperRef} onContextMenu={onCanvasContextMenu} style={{ position: 'relative' }}>
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges }}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
onInit={onInit}
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{contextTarget?.type === 'node' ? (
<ContextMenuItem onSelect={() => deleteNode(contextTarget.nodeId)}>Delete</ContextMenuItem>
) : (
<>
<ContextMenuGroup>
<ContextMenuLabel>New Node</ContextMenuLabel>
<ContextMenuItem onSelect={() => createNode('config')}>Config</ContextMenuItem>
<ContextMenuItem onSelect={() => createNode('render')}>Renderer</ContextMenuItem>
<ContextMenuItem onSelect={() => createNode('variable')}>Variable</ContextMenuItem>
<ContextMenuItem onSelect={() => createNode('function')}>Function</ContextMenuItem>
</ContextMenuGroup>
</>
)}
</ContextMenuContent>
</ContextMenu>
</FlowContext.Provider>
</div>
)
}