PageSourceSearch

https://pendragonx.com/assets/MindMap-CqXk5u-V.js

js pendragonx.com collected 2026-10-02 01:43:39 UTC 76,905 bytes, 1,840 lines download raw bytes

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...&#10;&#10;Use **bold**, _italic_, or • for bullets.&#10;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.