fix(reader): PDF search returned nothing — reactive proxy broke pdf.js; add back-to-location
PDF search diagnosis: extraction and matching were proven correct
against the real 609-page library PDF (pdfjs 5.5.207, incl. the exact
range-transport setup makePDF uses — 841 hits for 'SELECT'), and the
served bundle had every piece. The failure was Alpine's reactivity:
this.book is a plain object, so reading .pdf through component state
returns a reactive Proxy around the PDFDocumentProxy — and pdf.js
v5 uses #private fields, so getPage() through the proxy throws
'cannot read private member', which the empty catch rendered as a
silent empty result set. runPdfSearch now unwraps via Alpine.raw
(falls back to the raw read), and search failures surface in the
drawer ('Search failed — see console') plus console.warn instead of
masquerading as 'No matches'.
Back-to-location stack (research/footnote workflow): the current
position is recorded before every programmatic jump — search-result
clicks, TOC entries, bookmark and highlight jumps — and on every
internal link click (footnotes, cross-references) via foliate's
'link' event. A ↩ button appears in the topbar once a return target
exists; Alt+← works everywhere. Ordinary paging never pollutes the
stack (max depth 50, consecutive duplicates collapse).
This commit is contained in:
+14
-2
@@ -243,6 +243,17 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
|
||||
<a id="reader-back" href={ "/media/" + metadata.MediaItemID } class="text-base sm:text-lg hover:underline">
|
||||
← Back
|
||||
</a>
|
||||
<button
|
||||
x-show="backStack.length > 0"
|
||||
@click="goBackToLocation()"
|
||||
class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700"
|
||||
title="Return to previous location (Alt+←)"
|
||||
aria-label="Return to previous location"
|
||||
>
|
||||
<svg class="reader-icon" width="18" height="18" aria-hidden="true">
|
||||
<path d="M 9 4 A 6 6 0 1 1 4.5 14 M 4.5 14 L 2 11 M 4.5 14 L 8 15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</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>
|
||||
@@ -531,10 +542,11 @@ templ ReaderSearchDrawer() {
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="reader-search-status" x-show="searchQuery || searching || searchGroups.length">
|
||||
<div class="reader-search-status" x-show="searchQuery || searching || searchGroups.length || searchError">
|
||||
<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>
|
||||
<span x-show="!searching && searchQuery && !searchGroups.length && !searchError">No matches</span>
|
||||
<span x-show="searchError" x-text="searchError" style="color: #f87171;">Search failed</span>
|
||||
</div>
|
||||
<div class="reader-drawer-body">
|
||||
<template x-for="(group, gi) in searchGroups" :key="gi">
|
||||
|
||||
@@ -207,14 +207,14 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"text-base sm:text-lg hover:underline\">← Back</a><h1 class=\"text-base sm:text-lg font-semibold hidden sm:block sm:truncate\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"text-base sm:text-lg hover:underline\">← Back</a> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M 9 4 A 6 6 0 1 1 4.5 14 M 4.5 14 L 2 11 M 4.5 14 L 8 15\"></path></svg></button><h1 class=\"text-base sm:text-lg font-semibold hidden sm:block sm:truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 246, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 257, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -229,7 +229,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 285, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 296, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -239,7 +239,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 287, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 298, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -250,7 +250,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 290, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 301, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -265,7 +265,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 367, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 378, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -275,7 +275,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 369, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 380, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -318,7 +318,7 @@ func drawerHeader(title string) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 382, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 393, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -423,7 +423,7 @@ func ReaderSearchDrawer() templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 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>")
|
||||
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 || searchError\"><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 && !searchError\">No matches</span> <span x-show=\"searchError\" x-text=\"searchError\" style=\"color: #f87171;\">Search failed</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -414,9 +414,11 @@ document.addEventListener("alpine:init", () => {
|
||||
}[],
|
||||
searchProgress: 0,
|
||||
searching: false,
|
||||
searchError: "",
|
||||
searchGen: 0,
|
||||
pdfPagesCache: null as PdfPageText[] | null,
|
||||
pdfSearchKeys: [] as string[],
|
||||
backStack: [] as { cfi?: string; page?: number }[],
|
||||
selectionPopover: {
|
||||
open: false,
|
||||
mode: "create" as "create" | "edit",
|
||||
@@ -748,6 +750,10 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
});
|
||||
// ----- highlight rendering (foliate overlayer pipeline) -----
|
||||
// Internal link clicks (footnotes, cross-references): record where we
|
||||
// came from so the back-to-location stack can return; foliate then
|
||||
// navigates on its own.
|
||||
this.view.addEventListener("link", () => this.pushBackStack());
|
||||
this.view.addEventListener("draw-annotation", (e: any) => {
|
||||
const { draw, annotation } = e.detail;
|
||||
draw(Overlayer.highlight, { color: annotation.color || "#ffd54f" });
|
||||
@@ -1261,9 +1267,11 @@ document.addEventListener("alpine:init", () => {
|
||||
}) {
|
||||
if (hl.pdfPage >= 0) {
|
||||
// Fixed-layout: a bare number navigates to the section (page) index.
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(hl.pdfPage);
|
||||
this.closeDrawers();
|
||||
} else if (hl.cfi) {
|
||||
this.pushBackStack();
|
||||
this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
|
||||
this.closeDrawers();
|
||||
}
|
||||
@@ -1485,14 +1493,17 @@ document.addEventListener("alpine:init", () => {
|
||||
this.searching = true;
|
||||
this.searchProgress = 0;
|
||||
this.searchGroups = [];
|
||||
this.searchError = "";
|
||||
try {
|
||||
if (this.isFixedLayout && this.isPDF) {
|
||||
await this.runPdfSearch(q, gen);
|
||||
} else {
|
||||
await this.runEpubSearch(q, gen);
|
||||
}
|
||||
} catch (_e) {
|
||||
/* search errors leave partial results */
|
||||
} catch (e) {
|
||||
// Swallowing this silently made a wiring bug look like "no matches".
|
||||
console.warn("search failed:", e);
|
||||
this.searchError = "Search failed (see browser console)";
|
||||
}
|
||||
if (gen === this.searchGen) this.searching = false;
|
||||
},
|
||||
@@ -1521,8 +1532,19 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
},
|
||||
async runPdfSearch(q: string, gen: number) {
|
||||
const pdf = this.book?.pdf;
|
||||
if (!pdf) return;
|
||||
// CRITICAL: unwrap Alpine's reactive proxy. `this.book` is a plain
|
||||
// object, so reactivity wraps it — and reading `.pdf` through the
|
||||
// proxy wraps the PDFDocumentProxy too. pdf.js uses #private fields,
|
||||
// so calling getPage() on the proxy throws "cannot read private
|
||||
// member", which used to be swallowed as an empty result set.
|
||||
const rawBook = (Alpine as any).raw
|
||||
? (Alpine as any).raw(this.book)
|
||||
: this.book;
|
||||
const pdf = rawBook?.pdf;
|
||||
if (!pdf) {
|
||||
this.searchError = "PDF text engine unavailable";
|
||||
return;
|
||||
}
|
||||
if (!this.pdfPagesCache) {
|
||||
// Extraction reports progress; it's cached so re-searches are instant.
|
||||
this.pdfPagesCache = await extractPdfPages(pdf, (fraction) => {
|
||||
@@ -1566,6 +1588,7 @@ document.addEventListener("alpine:init", () => {
|
||||
clearSearchResults() {
|
||||
this.searchGen++;
|
||||
this.searching = false;
|
||||
this.searchError = "";
|
||||
this.searchGroups = [];
|
||||
this.searchProgress = 0;
|
||||
for (const key of this.pdfSearchKeys) {
|
||||
@@ -1575,11 +1598,41 @@ document.addEventListener("alpine:init", () => {
|
||||
this.view?.clearSearch?.();
|
||||
},
|
||||
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
||||
if (item.cfi) this.view?.goTo?.(item.cfi);
|
||||
else if (item.page != null) this.view?.goTo?.(item.page);
|
||||
else return;
|
||||
if (item.cfi) {
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(item.cfi);
|
||||
} else if (item.page != null) {
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(item.page);
|
||||
} else return;
|
||||
this.closeDrawers();
|
||||
},
|
||||
// ----- back-to-location stack -----
|
||||
// Research pattern: jump somewhere (search hit, footnote link, TOC
|
||||
// entry), read, then return. Recorded before every programmatic jump
|
||||
// and on every internal link click (footnotes). Ordinary paging never
|
||||
// touches the stack.
|
||||
pushBackStack() {
|
||||
let loc: { cfi?: string; page?: number } | null = null;
|
||||
if (this.isFixedLayout) {
|
||||
const page = this.renderer?.index;
|
||||
if (typeof page === "number" && page >= 0) loc = { page };
|
||||
} else {
|
||||
const cfi = this.view?.lastLocation?.cfi;
|
||||
if (cfi) loc = { cfi };
|
||||
}
|
||||
if (!loc) return;
|
||||
const top = this.backStack[this.backStack.length - 1];
|
||||
if (top && top.cfi === loc.cfi && top.page === loc.page) return;
|
||||
this.backStack.push(loc);
|
||||
if (this.backStack.length > 50) this.backStack.shift();
|
||||
},
|
||||
goBackToLocation() {
|
||||
const loc = this.backStack.pop();
|
||||
if (!loc) return;
|
||||
if (loc.cfi) this.view?.goTo?.(loc.cfi);
|
||||
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
|
||||
},
|
||||
flattenTOC(items: any[], depth = 0): any[] {
|
||||
const result: any[] = [];
|
||||
for (const item of items) {
|
||||
@@ -1592,6 +1645,7 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToTOCItem(item: any) {
|
||||
if (this.view && item.href) {
|
||||
this.pushBackStack();
|
||||
this.view.goTo(item.href);
|
||||
this.tocOpen = false;
|
||||
}
|
||||
@@ -1599,8 +1653,10 @@ document.addEventListener("alpine:init", () => {
|
||||
goToBookmark(item: { cfi: string; page: number | null }) {
|
||||
if (!this.view) return;
|
||||
if (item.cfi) {
|
||||
this.pushBackStack();
|
||||
this.view.goTo(item.cfi);
|
||||
} else if (item.page != null && item.page > 0) {
|
||||
this.pushBackStack();
|
||||
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
||||
this.view.goTo(item.page - 1);
|
||||
} else {
|
||||
@@ -2049,8 +2105,12 @@ document.addEventListener("alpine:init", () => {
|
||||
const typing =
|
||||
tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
|
||||
this.pokeChrome();
|
||||
if (k === "ArrowLeft" || k === "h") this.goLeft();
|
||||
else if (k === "ArrowRight" || k === "l") this.goRight();
|
||||
if (k === "ArrowLeft" || k === "h") {
|
||||
if (event.altKey) {
|
||||
event.preventDefault();
|
||||
this.goBackToLocation();
|
||||
} else this.goLeft();
|
||||
} else if (k === "ArrowRight" || k === "l") this.goRight();
|
||||
else if (k === "+" || k === "=") this.zoomIn();
|
||||
else if (k === "-" || k === "_") this.zoomOut();
|
||||
else if (k === "0") this.resetZoom();
|
||||
|
||||
Reference in New Issue
Block a user