819 lines
30 KiB
TypeScript
819 lines
30 KiB
TypeScript
/**
|
||
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
|
||
* Rendered inside the platform when a recollection is selected.
|
||
*/
|
||
|
||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||
import { useLocation } from 'react-router-dom'
|
||
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/graph/AnimatedEdge'
|
||
import {
|
||
GraphContext,
|
||
ConnectionPathContext,
|
||
FlowUIContext,
|
||
} from '@/lib/graph/flowContext'
|
||
import { useTheme } from '@/lib/themeContext'
|
||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
||
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
||
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||
import { useRecollectionTitleData } from '@/app/recollections/RecollectionTitleDataContext'
|
||
import { FluxMenubarContent } from '@/app/recollections/flux/FluxMenubarContent'
|
||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
||
import {
|
||
Empty,
|
||
EmptyContent,
|
||
EmptyDescription,
|
||
EmptyHeader,
|
||
EmptyMedia,
|
||
EmptyTitle,
|
||
} from '@/components/ui/empty'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
|
||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||
import {
|
||
getRegisteredNodeTypes,
|
||
getRegisteredNodeTypeIds,
|
||
getDefaultStyle,
|
||
getNodeType,
|
||
getConnectionLabelForTarget,
|
||
isConnectionAllowed,
|
||
} from '@/lib/graph/nodeRegistry'
|
||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||
import { toast } from 'sonner'
|
||
import { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
||
|
||
const SNAP_GRID: [number, number] = [15, 15]
|
||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||
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],
|
||
})
|
||
|
||
function FlowFitViewOnLoad() {
|
||
const nodesInitialized = useNodesInitialized()
|
||
const { fitView } = useReactFlow()
|
||
React.useEffect(() => {
|
||
if (nodesInitialized) fitView?.({ duration: 200 })
|
||
}, [nodesInitialized, fitView])
|
||
return null
|
||
}
|
||
|
||
type FullscreenNodeOverlayProps = {
|
||
nodeId: string
|
||
nodes: AppNode[]
|
||
onClose: () => void
|
||
}
|
||
|
||
function FullscreenNodeOverlay({ nodeId, nodes, onClose }: FullscreenNodeOverlayProps) {
|
||
const open = nodeId != null
|
||
const node = nodes.find((n) => n.id === nodeId)
|
||
if (!nodeId) return null
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||
<DialogContent
|
||
className="max-w-[94vw] w-[94vw] max-h-[90vh] h-[90vh] p-0 gap-0 overflow-hidden flex flex-col border rounded-lg shadow-xl bg-card"
|
||
aria-describedby={undefined}
|
||
hideCloseButton
|
||
>
|
||
<DialogTitle className="sr-only">Node: {node?.id ?? nodeId}</DialogTitle>
|
||
{node && node.type && (
|
||
<FullscreenNodeContent node={node} />
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function FullscreenNodeContent({ node }: { node: AppNode }) {
|
||
const descriptor = getNodeType(node.type ?? '')
|
||
const NodeComponent = descriptor?.component as React.ComponentType<{
|
||
id: string
|
||
data: Record<string, unknown>
|
||
type?: string
|
||
selected?: boolean
|
||
width?: number
|
||
height?: number
|
||
}> | undefined
|
||
if (!NodeComponent) return null
|
||
|
||
const w = typeof window !== 'undefined' ? Math.round(window.innerWidth * 0.94) : 1200
|
||
const h = typeof window !== 'undefined' ? Math.round(window.innerHeight * 0.9) : 800
|
||
|
||
const fullscreenNodeForStore: AppNode = {
|
||
...node,
|
||
position: node.position ?? { x: 0, y: 0 },
|
||
style: { ...(node.style as object), width: w, height: h },
|
||
}
|
||
|
||
return (
|
||
<div className="flex-1 min-h-0 overflow-hidden flex">
|
||
<ReactFlowProvider initialNodes={[fullscreenNodeForStore]} initialEdges={[]}>
|
||
<NodeComponent
|
||
id={node.id}
|
||
data={(node.data as Record<string, unknown>) ?? {}}
|
||
type={node.type}
|
||
selected={false}
|
||
width={w}
|
||
height={h}
|
||
/>
|
||
</ReactFlowProvider>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export type CanvasPageProps = {
|
||
/** Optional recollection id for per-recollection graph loading */
|
||
recollectionId?: string
|
||
}
|
||
|
||
export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||
const { theme } = useTheme()
|
||
const { showMinimap } = usePlatform()
|
||
const {
|
||
nodes,
|
||
edges,
|
||
setNodes,
|
||
setEdges,
|
||
setNodesSilent,
|
||
applyGraph,
|
||
saveForDragEnd,
|
||
commitDragEnd,
|
||
undo,
|
||
redo,
|
||
canUndo,
|
||
canRedo,
|
||
setStateImmediate,
|
||
save,
|
||
saveStatus,
|
||
} = useCanvasGraph(recollectionId)
|
||
|
||
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 wrapperRef = useRef<HTMLDivElement | null>(null)
|
||
const canvasWrapperRef = 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 [isPanning, setIsPanning] = React.useState(false)
|
||
const [isSelecting, setIsSelecting] = React.useState(false)
|
||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
|
||
|
||
const graphApplyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const GRAPH_APPLY_DEBOUNCE_MS = 300
|
||
useEffect(() => {
|
||
if (graphApplyTimeoutRef.current) clearTimeout(graphApplyTimeoutRef.current)
|
||
graphApplyTimeoutRef.current = setTimeout(() => {
|
||
graphApplyTimeoutRef.current = null
|
||
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
|
||
}, GRAPH_APPLY_DEBOUNCE_MS)
|
||
return () => {
|
||
if (graphApplyTimeoutRef.current) {
|
||
clearTimeout(graphApplyTimeoutRef.current)
|
||
graphApplyTimeoutRef.current = null
|
||
}
|
||
}
|
||
}, [nodes, edges])
|
||
|
||
const connectionPath = useCanvasConnectionPathFromStore()
|
||
|
||
const nodesRef = useRef(nodes)
|
||
nodesRef.current = nodes
|
||
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
|
||
graphRef.current.nodes = nodes
|
||
graphRef.current.edges = edges
|
||
|
||
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) return
|
||
setNodesSilent((nds) => {
|
||
// Drop dimension-only changes that don't change the node (e.g. React Flow re-reporting on visibility).
|
||
// Avoids graph/apply and store churn when nodes become visible with onlyRenderVisibleElements.
|
||
const filtered = toApply.filter((c) => {
|
||
const ch = c as NodeChange<Node> & { type?: string; dimensions?: { width?: number; height?: number } }
|
||
if (ch.type !== 'dimensions' || ch.dimensions == null) return true
|
||
const node = nds.find((n) => n.id === (ch as { id?: string }).id)
|
||
if (!node) return true
|
||
const nw = (node as Node & { width?: number }).width
|
||
const nh = (node as Node & { height?: number }).height
|
||
return nw !== ch.dimensions.width || nh !== ch.dimensions.height
|
||
})
|
||
if (filtered.length === 0) return nds
|
||
return applyNodeChanges(filtered, nds)
|
||
})
|
||
})
|
||
}
|
||
},
|
||
[setNodesSilent]
|
||
)
|
||
|
||
const nodeTypes = useMemo(
|
||
() =>
|
||
Object.fromEntries(
|
||
getRegisteredNodeTypes().map((r) => [r.id, createContextualNode(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) => {
|
||
const targetType =
|
||
nodesRef.current.find((n) => n.id === params.target)?.type ?? ''
|
||
const conn = { ...params, data: { targetType } as Record<string, unknown> }
|
||
setEdges((eds) => addEdge(conn, 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 currentNodes = nodesRef.current
|
||
const sourceNode = currentNodes.find((n) => n.id === src)
|
||
const targetNode = currentNodes.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)
|
||
}, [])
|
||
|
||
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), [])
|
||
|
||
const onMoveStart = useCallback(() => setIsPanning(true), [])
|
||
const onMoveEnd = useCallback(() => setIsPanning(false), [])
|
||
const onSelectionDragStart = useCallback(() => setIsSelecting(true), [])
|
||
const onSelectionDragStop = useCallback(() => setIsSelecting(false), [])
|
||
|
||
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
||
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
||
|
||
const handleExportRecollection = useCallback(() => {
|
||
const state = { version: RECOLLECTION_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 = `recollection${RECOLLECTION_FILE_EXT}`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
toast.success('Recollection exported')
|
||
}, [nodes, edges])
|
||
|
||
const handleImportRecollection = useCallback(() => importInputRef.current?.click(), [])
|
||
|
||
const handleLoadExample = useCallback(() => {
|
||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
||
toast.success('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)) {
|
||
toast.error('Invalid file: expected nodes and edges arrays')
|
||
return
|
||
}
|
||
const nodes = state.nodes as AppNode[]
|
||
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
|
||
setStateImmediate({ nodes, edges })
|
||
if (state.version != null && state.version > RECOLLECTION_VERSION) {
|
||
toast.error('Recollection was created with a newer app version')
|
||
} else {
|
||
toast.success('Recollection loaded')
|
||
}
|
||
} catch {
|
||
toast.error('Invalid file: not valid JSON')
|
||
}
|
||
}
|
||
reader.readAsText(file)
|
||
},
|
||
[setStateImmediate]
|
||
)
|
||
|
||
const selectedNodes = useMemo(
|
||
() => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
|
||
[nodes]
|
||
)
|
||
|
||
const handleDuplicate = useCallback(() => {
|
||
if (selectedNodes.length === 0 || !setNodes) return
|
||
setNodes((nds: Node[]) => {
|
||
const existingIds = nds.map((n) => n.id)
|
||
const toAdd: Node[] = []
|
||
for (const node of selectedNodes) {
|
||
const n = node as Node & { selected?: boolean }
|
||
const pos = n.position ?? { x: 0, y: 0 }
|
||
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
|
||
existingIds.push(newId)
|
||
const newNode: Node = {
|
||
id: newId,
|
||
type: n.type,
|
||
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
|
||
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
|
||
style: getDefaultStyle(String(n.type)),
|
||
}
|
||
if (
|
||
newNode.data &&
|
||
typeof newNode.data === 'object' &&
|
||
'title' in newNode.data &&
|
||
String(n.type) === 'config'
|
||
) {
|
||
; (newNode.data as Record<string, unknown>).title = `${newId}`
|
||
}
|
||
toAdd.push(newNode)
|
||
}
|
||
return nds.concat(toAdd)
|
||
})
|
||
}, [selectedNodes, setNodes])
|
||
|
||
const handleCopy = useCallback(() => {
|
||
if (selectedNodes.length !== 1) return
|
||
const node = selectedNodes[0] as Node & { selected?: boolean }
|
||
const copy = {
|
||
id: node.id,
|
||
type: node.type,
|
||
data: node.data,
|
||
position: node.position,
|
||
style: node.style,
|
||
}
|
||
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
|
||
}, [selectedNodes])
|
||
|
||
const handlePaste = useCallback(() => {
|
||
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||
}, [])
|
||
|
||
const { pathname } = useLocation()
|
||
const isFluxActive = pathname.endsWith('/flux')
|
||
const { setTitleData } = useRecollectionTitleData()
|
||
useEffect(() => {
|
||
setTitleData({
|
||
saveStatus,
|
||
onSave: recollectionId
|
||
? () => {
|
||
save()
|
||
toast.success('Saved')
|
||
}
|
||
: undefined,
|
||
canSave: Boolean(recollectionId),
|
||
onImport: handleImportRecollection,
|
||
onExport: handleExportRecollection,
|
||
})
|
||
return () => setTitleData(null)
|
||
}, [
|
||
setTitleData,
|
||
saveStatus,
|
||
recollectionId,
|
||
save,
|
||
handleImportRecollection,
|
||
handleExportRecollection,
|
||
])
|
||
|
||
const fluxMenubarProps = useMemo(
|
||
() => ({
|
||
undo,
|
||
redo,
|
||
canUndo,
|
||
canRedo,
|
||
onDuplicate: handleDuplicate,
|
||
onCopy: handleCopy,
|
||
onPaste: handlePaste,
|
||
canDuplicate: selectedNodes.length > 0,
|
||
canCopy: selectedNodes.length === 1,
|
||
onFitView: () => flowActionsRef.current?.fitView?.(),
|
||
}),
|
||
[
|
||
canUndo,
|
||
canRedo,
|
||
selectedNodes.length,
|
||
undo,
|
||
redo,
|
||
handleDuplicate,
|
||
handleCopy,
|
||
handlePaste,
|
||
]
|
||
)
|
||
|
||
const graphContextValue = useMemo(
|
||
() => ({ setNodes, setEdges, graphRef, edges }),
|
||
[setNodes, setEdges, edges]
|
||
)
|
||
const connectionPathContextValue = useMemo(
|
||
() => ({
|
||
connectionPathUpdatingNodeIds: connectionPath.connectionPathUpdatingNodeIds,
|
||
connectionPathTriggerNodeIds: connectionPath.connectionPathTriggerNodeIds,
|
||
addConnectionPathTrigger: connectionPath.addConnectionPathTrigger,
|
||
connectionPathNodeIds: connectionPath.connectionPathNodeIds,
|
||
connectionPathPausedSegmentNodeIds: connectionPath.connectionPathPausedSegmentNodeIds,
|
||
connectionPathActiveSegmentNodeIds: connectionPath.connectionPathActiveSegmentNodeIds,
|
||
connectionPathPausedNodeIds: connectionPath.connectionPathPausedNodeIds,
|
||
addConnectionPathPausedNode: connectionPath.addConnectionPathPausedNode,
|
||
removeConnectionPathPausedNode: connectionPath.removeConnectionPathPausedNode,
|
||
connectionPathErrorNodeIds: connectionPath.connectionPathErrorNodeIds,
|
||
addConnectionPathError: connectionPath.addConnectionPathError,
|
||
removeConnectionPathError: connectionPath.removeConnectionPathError,
|
||
startConnectionPathUpdate: connectionPath.startConnectionPathUpdate,
|
||
endConnectionPathUpdate: connectionPath.endConnectionPathUpdate,
|
||
}),
|
||
[connectionPath]
|
||
)
|
||
const flowUIContextValue = useMemo(
|
||
() => ({
|
||
renamingNodeId,
|
||
setRenamingNodeId,
|
||
connectionFrom,
|
||
setConnectionFrom,
|
||
isValidConnection,
|
||
flowActionsRef,
|
||
fullscreenNodeId,
|
||
setFullscreenNodeId,
|
||
}),
|
||
[
|
||
renamingNodeId,
|
||
setRenamingNodeId,
|
||
connectionFrom,
|
||
setConnectionFrom,
|
||
isValidConnection,
|
||
flowActionsRef,
|
||
fullscreenNodeId,
|
||
setFullscreenNodeId,
|
||
]
|
||
)
|
||
|
||
const prevNodesRef = useRef<AppNode[]>([])
|
||
const prevNodesForFlowRef = useRef<Node[]>([])
|
||
const nodesForFlow = useMemo(() => {
|
||
const prev = prevNodesRef.current
|
||
if (nodes === prev) return prevNodesForFlowRef.current
|
||
const prevById = new Map(prev.map((n) => [n.id, n]))
|
||
const prevWrappedById = new Map(
|
||
prevNodesForFlowRef.current.map((w, i) => [prev[i]?.id, w])
|
||
)
|
||
const result = nodes.map((n) => {
|
||
const prevNode = prevById.get(n.id)
|
||
if (prevNode === n && prevWrappedById.has(n.id)) {
|
||
return prevWrappedById.get(n.id)!
|
||
}
|
||
return {
|
||
...n,
|
||
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
||
}
|
||
})
|
||
prevNodesRef.current = nodes
|
||
prevNodesForFlowRef.current = result
|
||
return result
|
||
}, [nodes])
|
||
const edgesForFlow = useMemo(
|
||
() =>
|
||
edges.map((e) => {
|
||
const targetType =
|
||
(typeof e.data === 'object' && e.data !== null && (e.data as Record<string, unknown>).targetType != null
|
||
? (e.data as Record<string, unknown>).targetType
|
||
: '') as string
|
||
const connectionLabel = getConnectionLabelForTarget(targetType)
|
||
const baseData =
|
||
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
||
return {
|
||
...e,
|
||
data: { ...baseData, connectionLabel },
|
||
}
|
||
}),
|
||
[edges]
|
||
)
|
||
|
||
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
|
||
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 {
|
||
type ScreenToFlow = (p: { x: number; y: number }) => { x: number; y: number }
|
||
const inst = rfInstance as { screenToFlowPosition?: ScreenToFlow; [k: string]: unknown }
|
||
const screenToFlow = inst.screenToFlowPosition ?? (inst['project'] as ScreenToFlow | undefined)
|
||
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' }}
|
||
>
|
||
{isFluxActive && <FluxMenubarContent {...fluxMenubarProps} />}
|
||
<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
|
||
/>
|
||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||
<div className="flex-1 min-h-0 flex flex-col">
|
||
<GraphContext.Provider value={graphContextValue}>
|
||
<ConnectionPathContext.Provider value={connectionPathContextValue}>
|
||
<FlowUIContext.Provider value={flowUIContextValue}>
|
||
<ContextMenu>
|
||
<ContextMenuTrigger asChild>
|
||
<div
|
||
ref={canvasWrapperRef}
|
||
className="flex-1 min-h-0 w-full relative"
|
||
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>
|
||
Right‑click to add nodes. <br />
|
||
Import a recollection or paste a node.
|
||
</EmptyDescription>
|
||
</EmptyHeader>
|
||
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
||
<Button onClick={handleImportRecollection} 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>
|
||
<ViewportDisplayProvider>
|
||
<FlowFitViewOnLoad />
|
||
<FlowKeyboardShortcuts />
|
||
<ReactFlow
|
||
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
||
nodes={nodesForFlow}
|
||
edges={edgesForFlow}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
onConnect={onConnect}
|
||
onConnectStart={onConnectStart}
|
||
onConnectEnd={onConnectEnd}
|
||
onNodeDragStart={onNodeDragStart}
|
||
onNodeDragStop={onNodeDragStop}
|
||
onMoveStart={onMoveStart}
|
||
onMoveEnd={onMoveEnd}
|
||
onSelectionDragStart={onSelectionDragStart}
|
||
onSelectionDragStop={onSelectionDragStop}
|
||
isValidConnection={isValidConnection}
|
||
nodeTypes={nodeTypes}
|
||
edgeTypes={edgeTypes}
|
||
defaultEdgeOptions={defaultEdgeOptions}
|
||
colorMode={theme as ColorMode}
|
||
minZoom={0.1}
|
||
maxZoom={2}
|
||
snapToGrid
|
||
snapGrid={SNAP_GRID}
|
||
fitView
|
||
onInit={onInit}
|
||
nodeDragThreshold={1}
|
||
onlyRenderVisibleElements
|
||
nodeOrigin={[0, 0]}
|
||
nodesDraggable
|
||
nodesConnectable
|
||
elementsSelectable
|
||
panOnScroll
|
||
zoomOnScroll
|
||
zoomActivationKeyCode="Meta"
|
||
panOnDrag={[1]}
|
||
selectionOnDrag
|
||
>
|
||
{/* 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>
|
||
{showMinimap && nodes.length > 5 && (
|
||
<div role="region" aria-label="Minimap: overview of the graph">
|
||
<MiniMap />
|
||
</div>
|
||
)}
|
||
</ReactFlow>
|
||
</ViewportDisplayProvider>
|
||
</ReactFlowProvider>
|
||
</div>
|
||
</ContextMenuTrigger>
|
||
<CanvasContextMenuContent onCreateNode={createNode} onPaste={pasteNode} />
|
||
</ContextMenu>
|
||
{fullscreenNodeId && (
|
||
<FullscreenNodeOverlay
|
||
nodeId={fullscreenNodeId}
|
||
nodes={nodes}
|
||
onClose={() => setFullscreenNodeId(null)}
|
||
/>
|
||
)}
|
||
</FlowUIContext.Provider>
|
||
</ConnectionPathContext.Provider>
|
||
</GraphContext.Provider>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|