feat(reader): in-book search for reflowable formats

Wires foliate's search engine into the new drawer system:

- 🔍 topbar button (reflowable-only; PDF/comic sections have no
  searchable text documents) and the '/' keyboard shortcut open a
  Search drawer: query input (Enter to run), live progress while
  scanning (per-section percent), match count, and results grouped
  by section with TOC labels.
- Each result shows pre/match/post excerpt rendered as three text
  nodes (no x-html — book content never enters the DOM as markup);
  the match is styled with a translucent <mark>. Clicking jumps to
  the hit's CFI and closes the drawer.
- Hits are drawn on the page through foliate's overlayer (outline
  style) and persist across page turns — the engine re-applies
  search results when a section's overlay is created. Clearing the
  query removes the outlines.
- A generation counter discards results and progress from superseded
  searches (rapid re-query), and starting a new search clears the
  previous one server-side via view.clearSearch().
- Search integrates with the drawer system: scrim, Esc-to-close,
  one-drawer-at-a-time, / focuses the input via .
This commit is contained in:
2026-08-17 07:58:03 -04:00
parent eb09a5d939
commit 5e73b0a4f6
5 changed files with 252 additions and 26 deletions
+87 -1
View File
@@ -401,6 +401,15 @@ document.addEventListener("alpine:init", () => {
annotationsTab: "highlights" as string,
newNoteText: "",
highlightColors: HIGHLIGHT_COLORS,
searchOpen: false,
searchQuery: "",
searchGroups: [] as {
label: string;
items: { cfi: string; pre: string; match: string; post: string }[];
}[],
searchProgress: 0,
searching: false,
searchGen: 0,
selectionPopover: {
open: false,
mode: "create" as "create" | "edit",
@@ -477,6 +486,12 @@ document.addEventListener("alpine:init", () => {
};
});
},
get searchMatchCount(): number {
return this.searchGroups.reduce(
(n: number, g: { items: unknown[] }) => n + g.items.length,
0,
);
},
init() {
const link = document.getElementById("reader-back");
if (!link) return;
@@ -1425,13 +1440,80 @@ document.addEventListener("alpine:init", () => {
this.closeDrawers();
this.bookmarksOpen = opening;
},
toggleSearch() {
if (this.isFixedLayout) return; // search requires reflowable text
const opening = !this.searchOpen;
this.closeDrawers();
this.searchOpen = opening;
if (opening) {
(this as any).$nextTick(() =>
(this as any).$refs.searchInput?.focus(),
);
}
},
anyDrawerOpen(): boolean {
return this.tocOpen || this.settingsOpen || this.bookmarksOpen;
return this.tocOpen || this.settingsOpen || this.bookmarksOpen || this.searchOpen;
},
closeDrawers() {
this.tocOpen = false;
this.settingsOpen = false;
this.bookmarksOpen = false;
this.searchOpen = false;
},
// ----- in-book search (reflowable) -----
// view.search() is an async generator: it progressively scans sections,
// yields {progress} per section and {label, subitems} per section with
// hits, and draws outline highlights through the overlayer pipeline.
// A generation counter discards results from superseded searches.
async runSearch() {
const q = this.searchQuery.trim();
if (!q) {
this.clearSearchResults();
return;
}
this.searchGen++;
const gen = this.searchGen;
this.searching = true;
this.searchProgress = 0;
this.searchGroups = [];
try {
for await (const r of this.view.search({ query: q })) {
if (gen !== this.searchGen) return;
if (r === "done") break;
if (r && typeof r === "object") {
if (typeof r.progress === "number") {
this.searchProgress = r.progress;
continue;
}
if (Array.isArray(r.subitems) && r.subitems.length) {
this.searchGroups.push({
label: r.label || `Section ${this.searchGroups.length + 1}`,
items: r.subitems.map((s: any) => ({
cfi: s.cfi,
pre: s.excerpt?.pre ?? "",
match: s.excerpt?.match ?? "",
post: s.excerpt?.post ?? "",
})),
});
}
}
}
} catch (_e) {
/* search errors leave partial results */
}
if (gen === this.searchGen) this.searching = false;
},
clearSearchResults() {
this.searchGen++;
this.searching = false;
this.searchGroups = [];
this.searchProgress = 0;
this.view?.clearSearch?.();
},
goToSearchResult(cfi: string) {
if (!cfi) return;
this.view?.goTo?.(cfi);
this.closeDrawers();
},
flattenTOC(items: any[], depth = 0): any[] {
const result: any[] = [];
@@ -1923,6 +2005,10 @@ document.addEventListener("alpine:init", () => {
if (k === "t") this.toggleTOC();
else if (k === "s") this.toggleSettings();
else if (k === "b") this.addBookmark();
else if (k === "/") {
event.preventDefault();
this.toggleSearch();
}
}
},
}));
+34
View File
@@ -991,6 +991,40 @@
gap: 0.25rem;
}
/* In-book search */
.reader-search-bar {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.reader-search-status {
padding: 0.375rem 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
flex-shrink: 0;
min-height: 1.75rem;
}
.search-result {
display: block;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
line-height: 1.35;
}
.search-result:hover {
background-color: color-mix(in srgb, currentColor 9%, transparent);
}
.search-result mark {
background-color: rgba(255, 213, 79, 0.4);
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
/* ---------- Series stacked covers ---------- */
.stacked-covers {
position: relative;
+1 -1
View File
File diff suppressed because one or more lines are too long