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:
parent
3052e335a3
commit
aad1a9332a
4 changed files with 459 additions and 16 deletions
|
|
@ -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, "&").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) + "<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,6 +563,140 @@ export function initBook(api: FolioAPI) {
|
|||
meta = null;
|
||||
ordered = [];
|
||||
selectedId = null;
|
||||
// ---- 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: [] });
|
||||
|
|
@ -779,7 +958,15 @@ export function initBook(api: FolioAPI) {
|
|||
|
||||
async function selectChapter(id: string) {
|
||||
if (selectedId && dirty) {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
182
tests/selftest-scenario.js
Normal file
182
tests/selftest-scenario.js
Normal 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"
|
||||
);
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue