Files
zui/frontend/src/app/canvas/CanvasPage.tsx

615 lines
22 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.
/**
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
* Rendered inside the platform when a project is selected.
*/
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 { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
import { FlowKeyboardShortcuts } from '@/components/base/FlowKeyboardShortcuts'
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 CanvasMessage = { type: 'success' | 'error'; text: string }
function FlowFitViewOnLoad() {
const nodesInitialized = useNodesInitialized()
const { fitView } = useReactFlow()
React.useEffect(() => {
if (nodesInitialized) fitView?.({ duration: 200 })
}, [nodesInitialized, fitView])
return null
}
export type CanvasPageProps = {
/** Optional project id for future per-project graph loading */
projectId?: string
}
export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
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<unknown>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
const [canvasMessage, setCanvasMessage] = React.useState<CanvasMessage | null>(null)
const wrapperRef = useRef<HTMLDivElement | null>(null)
const lastClickRef = useRef<{ clientX: number; clientY: number } | null>(null)
const flowActionsRef = useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState<string | null>(null)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const nodesRef = useRef(nodes)
nodesRef.current = nodes
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
React.useEffect(() => {
fetch('/api/todos')
.then((r) => r.json())
.then((data: unknown) => {
if (Array.isArray(data)) setApiTodosCount(data.length)
})
.catch(() => setApiTodosCount(-1))
}, [])
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 = useMemo(
() => Object.fromEntries(getRegisteredNodeTypes().map((r) => [r.id, r.component])),
[]
)
const edgeTypes = useMemo(() => ({ animated: AnimatedEdge }), [])
const defaultEdgeOptions = useMemo(() => ({ type: 'animated' as const }), [])
const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
if (changes.length === 0) return
setEdges((eds) => applyEdgeChanges(changes, eds))
},
[setEdges]
)
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
)
const isValidConnection = useCallback(
(connection: Connection | AppEdge) => {
const src = 'source' in connection ? connection.source : undefined
const tgt = 'target' in connection ? connection.target : undefined
if (typeof src !== 'string' || typeof tgt !== 'string') return false
const sourceNode = nodes.find((n) => n.id === src)
const targetNode = nodes.find((n) => n.id === tgt)
const sourceType = sourceNode?.type
const targetType = targetNode?.type
if (!sourceType || !targetType) return false
if (sourceType === 'render' && targetType === 'config') {
const targetData = targetNode?.data as { configType?: string } | undefined
if (targetData?.configType !== 'markdown') return false
}
return isConnectionAllowed(sourceType, targetType, src, tgt)
},
[nodes]
)
const onConnectStart = useCallback(
(
_: React.MouseEvent<Element> | React.TouchEvent<Element> | MouseEvent | 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 = useCallback(() => setConnectionFrom(null), [])
const onInit = useCallback((instance: unknown) => setRfInstance(instance), [])
React.useEffect(() => {
if (!canvasMessage) return
const t = setTimeout(() => setCanvasMessage(null), 3000)
return () => clearTimeout(t)
}, [canvasMessage])
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)
setCanvasMessage({ 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 })
setCanvasMessage({ 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)) {
setCanvasMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' })
return
}
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
setCanvasMessage(
state.version != null && state.version > PROJECT_VERSION
? { type: 'error', text: 'Project was created with a newer app version' }
: { type: 'success', text: 'Project loaded' }
)
} catch {
setCanvasMessage({ type: 'error', text: 'Invalid file: not valid JSON' })
}
}
reader.readAsText(file)
},
[setStateImmediate]
)
const flowContextValue = useMemo(
() => ({
nodes,
setNodes,
edges,
setEdges,
renamingNodeId,
setRenamingNodeId,
connectionFrom,
setConnectionFrom,
isValidConnection,
flowActionsRef,
}),
[
nodes,
setNodes,
edges,
setEdges,
renamingNodeId,
setRenamingNodeId,
connectionFrom,
setConnectionFrom,
isValidConnection,
flowActionsRef,
]
)
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
const target = ev.target as HTMLElement
if (target.closest('.react-flow__node')) {
ev.preventDefault()
ev.stopPropagation()
}
}, [])
const onWheelCapture = useCallback((ev: React.WheelEvent) => {
const target = ev.target as HTMLElement
if (target.closest('.react-flow__node')) {
ev.preventDefault()
ev.stopPropagation()
}
}, [])
const onCanvasContextMenu = useCallback((ev: React.MouseEvent) => {
ev.preventDefault()
lastClickRef.current = { clientX: ev.clientX, clientY: ev.clientY }
setContextTarget({ type: 'canvas', clientX: ev.clientX, clientY: ev.clientY })
}, [])
const getMenuPosition = 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 inst = rfInstance as { screenToFlowPosition?: (p: { x: number; y: number }) => { x: number; y: number }; project?: (p: { x: number; y: number }) => { x: number; y: number } }
const screenToFlow = inst.screenToFlowPosition ?? inst.project
const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
return p ? snapToGrid(p.x, p.y) : null
} catch {
return snapToGrid(clientX, clientY)
}
}, [rfInstance, contextTarget])
const createNode = useCallback(
(type: string) => {
const position = getMenuPosition()
if (position == null || typeof type !== 'string') return
const existingIds = nodesRef.current.map((n) => n.id).filter((id): id is string => id != null)
const newId = getNextNodeId(type, existingIds)
const dataMap = getDefaultDataForType(type, newId)
const style = getDefaultStyle(type)
const newNode: Node = {
id: newId,
type: type as Node['type'],
position: { x: position.x, y: position.y },
data: dataMap,
style,
}
setNodes((nds) => nds.concat(newNode))
setLastCreatedNodeId(newId)
lastClickRef.current = null
setContextTarget(null)
},
[getMenuPosition, setNodes]
)
React.useEffect(() => {
if (lastCreatedNodeId == null) return
const id = lastCreatedNodeId
const raf = requestAnimationFrame(() => {
const nodeEl = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null
if (nodeEl) {
setAriaAnnouncement('Node created')
nodeEl.setAttribute('tabindex', '-1')
nodeEl.focus({ preventScroll: false })
setTimeout(() => setAriaAnnouncement(null), 1000)
}
setLastCreatedNodeId(null)
})
return () => cancelAnimationFrame(raf)
}, [lastCreatedNodeId, nodes])
const pasteNode = 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?: Record<string, unknown>; position?: { x: number; y: number }; style?: unknown }
const validIds = getRegisteredNodeTypeIds()
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
const nodeType = raw.type
setNodes((nds) => {
const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
const newId = getNextNodeId(nodeType, existingIds)
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
if (nodeType === 'config' && data && 'title' in data) data.title = `config-${newId}`
const style = getDefaultStyle(nodeType)
const newNode: Node = {
id: newId,
type: nodeType as Node['type'],
position: { x: position.x, y: position.y },
data,
style,
}
return nds.concat(newNode)
})
lastClickRef.current = null
setContextTarget(null)
} catch {
/* ignore */
}
}, [getMenuPosition, setNodes])
const deleteNode = 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' }}
>
<div role="status" aria-live="polite" aria-atomic className="sr-only">
{ariaAnnouncement}
</div>
<input
ref={importInputRef}
type="file"
accept=".json,.zui.json,application/json"
className="hidden"
onChange={onImportFileChange}
aria-hidden
/>
<CanvasMenubar
onImport={handleImportProject}
onExport={handleExportProject}
undo={undo}
redo={redo}
canUndo={canUndo}
canRedo={canRedo}
onFitView={() => flowActionsRef.current?.fitView?.()}
/>
{apiTodosCount !== null && (
<div className="shrink-0 px-3 py-1 text-xs text-muted-foreground border-b border-border/50">
Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'}
</div>
)}
{canvasMessage && (
<div
role="status"
className={`shrink-0 px-3 py-2 text-sm ${canvasMessage.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'
}`}
>
{canvasMessage.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}
role="application"
aria-label="Graph canvas"
tabIndex={0}
>
{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 />
<FlowKeyboardShortcuts />
<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
>
{/* BackgroundVariant from @xyflow/system expects enum; 'dots' is valid at runtime */}
<Background variant={'dots' as React.ComponentProps<typeof Background>['variant']} gap={20} />
<div role="group" aria-label="Canvas controls: zoom and fit view">
<Controls />
</div>
<div role="region" aria-label="Minimap: overview of the graph">
<MiniMap />
</div>
</ReactFlow>
</ReactFlowProvider>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48" aria-label="Canvas menu: create node or paste">
<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>
)
}