import { useEffect, useMemo, useRef, useState } from "react"; import type { Editor as TiptapEditor } from "@tiptap/react"; import { buildRegex, getSearchState } from "../editor/search"; import { countMatches, replaceInContent } from "../search-book"; import { useBook } from "../store/useBook"; import { Icon } from "./Icon"; interface FindBarProps { editor: TiptapEditor | null; open: boolean; initialExpanded: boolean; onClose: () => void; } type Scope = "chapter" | "book"; type PendingNav = "first" | "last" | null; export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps) { const book = useBook((s) => s.book); const activeChapterId = useBook((s) => s.activeChapterId); const setActiveChapter = useBook((s) => s.setActiveChapter); const setChapterContent = useBook((s) => s.setChapterContent); const setNotice = useBook((s) => s.setNotice); const [query, setQuery] = useState(""); const [replacement, setReplacement] = useState(""); const [caseSensitive, setCaseSensitive] = useState(false); const [wholeWord, setWholeWord] = useState(false); const [expanded, setExpanded] = useState(initialExpanded); const [scope, setScope] = useState("chapter"); const [active, setActive] = useState({ count: 0, current0: 0 }); const [pendingNav, setPendingNav] = useState(null); const findInputRef = useRef(null); const chapters = useMemo(() => book?.chapters ?? [], [book]); const activeIdx = chapters.findIndex((c) => c.id === activeChapterId); const options = { caseSensitive, wholeWord }; const jsonCounts = useMemo(() => { const regex = query ? buildRegex(query, options) : null; if (!regex) return chapters.map(() => 0); return chapters.map((c) => countMatches(c.content, regex)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [chapters, query, caseSensitive, wholeWord]); useEffect(() => { if (open) setExpanded(initialExpanded); }, [open, initialExpanded]); useEffect(() => { if (!editor) return; const update = () => { const s = getSearchState(editor.state); setActive({ count: s?.matches.length ?? 0, current0: s?.current ?? 0 }); }; editor.on("transaction", update); update(); return () => { editor.off("transaction", update); }; }, [editor, activeChapterId]); useEffect(() => { if (!editor) return; if (open) { editor.commands.setSearch(query, { caseSensitive, wholeWord }); } else { editor.commands.clearSearch(); } }, [editor, open, query, caseSensitive, wholeWord, activeChapterId]); useEffect(() => { if (open) { const input = findInputRef.current; input?.focus(); input?.select(); } }, [open]); useEffect(() => { if (!editor || !pendingNav) return; let done = false; const go = () => { if (done) return; done = true; const s = getSearchState(editor.state); if (s && s.matches.length) editor.commands.goToMatch(pendingNav === "first" ? 0 : s.matches.length - 1); setPendingNav(null); }; editor.on("transaction", go); const raf = requestAnimationFrame(go); return () => { editor.off("transaction", go); cancelAnimationFrame(raf); }; }, [editor, pendingNav, activeChapterId]); if (!open || !editor) return null; const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0); const after = jsonCounts.slice(activeIdx + 1).reduce((a, b) => a + b, 0); const total = before + active.count + after; const globalCurrent = before + active.current0 + 1; const otherHasMatches = chapters.some((c, i) => c.id !== activeChapterId && jsonCounts[i] > 0); const countLabel = !query ? "" : total === 0 ? "No results" : active.count === 0 ? `${total} found` : `${globalCurrent} of ${total}`; const nextChapterWith = (dir: 1 | -1): string | null => { const n = chapters.length; if (!n || activeIdx < 0) return null; for (let step = 1; step <= n; step++) { const i = (((activeIdx + dir * step) % n) + n) % n; if (chapters[i].id !== activeChapterId && jsonCounts[i] > 0) return chapters[i].id; } return null; }; const next = () => { const s = getSearchState(editor.state); const count = s?.matches.length ?? 0; const cur = s?.current ?? 0; if (count > 0 && cur < count - 1) { editor.commands.findNext(); return; } if (otherHasMatches) { const id = nextChapterWith(1); if (id) { setPendingNav("first"); setActiveChapter(id); return; } } editor.commands.findNext(); }; const prev = () => { const s = getSearchState(editor.state); const count = s?.matches.length ?? 0; const cur = s?.current ?? 0; if (count > 0 && cur > 0) { editor.commands.findPrev(); return; } if (otherHasMatches) { const id = nextChapterWith(-1); if (id) { setPendingNav("last"); setActiveChapter(id); return; } } editor.commands.findPrev(); }; const onFindKey = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); e.shiftKey ? prev() : next(); } else if (e.key === "Escape") { e.preventDefault(); onClose(); } }; const replaceOne = () => { editor.commands.replaceCurrent(replacement); editor.commands.focus(); }; const replaceAll = () => { if (scope === "chapter") { editor.commands.replaceAllInChapter(replacement); editor.commands.focus(); return; } const regex = buildRegex(query, options); if (!regex) return; let totalReplaced = 0; let touched = 0; const s = getSearchState(editor.state); const activeCount = s?.matches.length ?? 0; if (activeCount > 0) { editor.commands.replaceAllInChapter(replacement); totalReplaced += activeCount; touched++; } for (const c of chapters) { if (c.id === activeChapterId) continue; const result = replaceInContent(c.content, regex, replacement); if (result.count > 0) { setChapterContent(c.id, result.content); totalReplaced += result.count; touched++; } } setNotice(`Replaced ${totalReplaced} across ${touched} chapter${touched === 1 ? "" : "s"}`); editor.commands.focus(); }; return (
setQuery(e.target.value)} onKeyDown={onFindKey} /> {countLabel}
{expanded && (
setReplacement(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); replaceOne(); } else if (e.key === "Escape") { e.preventDefault(); onClose(); } }} />
)}
); }