feat(reader): page thumbnails tab + reliable PDF contents

Investigation: the contents drawer read book.toc, which makePDF
builds from pdf.getOutline() — verified against the real library PDF
(Head First SQL) through the exact vendored pdf.js build AND the exact
range transport the browser uses: 18 chapter entries come back. So
the source is right; manga-scan PDFs and CBZs simply have no embedded
outline, which made Contents look broken exactly where users expect
page-based navigation.

- TOC now populates eagerly right after the book opens (toggle-time
  lazy population removed), so an existing outline can never silently
  miss due to timing; the drawer keeps the honest empty-state text
  for books without outlines.
- New 'Pages' tab in the contents drawer for fixed-layout books:
  a Kavita-style thumbnail grid (3-up, current page highlighted and
  scrolled into view, click to jump — recorded on the back-to-
  location stack). Thumbnails render client-side: PDFs via the
  in-memory pdf.js document (small viewport render, Alpine.raw
  unwrap); comics via the page's image blob drawn down to a 110px
  canvas, then unloading the full-size blob so thumbnailling doesn't
  hoard page images. Lazy via IntersectionObserver scoped to the
  drawer's scroll container (200px margin), canvases cached at module
  level so revisits are instant; failures warn in console and allow
  retry. The backend /readers/thumbnails endpoint turned out to be an
  empty stub, so nothing server-side was worth wiring.
This commit is contained in:
2026-08-17 13:55:21 -04:00
parent dc68d03360
commit fd4c357d39
5 changed files with 212 additions and 3 deletions
+138
View File
@@ -18,6 +18,10 @@ const HIGHLIGHT_COLORS = [
"#ce93d8",
];
// Rendered page-thumbnail canvases (page index → canvas). Module-level so
// Alpine reactivity never wraps it and revisiting pages is instant.
const thumbCache = new Map<number, HTMLCanvasElement>();
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
const FONT_MAP: Record<string, string> = {
@@ -418,6 +422,9 @@ document.addEventListener("alpine:init", () => {
searchError: "",
searchGen: 0,
pdfPagesCache: null as PdfPageText[] | null,
tocTab: "contents" as string,
pageThumbList: [] as { index: number }[],
thumbObserver: null as IntersectionObserver | null,
pdfSearchKeys: [] as string[],
backStack: [] as { cfi?: string; page?: number }[],
selectionPopover: {
@@ -591,6 +598,11 @@ document.addEventListener("alpine:init", () => {
});
this.renderer = this.view.renderer;
this.book = this.view.book;
// Populate the TOC eagerly (PDF outlines and EPUB TOCs alike) so the
// drawer never depends on lazy-toggle timing; toggleTOC only opens.
if (this.book?.toc?.length) {
this.tocItems = this.flattenTOC(this.book.toc);
}
this.isFixedLayout = this.view.isFixedLayout;
if (this.isFixedLayout) {
// Manga/RTL comics: foliate's goLeft/goRight swap on book.dir === "rtl",
@@ -1682,6 +1694,132 @@ document.addEventListener("alpine:init", () => {
this.tocOpen = false;
}
},
// ----- page thumbnails (fixed-layout: PDF via pdf.js, comics via the
// page image blobs). Lazy-rendered with an IntersectionObserver so only
// visible cells pay extraction/render cost. -----
get currentPageIndex(): number {
return typeof this.renderer?.index === "number" ? this.renderer.index : -1;
},
initPageThumbs() {
if (!this.isFixedLayout || !this.book) return;
if (!this.pageThumbList.length) {
const sections = (this.book as any).sections ?? [];
this.pageThumbList = sections.map((_s: any, i: number) => ({ index: i }));
}
(this as any).$nextTick(() => {
const grid = document.getElementById("page-thumb-grid");
if (!grid || this.thumbObserver) {
this.resumeThumbObserver();
return;
}
const root = grid.closest(".reader-drawer-body");
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
io.unobserve(entry.target);
const page = parseInt(
(entry.target as HTMLElement).dataset.page ?? "",
10,
);
if (!isNaN(page)) this.renderPageThumb(page, entry.target as HTMLElement);
}
},
{ root: root instanceof Element ? root : null, rootMargin: "200px" },
);
this.thumbObserver = io;
for (const el of Array.from(
grid.querySelectorAll<HTMLElement>(".reader-thumb-img"),
)) {
io.observe(el);
}
this.scrollToCurrentThumb();
});
},
resumeThumbObserver() {
const grid = document.getElementById("page-thumb-grid");
if (grid && this.thumbObserver) {
for (const el of Array.from(
grid.querySelectorAll<HTMLElement>(
".reader-thumb-img:not([data-done])",
),
)) {
this.thumbObserver.observe(el);
}
}
this.scrollToCurrentThumb();
},
scrollToCurrentThumb() {
const cur = document.querySelector(
"#page-thumb-grid .reader-thumb.active",
);
cur?.scrollIntoView({ block: "center" });
},
async renderPageThumb(page: number, container: HTMLElement) {
if (container.dataset.done) return;
container.dataset.done = "1";
try {
const cached = thumbCache.get(page);
if (cached) {
container.appendChild(cached);
return;
}
const rawBook = (Alpine as any).raw
? (Alpine as any).raw(this.book)
: this.book;
if (this.isPDF && rawBook?.pdf) {
const pdfPage = await rawBook.pdf.getPage(page + 1);
const base = pdfPage.getViewport({ scale: 1 });
const scale = 110 / base.width;
const viewport = pdfPage.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
await pdfPage.render({
canvasContext: canvas.getContext("2d")!,
viewport,
}).promise;
thumbCache.set(page, canvas);
container.appendChild(canvas);
} else {
// Comic: section.load() yields a page-document blob URL; extract
// the embedded image URL, draw it small, then free the full-size
// blob (unload) so thumbnailing doesn't hoard page images.
const section = rawBook?.sections?.[page];
const url = await section?.load?.();
if (!url) return;
const html = await (await fetch(url)).text();
const m = html.match(/src="(blob:[^"]+)"/);
if (!m) return;
const img = new Image();
await new Promise<void>((res, rej) => {
img.onload = () => res();
img.onerror = () => rej(new Error("img load"));
img.src = m[1];
});
const scale = 110 / (img.naturalWidth || 1);
const canvas = document.createElement("canvas");
canvas.width = 110;
canvas.height = Math.round((img.naturalHeight || 150) * scale);
canvas.getContext("2d")!.drawImage(img, 0, 0, canvas.width, canvas.height);
thumbCache.set(page, canvas);
container.appendChild(canvas);
// Free the full-size page blob unless it's the page on screen
// (revoking a URL an iframe already loaded is harmless, but the
// current page may be re-requested on re-render).
if (this.renderer?.index !== page) section.unload?.();
}
} catch (e) {
delete container.dataset.done;
console.warn("thumbnail render failed for page", page + 1, e);
}
},
goToPage(index: number) {
if (!this.view || typeof index !== "number" || index < 0) return;
this.pushBackStack();
this.view.goTo(index);
this.tocOpen = false;
},
goToBookmark(item: { cfi: string; page: number | null }) {
if (!this.view) return;
if (item.cfi) {
+45
View File
@@ -1029,6 +1029,51 @@
background-color: color-mix(in srgb, currentColor 6%, transparent);
}
/* Page thumbnails grid (contents drawer, fixed-layout) */
.reader-thumb-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.reader-thumb {
position: relative;
border-radius: 0.375rem;
overflow: hidden;
border: 2px solid transparent;
padding: 0;
background-color: color-mix(in srgb, currentColor 6%, transparent);
}
.reader-thumb:hover {
border-color: color-mix(in srgb, currentColor 30%, transparent);
}
.reader-thumb.active {
border-color: #3b82f6;
}
.reader-thumb-img {
aspect-ratio: 3 / 4;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.reader-thumb-img canvas,
.reader-thumb-img img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.reader-thumb-num {
position: absolute;
bottom: 0.2rem;
right: 0.35rem;
font-size: 0.65rem;
line-height: 1;
padding: 0.15rem 0.3rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.55);
color: #ffffff;
}
/* In-book search */
.reader-search-bar {
display: flex;
+1 -1
View File
File diff suppressed because one or more lines are too long