import React, { useState, useEffect, useMemo, useRef } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Search, ChevronLeft, ChevronRight, Plus, Bookmark, Layers, FileText, CheckCircle, HelpCircle, X, CornerDownRight, Folder, Sliders, Sparkles, Command, Settings } from 'lucide-react'; import { Note, Carnet } from '../types'; interface SearchModalProps { isOpen: boolean; onClose: () => void; notes: Note[]; carnets: Carnet[]; onSelectNote: (noteId: string) => void; } interface SearchMatch { id: string; // Unique match identifier noteId: string; noteTitle: string; path: string; type: 'document' | 'heading' | 'paragraph' | 'list'; headingLevel?: number; text: string; matchedText: string; lineIndex: number; } export const SearchModal: React.FC = ({ isOpen, onClose, notes, carnets, onSelectNote }) => { const [query, setQuery] = useState(''); const [useRegex, setUseRegex] = useState(false); const [caseSensitive, setCaseSensitive] = useState(false); const [includeChildDocs, setIncludeChildDocs] = useState(true); const [searchInTrash, setSearchInTrash] = useState(false); const [savedQueries, setSavedQueries] = useState(['block', 'siyuan', 'guide']); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); // Focus input on launch useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 50); } }, [isOpen]); // Handle global keybindings in modal useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(prev => Math.min(prev + 1, filteredMatches.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(prev => Math.max(prev - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); if (filteredMatches[selectedIndex]) { const m = filteredMatches[selectedIndex]; onSelectNote(m.noteId); onClose(); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, selectedIndex]); // Helper: reconstruct carnet path const getCarnetPath = (carnetId: string): string => { const segments: string[] = []; let current = carnets.find(c => c.id === carnetId); while (current) { segments.unshift(current.name); current = current.parentId ? carnets.find(c => c.id === current.parentId) : undefined; } return segments.join('/'); }; // Safe term escape for RegExp const escapeRegExp = (string: string) => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; // Perform multi-match search logic across document titles and contents const filteredMatches = useMemo(() => { if (!query.trim()) return []; const matches: SearchMatch[] = []; const searchRegex = (() => { try { const flag = caseSensitive ? '' : 'i'; const pattern = useRegex ? query : escapeRegExp(query); return new RegExp(pattern, flag); } catch (e) { return null; // Handle partial regex input gracefully } })(); if (!searchRegex) return []; // Filter notes depending on trash status const targetNotes = notes.filter(n => searchInTrash ? n.isDeleted : !n.isDeleted); targetNotes.forEach(note => { const notePath = getCarnetPath(note.carnetId); const fullPath = notePath ? `${notePath}/${note.title}` : note.title; // 1. Check Title match if (searchRegex.test(note.title)) { matches.push({ id: `${note.id}-title`, noteId: note.id, noteTitle: note.title, path: fullPath, type: 'document', text: note.title, matchedText: note.title, lineIndex: -1 }); } // 2. Parse Content blocks / lines if (note.content) { const lines = note.content.split('\n'); lines.forEach((line, index) => { const trimmed = line.trim(); if (!trimmed) return; if (searchRegex.test(trimmed)) { let type: 'heading' | 'paragraph' | 'list' = 'paragraph'; let headingLevel = undefined; let displayVal = trimmed; // Classify content structure elements if (trimmed.startsWith('#')) { type = 'heading'; const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { headingLevel = headingMatch[1].length; displayVal = headingMatch[2]; } } else if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) { type = 'list'; displayVal = trimmed.replace(/^[-*+\d.]+\s+/, ''); } matches.push({ id: `${note.id}-line-${index}`, noteId: note.id, noteTitle: note.title, path: fullPath, type, headingLevel, text: trimmed, matchedText: displayVal, lineIndex: index }); } }); } }); return matches; }, [notes, query, useRegex, caseSensitive, searchInTrash, carnets]); // Ensure index remains in bounds when matches array updates useEffect(() => { setSelectedIndex(0); }, [query]); // Toggle saving criteria const handleSaveCriteria = () => { if (query.trim() && !savedQueries.includes(query.trim())) { setSavedQueries(prev => [...prev, query.trim()]); } }; const handleRemoveCriteria = () => { setSavedQueries(prev => prev.filter(q => q !== query.trim())); }; // Count distinct notes involved in match list const docMatchesCount = useMemo(() => { const uniqueNoteIds = new Set(filteredMatches.map(m => m.noteId)); return uniqueNoteIds.size; }, [filteredMatches]); const activeMatch = filteredMatches[selectedIndex]; // Dynamically load document content with visual query highlights const highlightedNotePreviewContent = useMemo(() => { if (!activeMatch) return null; const currentNote = notes.find(n => n.id === activeMatch.noteId); if (!currentNote) return null; if (!query.trim()) return currentNote.content; try { const flag = caseSensitive ? 'g' : 'gi'; const searchPattern = useRegex ? query : escapeRegExp(query); const highlightRegex = new RegExp(`(${searchPattern})`, flag); // Return content split by line to let us format block matches neatly const lines = (currentNote.content || '').split('\n'); // Let's frame the match around the matched line for contextual proximity const targetIndex = activeMatch.lineIndex >= 0 ? activeMatch.lineIndex : 0; const startLine = Math.max(0, targetIndex - 3); const endLine = Math.min(lines.length - 1, targetIndex + 5); return (
{startLine > 0 && (
...
)} {lines.slice(startLine, endLine + 1).map((line, idx) => { const absoluteIdx = startLine + idx; const isMatchLine = absoluteIdx === targetIndex; const hasMatches = highlightRegex.test(line); // Reconstruct highlighted segments const segments = line.split(highlightRegex); return (
{absoluteIdx + 1} {hasMatches ? ( segments.map((seg, sIdx) => { const matchesPattern = highlightRegex.test(seg); return matchesPattern ? ( {seg} ) : ( seg ); }) ) : ( line )}
); })} {endLine < lines.length - 1 && (
...
)}
); } catch (e) { return
{currentNote.content}
; } }, [activeMatch, notes, query, useRegex, caseSensitive]); // Render text segment highlight in results row items const renderHighlightedRowText = (text: string) => { if (!query.trim()) return text; try { const flag = caseSensitive ? 'gi' : 'gi'; const searchPattern = useRegex ? query : escapeRegExp(query); const highlightRegex = new RegExp(`(${searchPattern})`, flag); const segments = text.split(highlightRegex); return ( {segments.map((seg, sIdx) => { const isMatch = highlightRegex.test(seg); return isMatch ? ( {seg} ) : ( seg ); })} ); } catch (e) { return text; } }; if (!isOpen) return null; return (
{/* TOP Advanced Search Bar Row */}
setQuery(e.target.value)} placeholder="Rechercher des documents ou des blocs de texte..." className="w-full text-sm pl-10 pr-24 py-2.5 rounded-xl border border-border/70 dark:border-zinc-800/80 bg-white/85 dark:bg-[#1C1C1C] text-ink dark:text-dark-ink placeholder-concrete/50 outline-none focus:border-accent" /> {/* Config Quick Badges */}
{/* Quick saved criteria filter tags */} {savedQueries.length > 0 && (
Favoris:
{savedQueries.map(sq => ( ))}
)}
{/* UTILITY BAR Row -> Match statistics with action links */}
{/* Arrow Switchers */}
{filteredMatches.length > 0 ? `${selectedIndex + 1}/${filteredMatches.length}` : '0/0'}
{filteredMatches.length > 0 ? `Trouvé ${filteredMatches.length} occurrences dans ${docMatchesCount} documents` : query.trim() ? "Aucun élément ne correspond" : "Saisissez votre requête"}
{/* Toolbar Action Links */}
{query.trim() && ( )}
{/* DUAL SECTION LAYOUT */}
{/* Left Section: Scrollable matches list */}
{filteredMatches.map((m, idx) => { const isSelected = idx === selectedIndex; return (
setSelectedIndex(idx)} onDoubleClick={() => { onSelectNote(m.noteId); onClose(); }} className={`p-2.5 rounded-xl cursor-pointer text-left select-none relative group/item transition-all flex flex-col gap-1 border ${isSelected ? 'bg-white dark:bg-zinc-800 shadow-md border-amber-500/30' : 'border-transparent hover:bg-black/[0.02] dark:hover:bg-white/[0.02]/30'}`} > {/* Selection overlay accent */} {isSelected && (
)}
{/* Element classifier badges */} {m.type === 'document' && ( )} {m.type === 'heading' && ( H{m.headingLevel || ''} )} {m.type === 'list' && ( LIST )} {m.type === 'paragraph' && ( TXT )} {m.noteTitle}
{/* Highlighted snippet row content */}
{renderHighlightedRowText(m.matchedText)}
{/* Breadcrumb row path */}
{m.path}
); })} {filteredMatches.length === 0 && (

{query.trim() ? "Aucun bloc ou doc ne correspond à cette recherche." : "Taper pour obtenir des résultats instantanés."}

)}
{/* Right Section: Scrollable content preview card with visual highlighted markers */}
{activeMatch ? (
{/* Breadcrumb locator line */}
{activeMatch.path}
{/* Document focus heading title */}

{activeMatch.noteTitle}

APERÇU CONTEXTUEL DU BLOC

{/* Dynamic document contents highlighted and framed */}
{highlightedNotePreviewContent}
{/* Quick Actions trigger buttons */}
ID: {activeMatch.noteId.slice(0, 6)}...
) : (

Aperçu du document

Sélectionnez un résultat de recherche de la colonne et explorez immédiatement son contenu sémantique.

)}
{/* BOTTOM Status Keyboard shortcuts hint footer bar */}
↑↓ naviguer Entrée ouvrir Double clic ouvrir Échap fermer
Memento Search OS v2.3
); };