572 lines
19 KiB
TypeScript
572 lines
19 KiB
TypeScript
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>
|
||
Right‑click 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 >
|
||
)
|
||
}
|
||
|
||
|