|
|
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'; |
|
|
|
|
|
|
|
|
interface NodeData { |
|
|
label: string; |
|
|
bgColor: string; |
|
|
textColor: string; |
|
|
onChange: (nodeId: string, data: Partial<NodeData>) => void; |
|
|
} |
|
|
|
|
|
interface HistoryState { |
|
|
nodes: Node[]; |
|
|
edges: Edge[]; |
|
|
config: any; |
|
|
} |
|
|
|
|
|
|
|
|
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: () => {} |
|
|
}, |
|
|
position: { x: 250, y: 25 }, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const nodeTypes: NodeTypes = { |
|
|
editableColor: EditableColorNode, |
|
|
}; |
|
|
|
|
|
|
|
|
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 = () => { |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
const errorTimeout = useRef<NodeJS.Timeout | null>(null); |
|
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
setNodes(initialNodes); |
|
|
}, [initialNodes, setNodes]); |
|
|
|
|
|
|
|
|
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([]); |
|
|
}, []); |
|
|
|
|
|
|
|
|
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)); |
|
|
}, []); |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
return () => { |
|
|
if (saveTimeout) { |
|
|
clearTimeout(saveTimeout); |
|
|
} |
|
|
if (errorTimeout.current) { |
|
|
clearTimeout(errorTimeout.current); |
|
|
} |
|
|
}; |
|
|
}, [saveTimeout]); |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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') { |
|
|
|
|
|
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); |
|
|
|
|
|
(window as any)._copiedEdges = copiedEdges; |
|
|
} |
|
|
e.preventDefault(); |
|
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { |
|
|
|
|
|
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 }, |
|
|
}; |
|
|
}); |
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => { |
|
|
if (isDirty) { |
|
|
e.preventDefault(); |
|
|
e.returnValue = ''; |
|
|
} |
|
|
}; |
|
|
window.addEventListener('beforeunload', handleBeforeUnload); |
|
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload); |
|
|
}, [isDirty]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (error) { |
|
|
if (errorTimeout.current) clearTimeout(errorTimeout.current); |
|
|
errorTimeout.current = setTimeout(() => setError(null), 3000); |
|
|
} |
|
|
return () => { |
|
|
if (errorTimeout.current) clearTimeout(errorTimeout.current); |
|
|
}; |
|
|
}, [error]); |
|
|
|
|
|
|
|
|
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); |
|
|
}, []); |
|
|
|
|
|
|
|
|
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} |
|
|
> |
|
|
× |
|
|
</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; |