From aad1a9332a6ff8c8523252edd2e1f3f6823800e4 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 20 Aug 2026 15:35:28 -0500 Subject: [PATCH] Add in-app search across chapters and switch to manual save - Search: a 'Search' button in the sidebar footer (or Ctrl/Cmd+F) opens a modal that scans every chapter, lists matches grouped by chapter with highlighted snippets, and jumps to and scrolls the match on click. Includes a 'Match case' toggle. - Manual save: remove the per-keystroke auto-save; a chapter is only persisted via the new Save button or Ctrl/Cmd+S. Switching chapters or closing the book with unsaved edits now prompts to save first. - Extend the packaged self-test to exercise both features. --- src/renderer/book.ts | 215 ++++++++++++++++++++++++++++++++++--- src/renderer/index.css | 60 +++++++++++ src/renderer/index.html | 18 +++- tests/selftest-scenario.js | 182 +++++++++++++++++++++++++++++++ 4 files changed, 459 insertions(+), 16 deletions(-) create mode 100644 tests/selftest-scenario.js diff --git a/src/renderer/book.ts b/src/renderer/book.ts index e73f035..b8f6e32 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -97,6 +97,51 @@ function moveInList(items: T[], fromIndex: number, toIndex: number): T[] { return copy; } +// Reduce Markdown to searchable plain text (headings, emphasis, links, etc. +// are flattened so a search matches what the reader actually sees). +function markdownToPlain(md: string): string { + return md + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/^ {0,3}#{1,6}\s+/gm, "") + .replace(/^\s{0,3}>+\s?/gm, "") + .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, "$1") + .replace(/^\s*[-*+]\s+/gm, "• ") + .replace(/\r?\n\s*[-*_]{3,}\s*\r?\n/g, " ") + .replace(/\|/g, " ") + .replace(/[ \t]+/g, " "); +} + +function findMatches(plain: string, term: string, matchCase: boolean): number[] { + const out: number[] = []; + const hay = matchCase ? plain : plain.toLowerCase(); + const needle = matchCase ? term : term.toLowerCase(); + if (!needle) return out; + let idx = hay.indexOf(needle); + while (idx !== -1 && out.length < 200) { + out.push(idx); + idx = hay.indexOf(needle, idx + needle.length); + } + return out; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function makeSnippet(plain: string, pos: number, len: number): string { + const start = Math.max(0, pos - 40); + const end = Math.min(plain.length, pos + len + 40); + const before = plain.slice(start, pos); + const match = plain.slice(pos, pos + len); + const after = plain.slice(pos + len, end); + const pre = start > 0 ? "…" : ""; + const post = end < plain.length ? "…" : ""; + return escapeHtml(pre + before) + "" + escapeHtml(match) + "" + escapeHtml(after) + post; +} + export function initBook(api: FolioAPI) { let currentBook: { path: string; title: string } | null = null; let meta: BookMeta | null = null; @@ -518,7 +563,141 @@ export function initBook(api: FolioAPI) { meta = null; ordered = []; selectedId = null; - setChapterButtons(false); + // ---- search across all chapters ---- + const searchBtn = $("searchBtn") as HTMLButtonElement; + const searchModal = $("searchModal") as HTMLDivElement; + const searchInput = $("searchInput") as HTMLInputElement; + const searchCaseCb = $("searchCase") as HTMLInputElement; + const searchMeta = $("searchMeta") as HTMLElement; + const searchResults = $("searchResults") as HTMLUListElement; + const searchClose = $("searchClose") as HTMLButtonElement; + let searchTimer: number | undefined; + + const saveBtn = $("saveBtn") as HTMLButtonElement; + saveBtn.addEventListener("click", () => void saveAndNotify()); + + function openSearch(): void { + if (!meta) { + void alertMessage("No book open", "Open a book before searching."); + return; + } + searchModal.classList.remove("hidden"); + searchInput.value = ""; + searchResults.innerHTML = ""; + searchMeta.textContent = ""; + setTimeout(() => searchInput.focus(), 0); + } + function closeSearch(): void { + searchModal.classList.add("hidden"); + } + + function runSearchNow(): void { + const term = searchInput.value.trim(); + searchResults.innerHTML = ""; + if (!term) { + searchMeta.textContent = ""; + return; + } + if (!meta) { + searchMeta.textContent = "Open a book to search."; + return; + } + const matchCase = searchCaseCb.checked; + void (async () => { + const list = await api.getChapterList(); + const chapters = Array.isArray(list) ? list : []; + const byChapter: { id: string; title: string; plain: string; positions: number[] }[] = []; + let total = 0; + for (const ch of chapters) { + if (total >= 200) break; + const res = await api.getChapterContent(ch.id); + const md = typeof res === "string" ? res : ""; + const plain = markdownToPlain(md); + const positions = findMatches(plain, term, matchCase); + if (positions.length) { + byChapter.push({ id: ch.id, title: ch.title || ch.id, plain, positions }); + total += positions.length; + } + } + searchMeta.textContent = total + ? `${total} match${total === 1 ? "" : "es"} in ${byChapter.length} chapter${byChapter.length === 1 ? "" : "s"}` + : "No matches."; + for (const r of byChapter) { + const head = document.createElement("li"); + head.className = "search-chapter-head"; + head.textContent = `${r.title} (${r.positions.length})`; + searchResults.appendChild(head); + for (const pos of r.positions) { + const li = document.createElement("li"); + li.className = "search-result"; + li.innerHTML = makeSnippet(r.plain, pos, term.length); + li.addEventListener("click", () => void goToResult(r.id, pos, term, matchCase)); + searchResults.appendChild(li); + } + } + })(); + } + + function goToResult(id: string, pos: number, term: string, matchCase: boolean): void { + closeSearch(); + void (async () => { + await selectChapter(id); + scrollToTerm(term, matchCase); + })(); + } + + function scrollToTerm(term: string, matchCase: boolean): void { + if (!term) return; + const needle = matchCase ? term : term.toLowerCase(); + let found: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (found !== null) return false; + if (node.isText && node.text) { + const t = matchCase ? node.text : node.text.toLowerCase(); + const idx = t.indexOf(needle); + if (idx !== -1) { + found = pos + idx; + return false; + } + } + return true; + }); + if (found === null) return; + const to = Math.min(found + term.length, editor.state.doc.content.size); + editor.commands.focus(); + editor.commands.setTextSelection({ from: found, to }); + const dom = (editor.view as any).domAt(found); + const node = (dom.node.nodeType === 3 ? dom.node.parentElement : dom.node) as HTMLElement | null; + node?.scrollIntoView({ block: "center" }); + } + + searchInput.addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = window.setTimeout(runSearchNow, 200); + }); + searchCaseCb.addEventListener("change", runSearchNow); + searchBtn.addEventListener("click", openSearch); + searchClose.addEventListener("click", closeSearch); + searchModal.addEventListener("click", (e) => { + if (e.target === searchModal) closeSearch(); + }); + window.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") { + e.preventDefault(); + openSearch(); + } else if (e.key === "Escape" && !searchModal.classList.contains("hidden")) { + closeSearch(); + } else if ( + e.key === "Enter" && + !searchModal.classList.contains("hidden") && + document.activeElement === searchInput + ) { + const first = searchResults.querySelector(".search-result"); + if (first) first.click(); + } + }); + + setChapterButtons(false); fullBookBtn.classList.remove("active"); setEditorDoc(fullBookEditor, { type: "doc", content: [] }); fullBookChapters = []; @@ -779,7 +958,15 @@ export function initBook(api: FolioAPI) { async function selectChapter(id: string) { if (selectedId && dirty) { - await saveCurrentChapter(); + const ok = await confirmMessage( + "Unsaved changes", + "Save changes to this chapter before switching?" + ); + if (ok) { + await saveCurrentChapter(); + } else { + return; // keep editing the current chapter; don't switch + } } clearTimers(); selectedId = id; @@ -903,18 +1090,6 @@ export function initBook(api: FolioAPI) { saveStateEl.textContent = dirty ? "Unsaved" : "Saved"; saveStateEl.classList.toggle("unsaved", dirty); wordCountEl.textContent = `words: ${countWords(editor.getText())}`; - clearTimers(); - if (dirty) { - saveTimer = setTimeout(async () => { - const res = await api.saveChapterDoc(selectedId!, getEditorDoc(editor)); - if (!(res && "error" in res)) { - loadedDocJson = JSON.stringify(getEditorDoc(editor)); - dirty = false; - saveStateEl.textContent = "Saved"; - saveStateEl.classList.remove("unsaved"); - } - }, 600); - } } function handleSelection() { @@ -1284,7 +1459,17 @@ export function initBook(api: FolioAPI) { $("welcomeNewBook").addEventListener("click", () => void startNewBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); const homeBtn = $("homeBtn"); - homeBtn?.addEventListener("click", () => void api.closeBook()); + homeBtn?.addEventListener("click", async () => { + if (selectedId && dirty) { + const ok = await confirmMessage( + "Unsaved changes", + "Save changes to this chapter before closing the book?" + ); + if (ok) await saveCurrentChapter(); + else return; + } + void api.closeBook(); + }); newChapterBtn.addEventListener("click", () => void create()); addChapterBtn.addEventListener("click", () => void create()); exportBtn.addEventListener("click", () => void exportMarkdown()); diff --git a/src/renderer/index.css b/src/renderer/index.css index 7edb3de..1ba6fa1 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -1355,3 +1355,63 @@ body.focus-mode .editor { pointer-events: none; } .folio-prose:focus { outline: none; } + +/* Search across all chapters */ +.search-modal { + width: min(640px, 92vw); + max-height: 80vh; + display: flex; + flex-direction: column; +} +.search-box { + display: flex; + gap: var(--gap); + align-items: center; + margin-bottom: 8px; +} +.search-box .modal-input { flex: 1; } +.search-case { + font-size: 0.82rem; + color: var(--color-text-muted); + white-space: nowrap; + user-select: none; +} +.search-meta { + font-size: 0.8rem; + color: var(--color-text-muted); + margin-bottom: 6px; + min-height: 1em; +} +.search-results { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1; +} +.search-chapter-head { + font-weight: 600; + font-size: 0.82rem; + color: var(--color-accent, #3b82f6); + padding: 10px 4px 3px; + border-top: 1px solid var(--color-border); +} +.search-result { + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + line-height: 1.45; + color: var(--color-text); +} +.search-result:hover { background: rgba(127, 127, 127, 0.14); } +.search-result mark { + background: #fde047; + color: inherit; + border-radius: 2px; + padding: 0 1px; +} +:root[data-theme="dark-mode"] .search-result mark { + background: #a16207; + color: #fde68a; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 47d80bb..cb610e8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -17,7 +17,7 @@ Chapters - + +