Files
zui/src/App.tsx
2026-03-09 15:50:11 +01:00

572 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLInputElement | null>(null)
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
const [projectMessage, setProjectMessage] = React.useState<ProjectMessage | 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'; clientX: number; clientY: number }>(null)
// Throttle node changes during drag: merge changes and flush at most once per animation frame to reduce re-renders
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
const rafRef = useRef<number | null>(null)
const onNodesChange = useCallback(
(changes: NodeChange<Node>[]) => {
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<Edge>[]) => {
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<HTMLInputElement>) => {
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 (
<div
className="reactflow-wrapper flex flex-col h-full"
ref={wrapperRef}
onContextMenuCapture={onContextMenuCapture}
onContextMenu={onCanvasContextMenu}
style={{ position: 'relative' }}
>
<input
ref={importInputRef}
type="file"
accept=".json,.zui.json,application/json"
className="hidden"
onChange={onImportFileChange}
aria-hidden
/>
<AppMenubar
onImport={handleImportProject}
onExport={handleExportProject}
undo={undo}
redo={redo}
canUndo={canUndo}
canRedo={canRedo}
/>
{projectMessage && (
<div
role="status"
className={`shrink-0 px-3 py-2 text-sm ${projectMessage.type === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
}`}
>
{projectMessage.text}
</div>
)}
<div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
<FlowContext.Provider value={flowContextValue}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-1 min-h-0 w-full relative" onWheelCapture={onWheelCapture}>
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="pointer-events-auto rounded-lg border border-border bg-background/95 dark:bg-background/90 shadow-sm">
<Empty className="p-6 md:p-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<CircleDotDashed className="size-6" />
</EmptyMedia>
<EmptyTitle>Start adding a new node!</EmptyTitle>
<EmptyDescription>
Rightclick to add nodes. <br />Import a project or paste a node.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
<Button onClick={handleImportProject} variant="outline" size="sm">
<FolderOpen className="size-4" />
Import
</Button>
<Button onClick={handleLoadExample} variant="outline" size="sm">
<FileStack className="size-4" />
Load example
</Button>
</EmptyContent>
</Empty>
</div>
</div>
)}
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<FlowFitViewOnLoad />
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
isValidConnection={isValidConnection}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
colorMode={theme as ColorMode}
snapToGrid
snapGrid={SNAP_GRID}
fitView
onInit={onInit}
nodeDragThreshold={1}
nodesDraggable
nodesConnectable
elementsSelectable
>
<Background variant="dots" gap={20} />
<Controls />
<MiniMap />
</ReactFlow>
</ReactFlowProvider>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuGroup>
<ContextMenuSub>
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-44">
<ContextMenuGroup>
{getRegisteredNodeTypes().map((desc, index) => (
<React.Fragment key={desc.id}>
{index === 2 ? <ContextMenuSeparator /> : null}
<ContextMenuItem onSelect={() => createNode(desc.id)}>
{desc.menuIcon}
{desc.menuLabel}
</ContextMenuItem>
</React.Fragment>
))}
</ContextMenuGroup>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => pasteNode()}>
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
</FlowContext.Provider>
</div>
</div>
</div >
)
}