"use client"; /** * FindInPage — in-page text search for long reading pages (transcripts etc.). * * Renders a "Find in page" trigger for a page header; while open, a floating * find bar with a query input, match counter, and prev/next navigation. * Matches are highlighted by wrapping text nodes in directly * in the DOM, so it works against server-rendered content (including * dangerouslySetInnerHTML blocks) without needing the text as props. * * Matching is case- AND diacritic-insensitive — "krsna" finds "Kṛṣṇa", * "sanjaya" finds "Sañjaya" — essential for IAST transliteration content * that users can't easily type. * * The bar is portaled to : the sticky PageHeader animates with a * transform when it collapses on scroll, which would hijack any * position:fixed descendant and drag the bar off-screen with it. */ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ChevronDown, ChevronUp, Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; const MIN_QUERY = 2; const DEBOUNCE_MS = 250; const HIT_CLASS = "bg-primary/20 text-inherit rounded-[2px]"; const CURRENT_CLASS = "bg-primary text-primary-foreground rounded-[2px]"; /** Case + diacritic fold: "Kṛṣṇa" → "krsna". */ function fold(s: string): string { return s .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase(); } /** * Fold a string keeping a map from each folded char back to the index of * the original char it came from, so match offsets translate back. */ function foldWithMap(s: string): { folded: string; map: number[] } { let folded = ""; const map: number[] = []; for (let i = 0; i < s.length; i++) { const f = fold(s[i]); for (let j = 0; j < f.length; j++) { folded += f[j]; map.push(i); } } return { folded, map }; } /** Unwrap every mark this component added and re-merge the text nodes. */ function clearHighlights(container: HTMLElement) { container.querySelectorAll("mark[data-find]").forEach((mark) => { const parent = mark.parentNode; if (!parent) return; parent.replaceChild( document.createTextNode(mark.textContent ?? ""), mark, ); parent.normalize(); }); } /** * Highlight every occurrence of `query` inside `container`, returning the * created marks in document order. Searches each text node independently * (matches spanning inline-tag boundaries are not found — acceptable for * prose where emphasis runs are whole words/phrases). */ function applyHighlights(container: HTMLElement, query: string): HTMLElement[] { const foldedQuery = fold(query); if (!foldedQuery) return []; // Snapshot the nodes first — wrapping mutates the tree under the walker. const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); const textNodes: Text[] = []; for (let n = walker.nextNode(); n; n = walker.nextNode()) { textNodes.push(n as Text); } const marks: HTMLElement[] = []; for (const node of textNodes) { const text = node.textContent ?? ""; if (!text.trim()) continue; const { folded, map } = foldWithMap(text); // Collect match ranges in original-string coordinates. const ranges: Array<[number, number]> = []; let from = 0; for (;;) { const at = folded.indexOf(foldedQuery, from); if (at === -1) break; const endFolded = at + foldedQuery.length; const start = map[at]; const end = endFolded < map.length ? map[endFolded] : text.length; if (end > start) ranges.push([start, end]); from = endFolded; } if (ranges.length === 0) continue; // Rebuild the node as text/mark fragments. const frag = document.createDocumentFragment(); let cursor = 0; for (const [start, end] of ranges) { if (start > cursor) { frag.appendChild(document.createTextNode(text.slice(cursor, start))); } const mark = document.createElement("mark"); mark.setAttribute("data-find", ""); mark.className = HIT_CLASS; mark.textContent = text.slice(start, end); frag.appendChild(mark); marks.push(mark); cursor = end; } if (cursor < text.length) { frag.appendChild(document.createTextNode(text.slice(cursor))); } node.parentNode?.replaceChild(frag, node); } return marks; } /** Style the active mark and bring it into view. */ function focusMark(marks: HTMLElement[], index: number) { marks.forEach((m, i) => { m.className = i === index ? CURRENT_CLASS : HIT_CLASS; }); marks[index]?.scrollIntoView({ block: "center", behavior: "smooth" }); } export function FindInPage({ targetId, className, }: { /** id of the element whose text content is searched. */ targetId: string; className?: string; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [count, setCount] = useState(0); const [current, setCurrent] = useState(0); const marksRef = useRef([]); const inputRef = useRef(null); const close = useCallback(() => { const el = document.getElementById(targetId); if (el) clearHighlights(el); marksRef.current = []; setOpen(false); setQuery(""); setCount(0); setCurrent(0); }, [targetId]); // Debounced (re)search on every query change while open. useEffect(() => { if (!open) return; const id = window.setTimeout(() => { const el = document.getElementById(targetId); if (!el) return; clearHighlights(el); const q = query.trim(); const marks = q.length >= MIN_QUERY ? applyHighlights(el, q) : []; marksRef.current = marks; setCount(marks.length); setCurrent(0); if (marks.length > 0) focusMark(marks, 0); }, DEBOUNCE_MS); return () => window.clearTimeout(id); }, [query, open, targetId]); // Focus the input when the bar opens. useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); // Drop any leftover highlights if the component unmounts mid-search. useEffect(() => { return () => { const el = document.getElementById(targetId); if (el) clearHighlights(el); }; }, [targetId]); const step = useCallback( (dir: 1 | -1) => { const marks = marksRef.current; if (marks.length === 0) return; setCurrent((c) => { const next = (c + dir + marks.length) % marks.length; focusMark(marks, next); return next; }); }, [], ); return ( <> {open && createPortal(
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); step(e.shiftKey ? -1 : 1); } else if (e.key === "Escape") { e.preventDefault(); close(); } }} placeholder="Find in page…" aria-label="Find in page" // text-base (16px), not text-sm: iOS/iPadOS Safari auto-zooms // the page when focusing an input with a font-size below 16px. className="min-w-0 flex-1 bg-transparent py-1 text-base outline-none placeholder:text-muted-foreground" /> {count > 0 ? `${current + 1}/${count}` : query.trim().length >= MIN_QUERY ? "0 results" : ""}
, document.body, )} ); }