1const e=`import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; 2import html2canvas from 'html2canvas'; 3import { cn } from '@/lib/utils'; 4import { useIsMobile } from '@/hooks/use-mobile'; 5import { Button } from '@/components/ui/button'; 6import { Textarea } from '@/components/ui/textarea'; 7import { 8 Plus, Trash2, ZoomIn, ZoomOut, Maximize2, Download, ChevronDown, 9 Undo2, Redo2, Palette, RotateCcw, GitBranch, Search, X, ChevronUp, 10 ChevronRight, Copy, Edit3, Star, AlertCircle, Smile, StickyNote, 11 Map, Layout, Network, Building2, Sparkles, Loader2, Save, FolderOpen, 12 FileText, Link2, CreditCard, ExternalLink, MoreHorizontal, Image as ImageIcon 13} from 'lucide-react'; 14import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; 15import { 16 ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSub, 17 ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, 18 ContextMenuSeparator 19} from '@/components/ui/context-menu'; 20import { 21 Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter 22} from '@/components/ui/dialog'; 23import { Input } from '@/components/ui/input'; 24import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; 25import { toast } from 'sonner'; 26import { supabase } from '@/integrations/supabase/client'; 27import { useAuth } from '@/hooks/useAuth'; 28import { MindMapLibrary } from '@/components/MindMapLibrary'; 29import { MindMapTemplates } from '@/components/MindMapTemplates'; 30import { StudyGuideDialog } from '@/components/StudyGuideDialog'; 31import { ZettelCard as ZettelCardType } from '@/types/zettel'; 32 33// âââ Types ââââââââââââââââââââââââââââââââââââââââââââââââââââ 34type Priority = 'none' | 'low' | 'medium' | 'high'; 35type LayoutMode = 'radial' | 'tree' | 'orgchart'; 36 37interface MindMapNode { 38 id: string; 39 text: string; 40 children: string[]; 41 collapsed: boolean; 42 color: string; 43 x: number; 44 y: number; 45 parentId: string | null; 46 emoji: string; 47 note: string; 48 priority: Priority; 49 linked_card_id?: string | null; 50} 51 52interface MindMapData { 53 nodes: Record<string, MindMapNode>; 54 rootId: string; 55} 56 57// âââ Constants ââââââââââââââââââââââââââââââââââââââââââââââââ 58const BRANCH_COLORS = [ 59 'hsl(210, 70%, 55%)', 60 'hsl(150, 60%, 45%)', 61 'hsl(340, 65%, 55%)', 62 'hsl(35, 80%, 50%)', 63 'hsl(270, 55%, 55%)', 64 'hsl(180, 55%, 45%)', 65 'hsl(15, 70%, 55%)', 66 'hsl(100, 50%, 45%)', 67]; 68 69const PRIORITY_COLORS: Record<Priority, string> = { 70 none: 'transparent', 71 low: 'hsl(210, 70%, 55%)', 72 medium: 'hsl(35, 80%, 50%)', 73 high: 'hsl(0, 70%, 55%)', 74}; 75 76const EMOJI_OPTIONS = ['ð¡', 'â', 'ð¥', 'ð¯', 'ð', 'â ', 'â', 'â ï¸', 'ð', 'ð¬', 'ð', 'ð']; 77 78const H_GAP = 220; 79const V_GAP = 56; 80const TREE_H_GAP = 160; 81const TREE_V_GAP = 90; 82 83// âââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââ 84const uid = () => crypto.randomUUID(); 85 86function migrateNode(n: any): MindMapNode { 87 return { 88 ...n, 89 emoji: n.emoji || '', 90 note: n.note || '', 91 priority: n.priority || 'none', 92 linked_card_id: n.linked_card_id || null, 93 }; 94} 95 96function createDefaultMap(): MindMapData { 97 const rootId = uid(); 98 return { 99 rootId, 100 nodes: { 101 [rootId]: { id: rootId, text: 'Central Topic', children: [], collapsed: false, color: BRANCH_COLORS[0], x: 0, y: 0, parentId: null, emoji: 'ð¡', note: '', priority: 'none' }, 102 }, 103 }; 104} 105 106const STORAGE_KEY = 'pendragon-mindmap-v1'; 107const HINTS_KEY = 'pendragon-mindmap-hints-dismissed'; 108 109function loadMap(): MindMapData { 110 try { 111 const raw = localStorage.getItem(STORAGE_KEY); 112 if (raw) { 113 const parsed = JSON.parse(raw); 114 const nodes: Record<string, MindMapNode> = {}; 115 for (const [k, v] of Object.entries(parsed.nodes)) nodes[k] = migrateNode(v); 116 return { ...parsed, nodes }; 117 } 118 } catch {} 119 return createDefaultMap(); 120} 121 122function saveMap(data: MindMapData) { 123 localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); 124} 125 126// âââ Layout engines ââââââââââââââââââââââââââââââââââââââââââ 127function shiftSubtree(nodes: Record<string, MindMapNode>, nodeId: string, dx: number, dy: number) { 128 const node = nodes[nodeId]; 129 if (!node) return; 130 node.x += dx; 131 node.y += dy; 132 if (!node.collapsed) { 133 for (const cid of node.children) shiftSubtree(nodes, cid, dx, dy); 134 } 135} 136 137function layoutRadial(data: MindMapData): MindMapData { 138 const nodes: Record<string, MindMapNode> = {}; 139 for (const [k, v] of Object.entries(data.nodes)) nodes[k] = { ...v }; 140 const root = nodes[data.rootId]; 141 if (!root) return data; 142 143 root.x = 0; 144 root.y = 0; 145 146 const rootChildren = root.children; 147 const mid = Math.ceil(rootChildren.length / 2); 148 const rightChildren = rootChildren.slice(0, mid); 149 const leftChildren = rootChildren.slice(mid); 150 151 function layoutSubtree(nodeId: string, depth: number, direction: 1 | -1): number { 152 const node = nodes[nodeId]; 153 if (!node) return 0; 154 node.x = depth * H_GAP * direction; 155 if (node.collapsed || node.children.length === 0) return V_GAP; 156 157 let totalHeight = 0; 158 const childHeights: number[] = []; 159 for (const childId of node.children) { 160 const h = layoutSubtree(childId, depth + 1, direction); 161 childHeights.push(h); 162 totalHeight += h; 163 } 164
165 let currentY = node.y - totalHeight / 2 + childHeights[0] / 2; 166 for (let i = 0; i < node.children.length; i++) { 167 const child = nodes[node.children[i]]; 168 if (child) { 169 const shiftY = currentY - child.y; 170 shiftSubtree(nodes, child.id, 0, shiftY); 171 child.y = currentY; 172 currentY += childHeights[i]; 173 } 174 } 175 return Math.max(totalHeight, V_GAP); 176 } 177 178 // Right side 179 let rightTotalH = 0; 180 const rightHeights: number[] = []; 181 for (const cid of rightChildren) { 182 const h = layoutSubtree(cid, 1, 1); 183 rightHeights.push(h); 184 rightTotalH += h; 185 } 186 let ry = -rightTotalH / 2; 187 for (let i = 0; i < rightChildren.length; i++) { 188 const child = nodes[rightChildren[i]]; 189 if (child) { 190 const shift = ry + rightHeights[i] / 2 - child.y; 191 shiftSubtree(nodes, child.id, 0, shift); 192 child.y = ry + rightHeights[i] / 2; 193 ry += rightHeights[i]; 194 } 195 } 196 197 // Left side 198 let leftTotalH = 0; 199 const leftHeights: number[] = []; 200 for (const cid of leftChildren) { 201 const h = layoutSubtree(cid, 1, -1); 202 leftHeights.push(h); 203 leftTotalH += h; 204 } 205 let ly = -leftTotalH / 2; 206 for (let i = 0; i < leftChildren.length; i++) { 207 const child = nodes[leftChildren[i]]; 208 if (child) { 209 const shift = ly + leftHeights[i] / 2 - child.y; 210 shiftSubtree(nodes, child.id, 0, shift); 211 child.y = ly + leftHeights[i] / 2; 212 ly += leftHeights[i]; 213 } 214 } 215 216 return { ...data, nodes }; 217} 218 219function layoutTopDown(data: MindMapData, hGap: number, vGap: number): MindMapData { 220 const nodes: Record<string, MindMapNode> = {}; 221 for (const [k, v] of Object.entries(data.nodes)) nodes[k] = { ...v }; 222 const root = nodes[data.rootId]; 223 if (!root) return data; 224 225 root.x = 0; 226 root.y = 0; 227 228 function measure(nodeId: string): number { 229 const node = nodes[nodeId]; 230 if (!node || node.collapsed || node.children.length === 0) return hGap; 231 let total = 0; 232 for (const cid of node.children) total += measure(cid); 233 return Math.max(total, hGap); 234 } 235 236 function position(nodeId: string, x: number, y: number) { 237 const node = nodes[nodeId]; 238 if (!node) return; 239 node.x = x; 240 node.y = y; 241 if (node.collapsed || node.children.length === 0) return; 242 243 const widths = node.children.map(cid => measure(cid)); 244 const totalWidth = widths.reduce((a, b) => a + b, 0); 245 let cx = x - totalWidth / 2; 246 for (let i = 0; i < node.children.length; i++) { 247 position(node.children[i], cx + widths[i] / 2, y + vGap); 248 cx += widths[i]; 249 } 250 } 251 252 position(data.rootId, 0, 0); 253 return { ...data, nodes }; 254} 255 256function layoutTree(data: MindMapData, mode: LayoutMode): MindMapData { 257 switch (mode) { 258 case 'radial': return layoutRadial(data); 259 case 'tree': return layoutTopDown(data, TREE_H_GAP, TREE_V_GAP); 260 case 'orgchart': return layoutTopDown(data, TREE_H_GAP + 20, TREE_V_GAP + 10); 261 default: return layoutRadial(data); 262 } 263} 264 265// âââ Component ââââââââââââââââââââââââââââââââââââââââââââââââ 266interface MindMapProps { 267 cards?: ZettelCardType[]; 268 onCardSelect?: (card: ZettelCardType) => void; 269 onCreateCard?: (card: Partial<ZettelCardType>) => void; 270} 271 272export default function MindMap({ cards = [], onCardSelect, onCreateCard }: MindMapProps) { 273 const { user } = useAuth(); 274 const isMobile = useIsMobile(); 275 const [layoutMode, setLayoutMode] = useState<LayoutMode>('radial'); 276 const [data, setData] = useState<MindMapData>(() => layoutTree(loadMap(), 'radial')); 277 const [selectedId, setSelectedId] = useState<string | null>(null); 278 const [editingId, setEditingId] = useState<string | null>(null); 279 const [editText, setEditText] = useState(''); 280 const [zoom, setZoom] = useState(1); 281 const [pan, setPan] = useState({ x: 0, y: 0 }); 282 const [isPanning, setIsPanning] = useState(false); 283 const [panStart, setPanStart] = useState({ x: 0, y: 0 }); 284 const [history, setHistory] = useState<MindMapData[]>([]); 285 const [future, setFuture] = useState<MindMapData[]>([]); 286 const [hoveredId, setHoveredId] = useState<string | null>(null); 287 const [hintsDismissed, setHintsDismissed] = useState(() => localStorage.getItem(HINTS_KEY) === '1'); 288 289 // Search state 290 const [searchOpen, setSearchOpen] = useState(false); 291 const [searchQuery, setSearchQuery] = useState(''); 292 const [searchResults, setSearchResults] = useState<string[]>([]); 293 const [searchIndex, setSearchIndex] = useState(0); 294 295 // Minimap 296 const [minimapOpen, setMinimapOpen] = useState(true); 297 298 // AI Generate 299 const [aiDialogOpen, setAiDialogOpen] = useState(false); 300 const [aiSubject, setAiSubject] = useState(''); 301 const [aiLoading, setAiLoading] = useState(false); 302 303 // Drag 304 const [draggingId, setDraggingId] = useState<string | null>(null); 305 const [dragStartPos, setDragStartPos] = useState({ x: 0, y: 0 }); 306 307 // Save/Recall state 308 const [currentMapId, setCurrentMapId] = useState<string | null>(null); 309 const [currentMapTitle, setCurrentMapTitle] = useState<string>(''); 310 const [isDirty, setIsDirty] = useState(false); 311 const [saveDialogOpen, setSaveDialogOpen] = useState(false);
312 const [saveTitle, setSaveTitle] = useState(''); 313 const [libraryOpen, setLibraryOpen] = useState(false); 314 const [isSaving, setIsSaving] = useState(false); 315 316 // Card picker state 317 const [cardPickerOpen, setCardPickerOpen] = useState(false); 318 const [cardPickerNodeId, setCardPickerNodeId] = useState<string | null>(null); 319 const [cardPickerSearch, setCardPickerSearch] = useState(''); 320 321 // Node detail panel 322 const [nodeDetailOpen, setNodeDetailOpen] = useState(false); 323 const [nodeDetailId, setNodeDetailId] = useState<string | null>(null); 324 const [nodeDetailNote, setNodeDetailNote] = useState(''); 325 326 // Study guide state 327 const [studyGuideOpen, setStudyGuideOpen] = useState(false); 328 const [studyGuideContent, setStudyGuideContent] = useState(''); 329 const [studyGuideLoading, setStudyGuideLoading] = useState(false); 330 const [studyGuideLoadingText, setStudyGuideLoadingText] = useState(''); 331 332 // Templates 333 const [templatesOpen, setTemplatesOpen] = useState(false); 334 335 336 const canvasRef = useRef<HTMLDivElement>(null); 337 const editInputRef = useRef<HTMLInputElement>(null); 338 const searchInputRef = useRef<HTMLInputElement>(null); 339 340 // Save on change 341 // Mark dirty on data change & save locally 342 useEffect(() => { saveMap(data); setIsDirty(true); }, [data]); 343 useEffect(() => { if (editingId && editInputRef.current) editInputRef.current.focus(); }, [editingId]); 344 useEffect(() => { if (searchOpen && searchInputRef.current) searchInputRef.current.focus(); }, [searchOpen]); 345 346 // Center canvas initially 347 useEffect(() => { 348 if (canvasRef.current) { 349 const rect = canvasRef.current.getBoundingClientRect(); 350 setPan({ x: rect.width / 2, y: rect.height / 2 }); 351 } 352 }, []); 353 354 // Search logic 355 useEffect(() => { 356 if (!searchQuery.trim()) { setSearchResults([]); return; } 357 const q = searchQuery.toLowerCase(); 358 const matches = Object.values(data.nodes).filter(n => n.text.toLowerCase().includes(q) || n.note.toLowerCase().includes(q)).map(n => n.id); 359 setSearchResults(matches); 360 setSearchIndex(0); 361 }, [searchQuery, data.nodes]); 362 363 // Auto-pan to search result 364 useEffect(() => { 365 if (searchResults.length === 0) return; 366 const nodeId = searchResults[searchIndex]; 367 const node = data.nodes[nodeId]; 368 if (!node || !canvasRef.current) return; 369 const rect = canvasRef.current.getBoundingClientRect(); 370 setPan({ x: rect.width / 2 - node.x * zoom, y: rect.height / 2 - node.y * zoom }); 371 setSelectedId(nodeId); 372 }, [searchResults, searchIndex]); 373 374 const pushHistory = useCallback((d: MindMapData) => { 375 setHistory(prev => [...prev.slice(-30), d]); 376 setFuture([]); 377 }, []); 378 379 const updateData = useCallback((updater: (d: MindMapData) => MindMapData) => { 380 setData(prev => { 381 pushHistory(prev); 382 return layoutTree(updater(prev), layoutMode); 383 }); 384 }, [pushHistory, layoutMode]); 385 386 const undo = useCallback(() => { 387 if (history.length === 0) return; 388 setFuture(prev => [data, ...prev]); 389 const prev = history[history.length - 1]; 390 setHistory(h => h.slice(0, -1)); 391 setData(layoutTree(prev, layoutMode)); 392 }, [history, data, layoutMode]); 393 394 const redo = useCallback(() => { 395 if (future.length === 0) return; 396 setHistory(h => [...h, data]); 397 const next = future[0]; 398 setFuture(f => f.slice(1)); 399 setData(layoutTree(next, layoutMode)); 400 }, [future, data, layoutMode]); 401 402 // Relayout when mode changes 403 useEffect(() => { 404 setData(prev => layoutTree(prev, layoutMode)); 405 }, [layoutMode]); 406 407 // ââ Save/Load to Supabase ââ 408 const saveToDb = useCallback(async (title?: string) => { 409 if (!user) { toast.error('Sign in to save'); return; } 410 setIsSaving(true); 411 try { 412 const payload = { 413 user_id: user.id, 414 title: title || currentMapTitle || 'Untitled Map', 415 map_data: data, 416 layout_mode: layoutMode, 417 }; 418 if (currentMapId) { 419 const { error } = await supabase.from('mind_maps' as any).update(payload).eq('id', currentMapId); 420 if (error) throw error; 421 } else { 422 const { data: inserted, error } = await supabase.from('mind_maps' as any).insert(payload).select('id').single(); 423 if (error) throw error; 424 setCurrentMapId((inserted as any).id); 425 } 426 setCurrentMapTitle(payload.title); 427 setIsDirty(false); 428 toast.success('Mind map saved'); 429 } catch (err: any) { 430 toast.error('Failed to save: ' + (err.message || 'Unknown error')); 431 } finally { 432 setIsSaving(false); 433 } 434 }, [user, data, layoutMode, currentMapId, currentMapTitle]); 435 436 const handleSave = useCallback(() => { 437 if (currentMapId) { 438 saveToDb(); 439 } else { 440 setSaveTitle(''); 441 setSaveDialogOpen(true); 442 } 443 }, [currentMapId, saveToDb]); 444 445 const handleLoadMap = useCallback((id: string, title: string, mapData: any, lm: string) => { 446 pushHistory(data); 447 const nodes: Record<string, MindMapNode> = {}; 448 for (const [k, v] of Object.entries(mapData.nodes || {})) nodes[k] = migrateNode(v);
449 const loaded = layoutTree({ ...mapData, nodes }, lm as LayoutMode); 450 setData(loaded); 451 setCurrentMapId(id); 452 setCurrentMapTitle(title); 453 setLayoutMode(lm as LayoutMode); 454 setIsDirty(false); 455 setSelectedId(null); 456 setEditingId(null); 457 toast.success(\`Loaded: \${title}\`); 458 }, [data, pushHistory]); 459 460 const handleNewMap = useCallback(() => { 461 pushHistory(data); 462 const fresh = layoutTree(createDefaultMap(), layoutMode); 463 setData(fresh); 464 setCurrentMapId(null); 465 setCurrentMapTitle(''); 466 setIsDirty(false); 467 setSelectedId(null); 468 setEditingId(null); 469 toast.success('New mind map created'); 470 }, [data, pushHistory, layoutMode]); 471 472 const handleClearMap = useCallback(() => { 473 if (!confirm("Are you sure you want to clear the entire mind map? This cannot be undone.")) return; 474 pushHistory(data); 475 const fresh = layoutTree(createDefaultMap(), layoutMode); 476 setData(fresh); 477 setCurrentMapId(null); 478 setCurrentMapTitle(''); 479 setIsDirty(false); 480 setSelectedId(null); 481 setEditingId(null); 482 toast.success('Mind map cleared'); 483 }, [data, pushHistory, layoutMode]); 484 485 const handleExportPNG = async () => { 486 if (!canvasRef.current) return; 487 try { 488 const canvas = await html2canvas(canvasRef.current, { backgroundColor: null }); 489 const dataUrl = canvas.toDataURL('image/png'); 490 const link = document.createElement('a'); 491 link.download = \`mindmap-\${Date.now()}.png\`; 492 link.href = dataUrl; 493 document.body.appendChild(link); 494 link.click(); 495 document.body.removeChild(link); 496 toast.success('Mind map exported as PNG'); 497 } catch (err) { 498 toast.error('Failed to export image'); 499 } 500 }; 501 502 const handleApplyTemplate = useCallback((templateData: any) => { 503 pushHistory(data); 504 const nodes: Record<string, MindMapNode> = {}; 505 for (const [k, v] of Object.entries(templateData.nodes)) nodes[k] = migrateNode(v); 506 const laid = layoutTree({ ...templateData, nodes }, layoutMode); 507 setData(laid); 508 setCurrentMapId(null); 509 setCurrentMapTitle(''); 510 setIsDirty(false); 511 setSelectedId(null); 512 setEditingId(null); 513 toast.success('Template applied'); 514 }, [data, pushHistory, layoutMode]); 515 516 // ââ Card Linking ââ 517 const linkCardToNode = useCallback((nodeId: string, cardId: string) => { 518 updateData(d => { 519 const nodes = { ...d.nodes }; 520 nodes[nodeId] = { ...nodes[nodeId], linked_card_id: cardId }; 521 return { ...d, nodes }; 522 }); 523 toast.success('Card linked to node'); 524 }, [updateData]); 525 526 const unlinkCard = useCallback((nodeId: string) => { 527 updateData(d => { 528 const nodes = { ...d.nodes }; 529 nodes[nodeId] = { ...nodes[nodeId], linked_card_id: null }; 530 return { ...d, nodes }; 531 }); 532 toast.success('Card unlinked'); 533 }, [updateData]); 534 535 const createCardFromNode = useCallback((nodeId: string) => { 536 const node = data.nodes[nodeId]; 537 if (!node || !onCreateCard) return; 538 onCreateCard({ 539 title: node.text, 540 content: node.note || \`Created from mind map node: \${node.text}\`, 541 tags: ['mind-map'], 542 category: '000', 543 number: \`MM-\${Date.now()}\`, 544 linkedCards: [], 545 }); 546 toast.success('Card created from node'); 547 }, [data, onCreateCard]); 548 549 // ââ Study Guide ââ 550 const generateStudyGuide = useCallback(async () => { 551 setStudyGuideOpen(true); 552 setStudyGuideLoading(true); 553 setStudyGuideContent(''); 554 setStudyGuideLoadingText('Analyzing mind map...'); 555 556 try { 557 setTimeout(() => setStudyGuideLoadingText('Generating study guide...'), 1500); 558 559 // Collect linked cards content 560 const linkedCards = cards.filter(c => 561 Object.values(data.nodes).some(n => n.linked_card_id === c.id) 562 ); 563 564 const { data: fnData, error } = await supabase.functions.invoke('generate-study-guide', { 565 body: { 566 mapData: data, 567 linkedCards, 568 mapTitle: currentMapTitle || data.nodes[data.rootId]?.text || 'Mind Map', 569 }, 570 }); 571 572 if (error) throw error; 573 if (fnData?.error) throw new Error(fnData.error); 574 setStudyGuideContent(fnData?.guide || 'No content generated'); 575 } catch (err: any) { 576 toast.error(err.message || 'Failed to generate study guide'); 577 setStudyGuideContent(''); 578 setStudyGuideOpen(false); 579 } finally { 580 setStudyGuideLoading(false); 581 } 582 }, [data, cards, currentMapTitle]); 583 584 // ââ Node operations ââ 585 const addChild = useCallback((parentId: string) => { 586 const newId = uid(); 587 const parent = data.nodes[parentId]; 588 if (!parent) return; 589 const color = parent.parentId === null 590 ? BRANCH_COLORS[parent.children.length % BRANCH_COLORS.length] 591 : parent.color; 592 593 updateData(d => { 594 const nodes = { ...d.nodes }; 595 nodes[parentId] = { ...nodes[parentId], children: [...nodes[parentId].children, newId], collapsed: fal
595se }; 596 nodes[newId] = { id: newId, text: 'New Topic', children: [], collapsed: false, color, x: 0, y: 0, parentId, emoji: '', note: '', priority: 'none' }; 597 return { ...d, nodes }; 598 }); 599 setSelectedId(newId); 600 setEditingId(newId); 601 setEditText('New Topic'); 602 }, [data, updateData]); 603 604 const addSibling = useCallback((nodeId: string) => { 605 const node = data.nodes[nodeId]; 606 if (!node || !node.parentId) return; 607 addChild(node.parentId); 608 }, [data, addChild]); 609 610 const deleteNode = useCallback((nodeId: string) => { 611 if (nodeId === data.rootId) return; 612 const node = data.nodes[nodeId]; 613 if (!node || !node.parentId) return; 614 615 updateData(d => { 616 const nodes = { ...d.nodes }; 617 const parent = nodes[node.parentId!]; 618 if (parent) nodes[node.parentId!] = { ...parent, children: parent.children.filter(c => c !== nodeId) }; 619 const toRemove = [nodeId]; 620 while (toRemove.length) { 621 const id = toRemove.pop()!; 622 const n = nodes[id]; 623 if (n) { toRemove.push(...n.children); delete nodes[id]; } 624 } 625 return { ...d, nodes }; 626 }); 627 setSelectedId(node.parentId); 628 }, [data, updateData]); 629 630 const toggleCollapse = useCallback((nodeId: string) => { 631 updateData(d => { 632 const nodes = { ...d.nodes }; 633 nodes[nodeId] = { ...nodes[nodeId], collapsed: !nodes[nodeId].collapsed }; 634 return { ...d, nodes }; 635 }); 636 }, [updateData]); 637 638 const commitEdit = useCallback(() => { 639 if (!editingId) return; 640 updateData(d => { 641 const nodes = { ...d.nodes }; 642 nodes[editingId] = { ...nodes[editingId], text: editText || 'Untitled' }; 643 return { ...d, nodes }; 644 }); 645 setEditingId(null); 646 }, [editingId, editText, updateData]); 647 648 const changeColor = useCallback((nodeId: string, color: string) => { 649 const paintSubtree = (nodes: Record<string, MindMapNode>, id: string, c: string) => { 650 nodes[id] = { ...nodes[id], color: c }; 651 for (const cid of nodes[id].children) paintSubtree(nodes, cid, c); 652 }; 653 updateData(d => { 654 const nodes = { ...d.nodes }; 655 paintSubtree(nodes, nodeId, color); 656 return { ...d, nodes }; 657 }); 658 }, [updateData]); 659 660 const setEmoji = useCallback((nodeId: string, emoji: string) => { 661 updateData(d => { 662 const nodes = { ...d.nodes }; 663 nodes[nodeId] = { ...nodes[nodeId], emoji }; 664 return { ...d, nodes }; 665 }); 666 }, [updateData]); 667 668 const setPriority = useCallback((nodeId: string, priority: Priority) => { 669 updateData(d => { 670 const nodes = { ...d.nodes }; 671 nodes[nodeId] = { ...nodes[nodeId], priority }; 672 return { ...d, nodes }; 673 }); 674 }, [updateData]); 675 676 const setNote = useCallback((nodeId: string, note: string) => { 677 updateData(d => { 678 const nodes = { ...d.nodes }; 679 nodes[nodeId] = { ...nodes[nodeId], note }; 680 return { ...d, nodes }; 681 }); 682 }, [updateData]); 683 684 const duplicateBranch = useCallback((nodeId: string) => { 685 const node = data.nodes[nodeId]; 686 if (!node || !node.parentId) return; 687 688 updateData(d => { 689 const nodes = { ...d.nodes }; 690 691 function cloneSubtree(srcId: string, newParentId: string): string { 692 const src = nodes[srcId]; 693 const newId = uid(); 694 nodes[newId] = { ...src, id: newId, parentId: newParentId, children: [] }; 695 for (const cid of src.children) { 696 const clonedChild = cloneSubtree(cid, newId); 697 nodes[newId].children.push(clonedChild); 698 } 699 return newId; 700 } 701 702 const clonedId = cloneSubtree(nodeId, node.parentId); 703 const parent = nodes[node.parentId]; 704 const idx = parent.children.indexOf(nodeId); 705 const newChildren = [...parent.children]; 706 newChildren.splice(idx + 1, 0, clonedId); 707 nodes[node.parentId] = { ...parent, children: newChildren }; 708 return { ...d, nodes }; 709 }); 710 toast.success('Branch duplicated'); 711 }, [data, updateData]); 712 713 // ââ Keyboard shortcuts ââ 714 useEffect(() => { 715 const handler = (e: KeyboardEvent) => { 716 // Search shortcut 717 if ((e.ctrlKey || e.metaKey) && e.key === 'f') { 718 e.preventDefault(); 719 setSearchOpen(true); 720 return; 721 } 722 if (e.key === 'Escape' && searchOpen) { setSearchOpen(false); setSearchQuery(''); return; } 723 724 // Skip all mind-map shortcuts when editing a node label or typing in the detail panel 725 if (editingId || nodeDetailOpen) { 726 if (editingId) { 727 if (e.key === 'Enter') { e.preventDefault(); commitEdit(); } 728 if (e.key === 'Escape') { setEditingId(null); } 729 } 730 return; 731 } 732 if (!selectedId) return; 733 if (e.key === 'Tab') { e.preventDefault(); addChild(selectedId); } 734 if (e.key === 'Enter') { e.preventDefault(); addSibling(selectedId); } 735 if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteNode(selectedId); } 736 if (e.key === 'F2' || e.key === ' ') { e.preventDefault(); setEditingId(selectedId); setEditText(data.nodes[selectedId]?.text || ''); } 737 if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); e.shiftKey ? redo() : undo(); } 738 }; 739 window.addEventListener('keydown', handler); 740 return () => window.removeEventListener('keydown', handler); 741 }, [selectedId, editingId, nodeDetailOpen, commitEdit, addChild, addSibling, deleteNode, undo, redo, data, searchOpen]); 742 743 // ââ Pan & zoom ââ 744 const handleWheel = useCallback((e: React.WheelEvent) => { 745 if (e.ctrlKey || e.metaKey) { 746 e.preventDefault(); 747 setZoom(z => Math.max(0.2, Math.min(3, z - e.deltaY * 0.001))); 748 } else { 749 setPan(p => ({ x: p.x - e.deltaX, y: p.y - e.deltaY })); 750 } 751 }, []); 752 753 const handlePointerDown = useCallback((e: React.PointerEvent) => { 754 if (e.target === canvasRef.current || (e.target as HTMLElement).dataset.canvas) { 755 setIsPanning(true); 756 setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); 757 setSelectedId(null); 758 } 759 }, [pan]); 760 761 const handlePointerMove = useCallback((e: React.PointerEvent) => { 762 if (!isPanning) return; 763 setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y }); 764 }, [isPanning, panStart]); 765 766 const handlePointerUp = useCallback(() => { setIsPanning(false); setDraggingId(null); }, []); 767 768 const resetView = useCallback(() => { 769 setZoom(1); 770 if (canvasRef.current) { 771 const rect = canvasRef.current.getBoundingClientRect(); 772 setPan({ x: rect.width / 2, y: rect.height / 2 }); 773 } 774 }, []); 775 776 const fitToScreen = useCallback(() => { 777 if (!canvasRef.current) return; 778 const rect = canvasRef.current.getBoundingClientRect(); 779 const allNodes = Object.values(data.nodes); 780 if (allNodes.length === 0) return; 781 const xs = allNodes.map(n => n.x); 782 const ys = allNodes.map(n => n.y); 783 const minX = Math.min(...xs) - 140; 784 const maxX = Math.max(...xs) + 140; 785 const minY = Math.min(...ys) - 60; 786 const maxY = Math.max(...ys) + 60; 787 const w = maxX - minX || 1; 788 const h = maxY - minY || 1; 789 const z = Math.min(rect.width / w, rect.height / h, 2) * 0.85; 790 setZoom(z); 791 setPan({ x: rect.width / 2 - ((minX + maxX) / 2) * z, y: rect.height / 2 - ((minY + maxY) / 2) * z }); 792 }, [data]); 793 794 const exportAsJSON = useCallback(() => { 795 const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); 796 const url = URL.createObjectURL(blob); 797 const a = document.createElement('a'); 798 a.href = url; a.download = 'mindmap.json'; a.click(); 799 URL.revokeObjectURL(url); 800 toast.success('Mind map exported'); 801 }, [data]); 802 803 const resetMap = useCallback(() => { 804 pushHistory(data); 805 const fresh = layoutTree(createDefaultMap(), layoutMode); 806 setData(fresh); 807 setSelectedId(null); 808 setEditingId(null); 809 toast.success('Mind map reset'); 810 }, [data, pushHistory, layoutMode]); 811 812 const dismissHints = useCallback(() => { 813 setHintsDismissed(true); 814 localStorage.setItem(HINTS_KEY, '1'); 815 }, []); 816 817 // ââ AI Generate ââ 818 const aiGenerateMindMap = useCallback(async () => { 819 if (!aiSubject.trim()) return; 820 setAiLoading(true); 821 try { 822 const { data: fnData, error: fnError } = await supabase.functions.invoke('generate-mindmap', { 823 body: { subject: aiSubject.trim() }, 824 }); 825 826 if (fnError) throw new Error(fnError.message || 'Failed to generate mind map'); 827 if (fnData?.error) throw new Error(fnData.error); 828 829 const { root, branches } = fnData; 830 if (!root || !branches?.length) throw new Error('Invalid response structure'); 831 832 // Convert AI result to MindMapData 833 const rootId = uid(); 834 const nodes: Record<string, MindMapNode> = {}; 835 nodes[rootId] = { 836 id: rootId, text: root.text || aiSubject.trim(), children: [], collapsed: fal
836se, 837 color: BRANCH_COLORS[0], x: 0, y: 0, parentId: null, emoji: root.emoji || 'ð§ ', note: '', priority: 'none', 838 }; 839 840 for (let i = 0; i < branches.length && i < 8; i++) { 841 const branch = branches[i]; 842 const branchId = uid(); 843 const branchColor = BRANCH_COLORS[i % BRANCH_COLORS.length]; 844 nodes[branchId] = { 845 id: branchId, text: branch.text, children: [], collapsed: false, 846 color: branchColor, x: 0, y: 0, parentId: rootId, emoji: branch.emoji || '', note: '', priority: 'none', 847 }; 848 nodes[rootId].children.push(branchId); 849 850 if (branch.children) { 851 for (let j = 0; j < branch.children.length && j < 5; j++) { 852 const child = branch.children[j]; 853 const childId = uid(); 854 nodes[childId] = { 855 id: childId, text: child.text, children: [], collapsed: false, 856 color: branchColor, x: 0, y: 0, parentId: branchId, emoji: child.emoji || '', note: '', priority: 'none', 857 }; 858 nodes[branchId].children.push(childId); 859 } 860 } 861 } 862 863 const newData: MindMapData = { rootId, nodes }; 864 pushHistory(data); 865 const laid = layoutTree(newData, layoutMode); 866 setData(laid); 867 setSelectedId(null); 868 setEditingId(null); 869 setAiDialogOpen(false); 870 setAiSubject(''); 871 872 // Fit to screen after render 873 setTimeout(() => fitToScreen(), 100); 874 toast.success(\`Mind map generated with \${Object.keys(nodes).length} nodes\`); 875 } catch (err: any) { 876 console.error('AI mind map error:', err); 877 toast.error(err.message || 'Failed to generate mind map'); 878 } finally { 879 setAiLoading(false); 880 } 881 }, [aiSubject, data, pushHistory, layoutMode, fitToScreen]); 882 883 // ââ Drag to rearrange ââ 884 const handleNodeDragStart = useCallback((nodeId: string, e: React.PointerEvent) => { 885 const node = data.nodes[nodeId]; 886 if (!node || !node.parentId || nodeId === data.rootId) return; 887 e.preventDefault(); 888 e.stopPropagation(); 889 setDraggingId(nodeId); 890 setDragStartPos({ x: e.clientX, y: e.clientY }); 891 }, [data]); 892 893 const handleNodeDragEnd = useCallback((e: React.PointerEvent) => { 894 if (!draggingId) return; 895 const node = data.nodes[draggingId]; 896 if (!node || !node.parentId) { setDraggingId(null); return; } 897 898 const dy = e.clientY - dragStartPos.y; 899 const parent = data.nodes[node.parentId]; 900 if (!parent) { setDraggingId(null); return; } 901 902 const siblings = parent.children; 903 const currentIdx = siblings.indexOf(draggingId); 904 const step = dy > 30 ? 1 : dy < -30 ? -1 : 0; 905 const newIdx = Math.max(0, Math.min(siblings.length - 1, currentIdx + step)); 906 907 if (newIdx !== currentIdx) { 908 updateData(d => { 909 const nodes = { ...d.nodes }; 910 const newChildren = [...siblings]; 911 newChildren.splice(currentIdx, 1); 912 newChildren.splice(newIdx, 0, draggingId); 913 nodes[node.parentId!] = { ...nodes[node.parentId!], children: newChildren }; 914 return { ...d, nodes }; 915 }); 916 } 917 setDraggingId(null); 918 }, [draggingId, dragStartPos, data, updateData]); 919 920 // ââ Collect visible nodes and edges ââ 921 const { visibleNodes, edges } = useMemo(() => { 922 const visible: MindMapNode[] = []; 923 const edgeList: { from: MindMapNode; to: MindMapNode; depth: number }[] = []; 924 925 function walk(nodeId: string, depth: number) { 926 const node = data.nodes[nodeId]; 927 if (!node) return; 928 visible.push(node); 929 if (!node.collapsed) { 930 for (const cid of node.children) { 931 const child = data.nodes[cid]; 932 if (child) { 933 edgeList.push({ from: node, to: child, depth }); 934 walk(cid, depth + 1); 935 } 936 } 937 } 938 } 939 walk(data.rootId, 0); 940 return { visibleNodes: visible, edges: edgeList }; 941 }, [data]); 942 943 // ââ Minimap calculations ââ 944 const minimapData = useMemo(() => { 945 const allNodes = Object.values(data.nodes); 946 if (allNodes.length === 0) return null; 947 const xs = allNodes.map(n => n.x); 948 const ys = allNodes.map(n => n.y); 949 const minX = Math.min(...xs) - 50; 950 const maxX = Math.max(...xs) + 50; 951 const minY = Math.min(...ys) - 30; 952 const maxY = Math.max(...ys) + 30; 953 const w = maxX - minX || 1; 954 const h = maxY - minY || 1; 955 return { minX, maxX, minY, maxY, w, h }; 956 }, [data.nodes]); 957 958 const handleMinimapClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { 959 if (!minimapData || !canvasRef.current) return; 960 const rect = e.currentTarget.getBoundingClientRect(); 961 const mx = (e.clientX - rect.left) / rect.width; 962 const my = (e.clientY - rect.top) / rect.height; 963 const worldX = minimapData.minX + mx * minimapData.w; 964 const worldY = minimapData.minY + my * minimapData.h; 965 const canvasRect = canvasRef.current.getBoundingClientRect(); 966 setPan({ x: canvasRect.width / 2 - worldX * zoom, y: canvasRect.height / 2 - worldY * zoom }); 967 }, [minimapData, zoom]); 968 969 const nodeCount = Object.keys(data.nodes).length; 970 971 const layoutIcons: Record<LayoutMode, React.ReactNode> = {
972 radial: <Network className="h-3.5 w-3.5" />, 973 tree: <Layout className="h-3.5 w-3.5" />, 974 orgchart: <Building2 className="h-3.5 w-3.5" />, 975 }; 976 977 const layoutLabels: Record<LayoutMode, string> = { 978 radial: 'Radial', 979 tree: 'Tree', 980 orgchart: 'Org Chart', 981 }; 982 983 // Render a node with context menu 984 const renderNode = (node: MindMapNode) => { 985 const isRoot = node.id === data.rootId; 986 const isSelected = node.id === selectedId; 987 const isEditing = node.id === editingId; 988 const isHovered = node.id === hoveredId; 989 const hasChildren = node.children.length > 0; 990 const isSearchMatch = searchResults.includes(node.id); 991 const isCurrentSearchResult = searchResults[searchIndex] === node.id; 992 const nodeWidth = isRoot ? 150 : 130; 993 994 return ( 995 <ContextMenu key={node.id}> 996 <ContextMenuTrigger asChild> 997 <div 998 className={cn("absolute group", draggingId === node.id && "opacity-50")} 999 style={{ left: node.x, top: node.y, transform: 'translate(-50%, -50%)', zIndex: isSelected ? 20 : isHovered ? 15 : 10 }} 1000 onPointerEnter={() => setHoveredId(node.id)} 1001 onPointerLeave={() => setHoveredId(null)} 1002 > 1003 {/* Search highlight ring */} 1004 {isSearchMatch && ( 1005 <div 1006 className={cn("absolute inset-0 rounded-xl pointer-events-none", isCurrentSearchResult ? "mindmap-search-highlight" : "ring-2 ring-ring/50")} 1007 style={{ margin: '-6px' }} 1008 /> 1009 )} 1010 1011 {/* Node body */} 1012 <div 1013 className={cn( 1014 "relative rounded-xl cursor-pointer transition-all duration-200", 1015 isRoot 1016 ? "px-5 py-3 text-sm font-semibold text-center mindmap-node-glow" 1017 : "px-3.5 py-2 text-xs font-medium", 1018 isSelected 1019 ? "ring-2 ring-ring ring-offset-2 ring-offset-background" 1020 : "", 1021 )} 1022 style={{ 1023 minWidth: isRoot ? '120px' : '80px', 1024 maxWidth: \`\${nodeWidth}px\`, 1025 background: isRoot 1026 ? \`linear-gradient(135deg, \${node.color}, \${node.color}dd)\` 1027 : \`linear-gradient(135deg, color-mix(in srgb, \${node.color} 14%, hsl(var(--card))), hsl(var(--card)))\`, 1028 borderLeft: isRoot ? 'none' : \`3px solid \${node.color}\`, 1029 color: isRoot ? 'white' : 'hsl(var(--foreground))', 1030 boxShadow: isSelected 1031 ? \`0 4px 20px color-mix(in srgb, \${node.color} 30%, transparent)\` 1032 : isRoot 1033 ? \`0 6px 24px color-mix(in srgb, \${node.color} 35%, transparent)\` 1034 : \`0 2px 8px hsl(var(--card-shadow))\`, 1035 }} 1036 onClick={(e) => { e.stopPropagation(); setSelectedId(node.id); setNodeDetailId(node.id); setNodeDetailNote(node.note || ''); setNodeDetailOpen(true); }} 1037 onDoubleClick={(e) => { 1038 e.stopPropagation(); 1039 if (node.linked_card_id && onCardSelect) { 1040 const card = cards.find(c => c.id === node.linked_card_id); 1041 if (card) { onCardSelect(card); return; } 1042 } 1043 setEditingId(node.id); setEditText(node.text); 1044 }} 1045 onPointerDown={(e) => { if (e.button === 0 && !isRoot) handleNodeDragStart(node.id, e); }} 1046 > 1047 {/* Priority dot */} 1048 {node.priority !== 'none' && ( 1049 <div 1050 className="absolute -top-1 -right-1 h-3 w-3 rounded-full border-2" 1051 style={{ backgroundColor: PRIORITY_COLORS[node.priority], borderColor: isRoot ? node.color : 'hsl(var(--card))' }} 1052 /> 1053 )} 1054 1055 {/* Content */} 1056 <div className="flex items-center gap-1.5"> 1057 {node.emoji && <span className="text-sm flex-shrink-0">{node.emoji}</span>} 1058 {isEditing ? ( 1059 <input 1060 ref={editInputRef} 1061 value={editText} 1062 onChange={e => setEditText(e.target.value)} 1063 onBlur={commitEdit} 1064 onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingId(null); }} 1065 className="bg-transparent outline-none border-none text-inherit font-inherit min-w-[50px] w-full" 1066 onClick={e => e.stopPropagation()} 1067 /> 1068 ) : (
1069 <span className="truncate">{node.text}</span> 1070 )} 1071 </div> 1072 1073 {/* Note preview */} 1074 {node.note && !isEditing && ( 1075 <div className="text-[10px] mt-0.5 truncate" style={{ opacity: 0.55, maxWidth: \`\${nodeWidth - 30}px\` }}> 1076 {node.note.length > 30 ? node.note.slice(0, 30) + 'â¦' : node.note} 1077 </div> 1078 )} 1079 1080 {/* Linked card badge */} 1081 {node.linked_card_id && !isEditing && ( 1082 <div className="absolute -bottom-1.5 -right-1.5 h-4 w-4 rounded-full bg-primary flex items-center justify-center z-10" title="Linked to card"> 1083 <CreditCard className="h-2.5 w-2.5 text-primary-foreground" /> 1084 </div> 1085 )} 1086 </div> 1087 1088 {/* Collapse toggle */} 1089 {hasChildren && ( 1090 <button 1091 className="absolute h-5 w-5 rounded-full bg-card border border-border flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 hover:scale-110 z-20" 1092 style={{ 1093 top: layoutMode === 'radial' ? '50%' : '100%', 1094 left: layoutMode === 'radial' ? (node.x >= 0 || isRoot ? '100%' : '0%') : '50%', 1095 transform: layoutMode === 'radial' 1096 ? \`translate(\${node.x >= 0 || isRoot ? '4px' : '-24px'}, -50%)\` 1097 : 'translate(-50%, 4px)', 1098 }} 1099 onClick={(e) => { e.stopPropagation(); toggleCollapse(node.id); }} 1100 > 1101 {node.collapsed 1102 ? <span className="text-[9px] font-bold text-muted-foreground">{node.children.length}</span> 1103 : <ChevronDown className="h-2.5 w-2.5 text-muted-foreground" /> 1104 } 1105 </button> 1106 )} 1107 1108 {/* Action ring - appears on hover */} 1109 {isHovered && !isEditing && ( 1110 <div className="mindmap-action-ring absolute -top-3 left-1/2 -translate-x-1/2 flex items-center gap-1 z-30"> 1111 <button 1112 className="h-6 w-6 rounded-full bg-card border border-border flex items-center justify-center hover:bg-accent transition-all shadow-sm" 1113 onClick={(e) => { e.stopPropagation(); addChild(node.id); }} 1114 title="Add child" 1115 > 1116 <Plus className="h-3 w-3 text-muted-foreground" /> 1117 </button> 1118 {!isRoot && ( 1119 <button 1120 className="h-6 w-6 rounded-full bg-card border border-border flex items-center justify-center hover:bg-destructive/10 transition-all shadow-sm" 1121 onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }} 1122 title="Delete" 1123 > 1124 <Trash2 className="h-3 w-3 text-muted-foreground" /> 1125 </button> 1126 )} 1127 <button 1128 className="h-6 w-6 rounded-full bg-card border border-border flex items-center justify-center hover:bg-accent transition-all shadow-sm" 1129 onClick={(e) => { e.stopPropagation(); toggleCollapse(node.id); }} 1130 title={node.collapsed ? 'Expand' : 'Collapse'} 1131 > 1132 {node.collapsed ? <ChevronRight className="h-3 w-3 text-muted-foreground" /> : <ChevronDown className="h-3 w-3 text-muted-foreground" />} 1133 </button> 1134 </div> 1135 )} 1136 </div> 1137 </ContextMenuTrigger> 1138 1139 <ContextMenuContent className="w-52"> 1140 <ContextMenuItem onClick={() => addChild(node.id)}> 1141 <Plus className="h-3.5 w-3.5 mr-2" />Add Child 1142 </ContextMenuItem> 1143 {!isRoot && ( 1144 <ContextMenuItem onClick={() => addSibling(node.id)}> 1145 <Plus className="h-3.5 w-3.5 mr-2" />Add Sibling 1146 </ContextMenuItem> 1147 )} 1148 <ContextMenuItem onClick={() => { setEditingId(node.id); setEditText(node.text); }}> 1149 <Edit3 className="h-3.5 w-3.5 mr-2" />Edit 1150 </ContextMenuItem> 1151 <ContextMenuSeparator /> 1152 <ContextMenuSub> 1153 <ContextMenuSubTrigger> 1154 <Palette className="h-3.5 w-3.5 mr-2" />Color 1155 </ContextMenuSubTrigger> 1156 <ContextMenuSubContent> 1157 <div className="flex flex-wrap gap-1.5 p-2 max-w-[140px]"> 1158 {BRANCH_COLORS.map(c => ( 1159 <button key={c} className="h-5 w-5 rounded-full border border-border hover:scale-125 transition-transform" style={{ background: c }} onClick={() => changeColor(node.id, c)} /> 1160 ))} 1161 </div> 1162 </ContextMenuSubContent> 1163 </ContextMenuSub> 1164 <ContextMenuSub> 1165 <ContextMenuSubTrigger> 1166 <Smile className="h-3.5 w-3.5 mr-2" />Emoji 1167 </ContextMenuSubTrigger> 1168 <ContextMenuSubContent> 1169 <div className="flex flex-wrap gap-1 p-2 max-w-[160px]"> 1170 <button className="h-6 w-6 rounded text-xs hover:bg-accent flex items-center justify-center" onClick={() => setEmoji(node.id, '')}>â</button> 1171 {EMOJI_OPTIONS.map(em => ( 1172 <button key={em} className="h-6 w-6 rounded hover:bg-accent flex items-center justify-center text-sm" onClick={() => setEmoji(node.id, em)}>{em}</button> 1173 ))} 1174 </div> 1175 </ContextMenuSubContent> 1176 </ContextMenuSub> 1177 <ContextMenuSub> 1178 <ContextMenuSubTrigger> 1179 <Star className="h-3.5 w-3.5 mr-2" />Priority 1180 </ContextMenuSubTrigger> 1181 <ContextMenuSubContent> 1182 {(['none', 'low', 'medium', 'high'] as Priority[]).map(p => ( 1183 <ContextMenuItem key={p} onClick={() => setPriority(node.id, p)}> 1184 <div className="h-2.5 w-2.5 rounded-full mr-2" style={{ background: PRIORITY_COLORS[p], border: p === 'none' ? '1px solid hsl(var(--border))' : 'none' }} /> 1185 {p.charAt(0).toUpperCase() + p.slice(1)} 1186 </ContextMenuItem> 1187 ))} 1188 </ContextMenuSubContent> 1189 </ContextMenuSub> 1190 <ContextMenuItem onClick={() => { 1191 setNodeDetailId(node.id); 1192 setNodeDetailNote(node.note || ''); 1193 setNodeDetailOpen(true); 1194 }}> 1195 <StickyNote className="h-3.5 w-3.5 mr-2" />Edit Note 1196 </ContextMenuItem> 1197 <ContextMenuSeparator /> 1198 {/* Card linking */} 1199 <ContextMenuItem onClick={() => { setCardPickerNodeId(node.id); setCardPickerSearch(''); setCardPickerOpen(true); }}> 1200 <Link2 className="h-3.5 w-3.5 mr-2" />Link to Card 1201 </ContextMenuItem> 1202 {node.linked_card_id && ( 1203 <> 1204 <ContextMenuItem onClick={() => { 1205 const card = cards.find(c => c.id === node.linked_card_id); 1206 if (card && onCardSelect) onCardSelect(card); 1207 else toast.error('Linked card not found'); 1208 }}> 1209 <ExternalLink className="h-3.5 w-3.5 mr-2" />Open Linked Card 1210 </ContextMenuItem> 1211 <ContextMenuItem onClick={() => unlinkCard(node.id)}> 1212 <X className="h-3.5 w-3.5 mr-2" />Unlink Card 1213 </ContextMenuItem> 1214 </> 1215 )} 1216 {onCreateCard && ( 1217 <ContextMenuItem onClick={() => createCardFromNode(node.id)}> 1218 <CreditCard className="h-3.5 w-3.5 mr-2" />Create Card from Node 1219 </ContextMenuItem> 1220 )} 1221 <ContextMenuSeparator /> 1222 <ContextMenuItem onClick={() => toggleCollapse(node.id)}> 1223 {node.collapsed ? <ChevronRight className="h-3.5 w-3.5 mr-2" /> : <ChevronDown className="h-3.5 w-3.5 mr-2" />} 1224 {node.collapsed ? 'Expand' : 'Collapse'} 1225 </ContextMenuItem> 1226 {!isRoot && ( 1227 <ContextMenuItem onClick={() => duplicateBranch(node.id)}> 1228 <Copy className="h-3.5 w-3.5 mr-2" />Duplicate Branch 1229 </ContextMenuItem> 1230 )} 1231 {!isRoot && ( 1232 <> 1233 <ContextMenuSeparator /> 1234 <ContextMenuItem className="text-destructive focus:text-destructive" onClick={() => deleteNode(node.id)}> 1235 <Trash2 className="h-3.5 w-3.5 mr-2" />Delete 1236 </ContextMenuItem> 1237 </> 1238 )} 1239 </ContextMenuContent> 1240 </ContextMenu> 1241 ); 1242 }; 1243 1244 return ( 1245 <> 1246 {/* Toolbar */} 1247 {isMobile ? ( 1248 /* âââ Mobile Toolbar âââ */ 1249 <div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-card/60 backdrop-blur-sm shrink-0"> 1250 {/* Layout toggle â icons only */} 1251 <div className="flex items-center bg-muted rounded-md p-0.5"> 1252 {(['radial', 'tree', 'orgchart'] as LayoutMode[]).map(m => ( 1253 <button 1254 key={m} 1255 className={cn( 1256 "h-8 w-8 rounded flex items-center justify-center transition-all", 1257 layoutMode === m ? "bg-card text-foreground shadow-sm" : "text-muted-foreground" 1258 )} 1259 onClick={() => setLayoutMode(m)} 1260 aria-label={layoutLabels[m]} 1261 > 1262 {layoutIcons[m]} 1263 </button> 1264 ))} 1265 </div> 1266 1267 {/* Undo / Redo */} 1268 <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={undo} disabled={history.length === 0} aria-label="Undo"> 1269 <Undo2 className="h-4 w-4" /> 1270 </Button> 1271 <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={redo} disabled={future.length === 0} aria-label="Redo"> 1272 <Redo2 className="h-4 w-4" /> 1273 </Button> 1274 1275 <div className="flex-1" /> 1276 1277 {/* Open saved maps */} 1278 <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setLibraryOpen(true)} aria-label="Open saved maps"> 1279 <FolderOpen className="h-4 w-4" /> 1280 </Button> 1281 1282 {/* Search */} 1283 <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setSearchOpen(o => !o)} aria-label="Search"> 1284 <Search className="h-4 w-4" /> 1285 </Button> 1286 1287 {/* Zoom controls */} 1288 <div className="flex items-center bg-muted rounded-md p-0.5"> 1289 <Button variant="ghost" size="sm" className="h-7 w-7 p-0 rounded" onClick={() => setZoom(z => Math.max(0.2, z - 0.2))}> 1290 <ZoomOut className="h-3.5 w-3.5" /> 1291 </Button>
1292 <span className="text-[10px] text-muted-foreground w-7 text-center font-medium">{Math.round(zoom * 100)}%</span> 1293 <Button variant="ghost" size="sm" className="h-7 w-7 p-0 rounded" onClick={() => setZoom(z => Math.min(3, z + 0.2))}> 1294 <ZoomIn className="h-3.5 w-3.5" /> 1295 </Button> 1296 </div> 1297 1298 <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={fitToScreen} aria-label="Fit"> 1299 <Maximize2 className="h-4 w-4" /> 1300 </Button> 1301 1302 {/* Overflow menu for secondary actions */} 1303 <Popover> 1304 <PopoverTrigger asChild> 1305 <Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label="More actions"> 1306 <MoreHorizontal className="h-4 w-4" /> 1307 </Button> 1308 </PopoverTrigger> 1309 <PopoverContent side="bottom" align="end" className="w-44 p-1"> 1310 <Button variant="ghost" size="sm" className="w-full justify-start gap-2 h-9 text-xs" onClick={handleSave} disabled={isSaving}> 1311 <Save className="h-3.5 w-3.5" />{isSaving ? 'Savingâ¦' : 'Save'} 1312 </Button> 1313 <Button variant="ghost" size="sm" className="w-full justify-start gap-2 h-9 text-xs" onClick={() => setLibraryOpen(true)}> 1314 <FolderOpen className="h-3.5 w-3.5" />Open 1315 </Button> 1316 <Button variant="ghost" size="sm" className="w-full justify-start gap-2 h-9 text-xs" onClick={exportAsJSON}> 1317 <Download className="h-3.5 w-3.5" />Export 1318 </Button> 1319 <Button variant="ghost" size="sm" className="w-full justify-start gap-2 h-9 text-xs" onClick={handleNewMap}> 1320 <RotateCcw className="h-3.5 w-3.5" />New Map 1321 </Button> 1322 <Button variant="ghost" size="sm" className="w-full justify-start gap-2 h-9 text-xs" onClick={generateStudyGuide}> 1323 <FileText className="h-3.5 w-3.5" />Study Guide 1324 </Button> 1325 <Button variant="ghost" size="sm" className="w-full justify-start gap-2 h-9 text-xs" onClick={() => setTemplatesOpen(true)}> 1326 <Layout className="h-3.5 w-3.5" />Templates 1327 </Button> 1328 <Button variant="default" size="sm" className="w-full justify-start gap-2 h-9 text-xs mt-1" onClick={() => setAiDialogOpen(true)}> 1329 <Sparkles className="h-3.5 w-3.5" />AI Generate 1330 </Button> 1331 </PopoverContent> 1332 </Popover> 1333 </div> 1334 ) : ( 1335 /* âââ Desktop Toolbar âââ */ 1336 <div className="flex items-center gap-1.5 px-3 py-2 border-b border-border bg-card/60 backdrop-blur-sm shrink-0 flex-wrap"> 1337 {/* Brand */} 1338 <div className="flex items-center gap-1.5 mr-1"> 1339 <GitBranch className="h-4 w-4 text-muted-foreground" /> 1340 <span className="text-xs font-semibold text-foreground">Mind Map</span> 1341 <span className="text-[10px] text-muted-foreground/60">{nodeCount}</span> 1342 </div> 1343 1344 <div className="h-5 w-px bg-border" /> 1345 1346 {/* Layout toggle */} 1347 <div className="flex items-center bg-muted rounded-md p-0.5"> 1348 {(['radial', 'tree', 'orgchart'] as LayoutMode[]).map(m => ( 1349 <button 1350 key={m} 1351 className={cn( 1352 "h-7 px-2 rounded text-[11px] font-medium flex items-center gap-1 transition-all", 1353 layoutMode === m ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground" 1354 )} 1355 onClick={() => setLayoutMode(m)} 1356 title={layoutLabels[m]} 1357 > 1358 {layoutIcons[m]} 1359 <span className="hidden sm:inline">{layoutLabels[m]}</span> 1360 </button> 1361 ))} 1362 </div> 1363 1364 <div className="h-5 w-px bg-border" /> 1365 1366 {/* Actions */} 1367 <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={undo} disabled={history.length === 0} aria-label="Undo"> 1368 <Undo2 className="h-3.5 w-3.5" /> 1369 </Button> 1370 <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={redo} disabled={future.length === 0} aria-label="Redo"> 1371 <Redo2 className="h-3.5 w-3.5" /> 1372 </Button> 1373 1374 <div className="flex-1" /> 1375 1376 {/* Search */} 1377 <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setSearchOpen(o => !o)} aria-label="Search"> 1378 <Search className="h-3.5 w-3.5" /> 1379 </Button> 1380 1381 {/* Zoom */} 1382 <div className="flex items-center bg-muted rounded-md p-0.5"> 1383 <Button variant="ghost" size="sm" className="h-6 w-6 p-0 rounded" onClick={() => setZoom(z => Math.min(3, z + 0.2))}> 1384 <ZoomIn className="h-3 w-3" /> 1385 </Button>
1386 <span className="text-[10px] text-muted-foreground w-8 text-center font-medium">{Math.round(zoom * 100)}%</span> 1387 <Button variant="ghost" size="sm" className="h-6 w-6 p-0 rounded" onClick={() => setZoom(z => Math.max(0.2, z - 0.2))}> 1388 <ZoomOut className="h-3 w-3" /> 1389 </Button> 1390 </div> 1391 1392 <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={fitToScreen} aria-label="Fit"> 1393 <Maximize2 className="h-3.5 w-3.5" /> 1394 </Button> 1395 <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={resetView} aria-label="Reset"> 1396 <RotateCcw className="h-3.5 w-3.5" /> 1397 </Button> 1398 1399 <div className="h-5 w-px bg-border" /> 1400 1401 {/* Save/Open */} 1402 <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleSave} disabled={isSaving}> 1403 {isSaving ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Save className="h-3 w-3 mr-1" />} 1404 Save 1405 </Button> 1406 <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setLibraryOpen(true)}> 1407 <FolderOpen className="h-3 w-3 mr-1" />Open 1408 </Button> 1409 1410 <div className="h-5 w-px bg-border" /> 1411 1412 <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={exportAsJSON}> 1413 <Download className="h-3 w-3 mr-1" />Export JSON 1414 </Button> 1415 <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleExportPNG}> 1416 <ImageIcon className="h-3 w-3 mr-1" />Export PNG 1417 </Button> 1418 <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleClearMap}> 1419 <Trash2 className="h-3 w-3 mr-1" />Clear 1420 </Button> 1421 <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleNewMap}> 1422 New 1423 </Button> 1424 <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={generateStudyGuide}> 1425 <FileText className="h-3 w-3 mr-1" />Study Guide 1426 </Button> 1427 <Button variant="default" size="sm" className="h-7 px-2.5 text-xs" onClick={() => setAiDialogOpen(true)}> 1428 <Sparkles className="h-3 w-3 mr-1" />AI Generate 1429 </Button> 1430 <Button variant="outline" size="sm" className="h-7 px-2 text-xs" onClick={() => setTemplatesOpen(true)}> 1431 <Layout className="h-3 w-3 mr-1" />Templates 1432 </Button> 1433 {currentMapTitle && ( 1434 <span className="text-[10px] text-muted-foreground ml-1 hidden sm:inline"> 1435 {currentMapTitle}{isDirty ? ' â¢' : ' â'} 1436 </span> 1437 )} 1438 </div> 1439 )} 1440 1441 {/* Canvas */} 1442 <div 1443 ref={canvasRef} 1444 className="flex-1 relative overflow-hidden cursor-grab active:cursor-grabbing select-none" 1445 data-canvas="true" 1446 onWheel={handleWheel} 1447 onPointerDown={handlePointerDown} 1448 onPointerMove={handlePointerMove} 1449 onPointerUp={(e) => { handlePointerUp(); handleNodeDragEnd(e); }} 1450 onPointerLeave={handlePointerUp} 1451 > 1452 {/* Dot grid */} 1453 <div 1454 className="absolute inset-0 pointer-events-none" 1455 data-canvas="true" 1456 style={{ 1457 backgroundImage: \`radial-gradient(circle, hsl(var(--border) / 0.4) 1px, transparent 1px)\`, 1458 backgroundSize: \`\${24 * zoom}px \${24 * zoom}px\`, 1459 backgroundPosition: \`\${pan.x % (24 * zoom)}px \${pan.y % (24 * zoom)}px\`, 1460 }} 1461 /> 1462 1463 {/* Transformed layer */} 1464 <div 1465 style={{ transform: \`translate(\${pan.x}px, \${pan.y}px) scale(\${zoom})\`, transformOrigin: '0 0' }} 1466 className="absolute top-0 left-0" 1467 > 1468 {/* SVG edges with tapered organic branches */} 1469 <svg className="absolute top-0 left-0 overflow-visible pointer-events-none" style={{ width: 1, height: 1 }}> 1470 <defs> 1471 {edges.map(({ from, to }) => ( 1472 <linearGradient key={\`grad-\${from.id}-\${to.id}\`} id={\`grad-\${from.id}-\${to.id}\`} x1="0%" y1="0%" x2="100%" y2="0%"> 1473 <stop offset="0%" stopColor={from.id === data.rootId ? to.color : from.color} stopOpacity={0.7} /> 1474 <stop offset="100%" stopColor={to.color} stopOpacity={0.5} /> 1475 </linearGradient> 1476 ))} 1477 </defs> 1478 {edges.map(({ from, to, depth }) => { 1479 const isVertical = layoutMode !== 'radial'; 1480 let d: string; 1481 if (isVertical) { 1482 const midY = (from.y + to.y) / 2; 1483 d = \`M \${from.x} \${from.y + 20} C \${from.x} \${midY}, \${to.x} \${midY}, \${to.x} \${to.y - 18}\`; 1484 } else { 1485 const fromX = from.x + (to.x > from.x ? 65 : -65); 1486 const toX = to.x + (to.x > from.x ? -55 : 55); 1487 const midX = (fromX + toX) / 2; 1488 d = \`M \${fromX} \${from.y} C \${midX} \${from.y}, \${midX} \${to.y}, \${toX} \${to.y}\`; 1489 } 1490 return ( 1491 <path 1492 key={\`\${from.id}-\${to.id}\`} 1493 d={d} 1494 fill="none" 1495 stroke={\`url(#grad-\${from.id}-\${to.id})\`} 1496 strokeWidth={depth === 0 ? 3.5 : depth === 1 ? 2.5 : 1.8} 1497 strokeLinecap="round" 1498 /> 1499 ); 1500 })} 1501 </svg> 1502 1503 {/* Nodes */} 1504 {visibleNodes.map(renderNode)} 1505 </div> 1506 1507 {/* Search overlay */} 1508 {searchOpen && ( 1509 <div className="absolute top-3 right-3 z-40 flex items-center gap-1 bg-card/95 backdrop-blur-md border border-border rounded-lg shadow-lg px-2 py-1.5"> 1510 <Search className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> 1511 <input 1512 ref={searchInputRef} 1513 value={searchQuery} 1514 onChange={e => setSearchQuery(e.target.value)} 1515 placeholder="Find nodesâ¦" 1516 className="bg-transparent outline-none text-xs w-36 placeholder:text-muted-foreground
1516/50" 1517 onKeyDown={e => { 1518 if (e.key === 'Enter') setSearchIndex(i => (i + 1) % Math.max(1, searchResults.length)); 1519 if (e.key === 'Escape') { setSearchOpen(false); setSearchQuery(''); } 1520 }} 1521 /> 1522 {searchResults.length > 0 && ( 1523 <span className="text-[10px] text-muted-foreground whitespace-nowrap"> 1524 {searchIndex + 1}/{searchResults.length} 1525 </span> 1526 )} 1527 <button className="h-5 w-5 rounded flex items-center justify-center hover:bg-accent" onClick={() => setSearchIndex(i => Math.max(0, i - 1))}> 1528 <ChevronUp className="h-3 w-3" /> 1529 </button> 1530 <button className="h-5 w-5 rounded flex items-center justify-center hover:bg-accent" onClick={() => setSearchIndex(i => (i + 1) % Math.max(1, searchResults.length))}> 1531 <ChevronDown className="h-3 w-3" /> 1532 </button> 1533 <button className="h-5 w-5 rounded flex items-center justify-center hover:bg-accent" onClick={() => { setSearchOpen(false); setSearchQuery(''); }}> 1534 <X className="h-3 w-3" /> 1535 </button> 1536 </div> 1537 )} 1538 1539 {/* Minimap */} 1540 {minimapOpen && minimapData && ( 1541 <div className="mindmap-minimap absolute bottom-3 right-3 z-30"> 1542 <button 1543 className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-card border border-border flex items-center justify-center z-10 hover:bg-accent" 1544 onClick={() => setMinimapOpen(false)} 1545 > 1546 <X className="h-2.5 w-2.5 text-muted-foreground" /> 1547 </button> 1548 <div 1549 className="w-[140px] h-[90px] relative cursor-pointer rounded-md overflow-hidden" 1550 onClick={handleMinimapClick} 1551 > 1552 {/* Nodes as dots */} 1553 {visibleNodes.map(node => { 1554 const nx = ((node.x - minimapData.minX) / minimapData.w) * 140; 1555 const ny = ((node.y - minimapData.minY) / minimapData.h) * 90; 1556 return ( 1557 <div 1558 key={node.id} 1559 className="absolute rounded-full" 1560 style={{ 1561 left: nx, 1562 top: ny, 1563 width: node.id === data.rootId ? 5 : 3, 1564 height: node.id === data.rootId ? 5 : 3, 1565 backgroundColor: node.color, 1566 transform: 'translate(-50%, -50%)', 1567 }} 1568 /> 1569 ); 1570 })} 1571 {/* Viewport rectangle */} 1572 {canvasRef.current && (() => { 1573 const cr = canvasRef.current!.getBoundingClientRect(); 1574 const vwLeft = (-pan.x / zoom); 1575 const vwTop = (-pan.y / zoom); 1576 const vwWidth = cr.width / zoom; 1577 const vwHeight = cr.height / zoom; 1578 const rx = ((vwLeft - minimapData.minX) / minimapData.w) * 140; 1579 const ry = ((vwTop - minimapData.minY) / minimapData.h) * 90; 1580 const rw = (vwWidth / minimapData.w) * 140; 1581 const rh = (vwHeight / minimapData.h) * 90; 1582 return ( 1583 <div 1584 className="absolute border border-foreground
1584/30 bg-foreground/5 rounded-sm" 1585 style={{ left: rx, top: ry, width: rw, height: rh }} 1586 /> 1587 ); 1588 })()} 1589 </div> 1590 </div> 1591 )} 1592 1593 {/* Minimap reopen button */} 1594 {!minimapOpen && ( 1595 <button 1596 className="absolute bottom-3 right-3 z-30 h-7 w-7 rounded-md bg-card/80 backdrop-blur border border-border flex items-center justify-center hover:bg-accent transition-colors" 1597 onClick={() => setMinimapOpen(true)} 1598 > 1599 <Map className="h-3.5 w-3.5 text-muted-foreground" /> 1600 </button> 1601 )} 1602 1603 {/* Keyboard shortcuts bar */} 1604 {!hintsDismissed && ( 1605 <div className="absolute bottom-0 left-0 right-0 z-30 flex items-center justify-center gap-4 py-1.5 px-4 bg-card/80 backdrop-blur-sm border-t border-border text-[10px] text-muted-foreground"> 1606 <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">Tab</kbd> Add child</span> 1607 <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">Enter</kbd> Sibling</span> 1608 <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">Del</kbd> Remove</span> 1609 <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">F2</kbd> Edit</span> 1610 <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">âF</kbd> Search</span> 1611 <span>Right-click for more</span> 1612 <button className="ml-2 hover:text-foreground" onClick={dismissHints}> 1613 <X className="h-3 w-3" /> 1614 </button> 1615 </div> 1616 )} 1617 </div> 1618 1619 {/* AI Generate Dialog */} 1620 <Dialog open={aiDialogOpen} onOpenChange={(open) => { if (!aiLoading) setAiDialogOpen(open); }}> 1621 <DialogContent className="sm:max-w-md"> 1622 <DialogHeader> 1623 <DialogTitle className="flex items-center gap-2"> 1624 <Sparkles className="h-5 w-5 text-primary" /> 1625 AI Mind Map Generator 1626 </DialogTitle> 1627 <DialogDescription> 1628 Enter a subject and AI will research it online and create a structured mind map. 1629 </DialogDescription> 1630 </DialogHeader> 1631 <div className="space-y-4 py-2"> 1632 <Input 1633 placeholder="e.g. Part 107 exam preparation" 1634 value={aiSubject} 1635 onChange={(e) => setAiSubject(e.target.value)} 1636 onKeyDown={(e) => { if (e.key === 'Enter' && !aiLoading && aiSubject.trim()) aiGenerateMindMap(); }} 1637 disabled={aiLoading} 1638 /> 1639 <div className="flex flex-wrap gap-1.5"> 1640 {['Part 107 exam prep', 'Machine Learning basics', 'Project management', 'React architecture', 'Startup funding'].map(s => ( 1641 <button 1642 key={s} 1643 className="px-2.5 py-1 text-xs rounded-full bg-muted hover:bg-accent text-muted-foreground hover:text-foreground transition-colors" 1644 onClick={() => setAiSubject(s)} 1645 disabled={aiLoading} 1646 > 1647 {s} 1648 </button> 1649 ))} 1650 </div> 1651 </div> 1652 <DialogFooter> 1653 <Button variant="outline" onClick={() => setAiDialogOpen(false)} disabled={aiLoading}> 1654 Cancel 1655 </Button> 1656 <Button onClick={aiGenerateMindMap} disabled={aiLoading || !aiSubject.trim()}> 1657 {aiLoading ? ( 1658 <> 1659 <Loader2 className="h-4 w-4 animate-spin mr-1" /> 1660 Researching⦠1661 </> 1662 ) : ( 1663 <> 1664 <Sparkles className="h-4 w-4 mr-1" /> 1665 Generate 1666 </> 1667 )} 1668 </Button> 1669 </DialogFooter> 1670 </DialogContent> 1671 </Dialog> 1672 1673 {/* Save As Dialog */} 1674 <Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}> 1675 <DialogContent className="max-w-sm"> 1676 <DialogHeader> 1677 <DialogTitle>Save Mind Map</DialogTitle> 1678 <DialogDescription>Give your mind map a name</DialogDescription> 1679 </DialogHeader> 1680 <Input value={saveTitle} onChange={e =>
1680 setSaveTitle(e.target.value)} placeholder="Mind map title..." autoFocus onKeyDown={e => { if (e.key === 'Enter' && saveTitle.trim()) { setSaveDialogOpen(false); saveToDb(saveTitle.trim()); } }} /> 1681 <DialogFooter> 1682 <Button variant="outline" onClick={() => setSaveDialogOpen(false)}>Cancel</Button> 1683 <Button onClick={() => { setSaveDialogOpen(false); saveToDb(saveTitle.trim()); }} disabled={!saveTitle.trim()}>Save</Button> 1684 </DialogFooter> 1685 </DialogContent> 1686 </Dialog> 1687 1688 <MindMapLibrary open={libraryOpen} onOpenChange={setLibraryOpen} onLoad={handleLoadMap} /> 1689 1690 <Dialog open={cardPickerOpen} onOpenChange={setCardPickerOpen}> 1691 <DialogContent className="max-w-sm max-h-[60vh] flex flex-col"> 1692 <DialogHeader> 1693 <DialogTitle>Link to Card</DialogTitle> 1694 <DialogDescription>Select a ZettelCard to link</DialogDescription> 1695 </DialogHeader> 1696 <Input value={cardPickerSearch} onChange={e => setCardPickerSearch(e.target.value)} placeholder="Search cards..." className="h-9 text-sm" /> 1697 <div className="flex-1 overflow-y-auto space-y-1 min-h-0"> 1698 {cards.filter(c => !cardPickerSearch.trim() || c.title.toLowerCase().includes(cardPickerSearch.toLowerCase())).slice(0, 20).map(card => ( 1699 <button 1700 key={card.id} 1701 className="w-full text-left px-3 py-2 rounded-lg border border-border hover:bg-accent/50 transition-colors text-sm" 1702 onClick={() => { if (cardPickerNodeId) linkCardToNode(cardPickerNodeId, card.id); setCardPickerOpen(false); }} 1703 > 1704 <div className="font-medium truncate">{card.title}</div> 1705 {card.category && <div className="text-xs text-muted-foreground truncate">{card.category}</div>} 1706 </button> 1707 ))} 1708 {cards.length === 0 && <p className="text-center text-sm text-muted-foreground py-4">No cards available</p>} 1709 </div> 1710 </DialogContent> 1711 </Dialog> 1712 1713 <StudyGuideDialog 1714 open={studyGuideOpen} 1715 onOpenChange={setStudyGuideOpen} 1716 content={studyGuideContent} 1717 isLoading={studyGuideLoading} 1718 loadingText={studyGuideLoadingText} 1719 mapTitle={currentMapTitle || data.nodes[data.rootId]?.text} 1720 /> 1721 1722 {/* Node Detail Panel */} 1723 <Sheet open={nodeDetailOpen} onOpenChange={(open) => { 1724 if (!open && nodeDetailId) { setNote(nodeDetailId, nodeDetailNote); } 1725 setNodeDetailOpen(open); 1726 }}> 1727 <SheetContent className="w-80 sm:w-96 flex flex-col"> 1728 <SheetHeader> 1729 <SheetTitle className="text-sm flex items-center gap-2"> 1730 {nodeDetailId && data.nodes[nodeDetailId]?.emoji && ( 1731 <span>{data.nodes[nodeDetailId].emoji}</span> 1732 )} 1733 {nodeDetailId ? data.nodes[nodeDetailId]?.text : 'Node'} 1734 </SheetTitle> 1735 </SheetHeader> 1736 <div className="flex-1 flex flex-col gap-3 pt-4"> 1737 <div className="space-y-1"> 1738 <p className="text-xs font-medium text-muted-foreground">Priority</p> 1739 <div className="flex gap-1"> 1740 {(['none', 'low', 'medium', 'high'] as Priority[]).map(p => ( 1741 <Button 1742 key={p} 1743 variant={nodeDetailId && data.nodes[nodeDetailId]?.priority === p ? 'default' : 'outline'} 1744 size="sm" 1745 onClick={() => { if (nodeDetailId) setPriority(nodeDetailId, p); }} 1746 className="text-[10px] h-6 px-2 capitalize flex-1" 1747 > 1748 {p} 1749 </Button> 1750 ))} 1751 </div> 1752 </div> 1753 <div className="flex-1 flex flex-col space-y-1"> 1754 <p className="text-xs font-medium text-muted-foreground">Notes</p> 1755 {/* Formatting toolbar */} 1756 <div className="flex items-center gap-0.5 pb-1"> 1757 <Button 1758 variant="ghost" 1759 size="sm" 1760 className="h-6 w-6 p-0 text-xs font-bold" 1761 onClick={() => { 1762 const ta = document.getElementById('node-detail-textarea') as HTMLTextAreaElement; 1763 if (!ta) return; 1764 const start = ta.selectionStart; 1765 const end = ta.selectionEnd; 1766 const selected = nodeDetailNote.slice(start, end); 1767 const newNote = nodeDetailNote.slice(0, start) + \`**\${selected}**\` + nodeDetailNote.slice(end); 1768 setNodeDetailNote(newNote); 1769 }} 1770 title="Bold" 1771 > 1772 B 1773 </Button> 1774 <Button 1775 variant="ghost" 1776 size="sm" 1777 className="h-6 w-6 p-0 text-xs italic" 1778 onClick={() => { 1779 const ta = document.getElementById('node-detail-textarea') as HTMLTextAreaElement; 1780 if (!ta) return; 1781 const start = ta.selectionStart; 1782 const end = ta.selectionEnd; 1783 const selected = nodeDetailNote.slice(start, end); 1784 const newNote = nodeDetailNote.slice(0, start) + \`_\${selected}_\` + nodeDetailNote.slice(end); 1785 setNodeDetailNote(newNote); 1786 }} 1787 title="Italic" 1788 > 1789 I 1790 </Button> 1791 <Button 1792 variant="ghost" 1793 size="sm" 1794 className="h-6 w-6 p-0 text-xs" 1795 onClick={() => { 1796 const ta = document.getElementById('node-detail-textarea') as HTMLTextAreaElement; 1797 if (!ta) return; 1798 const start = ta.selectionStart; 1799 const lineStart = nodeDetailNote.lastIndexOf('\\n', start - 1) + 1; 1800 const newNote = nodeDetailNote.slice(0, lineStart) + '⢠' + nodeDetailNote.slice(lineStart); 1801 setNodeDetailNote(newNote); 1802 }} 1803 title="Bullet list" 1804 > 1805 ⢠1806 </Button>
1807 <span className="text-[9px] text-muted-foreground ml-auto">Enter = new line</span> 1808 </div> 1809 <Textarea 1810 id="node-detail-textarea" 1811 value={nodeDetailNote} 1812 onChange={e => setNodeDetailNote(e.target.value)} 1813 onBlur={() => { if (nodeDetailId) setNote(nodeDetailId, nodeDetailNote); }} 1814 onKeyDown={e => { 1815 // Allow Enter key to create new lines (default textarea behavior) 1816 // Don't prevent default - let the textarea handle Enter normally 1817 if (e.key === 'Enter') { 1818 e.stopPropagation(); // Prevent mind map shortcuts from firing 1819 } 1820 }} 1821 placeholder="Add notes for this node... Use **bold**, _italic_, or ⢠for bullets. Press Enter for new lines." 1822 className="flex-1 text-sm resize-none min-h-[200px] font-mono leading-relaxed whitespace-pre-wrap" 1823 /> 1824 <p className="text-[10px] text-muted-foreground text-right">{nodeDetailNote.length} chars · {nodeDetailNote.split('\\n').length} lines</p> 1825 </div> 1826 <Button 1827 size="sm" 1828 onClick={() => { if (nodeDetailId) { setNote(nodeDetailId, nodeDetailNote); setNodeDetailOpen(false); } }} 1829 > 1830 Save & Close 1831 </Button> 1832 </div> 1833 </SheetContent> 1834 </Sheet> 1835 1836 <MindMapTemplates open={templatesOpen} onOpenChange={setTemplatesOpen} onApplyTemplate={handleApplyTemplate} /> 1837 </> 1838 ); 1839} 1840`;export{e as default};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.