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.
This commit is contained in:
avi 2026-08-20 15:35:28 -05:00
commit aad1a9332a
4 changed files with 459 additions and 16 deletions

View file

@ -97,6 +97,51 @@ function moveInList<T>(items: T[], fromIndex: number, toIndex: number): T[] {
return copy; 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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) + "<mark>" + escapeHtml(match) + "</mark>" + escapeHtml(after) + post;
}
export function initBook(api: FolioAPI) { export function initBook(api: FolioAPI) {
let currentBook: { path: string; title: string } | null = null; let currentBook: { path: string; title: string } | null = null;
let meta: BookMeta | null = null; let meta: BookMeta | null = null;
@ -518,7 +563,141 @@ export function initBook(api: FolioAPI) {
meta = null; meta = null;
ordered = []; ordered = [];
selectedId = null; 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<HTMLElement>(".search-result");
if (first) first.click();
}
});
setChapterButtons(false);
fullBookBtn.classList.remove("active"); fullBookBtn.classList.remove("active");
setEditorDoc(fullBookEditor, { type: "doc", content: [] }); setEditorDoc(fullBookEditor, { type: "doc", content: [] });
fullBookChapters = []; fullBookChapters = [];
@ -779,7 +958,15 @@ export function initBook(api: FolioAPI) {
async function selectChapter(id: string) { async function selectChapter(id: string) {
if (selectedId && dirty) { 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(); clearTimers();
selectedId = id; selectedId = id;
@ -903,18 +1090,6 @@ export function initBook(api: FolioAPI) {
saveStateEl.textContent = dirty ? "Unsaved" : "Saved"; saveStateEl.textContent = dirty ? "Unsaved" : "Saved";
saveStateEl.classList.toggle("unsaved", dirty); saveStateEl.classList.toggle("unsaved", dirty);
wordCountEl.textContent = `words: ${countWords(editor.getText())}`; 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() { function handleSelection() {
@ -1284,7 +1459,17 @@ export function initBook(api: FolioAPI) {
$("welcomeNewBook").addEventListener("click", () => void startNewBook()); $("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
const homeBtn = $("homeBtn"); 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()); newChapterBtn.addEventListener("click", () => void create());
addChapterBtn.addEventListener("click", () => void create()); addChapterBtn.addEventListener("click", () => void create());
exportBtn.addEventListener("click", () => void exportMarkdown()); exportBtn.addEventListener("click", () => void exportMarkdown());

View file

@ -1355,3 +1355,63 @@ body.focus-mode .editor {
pointer-events: none; pointer-events: none;
} }
.folio-prose:focus { outline: 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;
}

View file

@ -17,7 +17,7 @@
<span class="sidebar-title">Chapters</span> <span class="sidebar-title">Chapters</span>
</div> </div>
<button class="icon-button" id="addChapterBtn" title="Add chapter">+</button> <button class="icon-button" id="addChapterBtn" title="Add chapter">+</button>
</div> </div>
<div class="project-bar hidden" id="projectBar"> <div class="project-bar hidden" id="projectBar">
<button class="icon-button" id="homeBtn" title="Home" aria-label="Home">🏠</button> <button class="icon-button" id="homeBtn" title="Home" aria-label="Home">🏠</button>
<span class="book-title" id="bookTitle"></span> <span class="book-title" id="bookTitle"></span>
@ -29,6 +29,7 @@
<button class="full-book-btn" id="fullBookBtn" title="View the entire book as one continuous manuscript">Full Book</button> <button class="full-book-btn" id="fullBookBtn" title="View the entire book as one continuous manuscript">Full Book</button>
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="button button-ghost" id="searchBtn" title="Search all chapters (Ctrl+F)">Search</button>
<div class="theme-control"> <div class="theme-control">
<label class="theme-label" for="themeSelect">Theme</label> <label class="theme-label" for="themeSelect">Theme</label>
<select id="themeSelect" class="theme-select" title="Interface theme"> <select id="themeSelect" class="theme-select" title="Interface theme">
@ -61,6 +62,7 @@
<h2 class="chapter-title" id="chapterTitle"></h2> <h2 class="chapter-title" id="chapterTitle"></h2>
<div class="editor-actions"> <div class="editor-actions">
<span class="save-state" id="saveState">Saved</span> <span class="save-state" id="saveState">Saved</span>
<button class="button button-ghost" id="saveBtn" title="Save chapter (Ctrl+S)">Save</button>
<span class="word-count" id="wordCount">words: 0</span> <span class="word-count" id="wordCount">words: 0</span>
<button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button> <button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button>
<button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button> <button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button>
@ -271,6 +273,20 @@
</div> </div>
</div> </div>
<div class="toast hidden" id="toast"></div> <div class="toast hidden" id="toast"></div>
<div class="modal-backdrop hidden" id="searchModal">
<div class="modal search-modal" role="dialog" aria-modal="true" aria-label="Search all chapters">
<h3 class="modal-title">Search</h3>
<div class="search-box">
<input type="text" class="modal-input" id="searchInput" placeholder="Search all chapters…" autocomplete="off" />
<label class="search-case"><input type="checkbox" id="searchCase" /> Match case</label>
</div>
<div class="search-meta" id="searchMeta"></div>
<ul class="search-results" id="searchResults"></ul>
<div class="modal-actions">
<button class="button" id="searchClose">Close</button>
</div>
</div>
</div>
<div class="modal-backdrop hidden" id="exportModal"> <div class="modal-backdrop hidden" id="exportModal">
<div class="modal export-modal" role="dialog" aria-modal="true" aria-label="Export book"> <div class="modal export-modal" role="dialog" aria-modal="true" aria-label="Export book">
<h3 class="modal-title">Export <span id="exportFormatLabel"></span></h3> <h3 class="modal-title">Export <span id="exportFormatLabel"></span></h3>

182
tests/selftest-scenario.js Normal file
View file

@ -0,0 +1,182 @@
// Packaged self-test scenario. Electron Builder ships this file inside the
// distributable (resources/app.asar/tests/selftest-scenario.js) and the main
// process executes it in the renderer (see src/main/index.ts FOLIO_SELF_TEST)
// only when the app is launched with FOLIO_SELF_TEST=1 and FOLIO_TEST_BOOK set.
// Normal launches never touch this file. It drives the real packaged binary
// through the same public API the UI uses and returns a summary JSON.
(async () => {
function withTimeout(p, ms, tag) {
return Promise.race([
p,
new Promise((r) => setTimeout(() => r({ ok: false, timedOut: tag }), ms)),
]);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function waitFor(fn, timeout = 10000, interval = 100) {
return new Promise((resolve) => {
const start = Date.now();
(function check() {
let ok = false;
try {
ok = fn();
} catch (e) {}
if (ok) return resolve(true);
if (Date.now() - start > timeout) return resolve(false);
setTimeout(check, interval);
})();
});
}
return withTimeout(
(async () => {
const out = { ok: false, steps: {} };
const step = (k, v) => {
out.steps[k] = v;
};
const BOOK = __BOOKDIR_VALUE__;
try {
const api = window.folio;
step("api", !!api);
const opened = await api.openBookPath(BOOK);
step("openBook", opened);
const editorMounted = await waitFor(() =>
!!document.querySelector("#editorHost .ProseMirror")
);
step("editorMounted", editorMounted);
const meta = await api.getBookMeta();
step("meta", {
title: meta && meta.title,
chapters: meta && meta.chapters ? Object.keys(meta.chapters).length : 0,
});
const chapters = await api.getChapterList();
step("chapterList", Array.isArray(chapters) ? chapters.length : "n/a");
// Promote legacy chapters to the native model so DOCX/ODT have content.
try {
step("migrate", await api.migrateBook());
} catch (e) {
step("migrate", "err:" + e.message);
}
// Theme switching must update the document attribute. Drive it through
// the actual UI control (the theme <select>), not just the stored pref.
const sel = document.getElementById("themeSelect");
if (sel) {
sel.value = "dark-mode";
sel.dispatchEvent(new Event("change", { bubbles: true }));
await sleep(200);
step("themeDark", document.documentElement.getAttribute("data-theme"));
sel.value = "light";
sel.dispatchEvent(new Event("change", { bubbles: true }));
await sleep(200);
step("themeLight", document.documentElement.getAttribute("data-theme"));
} else {
step("themeDark", "no-theme-select");
step("themeLight", "no-theme-select");
}
// Chapter navigation: clicking a chapter row selects it.
const row = document.querySelector("#chapterList li.chapter-row");
if (row) {
row.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await sleep(300);
}
step(
"navActive",
!!document.querySelector("#chapterList li.chapter-row.active")
);
// Manual save: typing marks the chapter Unsaved; the Save button (or
// Ctrl/Cmd+S) is required to persist. No auto-save should occur.
const ed = window.__folioEditor;
if (ed) {
ed.commands.focus("end");
ed.commands.insertContent(" manual-save-check");
await sleep(150);
const unsaved = document.getElementById("saveState");
step("afterTypeUnsaved", unsaved ? unsaved.textContent : "n/a");
const sb = document.getElementById("saveBtn");
if (sb) {
sb.click();
await sleep(400);
}
const after = document.getElementById("saveState");
step("afterSaveState", after ? after.textContent : "n/a");
} else {
step("afterTypeUnsaved", "no-editor");
}
// Full Book view.
const fb = document.getElementById("fullBookBtn");
if (fb) {
fb.click();
await sleep(1200);
}
const fbv = document.getElementById("fullBookView");
step("fullBookVisible", fbv ? !fbv.classList.contains("hidden") : false);
step(
"fullBookContent",
!!document.querySelector("#fullBookHost .ProseMirror") ||
!!document.querySelector("#fullBookHost *")
);
// Search across all chapters (the new feature): open the search UI,
// type a known term, confirm results, and jump to the first match.
const sb = document.getElementById("searchBtn");
if (sb) {
sb.click();
await sleep(120);
const si = document.getElementById("searchInput");
if (si) {
si.value = "bold";
si.dispatchEvent(new Event("input", { bubbles: true }));
await sleep(600);
}
step(
"searchResults",
document.querySelectorAll("#searchResults .search-result").length
);
const first = document.querySelector("#searchResults .search-result");
if (first) {
first.click();
await sleep(400);
}
const cv = document.getElementById("chapterView");
step("afterSearchChapterView", cv ? !cv.classList.contains("hidden") : false);
} else {
step("searchResults", "no-search-btn");
}
// Exports (save paths come from FOLIO_TEST_* env in the main process).
const ex = {};
try {
ex.pdf = await api.exportPdf(null);
} catch (e) {
ex.pdf = "err:" + e.message;
}
try {
ex.docx = await api.exportDocx(null);
} catch (e) {
ex.docx = "err:" + e.message;
}
try {
ex.odt = await api.exportOdt(null);
} catch (e) {
ex.odt = "err:" + e.message;
}
step("exports", ex);
out.ok = true;
} catch (e) {
out.error = String((e && e.stack) || e);
}
return out;
})(),
90000,
"scenario"
);
})();