/** * 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 ( { if (!isOpen) onClose() }}> Node: {node?.id ?? nodeId} {node && node.type && ( )} ) } function FullscreenNodeContent({ node }: { node: AppNode }) { const descriptor = getNodeType(node.type ?? '') const NodeComponent = descriptor?.component as React.ComponentType<{ id: string data: Record 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 (
) ?? {}} type={node.type} selected={false} width={w} height={h} />
) } 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(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 wrapperRef = useRef(null) const canvasWrapperRef = useRef(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) const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState(null) const [isPanning, setIsPanning] = React.useState(false) const [isSelecting, setIsSelecting] = React.useState(false) const [ariaAnnouncement, setAriaAnnouncement] = React.useState(null) const [fullscreenNodeId, setFullscreenNodeId] = React.useState(null) const graphApplyTimeoutRef = React.useRef | 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[]>([]) 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) 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 & { 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[]) => { 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 } 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 | React.TouchEvent | 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) => { 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).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([]) const prevNodesForFlowRef = useRef([]) 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).targetType != null ? (e.data as Record).targetType : '') as string const connectionLabel = getConnectionLabelForTarget(targetType) const baseData = typeof e.data === 'object' && e.data !== null ? (e.data as Record) : {} 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; 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 (
{isFluxActive && }
{ariaAnnouncement}
{nodes.length === 0 && (
Start adding a new node! Right‑click to add nodes.
Import a recollection or paste a node.
)} {/* BackgroundVariant from @xyflow/system expects enum; 'dots' is valid at runtime */} ['variant']} gap={20} />
{showMinimap && nodes.length > 5 && (
)}
{fullscreenNodeId && ( setFullscreenNodeId(null)} /> )}
) }