"use client"; import { useState, useEffect, useRef } from "react"; import { X, ChevronUp, ChevronDown, Regex, CaseSensitive } from "lucide-react"; import { cn } from "@/lib/utils"; interface SearchBarProps { content: string; containerRef: React.RefObject; onClose: () => void; } export function SearchBar({ content, containerRef, onClose }: SearchBarProps) { const [query, setQuery] = useState(""); const [isRegex, setIsRegex] = useState(false); const [caseSensitive, setCaseSensitive] = useState(false); const [currentMatch, setCurrentMatch] = useState(0); const [totalMatches, setTotalMatches] = useState(0); const [hasError, setHasError] = useState(false); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); // Compute matches useEffect(() => { if (!query) { setTotalMatches(0); setCurrentMatch(0); clearHighlights(); return; } try { const flags = caseSensitive ? "g" : "gi"; const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags); const matches = Array.from(content.matchAll(pattern)); setTotalMatches(matches.length); setCurrentMatch(matches.length > 0 ? 1 : 0); setHasError(false); } catch { setHasError(true); setTotalMatches(0); setCurrentMatch(0); } }, [query, isRegex, caseSensitive, content]); // Apply DOM highlights useEffect(() => { if (!containerRef.current) return; clearHighlights(); if (!query || hasError || totalMatches === 0) return; try { const flags = caseSensitive ? "g" : "gi"; const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags); const walker = document.createTreeWalker( containerRef.current, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // Skip nodes inside already-marked elements if ((node.parentElement as HTMLElement)?.tagName === "MARK") { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; }, } ); const textNodes: Text[] = []; let node: Text | null; while ((node = walker.nextNode() as Text | null)) { textNodes.push(node); } let matchIdx = 0; // Process in reverse order to avoid position shifting const replacements: { node: Text; ranges: { start: number; end: number; idx: number }[] }[] = []; for (const textNode of textNodes) { const text = textNode.textContent ?? ""; pattern.lastIndex = 0; const nodeRanges: { start: number; end: number; idx: number }[] = []; let m: RegExpExecArray | null; while ((m = pattern.exec(text)) !== null) { nodeRanges.push({ start: m.index, end: m.index + m[0].length, idx: matchIdx++ }); if (m[0].length === 0) break; // prevent infinite loop on zero-width matches } if (nodeRanges.length > 0) { replacements.push({ node: textNode, ranges: nodeRanges }); } } // Apply replacements in document order but process ranges in reverse for (const { node: textNode, ranges } of replacements) { const text = textNode.textContent ?? ""; const fragment = document.createDocumentFragment(); let lastEnd = 0; for (const { start, end, idx } of ranges) { if (start > lastEnd) { fragment.appendChild(document.createTextNode(text.slice(lastEnd, start))); } const mark = document.createElement("mark"); mark.className = cn( "search-highlight", idx === currentMatch - 1 ? "search-highlight-current" : "" ); mark.textContent = text.slice(start, end); fragment.appendChild(mark); lastEnd = end; } if (lastEnd < text.length) { fragment.appendChild(document.createTextNode(text.slice(lastEnd))); } textNode.parentNode?.replaceChild(fragment, textNode); } // Scroll current match into view const currentEl = containerRef.current?.querySelector(".search-highlight-current"); currentEl?.scrollIntoView({ block: "center", behavior: "smooth" }); } catch { // Ignore DOM errors } return () => { if (containerRef.current) clearHighlights(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, isRegex, caseSensitive, currentMatch, totalMatches]); function clearHighlights() { if (!containerRef.current) return; const marks = containerRef.current.querySelectorAll("mark.search-highlight"); marks.forEach((mark) => { mark.replaceWith(mark.textContent ?? ""); }); // Normalize text nodes containerRef.current.normalize(); } const goNext = () => { setCurrentMatch((c) => (c >= totalMatches ? 1 : c + 1)); }; const goPrev = () => { setCurrentMatch((c) => (c <= 1 ? totalMatches : c - 1)); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.shiftKey ? goPrev() : goNext(); } if (e.key === "Escape") { clearHighlights(); onClose(); } }; return (
setQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder="Search..." className="flex-1 bg-transparent text-xs text-surface-100 placeholder-surface-500 outline-none min-w-0" /> {/* Match count */} {query && ( {hasError ? "Invalid regex" : totalMatches === 0 ? "No results" : `${currentMatch}/${totalMatches}`} )} {/* Toggles */}
{/* Navigation */}
); } function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }