mindmap / frontend /src /components /MindMapEditor.tsx
Rsnarsna's picture
Upload 44 files
b0c3c39 verified
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<NodeData>) => 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<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mindMapName, setMindMapName] = useState<string>('Untitled Mind Map');
const [isSidePaneCollapsed, setIsSidePaneCollapsed] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [errorStack, setErrorStack] = useState<string[]>([]);
const [history, setHistory] = useState<HistoryState[]>([]);
const [future, setFuture] = useState<HistoryState[]>([]);
const [config, setConfig] = useState(() => FALLBACK_COLOR_CONFIG);
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance | null>(null);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [editPosition, setEditPosition] = useState({ x: 0, y: 0 });
const editInputRef = useRef<HTMLInputElement>(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<Node[]>([]);
const [copiedNodes, setCopiedNodes] = useState<Node[] | null>(null);
// Refs
const errorTimeout = useRef<NodeJS.Timeout | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Node data change handler - Moved before initialNodes
const handleNodeDataChange = useCallback((nodeId: string, newData: Partial<NodeData>) => {
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<HTMLInputElement>) => {
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<string, string> = {};
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 (
<div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'row' }}>
<div style={{
width: isSidePaneCollapsed ? '64px' : '260px',
minWidth: isSidePaneCollapsed ? '64px' : '260px',
maxWidth: isSidePaneCollapsed ? '64px' : '260px',
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
height: '100%',
zIndex: 1000,
}}>
<SidePane
onSelectMindMap={handleSelectMindMap}
onNewMindMap={handleNewMindMap}
isCollapsed={isSidePaneCollapsed}
setIsCollapsed={setIsSidePaneCollapsed}
onDeleteMindMap={handleDeleteMindMap}
/>
</div>
<div
style={{
flexGrow: 1,
minWidth: 0,
height: '100%',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
>
<div className={styles.mindMapHeader}>
<input
type="text"
value={mindMapName}
onChange={e => handleNameChange(e.target.value)}
className={styles.titleInput}
placeholder="Untitled Mind Map"
aria-label="Mind Map Title"
/>
</div>
<div className={styles.verticalToolbar}>
{selectedNode && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<label style={{ fontSize: 12 }}>Node Background:</label>
<input
type="color"
value={selectedNode.data.bgColor}
onChange={e => 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' }}
/>
<label style={{ fontSize: 12 }}>Node Text:</label>
<input
type="color"
value={selectedNode.data.textColor}
onChange={e => 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' }}
/>
</div>
)}
<button
onClick={onAddNode}
className={styles.toolButton}
title="Add Node"
aria-label="Add Node"
>
<MdIcons.MdAdd />
</button>
<button
onClick={debouncedAutoSave}
disabled={isSaving || !isDirty}
className={`${styles.toolButton} ${isSaving ? styles.saving : isDirty ? styles.dirty : ''}`}
title={isSaving ? 'Saving changes' : isDirty ? 'Save changes' : 'No changes to save'}
aria-label={isSaving ? 'Saving changes' : isDirty ? 'Save changes' : 'No changes to save'}
>
{isSaving ? <MdIcons.MdOutlineSaveAlt /> : isDirty ? <MdIcons.MdOutlineSaveAlt /> : <MdIcons.MdOutlineSave />}
</button>
<hr className={styles.toolbarDivider} />
<button
className={styles.toolButton}
onClick={() => setShowColorPicker(!showColorPicker)}
aria-label="Toggle color picker"
aria-expanded={showColorPicker}
aria-controls="color-picker"
title="Change mind map colors"
>
<MdIcons.MdColorLens />
</button>
{showColorPicker && (
<div
id="color-picker"
className={styles.colorPickerToolbar}
role="dialog"
aria-label="Color picker"
>
<div className={styles.colorPresets}>
{COLOR_PRESETS.map((preset) => (
<button
key={preset.name}
className={styles.colorPreset}
onClick={() => handlePresetSelect(preset)}
style={{
background: `linear-gradient(45deg, ${preset.bg} 50%, ${preset.text} 50%)`,
}}
title={preset.name}
aria-label={`Select ${preset.name} color preset`}
/>
))}
</div>
<div className={styles.customColors}>
<div className={styles.colorControl}>
<label htmlFor="bgColor">Background:</label>
<div className={styles.colorInputWrapper}>
<input
type="color"
id="bgColor"
value={config.nodeBgColor}
onChange={e => handleConfigChange({ ...config, nodeBgColor: e.target.value })}
className={styles.colorInput}
title="Choose background color"
aria-label="Background Color"
/>
<span className={styles.colorValue}>{config.nodeBgColor}</span>
</div>
</div>
<div className={styles.colorControl}>
<label htmlFor="textColor">Text:</label>
<div className={styles.colorInputWrapper}>
<input
type="color"
id="textColor"
value={config.nodeTextColor}
onChange={e => handleConfigChange({ ...config, nodeTextColor: e.target.value })}
className={styles.colorInput}
title="Choose text color"
aria-label="Text Color"
/>
<span className={styles.colorValue}>{config.nodeTextColor}</span>
</div>
</div>
</div>
</div>
)}
<button
className={styles.toolButton}
onClick={() => setIsChatOpen(true)}
title="Open Mind Map Chat Assistant"
aria-label="Open Mind Map Chat Assistant"
>
<MdIcons.MdChatBubbleOutline />
</button>
<button onClick={undo} className={styles.toolButton} title="Undo" aria-label="Undo"><MdIcons.MdUndo /></button>
<button onClick={redo} className={styles.toolButton} title="Redo" aria-label="Redo"><MdIcons.MdRedo /></button>
<button onClick={zoomIn} className={styles.toolButton} title="Zoom In" aria-label="Zoom In"><MdIcons.MdZoomIn /></button>
<button onClick={zoomOut} className={styles.toolButton} title="Zoom Out" aria-label="Zoom Out"><MdIcons.MdZoomOut /></button>
<button onClick={centerView} className={styles.toolButton} title="Center View" aria-label="Center View"><MdIcons.MdCenterFocusStrong /></button>
<button
onClick={handleExportJSON}
className={`${styles.toolButton} ${isDirty ? styles.dirty : ''}`}
title={isDirty ? "Save Changes" : "All Changes Saved"}
aria-label={isDirty ? "Save Changes" : "All Changes Saved"}
>
<MdIcons.MdOutlineSaveAlt />
</button>
<button onClick={() => fileInputRef.current?.click()} className={styles.toolButton} title="Import from JSON" aria-label="Import from JSON"><MdIcons.MdFileUpload /></button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleImportJSON}
accept=".json"
style={{ display: 'none' }}
aria-label="Import mind map"
/>
{errorStack.map((err, idx) => (
<div key={idx} className={styles.errorToast} role="alert">
{err}
<button
aria-label="Close error"
onClick={() => removeError(idx)}
tabIndex={0}
className={styles.errorCloseButton}
>
&times;
</button>
</div>
))}
<div style={{ flexGrow: 1, minHeight: 0 }}>
<div className={styles.flowContainer} ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={setReactFlowInstance}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
onNodeDragStop={handleNodeDragStop}
onNodeDoubleClick={handleNodeDoubleClick}
nodeTypes={nodeTypes}
fitView
attributionPosition="bottom-right"
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
</div>
</div>
<ChatModal
open={isChatOpen}
onClose={() => setIsChatOpen(false)}
onImportJSON={handleImportJSON}
currentMindMapData={{
nodes,
edges,
config,
mindMapName
}}
/>
</div>
);
};
export default MindMapEditor;