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
+27 -1
View File
@@ -521,8 +521,18 @@ templ drawerHeader(title string) {
templ ReaderTOCDrawer() {
@drawerHeader("Contents")
<!-- Fixed-layout books get a Pages (thumbnails) tab; outline TOC may be
absent in manga-scan PDFs and CBZs. -->
<div class="reader-tabs" x-show="isFixedLayout">
<button :class="tocTab === 'contents' ? 'active' : ''" @click="tocTab = 'contents'">
Contents <span class="reader-tab-count" x-text="tocItems.length">0</span>
</button>
<button :class="tocTab === 'pages' ? 'active' : ''" @click="tocTab = 'pages'; initPageThumbs()">
Pages <span class="reader-tab-count" x-text="pageThumbList.length || ''">0</span>
</button>
</div>
<div class="reader-drawer-body">
<nav id="toc-list" class="space-y-1">
<nav id="toc-list" class="space-y-1" x-show="tocTab === 'contents' || !isFixedLayout">
<template x-for="item in tocItems" :key="item.href">
<a
href="#"
@@ -536,6 +546,22 @@ templ ReaderTOCDrawer() {
<p class="text-sm py-2" style="color: var(--text-secondary)">No table of contents</p>
</template>
</nav>
<div id="page-thumb-grid" class="reader-thumb-grid" x-show="isFixedLayout && tocTab === 'pages'">
<template x-for="p in pageThumbList" :key="p.index">
<button
class="reader-thumb"
:class="p.index === currentPageIndex ? 'active' : ''"
@click="goToPage(p.index)"
:title="'Page ' + (p.index + 1)"
>
<div class="reader-thumb-img" :data-page="p.index"></div>
<span class="reader-thumb-num" x-text="p.index + 1">1</span>
</button>
</template>
<template x-if="pageThumbList.length === 0">
<p class="text-sm py-2" style="color: var(--text-secondary)">No pages</p>
</template>
</div>
</div>
}
+1 -1
View File
@@ -357,7 +357,7 @@ func ReaderTOCDrawer() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"reader-drawer-body\"><nav id=\"toc-list\" class=\"space-y-1\"><template x-for=\"item in tocItems\" :key=\"item.href\"><a href=\"#\" @click.prevent=\"goToTOCItem(item)\" class=\"block py-1 px-2 rounded hover:bg-gray-700 text-sm\" :style=\"'padding-left: ' + ((item.depth || 0) * 1 + 0.5) + 'rem'\" x-text=\"item.label\"></a></template><template x-if=\"tocItems.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No table of contents</p></template></nav></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<!-- Fixed-layout books get a Pages (thumbnails) tab; outline TOC may be\n\t absent in manga-scan PDFs and CBZs. --><div class=\"reader-tabs\" x-show=\"isFixedLayout\"><button :class=\"tocTab === 'contents' ? 'active' : ''\" @click=\"tocTab = 'contents'\">Contents <span class=\"reader-tab-count\" x-text=\"tocItems.length\">0</span></button> <button :class=\"tocTab === 'pages' ? 'active' : ''\" @click=\"tocTab = 'pages'; initPageThumbs()\">Pages <span class=\"reader-tab-count\" x-text=\"pageThumbList.length || ''\">0</span></button></div><div class=\"reader-drawer-body\"><nav id=\"toc-list\" class=\"space-y-1\" x-show=\"tocTab === 'contents' || !isFixedLayout\"><template x-for=\"item in tocItems\" :key=\"item.href\"><a href=\"#\" @click.prevent=\"goToTOCItem(item)\" class=\"block py-1 px-2 rounded hover:bg-gray-700 text-sm\" :style=\"'padding-left: ' + ((item.depth || 0) * 1 + 0.5) + 'rem'\" x-text=\"item.label\"></a></template><template x-if=\"tocItems.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No table of contents</p></template></nav><div id=\"page-thumb-grid\" class=\"reader-thumb-grid\" x-show=\"isFixedLayout && tocTab === 'pages'\"><template x-for=\"p in pageThumbList\" :key=\"p.index\"><button class=\"reader-thumb\" :class=\"p.index === currentPageIndex ? 'active' : ''\" @click=\"goToPage(p.index)\" :title=\"'Page ' + (p.index + 1)\"><div class=\"reader-thumb-img\" :data-page=\"p.index\"></div><span class=\"reader-thumb-num\" x-text=\"p.index + 1\">1</span></button></template><template x-if=\"pageThumbList.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No pages</p></template></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}