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,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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue