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;
}
// 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) {
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<HTMLElement>(".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());

View file

@ -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;
}

View file

@ -17,7 +17,7 @@
<span class="sidebar-title">Chapters</span>
</div>
<button class="icon-button" id="addChapterBtn" title="Add chapter">+</button>
</div>
</div>
<div class="project-bar hidden" id="projectBar">
<button class="icon-button" id="homeBtn" title="Home" aria-label="Home">🏠</button>
<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>
</div>
<div class="sidebar-footer">
<button class="button button-ghost" id="searchBtn" title="Search all chapters (Ctrl+F)">Search</button>
<div class="theme-control">
<label class="theme-label" for="themeSelect">Theme</label>
<select id="themeSelect" class="theme-select" title="Interface theme">
@ -61,6 +62,7 @@
<h2 class="chapter-title" id="chapterTitle"></h2>
<div class="editor-actions">
<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>
<button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button>
<button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button>
@ -271,6 +273,20 @@
</div>
</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 export-modal" role="dialog" aria-modal="true" aria-label="Export book">
<h3 class="modal-title">Export <span id="exportFormatLabel"></span></h3>