186 lines
6.2 KiB
TypeScript
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>
|
|
)
|
|
}
|
|
|
|
|