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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user