feat(reader): in-book search for PDFs
PDFs have fully searchable text (pdf.js text layer) — the previous
reflowable-only gate existed only because foliate's generic search
needs DOM documents that PDF sections don't provide. This adds a PDF
pipeline alongside it:
- Fork d065495 exposes the pdf.js document proxy as book.pdf so the
host can drive text extraction directly.
- New web/src/reader/pdf-search.ts: extractPdfPages() pulls each
page's textContent with item geometry (progress-reported, cached
after first search). PDF text items often omit inter-word spaces
(gaps are positional), so pages are joined gap-aware — baseline
changes, hasEOL, or horizontal gaps past a font-size threshold
become spaces — recording a char→item map. searchPdfPages() does
case-insensitive matching over the joined text and maps each hit
back to the page-fraction rects of the items it spans, with
ellipsized pre/match/post excerpts. Pure functions, unit-sanity
checked (cross-item 'brave new' → two rects).
- runSearch branches: EPUB keeps foliate's DOM search; PDFs search
the extracted pages, group hits per page ('Page 12'), and render
on-page hit rectangles through the existing fraction-rect overlay
(addRectAnnotation) — which re-render automatically when pages
revisit, same as highlights. Clearing the query removes them.
- Results navigate by page index; the 🔍 button and '/' shortcut now
appear for PDFs too (comics remain without searchable text).
This commit is contained in:
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"dev": "npm run build:ts:dev && npm run build:css"
|
"dev": "npm run build:ts:dev && npm run build:css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#1c0ebf3",
|
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#d065495",
|
||||||
"alpinejs": "^3.15.8",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
|
|||||||
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
|
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button>
|
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button>
|
||||||
<button x-show="!isFixedLayout" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
|
<button x-show="!isFixedLayout || isPDF" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
|
||||||
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button>
|
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button>
|
||||||
<button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button>
|
<button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -541,10 +541,10 @@ templ ReaderSearchDrawer() {
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)" x-text="group.label"></div>
|
<div class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)" x-text="group.label"></div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<template x-for="item in group.items" :key="item.cfi">
|
<template x-for="(item, si) in group.items" :key="gi + '-' + si">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@click.prevent="goToSearchResult(item.cfi)"
|
@click.prevent="goToSearchResult(item)"
|
||||||
class="search-result"
|
class="search-result"
|
||||||
>
|
>
|
||||||
<span x-text="item.pre"></span><mark x-text="item.match"></mark><span x-text="item.post"></span>
|
<span x-text="item.pre"></span><mark x-text="item.match"></mark><span x-text="item.post"></span>
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"!isFixedLayout\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button><div class=\"w-px h-6 mx-1 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"!isFixedLayout || isPDF\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button><div class=\"w-px h-6 mx-1 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -423,7 +423,7 @@ func ReaderSearchDrawer() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"reader-search-bar\"><input type=\"search\" x-ref=\"searchInput\" x-model=\"searchQuery\" @keydown.enter.prevent=\"runSearch()\" placeholder=\"Search in book…\" class=\"reader-note-input grow\" aria-label=\"Search query\"> <button x-show=\"searchQuery || searchGroups.length\" @click=\"searchQuery = ''; clearSearchResults()\" class=\"p-2 rounded-lg hover:bg-gray-700 shrink-0\" title=\"Clear results\" aria-label=\"Clear results\">✕</button></div><div class=\"reader-search-status\" x-show=\"searchQuery || searching || searchGroups.length\"><span x-show=\"searching\" x-text=\"'Searching… ' + Math.round(searchProgress * 100) + '%'\">Searching…</span> <span x-show=\"!searching && searchGroups.length\" x-text=\"searchMatchCount + ' match' + (searchMatchCount === 1 ? '' : 'es')\">0 matches</span> <span x-show=\"!searching && searchQuery && !searchGroups.length\">No matches</span></div><div class=\"reader-drawer-body\"><template x-for=\"(group, gi) in searchGroups\" :key=\"gi\"><div class=\"mb-3\"><div class=\"text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\" x-text=\"group.label\"></div><div class=\"space-y-1\"><template x-for=\"item in group.items\" :key=\"item.cfi\"><a href=\"#\" @click.prevent=\"goToSearchResult(item.cfi)\" class=\"search-result\"><span x-text=\"item.pre\"></span><mark x-text=\"item.match\"></mark><span x-text=\"item.post\"></span></a></template></div></div></template></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"reader-search-bar\"><input type=\"search\" x-ref=\"searchInput\" x-model=\"searchQuery\" @keydown.enter.prevent=\"runSearch()\" placeholder=\"Search in book…\" class=\"reader-note-input grow\" aria-label=\"Search query\"> <button x-show=\"searchQuery || searchGroups.length\" @click=\"searchQuery = ''; clearSearchResults()\" class=\"p-2 rounded-lg hover:bg-gray-700 shrink-0\" title=\"Clear results\" aria-label=\"Clear results\">✕</button></div><div class=\"reader-search-status\" x-show=\"searchQuery || searching || searchGroups.length\"><span x-show=\"searching\" x-text=\"'Searching… ' + Math.round(searchProgress * 100) + '%'\">Searching…</span> <span x-show=\"!searching && searchGroups.length\" x-text=\"searchMatchCount + ' match' + (searchMatchCount === 1 ? '' : 'es')\">0 matches</span> <span x-show=\"!searching && searchQuery && !searchGroups.length\">No matches</span></div><div class=\"reader-drawer-body\"><template x-for=\"(group, gi) in searchGroups\" :key=\"gi\"><div class=\"mb-3\"><div class=\"text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\" x-text=\"group.label\"></div><div class=\"space-y-1\"><template x-for=\"(item, si) in group.items\" :key=\"gi + '-' + si\"><a href=\"#\" @click.prevent=\"goToSearchResult(item)\" class=\"search-result\"><span x-text=\"item.pre\"></span><mark x-text=\"item.match\"></mark><span x-text=\"item.post\"></span></a></template></div></div></template></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// PDF in-book search: text extraction with item geometry, and a matcher
|
||||||
|
// that maps hits back to page-fraction rects for the overlay renderer.
|
||||||
|
//
|
||||||
|
// pdf.js text items carry positional data (transform/width/height in PDF
|
||||||
|
// units at scale 1) but their strings often omit inter-word spaces — gaps
|
||||||
|
// are positional. Pages are therefore joined gap-aware, with a char→item
|
||||||
|
// map so each match can be covered by the rects of the items it spans.
|
||||||
|
|
||||||
|
export interface PdfSearchItem {
|
||||||
|
/** page-fraction rect of this text item */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
/** char offset of this item's text within the page string */
|
||||||
|
start: number;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfPageText {
|
||||||
|
index: number;
|
||||||
|
/** normalized, gap-joined page text (lowercased by the matcher) */
|
||||||
|
text: string;
|
||||||
|
items: PdfSearchItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfSearchHit {
|
||||||
|
page: number;
|
||||||
|
rects: number[][];
|
||||||
|
pre: string;
|
||||||
|
match: string;
|
||||||
|
post: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawItem {
|
||||||
|
str: string;
|
||||||
|
transform: number[];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
hasEOL: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join one page's text items into a searchable string + item map. */
|
||||||
|
export function buildPageText(
|
||||||
|
rawItems: RawItem[],
|
||||||
|
viewportWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
index: number,
|
||||||
|
): PdfPageText {
|
||||||
|
const vw = viewportWidth || 1;
|
||||||
|
const vh = viewportHeight || 1;
|
||||||
|
let text = "";
|
||||||
|
const items: PdfSearchItem[] = [];
|
||||||
|
|
||||||
|
let prevRight: number | null = null;
|
||||||
|
let prevBaseline: number | null = null;
|
||||||
|
|
||||||
|
for (const item of rawItems) {
|
||||||
|
if (!item.str) continue;
|
||||||
|
const t = item.transform ?? [1, 0, 0, 1, 0, 0];
|
||||||
|
const baseline = t[5] ?? 0;
|
||||||
|
const x = t[4] ?? 0;
|
||||||
|
const size =
|
||||||
|
Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10;
|
||||||
|
const w = Math.abs(item.width) || 0;
|
||||||
|
const h = size;
|
||||||
|
|
||||||
|
let sep = "";
|
||||||
|
if (text && !text.endsWith(" ") && prevRight != null) {
|
||||||
|
const newLine =
|
||||||
|
item.hasEOL ||
|
||||||
|
prevBaseline == null ||
|
||||||
|
Math.abs(baseline - prevBaseline) > size * 0.5;
|
||||||
|
const gap = x - prevRight;
|
||||||
|
if (newLine || gap > size * 0.2) sep = " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = item.str.replace(/\s+/g, " ");
|
||||||
|
const start = text.length + sep.length;
|
||||||
|
text += sep + s;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
x: x / vw,
|
||||||
|
y: (vh - baseline - h) / vh,
|
||||||
|
w: w / vw,
|
||||||
|
h: h / vh,
|
||||||
|
start,
|
||||||
|
length: s.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
prevRight = x + w;
|
||||||
|
prevBaseline = baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { index, text: text.trimStart(), items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract all pages of a PDF via pdf.js, reporting progress 0..1. */
|
||||||
|
export async function extractPdfPages(
|
||||||
|
pdf: any,
|
||||||
|
onProgress?: (fraction: number) => void,
|
||||||
|
): Promise<PdfPageText[]> {
|
||||||
|
const pages: PdfPageText[] = [];
|
||||||
|
const num = pdf.numPages as number;
|
||||||
|
for (let i = 0; i < num; i++) {
|
||||||
|
const page = await pdf.getPage(i + 1);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const tc = await page.getTextContent();
|
||||||
|
pages.push(
|
||||||
|
buildPageText(tc.items, viewport.width, viewport.height, i),
|
||||||
|
);
|
||||||
|
onProgress?.((i + 1) / num);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTEXT = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-insensitive search over extracted pages. Returns hits grouped in
|
||||||
|
* page order; each hit carries the page-fraction rects of the items it
|
||||||
|
* spans (capped to keep pathological fills cheap) plus a trimmed excerpt.
|
||||||
|
*/
|
||||||
|
export function searchPdfPages(
|
||||||
|
pages: PdfPageText[],
|
||||||
|
query: string,
|
||||||
|
locales = "en",
|
||||||
|
): PdfSearchHit[] {
|
||||||
|
const needle = query.toLocaleLowerCase(locales).replace(/\s+/g, " ").trim();
|
||||||
|
if (!needle) return [];
|
||||||
|
const hits: PdfSearchHit[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const haystack = page.text.toLocaleLowerCase(locales);
|
||||||
|
let from = 0;
|
||||||
|
for (;;) {
|
||||||
|
const s = haystack.indexOf(needle, from);
|
||||||
|
if (s === -1) break;
|
||||||
|
const e = s + needle.length;
|
||||||
|
from = s + Math.max(1, needle.length);
|
||||||
|
|
||||||
|
const rects: number[][] = [];
|
||||||
|
for (const it of page.items) {
|
||||||
|
if (it.length <= 0) continue;
|
||||||
|
if (it.start + it.length <= s || it.start >= e) continue;
|
||||||
|
if (rects.length >= 12) break;
|
||||||
|
rects.push([it.x, it.y, it.w, it.h]);
|
||||||
|
}
|
||||||
|
if (!rects.length) continue;
|
||||||
|
|
||||||
|
const pre = page.text.slice(Math.max(0, s - CONTEXT), s);
|
||||||
|
const post = page.text.slice(e, e + CONTEXT);
|
||||||
|
hits.push({
|
||||||
|
page: page.index,
|
||||||
|
rects,
|
||||||
|
pre: (s > CONTEXT ? "…" : "") + pre.trimStart(),
|
||||||
|
match: page.text.slice(s, e),
|
||||||
|
post: post.trimEnd() + (page.text.length > e + CONTEXT ? "…" : ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@ import { Overlayer } from "@bookhoard/foliate-js/overlayer.js";
|
|||||||
import { Alpine } from "../alpine";
|
import { Alpine } from "../alpine";
|
||||||
import { loadSettings, saveSettings } from "./settings-manager";
|
import { loadSettings, saveSettings } from "./settings-manager";
|
||||||
import { getToken } from "../storage";
|
import { getToken } from "../storage";
|
||||||
|
import {
|
||||||
|
extractPdfPages,
|
||||||
|
searchPdfPages,
|
||||||
|
type PdfPageText,
|
||||||
|
} from "./pdf-search";
|
||||||
|
|
||||||
const HIGHLIGHT_COLORS = [
|
const HIGHLIGHT_COLORS = [
|
||||||
"#ffd54f",
|
"#ffd54f",
|
||||||
@@ -410,6 +415,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
searchProgress: 0,
|
searchProgress: 0,
|
||||||
searching: false,
|
searching: false,
|
||||||
searchGen: 0,
|
searchGen: 0,
|
||||||
|
pdfPagesCache: null as PdfPageText[] | null,
|
||||||
|
pdfSearchKeys: [] as string[],
|
||||||
selectionPopover: {
|
selectionPopover: {
|
||||||
open: false,
|
open: false,
|
||||||
mode: "create" as "create" | "edit",
|
mode: "create" as "create" | "edit",
|
||||||
@@ -1441,7 +1448,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.bookmarksOpen = opening;
|
this.bookmarksOpen = opening;
|
||||||
},
|
},
|
||||||
toggleSearch() {
|
toggleSearch() {
|
||||||
if (this.isFixedLayout) return; // search requires reflowable text
|
// Reflowable books use foliate's DOM search; PDFs use extracted text.
|
||||||
|
// Comics have no text at all.
|
||||||
|
if (this.isFixedLayout && !this.isPDF) return;
|
||||||
const opening = !this.searchOpen;
|
const opening = !this.searchOpen;
|
||||||
this.closeDrawers();
|
this.closeDrawers();
|
||||||
this.searchOpen = opening;
|
this.searchOpen = opening;
|
||||||
@@ -1477,6 +1486,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.searchProgress = 0;
|
this.searchProgress = 0;
|
||||||
this.searchGroups = [];
|
this.searchGroups = [];
|
||||||
try {
|
try {
|
||||||
|
if (this.isFixedLayout && this.isPDF) {
|
||||||
|
await this.runPdfSearch(q, gen);
|
||||||
|
} else {
|
||||||
|
await this.runEpubSearch(q, gen);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
/* search errors leave partial results */
|
||||||
|
}
|
||||||
|
if (gen === this.searchGen) this.searching = false;
|
||||||
|
},
|
||||||
|
async runEpubSearch(q: string, gen: number) {
|
||||||
for await (const r of this.view.search({ query: q })) {
|
for await (const r of this.view.search({ query: q })) {
|
||||||
if (gen !== this.searchGen) return;
|
if (gen !== this.searchGen) return;
|
||||||
if (r === "done") break;
|
if (r === "done") break;
|
||||||
@@ -1490,6 +1510,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
label: r.label || `Section ${this.searchGroups.length + 1}`,
|
label: r.label || `Section ${this.searchGroups.length + 1}`,
|
||||||
items: r.subitems.map((s: any) => ({
|
items: r.subitems.map((s: any) => ({
|
||||||
cfi: s.cfi,
|
cfi: s.cfi,
|
||||||
|
page: null,
|
||||||
pre: s.excerpt?.pre ?? "",
|
pre: s.excerpt?.pre ?? "",
|
||||||
match: s.excerpt?.match ?? "",
|
match: s.excerpt?.match ?? "",
|
||||||
post: s.excerpt?.post ?? "",
|
post: s.excerpt?.post ?? "",
|
||||||
@@ -1498,21 +1519,65 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
},
|
||||||
/* search errors leave partial results */
|
async runPdfSearch(q: string, gen: number) {
|
||||||
|
const pdf = this.book?.pdf;
|
||||||
|
if (!pdf) return;
|
||||||
|
if (!this.pdfPagesCache) {
|
||||||
|
// Extraction reports progress; it's cached so re-searches are instant.
|
||||||
|
this.pdfPagesCache = await extractPdfPages(pdf, (fraction) => {
|
||||||
|
if (gen === this.searchGen) this.searchProgress = fraction;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (gen === this.searchGen) this.searching = false;
|
if (gen !== this.searchGen) return;
|
||||||
|
const hits = searchPdfPages(this.pdfPagesCache, q);
|
||||||
|
const byPage = new Map<number, typeof hits>();
|
||||||
|
for (const hit of hits) {
|
||||||
|
const list = byPage.get(hit.page) ?? [];
|
||||||
|
list.push(hit);
|
||||||
|
byPage.set(hit.page, list);
|
||||||
|
}
|
||||||
|
const groups: typeof this.searchGroups = [];
|
||||||
|
let i = 0;
|
||||||
|
for (const [page, list] of byPage) {
|
||||||
|
groups.push({
|
||||||
|
label: `Page ${page + 1}`,
|
||||||
|
items: list.map((hit) => {
|
||||||
|
const key = `pdfsearch:${i++}`;
|
||||||
|
this.pdfSearchKeys.push(key);
|
||||||
|
this.renderer?.addRectAnnotation?.({
|
||||||
|
key,
|
||||||
|
index: page,
|
||||||
|
rects: hit.rects,
|
||||||
|
color: "#ffd54f",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
cfi: "",
|
||||||
|
page,
|
||||||
|
pre: hit.pre,
|
||||||
|
match: hit.match,
|
||||||
|
post: hit.post,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.searchGroups = groups;
|
||||||
},
|
},
|
||||||
clearSearchResults() {
|
clearSearchResults() {
|
||||||
this.searchGen++;
|
this.searchGen++;
|
||||||
this.searching = false;
|
this.searching = false;
|
||||||
this.searchGroups = [];
|
this.searchGroups = [];
|
||||||
this.searchProgress = 0;
|
this.searchProgress = 0;
|
||||||
|
for (const key of this.pdfSearchKeys) {
|
||||||
|
this.renderer?.removeRectAnnotation?.(key);
|
||||||
|
}
|
||||||
|
this.pdfSearchKeys = [];
|
||||||
this.view?.clearSearch?.();
|
this.view?.clearSearch?.();
|
||||||
},
|
},
|
||||||
goToSearchResult(cfi: string) {
|
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
||||||
if (!cfi) return;
|
if (item.cfi) this.view?.goTo?.(item.cfi);
|
||||||
this.view?.goTo?.(cfi);
|
else if (item.page != null) this.view?.goTo?.(item.page);
|
||||||
|
else return;
|
||||||
this.closeDrawers();
|
this.closeDrawers();
|
||||||
},
|
},
|
||||||
flattenTOC(items: any[], depth = 0): any[] {
|
flattenTOC(items: any[], depth = 0): any[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user