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;
|
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) {
|
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());
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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