import React, { useCallback, useState, useRef, useEffect, useMemo } from 'react'; import ReactFlow, { Node, Edge, Controls, Background, useNodesState, useEdgesState, addEdge, Connection, Panel, NodeMouseHandler, ReactFlowInstance, MiniMap, NodeTypes, } from 'reactflow'; import 'reactflow/dist/style.css'; import axios from '../utils/axios'; import SidePane from './SidePane'; import { v4 as uuidv4 } from 'uuid'; import EditableColorNode from './EditableColorNode'; import { saveMindMapToJSON, loadMindMapFromJSON, MindMapData } from '../utils/jsonUtils'; import styles from './MindMapEditor.module.css'; import * as MdIcons from 'react-icons/md'; import ChatModal from './ChatModal'; // Types interface NodeData { label: string; bgColor: string; textColor: string; onChange: (nodeId: string, data: Partial) => void; } interface HistoryState { nodes: Node[]; edges: Edge[]; config: any; } // Constants const MAX_HISTORY_SIZE = 50; const AUTO_SAVE_DELAY = 1000; const initialEdges: Edge[] = []; const FALLBACK_COLOR_CONFIG = { nodeBgColor: '#ffffff', nodeTextColor: '#000000' }; const initialNodesWithHandler: Node[] = [ { id: '1', type: 'editableColor', data: { label: 'Main Idea', bgColor: FALLBACK_COLOR_CONFIG.nodeBgColor, textColor: FALLBACK_COLOR_CONFIG.nodeTextColor, onChange: () => {} // Will be replaced with actual handler }, position: { x: 250, y: 25 }, }, ]; const nodeTypes: NodeTypes = { editableColor: EditableColorNode, }; // Add color presets const COLOR_PRESETS = [ { name: 'Default', bg: '#ffffff', text: '#000000' }, { name: 'Dark', bg: '#2d3748', text: '#ffffff' }, { name: 'Light Blue', bg: '#ebf8ff', text: '#2b6cb0' }, { name: 'Warm', bg: '#fffaf0', text: '#744210' }, { name: 'Cool', bg: '#f0fff4', text: '#22543d' }, { name: 'Modern', bg: '#f7fafc', text: '#1a202c' }, ]; const MindMapEditor = () => { // State declarations const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [currentMindMapId, setCurrentMindMapId] = useState(null); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [mindMapName, setMindMapName] = useState('Untitled Mind Map'); const [isSidePaneCollapsed, setIsSidePaneCollapsed] = useState(false); const [isDirty, setIsDirty] = useState(false); const [errorStack, setErrorStack] = useState([]); const [history, setHistory] = useState([]); const [future, setFuture] = useState([]); const [config, setConfig] = useState(() => FALLBACK_COLOR_CONFIG); const [saveTimeout, setSaveTimeout] = useState(null); const reactFlowWrapper = useRef(null); const [reactFlowInstance, setReactFlowInstance] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(''); const [editPosition, setEditPosition] = useState({ x: 0, y: 0 }); const editInputRef = useRef(null); const [isHistoryEnabled, setIsHistoryEnabled] = useState(true); const [isLoading, setIsLoading] = useState(false); const [showColorPicker, setShowColorPicker] = useState(false); const [isChatOpen, setIsChatOpen] = useState(false); const [selectedNodes, setSelectedNodes] = useState([]); const [copiedNodes, setCopiedNodes] = useState(null); // Refs const errorTimeout = useRef(null); const fileInputRef = useRef(null); // Node data change handler - Moved before initialNodes const handleNodeDataChange = useCallback((nodeId: string, newData: Partial) => { setNodes((nds) => nds.map((node) => { if (node.id === nodeId) { return { ...node, data: { ...node.data, ...newData, bgColor: newData.bgColor || config.nodeBgColor, textColor: newData.textColor || config.nodeTextColor, }, }; } return node; }) ); setIsDirty(true); }, [setNodes, config]); // Memoized initial nodes const initialNodes = useMemo(() => [{ id: '1', type: 'editableColor', data: { label: 'Main Idea', bgColor: config.nodeBgColor, textColor: config.nodeTextColor, onChange: handleNodeDataChange }, position: { x: 250, y: 25 }, }], [handleNodeDataChange]); // Initialize nodes with handler useEffect(() => { setNodes(initialNodes); }, [initialNodes, setNodes]); // History management const pushHistory = useCallback((state: HistoryState) => { setHistory((h) => { const newHistory = [...h, state]; if (newHistory.length > MAX_HISTORY_SIZE) { return newHistory.slice(-MAX_HISTORY_SIZE); } return newHistory; }); setFuture([]); }, []); // Error handling const addError = useCallback((msg: string, details?: string) => { const errorMessage = details ? `${msg}: ${details}` : msg; setErrorStack((stack) => [...stack, errorMessage]); }, []); const removeError = useCallback((idx: number) => { setErrorStack((stack) => stack.filter((_, i) => i !== idx)); }, []); // Auto-save functionality const debouncedAutoSave = useCallback(async () => { if (saveTimeout) { clearTimeout(saveTimeout); } const timeout = setTimeout(async () => { if (!isDirty) return; setIsSaving(true); try { const data = { nodes, edges, name: mindMapName, config, }; const apiBase = process.env.REACT_APP_API_URL || ''; if (currentMindMapId) { await axios.put(`${apiBase}/api/workflows/${currentMindMapId}`, data); } else { const response = await axios.post(`${apiBase}/api/workflows`, data); setCurrentMindMapId(response.data.id); } setIsDirty(false); } catch (error) { addError('Failed to save mind map', error instanceof Error ? error.message : 'Unknown error'); } finally { setIsSaving(false); } }, 1000); setSaveTimeout(timeout); }, [isDirty, nodes, edges, mindMapName, config, currentMindMapId, addError]); // Cleanup on unmount useEffect(() => { return () => { if (saveTimeout) { clearTimeout(saveTimeout); } if (errorTimeout.current) { clearTimeout(errorTimeout.current); } }; }, [saveTimeout]); // Event handlers const handleConfigChange = useCallback((newConfig: any) => { const currentState = { nodes, edges, config }; pushHistory(currentState); setConfig(newConfig); setNodes((nds) => nds.map((node: any) => ({ ...node, data: { ...node.data, bgColor: newConfig.nodeBgColor, textColor: newConfig.nodeTextColor, }, }))); setIsDirty(true); }, [nodes, edges, config, pushHistory, setNodes]); const handleNameChange = useCallback(async (newName: string) => { if (newName.trim().length === 0) { addError('Mind map name cannot be empty'); return; } setMindMapName(newName); setIsDirty(true); }, [addError]); const handleSelectMindMap = useCallback(async (mindMap: any) => { setIsLoading(true); try { setError(null); const loadedConfig = mindMap.config || FALLBACK_COLOR_CONFIG; React.startTransition(() => { setConfig(loadedConfig); setNodes( mindMap.nodes.map((node: any) => ({ ...node, type: 'editableColor', data: { ...node.data, onChange: handleNodeDataChange, }, })) ); setEdges(mindMap.edges); setCurrentMindMapId(mindMap.id); setMindMapName(mindMap.name || 'Untitled Mind Map'); }); } catch (error) { console.error('Error loading mind map:', error); addError('Failed to load mind map', error instanceof Error ? error.message : 'Unknown error'); } finally { setIsLoading(false); } }, [setNodes, setEdges, handleNodeDataChange, addError]); const handleNewMindMap = useCallback(() => { setConfig(config || FALLBACK_COLOR_CONFIG); setNodes([ { id: '1', type: 'editableColor', data: { label: 'Main Idea', bgColor: (config && config.nodeBgColor) || FALLBACK_COLOR_CONFIG.nodeBgColor, textColor: (config && config.nodeTextColor) || FALLBACK_COLOR_CONFIG.nodeTextColor, onChange: handleNodeDataChange, }, position: { x: 250, y: 25 }, }, ]); setEdges([]); setCurrentMindMapId(null); setMindMapName('Untitled Mind Map'); setError(null); }, [setNodes, setEdges, handleNodeDataChange, config]); const handleExportJSON = useCallback(() => { try { const mindMapData: MindMapData = { id: currentMindMapId || uuidv4(), name: mindMapName, updatedAt: new Date().toISOString(), nodes, edges, config, }; saveMindMapToJSON(mindMapData); } catch (error) { addError('Failed to export mind map', error instanceof Error ? error.message : 'Unknown error'); } }, [currentMindMapId, mindMapName, nodes, edges, config, addError]); const handleImportJSON = (event: React.ChangeEvent) => { const file = event.target?.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const json = JSON.parse(e.target?.result as string); if (json.nodes && json.edges) { const importedNodes = json.nodes.map((node: any) => ({ ...node, type: 'editableColor', data: { ...node.data, onChange: handleNodeDataChange, bgColor: node.data?.bgColor || (json.config?.nodeBgColor || FALLBACK_COLOR_CONFIG.nodeBgColor), textColor: node.data?.textColor || (json.config?.nodeTextColor || FALLBACK_COLOR_CONFIG.nodeTextColor), }, })); setNodes(importedNodes); setEdges(json.edges); } else { addError('Invalid mind map JSON: Missing nodes or edges'); } if (json.config) setConfig(json.config); if (json.name) setMindMapName(json.name); } catch (err) { addError('Invalid mind map JSON', err instanceof Error ? err.message : 'Unknown error'); } }; reader.readAsText(file); }; const handleDeleteMindMap = useCallback(async (id: string) => { if (currentMindMapId === id) { // Create a new mind map before deleting the current one const data = { nodes, edges, name: mindMapName, config, }; try { const response = await axios.post('/api/workflows', data); setCurrentMindMapId(response.data.id); setIsDirty(false); } catch (error) { console.error('Error creating new mind map:', error); addError('Failed to create new mind map'); } } }, [currentMindMapId, nodes, edges, mindMapName, config, addError]); // Effects useEffect(() => { if (isDirty) { debouncedAutoSave(); } }, [isDirty, debouncedAutoSave]); useEffect(() => { if (errorStack.length > 0) { const timeout = setTimeout(() => removeError(0), 3000); return () => clearTimeout(timeout); } }, [errorStack, removeError]); useEffect(() => { return () => { if (saveTimeout) { clearTimeout(saveTimeout); } }; }, [saveTimeout]); const onConnect = useCallback( (params: Connection) => { setEdges((eds) => addEdge(params, eds)); }, [setEdges] ); const onAddNode = useCallback(() => { pushHistory({ nodes, edges, config }); const newNode: Node = { id: uuidv4(), type: 'editableColor', data: { label: `Node ${nodes.length + 1}`, bgColor: config.nodeBgColor, textColor: config.nodeTextColor, onChange: handleNodeDataChange }, position: { x: Math.random() * 500, y: Math.random() * 500, }, }; setNodes((nds) => [...nds, newNode]); setIsDirty(true); }, [nodes.length, setNodes, handleNodeDataChange, config, pushHistory]); const undo = useCallback(() => { if (history.length > 0) { setFuture((f) => [ { nodes, edges, config }, ...f ]); const prev = history[history.length - 1]; setNodes(prev.nodes); setEdges(prev.edges); setConfig(prev.config); setHistory((h) => h.slice(0, -1)); } }, [history, nodes, edges, config, setNodes, setEdges, setConfig]); const redo = useCallback(() => { if (future.length > 0) { setHistory((h) => [...h, { nodes, edges, config }]); const next = future[0]; setNodes(next.nodes); setEdges(next.edges); setConfig(next.config); setFuture((f) => f.slice(1)); } }, [future, nodes, edges, config, setNodes, setEdges, setConfig]); const zoomIn = useCallback(() => { reactFlowInstance?.zoomIn(); }, [reactFlowInstance]); const zoomOut = useCallback(() => { reactFlowInstance?.zoomOut(); }, [reactFlowInstance]); const centerView = useCallback(() => { reactFlowInstance?.fitView(); }, [reactFlowInstance]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redo(); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { // Copy selected nodes and their connecting edges if (selectedNodes.length > 0) { const selectedIds = selectedNodes.map(n => n.id); const copied = selectedNodes.map(n => ({ ...n, position: { ...n.position } })); const copiedEdges = edges.filter(e => selectedIds.includes(e.source) && selectedIds.includes(e.target)); setCopiedNodes(copied); // Store copied edges in a ref for pasting (window as any)._copiedEdges = copiedEdges; } e.preventDefault(); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { // Paste nodes and edges if (copiedNodes && copiedNodes.length > 0) { const idMap: Record = {}; const newNodes = copiedNodes.map(n => { const newId = uuidv4(); idMap[n.id] = newId; return { ...n, id: newId, position: { x: n.position.x + 40, y: n.position.y + 40 }, data: { ...n.data, label: n.data.label + ' (copy)', onChange: handleNodeDataChange }, }; }); // Paste only edges between copied nodes const copiedEdges = (window as any)._copiedEdges || []; const newEdges = copiedEdges.map((e: Edge) => ({ ...e, id: uuidv4(), source: idMap[e.source], target: idMap[e.target], })); setNodes(nds => [...nds, ...newNodes]); setEdges(eds => [...eds, ...newEdges]); setSelectedNodes(newNodes); setIsDirty(true); } e.preventDefault(); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { setSelectedNodes(nodes); e.preventDefault(); } else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodes.length > 0) { const idsToDelete = selectedNodes.map(n => n.id); setNodes(nds => nds.filter(n => !idsToDelete.includes(n.id))); setEdges(eds => eds.filter(e => !idsToDelete.includes(e.source) && !idsToDelete.includes(e.target))); setSelectedNodes([]); setSelectedNode(null); setIsDirty(true); e.preventDefault(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [undo, redo, selectedNodes, copiedNodes, nodes, edges, setNodes, setEdges, handleNodeDataChange]); // Warn before leaving with unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (isDirty) { e.preventDefault(); e.returnValue = ''; } }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [isDirty]); // Auto-dismiss error useEffect(() => { if (error) { if (errorTimeout.current) clearTimeout(errorTimeout.current); errorTimeout.current = setTimeout(() => setError(null), 3000); } return () => { if (errorTimeout.current) clearTimeout(errorTimeout.current); }; }, [error]); // Add missing handlers const handleNodeClick: NodeMouseHandler = useCallback((event, node) => { if (event.ctrlKey || event.metaKey) { setSelectedNodes((prev) => { if (prev.find((n) => n.id === node.id)) { return prev.filter((n) => n.id !== node.id); } else { return [...prev, node]; } }); } else { setSelectedNodes([node]); } setSelectedNode(node); }, []); const handlePaneClick = useCallback(() => { setSelectedNode(null); setSelectedNodes([]); }, []); const handleNodeDragStop: NodeMouseHandler = useCallback((_, node) => { setNodes((nds) => nds.map((n) => { if (n.id === node.id) { return { ...n, position: node.position, }; } return n; }) ); setIsDirty(true); }, [setNodes]); const handleNodeDoubleClick: NodeMouseHandler = useCallback((_, node) => { setSelectedNode(node); }, []); // Add handlePresetSelect const handlePresetSelect = (preset: { name: string; bg: string; text: string }) => { handleConfigChange({ ...config, nodeBgColor: preset.bg, nodeTextColor: preset.text }); setShowColorPicker(false); }; return (
handleNameChange(e.target.value)} className={styles.titleInput} placeholder="Untitled Mind Map" aria-label="Mind Map Title" />
{selectedNode && (
handleNodeDataChange(selectedNode.id, { bgColor: e.target.value })} aria-label="Node background color" title="Node background color" style={{ width: 32, height: 24, border: 'none', background: 'none' }} /> handleNodeDataChange(selectedNode.id, { textColor: e.target.value })} aria-label="Node text color" title="Node text color" style={{ width: 32, height: 24, border: 'none', background: 'none' }} />
)}
{showColorPicker && ( )}
{errorStack.map((err, idx) => (
{err}
))}
setIsChatOpen(false)} onImportJSON={handleImportJSON} currentMindMapData={{ nodes, edges, config, mindMapName }} />
); }; export default MindMapEditor;