import React, { useCallback, useMemo, useRef } from 'react' import { ReactFlow, ReactFlowProvider, Controls, Background, MiniMap, addEdge, applyNodeChanges, applyEdgeChanges, useNodesInitialized, useReactFlow, type Node, type Edge, type Connection, type ColorMode, type NodeChange, type EdgeChange, } from '@xyflow/react' import { AnimatedEdge } from './components/base/AnimatedEdge' import FlowContext from './lib/flowContext' import { useTheme } from './lib/themeContext' import { useGraphStateWithHistory } from './hooks/useGraphStateWithHistory' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, ContextMenuGroup } from "@/components/ui/context-menu" import { AppMenubar } from '@/components/AppMenubar' import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty' import { Button } from '@/components/ui/button' import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react' import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils' import { getRegisteredNodeTypes, getRegisteredNodeTypeIds, getDefaultStyle, isConnectionAllowed, } from './lib/nodeRegistry' import type { AppNode, AppEdge } from './lib/nodeTypes' const SNAP_GRID: [number, number] = [15, 15] const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0], y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1], }) const NODE_GAP = 150 const initialNodes: AppNode[] = [ { id: 'var_001', position: { x: 50, y: 100 }, data: { value: 'Zoe', valueType: 'string' as const }, type: 'variable', style: DEFAULT_NODE_STYLE.variable, }, { id: 'cfg_001', position: { x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP, y: 50 }, data: { plantuml: '@startuml\nactor User\nparticipant "{{ var_001 }}" as R\nUser -> R : loves\n@enduml\n', title: 'config-cfg_001', }, type: 'config', style: DEFAULT_NODE_STYLE.config, }, { id: 'rnd_001', position: { x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP + DEFAULT_NODE_STYLE.config.width + NODE_GAP, y: 50, }, data: {}, type: 'render', style: DEFAULT_NODE_STYLE.render, }, ] const initialEdges: AppEdge[] = [ { id: 'e-var_001-cfg_001', source: 'var_001', target: 'cfg_001', type: 'animated' }, { id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' }, ] function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } { return { nodes: initialNodes.map((n) => ({ ...n, data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data, })), edges: initialEdges.map((e) => ({ ...e })), } } const PROJECT_FILE_EXT = '.zui.json' const PROJECT_VERSION = 1 export type ProjectMessage = { type: 'success' | 'error'; text: string } /** Calls fitView when nodes are initialized (e.g. after load/import). Must be rendered inside ReactFlowProvider. */ function FlowFitViewOnLoad() { const nodesInitialized = useNodesInitialized() const { fitView } = useReactFlow() React.useEffect(() => { if (nodesInitialized) { fitView?.({ duration: 200 }) } }, [nodesInitialized, fitView]) return null } export default function App() { const { theme } = useTheme() const { nodes, edges, setNodes, setEdges, setNodesSilent, applyGraph, saveForDragEnd, commitDragEnd, undo, redo, canUndo, canRedo, setStateImmediate, } = useGraphStateWithHistory(getExampleGraph().nodes, getExampleGraph().edges) const importInputRef = useRef(null) const [rfInstance, setRfInstance] = React.useState(null) const [renamingNodeId, setRenamingNodeId] = React.useState(null) const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null) const [projectMessage, setProjectMessage] = React.useState(null) const wrapperRef = React.useRef(null) const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null) const [contextTarget, setContextTarget] = React.useState(null) // Throttle node changes during drag: merge changes and flush at most once per animation frame to reduce re-renders const pendingChangesRef = useRef[]>([]) const rafRef = useRef(null) const onNodesChange = useCallback( (changes: NodeChange[]) => { if (changes.length === 0) return const pending = pendingChangesRef.current for (const c of changes) { const id = (c as { id?: string }).id if (id != null) { const i = pending.findIndex((p) => (p as { id?: string }).id === id) if (i >= 0) pending[i] = c else pending.push(c) } else { pending.push(c) } } if (rafRef.current === null) { rafRef.current = requestAnimationFrame(() => { rafRef.current = null const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length) if (toApply.length > 0) { setNodesSilent((nds) => applyNodeChanges(toApply, nds)) } }) } }, [setNodesSilent] ) const nodeTypes = React.useMemo( () => Object.fromEntries(getRegisteredNodeTypes().map((r) => [r.id, r.component])), [] ) const edgeTypes = React.useMemo(() => ({ animated: AnimatedEdge }), []) const defaultEdgeOptions = React.useMemo(() => ({ type: 'animated' as const }), []) const onEdgesChange = useCallback( (changes: EdgeChange[]) => { if (changes.length === 0) return setEdges((eds) => applyEdgeChanges(changes, eds)) }, [setEdges] ) const onConnect = React.useCallback( (params: Connection) => setEdges((eds) => addEdge(params, eds)), [setEdges] ) const isValidConnection = React.useCallback( (connection: Connection) => { const sourceNode = nodes.find((n) => n.id === connection.source) const targetNode = nodes.find((n) => n.id === connection.target) const sourceType = sourceNode?.type const targetType = targetNode?.type if (!sourceType || !targetType) return false return isConnectionAllowed( sourceType, targetType, connection.source, connection.target ) }, [nodes] ) const onConnectStart = React.useCallback( (_: React.MouseEvent | React.TouchEvent, params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null }) => { if (params.handleType !== 'source' || !params.nodeId) { setConnectionFrom(null) return } setConnectionFrom({ nodeId: params.nodeId, sourceHandle: params.handleId ?? undefined }) }, [] ) const onConnectEnd = React.useCallback(() => { setConnectionFrom(null) }, []) const onInit = React.useCallback((instance: any) => { setRfInstance(instance) }, []) React.useEffect(() => { if (!projectMessage) return const t = setTimeout(() => setProjectMessage(null), 3000) return () => clearTimeout(t) }, [projectMessage]) const onNodeDragStart = useCallback(() => { saveForDragEnd() }, [saveForDragEnd]) const onNodeDragStop = useCallback(() => { commitDragEnd() }, [commitDragEnd]) const handleExportProject = useCallback(() => { const state = { version: PROJECT_VERSION, nodes, edges } const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `project${PROJECT_FILE_EXT}` a.click() URL.revokeObjectURL(url) setProjectMessage({ type: 'success', text: 'Project exported' }) }, [nodes, edges]) const handleImportProject = useCallback(() => { importInputRef.current?.click() }, []) const handleLoadExample = useCallback(() => { const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph() setStateImmediate({ nodes: exampleNodes, edges: exampleEdges }) setProjectMessage({ type: 'success', text: 'Example loaded' }) }, [setStateImmediate]) const onImportFileChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0] e.target.value = '' if (!file) return const reader = new FileReader() reader.onload = () => { try { const text = reader.result as string const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] } if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) { setProjectMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' }) return } setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] }) setProjectMessage( state.version != null && state.version > PROJECT_VERSION ? { type: 'error', text: 'Project was created with a newer app version' } : { type: 'success', text: 'Project loaded' } ) } catch { setProjectMessage({ type: 'error', text: 'Invalid file: not valid JSON' }) } } reader.readAsText(file) }, [setStateImmediate] ) const flowContextValue = useMemo( () => ({ nodes, setNodes, edges, setEdges, renamingNodeId, setRenamingNodeId, connectionFrom, setConnectionFrom, isValidConnection, }), [ nodes, setNodes, edges, setEdges, renamingNodeId, setRenamingNodeId, connectionFrom, setConnectionFrom, isValidConnection, ] ) const onContextMenuCapture = React.useCallback((ev: React.MouseEvent) => { const target = ev.target as HTMLElement const nodeEl = target.closest('.react-flow__node') if (nodeEl) { ev.preventDefault() ev.stopPropagation() } }, []) // Disable canvas zoom when the cursor is over a node (scroll/pinch only zooms when over the pane) const onWheelCapture = React.useCallback((ev: React.WheelEvent) => { const target = ev.target as HTMLElement if (target.closest('.react-flow__node')) { ev.preventDefault() ev.stopPropagation() } }, []) const onCanvasContextMenu = React.useCallback((ev: React.MouseEvent) => { ev.preventDefault() const clientX = ev.clientX const clientY = ev.clientY lastClickRef.current = { clientX, clientY } setContextTarget({ type: 'canvas', clientX, clientY }) }, []) const getMenuPosition = React.useCallback(() => { if (!rfInstance) return null const click = contextTarget ?? lastClickRef.current const clientX = click?.clientX ?? window.innerWidth / 2 const clientY = click?.clientY ?? window.innerHeight / 2 try { const screenToFlow = rfInstance.screenToFlowPosition ?? rfInstance.project const p = screenToFlow.call(rfInstance, { x: clientX, y: clientY }) return snapToGrid(p.x, p.y) } catch { return snapToGrid(clientX, clientY) } }, [rfInstance, contextTarget]) const createNode = React.useCallback( (type: string) => { const position = getMenuPosition() if (position == null) return const nodeType = type as Node['type'] setNodes((nds) => { const newId = getNextNodeId(nodeType, nds.map((n) => n.id)) const dataMap = getDefaultDataForType(nodeType, newId) const style = getDefaultStyle(nodeType) const newNode: Node = { id: newId, type: nodeType, position: { x: position.x, y: position.y }, data: dataMap, style, } return nds.concat(newNode) }) lastClickRef.current = null setContextTarget(null) }, [getMenuPosition, setNodes] ) const pasteNode = React.useCallback(async () => { const position = getMenuPosition() if (position == null) return try { const text = await navigator.clipboard?.readText() if (!text) return const raw = JSON.parse(text) as { id?: string; type?: string; data?: any; position?: { x: number; y: number }; style?: any } const validIds = getRegisteredNodeTypeIds() if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return setNodes((nds) => { const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id)) const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {} if (raw.type === 'config' && data.title != null) data.title = `config-${newId}` const style = getDefaultStyle(raw.type) const newNode: Node = { id: newId, type: raw.type as Node['type'], position: { x: position.x, y: position.y }, data, style, } return nds.concat(newNode) }) lastClickRef.current = null setContextTarget(null) } catch { // Invalid clipboard or not a copied node — do nothing } }, [getMenuPosition, setNodes]) const deleteNode = React.useCallback( (id: string | undefined) => { if (!id) return applyGraph(({ nodes: nds, edges: eds }) => ({ nodes: nds.filter((n) => n.id !== id), edges: eds.filter((e) => e.source !== id && e.target !== id), })) setContextTarget(null) }, [applyGraph] ) return (
{projectMessage && (
{projectMessage.text}
)}
{nodes.length === 0 && (
Start adding a new node! Right‑click to add nodes.
Import a project or paste a node.
)}
Create Node {getRegisteredNodeTypes().map((desc, index) => ( {index === 2 ? : null} createNode(desc.id)}> {desc.menuIcon} {desc.menuLabel} ))} pasteNode()}> Paste
) }