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:
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user