Skip to content

Reader: paginated/scrolled mode parity (PDF + EPUB) with shared settings - #62

Merged
PetalCat merged 22 commits into
mainfrom
feature/reader-paginated-mode
Apr 19, 2026
Merged

Reader: paginated/scrolled mode parity (PDF + EPUB) with shared settings#62
PetalCat merged 22 commits into
mainfrom
feature/reader-paginated-mode

Conversation

@PetalCat

Copy link
Copy Markdown
Collaborator

Closes #61.

Summary

  • PDF reader gains paginated mode (was scroll-only). EPUB gains the missing flow/spread/animation/inputs/direction controls.
  • Shared ReaderSettings model + shared settings UI (ReaderSettingsPanel), persisted client-side in localStorage under the existing nexus-reader-settings key.
  • New components: PaginatedViewport (generic wrapper), useReaderInputs rune (tap zones / swipe / keyboard with RTL flip), ReaderSettingsPanel.
  • Animations: slide / fade / none (no page-curl).
  • PDF toolbar gains a Reader Settings gear button (otherwise no entry point in scrolled mode).
  • Bug fix: /books/read/[id]/+page.server.ts no longer crashes when metadata.formats are CalibreFormat objects (it always was — .toLowerCase() on an object), and no longer defaults to EPUB when EPUB isn't an available format.

Spec: docs/superpowers/specs/2026-04-19-reader-paginated-mode-design.md
Plan: docs/superpowers/plans/2026-04-19-reader-paginated-mode.md

What was harder than expected

  • bind:this with array indexing inside Svelte 5 snippets is fragile — the slot can read empty even when the canvas is mounted. Switched to a use:onCanvasMount action that caches the ref into the array directly and kicks off render via queueMicrotask (RAF gets cancelled by the action's lifecycle during commit).
  • PaginatedViewport's original {#key animationKey} wrap killed the EPUB iframe each navigation (foliate-js's addEventListener.once then hit a null contentDocument). Removed the key wrap; PDF still re-mounts naturally on currentPage change so animations still play.
  • Paginated dual mode rendered each page at single-page-fit-width scale → pages overflowed (page 1 at x=-370). Now fit-to-viewport, divided by pagesAcross.
  • Zoom in/out in paginated mode didn't take while a page was mid-render — invalidateAllPages only iterated renderedPages (finished), not renderingPages (in-flight). Fixed by iterating the union so the active task gets cancelled.
  • T5 implementer renamed BookReader's existing oled/display/medium keys to match a stale plan, which would silently reset existing users' stored settings; reverted to the original keys.

Out of scope (per spec, intentional)

  • Page-curl animation, per-zone tap remapping, two-up cover handling, per-book settings overrides.
  • W (fit-width) / P (fit-page) toolbar buttons in PDF — they remain scrolled-mode-only since paginated already auto-fits. Can hide them in a follow-up if the dead buttons confuse.

Test plan

  • Open an EPUB at /books/read/<id> — paginated mode renders, settings drawer has new controls
  • Toggle Flow → Scrolled, verify behavior changes
  • Open a PDF — paginated mode renders, gear icon opens settings drawer
  • Single ↔ Dual spread toggle works for PDF
  • Zoom in/out works in PDF paginated mode (canvas grows/shrinks)
  • ArrowLeft/Right and Space advance pages in both readers
  • Direction → RTL inverts swipe and tap-zone semantics
  • Settings persist across reload

pnpm check clean, pnpm vitest run 161/161 passing, pnpm build succeeds. Verified live on jellyfin host via :dev build through the entire iteration.

🤖 Generated with Claude Code

PetalCat and others added 22 commits April 19, 2026 10:02
Adds the design for unifying paginated/scrolled flow across both
readers with a shared settings model. Also fixes the books-read 500
where availableFormats are CalibreFormat objects, not strings, which
breaks .toLowerCase() in the load fn — and switches the format default
to fall back to the first available format instead of always 'epub'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bite-sized tasks for the spec — shared ReaderSettings module + tests,
useReaderInputs rune + tests, PaginatedViewport wrapper, shared
ReaderSettingsPanel, BookReader/PdfReader rewires, cleanup, build
verification, deploy to jellyfin host. Also patched the spec's
persistence section to reflect that settings live in localStorage,
not server-side userReaderPrefs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node 25 ships a stub `localStorage` that requires `--localstorage-file=<path>`
to function. Inside vitest's jsdom environment, that stub shadows jsdom's own
Storage and leaves `setItem`/`clear` undefined. Add a Map-backed Storage
polyfill as a vitest setup file so component tests get the DOM behavior they
expect without Node CLI flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nce (#61)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shim added in e5af12f works without happy-dom or jsdom — the
optional-peer churn in the lockfile was leftover from experimentation
and is not needed. Tests still 9/9 with the original lockfile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace BookReader's bespoke localStorage settings + discrete per-field
$state with the shared ReaderSettings module. Swap the ad-hoc flow toggle
for <ReaderSettingsPanel variant="epub" /> and wrap the foliate-view host
in <PaginatedViewport> so paginated-mode inputs/animation live in one
place. Theme/font/margin dictionaries renamed to match the canonical
ReaderSettings type (oled→night, display→dyslexic, medium→normal);
visible UI labels are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T5 implementer renamed BookReader's existing oled/display/medium keys
to night/dyslexic/normal to match the plan's reader-settings.ts. That
silently broke existing users' stored settings (coerce() would reject
oled/display/medium and reset to defaults) and also mislabeled the
'dyslexic' font key — it actually maps to Playfair Display, not an
OpenDyslexic stack. Realign reader-settings.ts to the names already
in use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T6 added the settings drawer to PdfReader but wired only the
PaginatedViewport tap-zone as the entry point. That leaves scrolled
mode with no way to open the drawer. Add a Settings icon to the PDF
toolbar so the drawer is reachable from any flow mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ader

PaginatedViewport's keyboard listener (gated by settings.inputs.keyboard)
now owns prev/next navigation. BookReader's own arrow handler was firing
the same view.prev()/next() calls, doubling each press. Foliate's prev/next
is idempotent so this didn't visibly break anything, but it's redundant
and would surface as a race the moment those calls weren't idempotent.
KeyboardShortcuts.svelte is purely a help-overlay panel — no listener
work to consolidate there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PaginatedViewport wrapped its children in {#key animationKey} so each
prev/next would replay the slide animation. For PDF that's harmless —
the page-card snippet remounts on currentPage anyway. For EPUB it's
fatal: foliate-js's iframe gets destroyed mid-load and the dangling
"once" load handler hits a null contentDocument, throwing
"Cannot read properties of null (reading 'head')". The reader
appears blank and pages "fail to load."

Drop the {#key} wrap. Animations still play in PDF (canvas mounts on
each page change). EPUB loses the cross-iframe slide effect, which is
acceptable — foliate-js handles its own internal page transitions.

Also patches a separate bug in PdfReader's paginated render: the
canvas mounts inside the page-card snippet but renderedPages was
keeping the old page numbers, so revisits would skip the re-render
onto the freshly-mounted canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed mode

Two real bugs the deploy surfaced:

1. PDF canvases stayed blank in paginated mode. The render-trigger
   effect ran on currentPage change, but bind:this with array indexing
   inside a snippet doesn't reliably populate the array slot before the
   effect fires — canvasRefs[idx] reads null and renderPage early-returns.
   Add a use:onCanvasMount action that fires render after the canvas is
   in the DOM and the ref has settled. Independent of the bind:this
   timing, and gets called on update when pageNum changes (revisits).

2. Paginated dual mode rendered each page at single-page-fit-width
   scale, so two pages ended up wider than the viewport (page 1 sat
   at x=-370). Fit-to-viewport: divide width by pagesAcross (1 or 2),
   take min of fit-width and fit-height. Apply the same scale in both
   getPageDims (wrapper sizing) and renderPage (canvas content) so
   wrapper and canvas always agree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Even with use:onCanvasMount firing, canvasRefs[idx] reads as null
inside renderPage. bind:this with array indexing (canvasRefs[pageNum-1])
inside a snippet is fragile in Svelte 5 — the slot can be observed
empty by code running on the same tick as the snippet mount, even when
the canvas is fully in the DOM.

Fall back to a data-page DOM lookup when the array slot is empty;
populate the array slot at the same time so subsequent calls hit the
cache. Works for both paginated and scrolled modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Paginated mode auto-fits each page to the viewport, which made the
toolbar zoom controls dead — they wrote to scale but renderPage
ignored scale in favor of the fit calculation. Add a paginatedZoom
multiplier (default 1.0 = exact fit) that gets applied on top of
auto-fit. ZoomIn/Out adjust paginatedZoom in paginated mode and
scale in scrolled mode. The toolbar percentage display reflects
whichever is active.

Toolbar W (fit-width) / P (fit-page) buttons remain only meaningful
in scrolled mode — they're no-ops in paginated since auto-fit owns
that. Leaving them visible for now; can hide later if it confuses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Zoom-in clicked while page 1 was still mid-render did nothing because
invalidateAllPages only iterated renderedPages (finished pages), not
renderingPages (in-flight). The in-flight render kept its old scale,
finished, and added itself to renderedPages — masking the new zoom.
Iterate the union so cleanupPage can cancel the active render task too.

Also strip the temporary console traces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@PetalCat
PetalCat merged commit 67c71c4 into main Apr 19, 2026
2 checks passed
@PetalCat
PetalCat deleted the feature/reader-paginated-mode branch April 19, 2026 16:29
PetalCat added a commit that referenced this pull request Apr 19, 2026
Includes the reader paginated-mode parity feature (#62, closes #61).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PetalCat added a commit that referenced this pull request May 19, 2026
PDF reader gains paginated mode (was scroll-only). EPUB gains the missing flow/spread/animation/inputs/direction controls. Shared ReaderSettings model + shared settings UI (ReaderSettingsPanel), persisted client-side under nexus-reader-settings. New components: PaginatedViewport (generic wrapper), useReaderInputs rune (tap zones / swipe / keyboard with RTL flip), ReaderSettingsPanel. Animations: slide / fade / none.

PDF toolbar gains a Reader Settings gear button. Bug fix: /books/read/[id]/+page.server.ts no longer crashes on CalibreFormat objects and no longer defaults to EPUB when EPUB isn't available.

Closes #61.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PetalCat added a commit that referenced this pull request May 19, 2026
Includes the reader paginated-mode parity feature (#62, closes #61).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PetalCat added a commit that referenced this pull request May 19, 2026
PDF reader gains paginated mode (was scroll-only). EPUB gains the missing flow/spread/animation/inputs/direction controls. Shared ReaderSettings model + shared settings UI (ReaderSettingsPanel), persisted client-side under nexus-reader-settings. New components: PaginatedViewport (generic wrapper), useReaderInputs rune (tap zones / swipe / keyboard with RTL flip), ReaderSettingsPanel. Animations: slide / fade / none.

PDF toolbar gains a Reader Settings gear button. Bug fix: /books/read/[id]/+page.server.ts no longer crashes on CalibreFormat objects and no longer defaults to EPUB when EPUB isn't available.

Closes #61.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PetalCat added a commit that referenced this pull request May 19, 2026
Includes the reader paginated-mode parity feature (#62, closes #61).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reader: paginated/scrolled mode parity (PDF + EPUB) with shared settings

1 participant