Skip to content

Commit 35cb434

Browse files
PetalCatclaude
andauthored
feat(reader): paginated mode parity for PDF + EPUB (#61, #62)
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>
1 parent aca10bf commit 35cb434

14 files changed

Lines changed: 2347 additions & 158 deletions

docs/superpowers/plans/2026-04-19-reader-paginated-mode.md

Lines changed: 1190 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
Status: in-flight
3+
Closes: #61
4+
---
5+
6+
# Reader Paginated Mode — Design
7+
8+
## Goal
9+
10+
Bring the PDF reader to feature parity with the EPUB reader's paginated/scrolled flow modes, and unify the reader settings surface across both. The result: one familiar control panel that works on phone, tablet, and desktop, with the page-turn experience that physical-book readers expect.
11+
12+
Also: a small bug fix so the EPUB reader doesn't load for books that have no EPUB format.
13+
14+
## Background
15+
16+
`BookReader.svelte` (foliate-js, EPUB) already supports `flow: paginated | scrolled` via a settings drawer toggle. `PdfReader.svelte` (PDF.js, our wrapper) is scroll-only with a `single | dual` spread mode. There is no shared settings model — each reader carries its own state and persistence.
17+
18+
Today, opening a book with no EPUB format still routes to the EPUB reader because `+page.server.ts` defaults `format = 'epub'` regardless of what's actually available. This produces a broken reader for PDF-only books.
19+
20+
## Requirements
21+
22+
1. PDF reader gains `paginated` flow mode.
23+
2. EPUB reader gains the missing controls (spread, animation, per-input toggles, direction).
24+
3. Both readers share one settings model and one settings UI pattern.
25+
4. Settings persist per-user (extending the existing `userReaderPrefs` storage).
26+
5. Touch-friendly on phones, comfortable on tablets, fast on desktop.
27+
6. Bug fix: pick a sensible default format when EPUB is unavailable.
28+
29+
## Settings Model
30+
31+
A shared `ReaderSettings` shape, persisted per user:
32+
33+
```ts
34+
interface ReaderSettings {
35+
flow: 'paginated' | 'scrolled'; // default: 'paginated'
36+
spread: 'auto' | 'single' | 'dual'; // default: 'auto'
37+
pageAnimation: 'slide' | 'fade' | 'none'; // default: 'slide'
38+
inputs: {
39+
tapZones: boolean; // default: true
40+
swipe: boolean; // default: true
41+
keyboard: boolean; // default: true
42+
};
43+
direction: 'ltr' | 'rtl'; // default: 'ltr'
44+
}
45+
```
46+
47+
`spread: 'auto'` resolves at runtime: phones and portrait tablets → single, landscape tablets and desktop → dual. Resolution uses a CSS media query observed via a `$state` rune so it stays reactive when the device rotates.
48+
49+
## Components
50+
51+
### `PaginatedViewport.svelte` (new)
52+
53+
A generic wrapper that owns flow, spread, animation, and input handling. PDF and EPUB readers each render their format-specific content as the viewport's slot child. The viewport's job is to translate user inputs (tap, swipe, key) and the active settings into "advance N pages" / "go back N pages" calls that the child handles.
54+
55+
Why a wrapper rather than duplicating logic in each reader: input handling, gesture thresholds, animation state, and accessibility wiring are not format-specific. Splitting them out keeps each reader focused on rendering its own content.
56+
57+
### `useReaderInputs.svelte.ts` (new)
58+
59+
A small rune that wires touch (swipe), pointer (tap zones), and keyboard handlers. Caller passes `{ onPrev, onNext, onToggleUI, settings }` and gets back the appropriate event listeners as derived attachments. Centralizing this avoids three near-identical implementations.
60+
61+
Tap zones: invisible overlays at left-third / middle / right-third. Tap-zone hit detection uses pointer events; debounced 200ms to prevent double-fire on touch-then-click.
62+
63+
Swipe threshold: 50px minimum horizontal travel, must exceed vertical travel by 2x (avoids fighting vertical scroll in scrolled mode).
64+
65+
Keyboard: ←/→ and PageUp/PageDown for prev/next, Space for next, Esc to close drawers. Already wired for EPUB; this consolidates.
66+
67+
`direction: 'rtl'` flips the prev/next mapping for tap zones, swipe, and keyboard.
68+
69+
### `KeyboardShortcuts.svelte`
70+
71+
Extend the existing component to be format-agnostic; PDF reader picks it up.
72+
73+
### `PdfReader.svelte` (modify)
74+
75+
- Wrap rendered pages in `PaginatedViewport`.
76+
- In paginated mode: render only the current page (or current pair if `spread = dual`) at fit-to-viewport sizing, no scroll. Advance/back updates the current page index.
77+
- Existing `spreadMode` state becomes the `spread` setting; `auto` resolves at render time.
78+
- Page index continues to flow through `play_sessions.position` (already does for scrolled mode).
79+
80+
### `BookReader.svelte` (modify)
81+
82+
- Pull existing `flow` state out into the shared settings model.
83+
- Add the spread/animation/input/direction controls to its settings drawer.
84+
- Pass everything to `PaginatedViewport`.
85+
- foliate-js's `renderer.setAttribute('flow', ...)` continues to drive the underlying paginated layout; the wrapper just owns the chrome and inputs.
86+
87+
### Settings UI
88+
89+
Both readers' right-side drawers render the same `<ReaderSettingsPanel>` component. Single source of truth for layout and copy.
90+
91+
## Animation
92+
93+
Implemented via CSS on the viewport's page container:
94+
95+
- `slide` (default): `transform: translateX(±100%)` with `transition: transform 220ms ease-out`. Two-buffer approach (current + incoming) so slide direction matches navigation direction.
96+
- `fade`: `opacity 0 → 1` over 150ms.
97+
- `none`: no transition.
98+
99+
All three driven by the single `pageAnimation` setting; no per-format implementations.
100+
101+
## Persistence
102+
103+
Reader settings are persisted **client-side** in `localStorage` under the key `nexus-reader-settings`. The EPUB reader already uses this key for theme/font/etc. The PDF reader currently has no persistence; it will adopt the same key and shape.
104+
105+
Storage shape extends the existing JSON object with the new fields. Reads tolerate missing keys (fall back to defaults), so existing users keep their theme/font/etc. without a migration. Writes always emit the full shape.
106+
107+
A single helper module (`src/lib/components/books/reader-settings.ts`) owns the schema, defaults, load, and persist functions. Both readers consume it — no inline `localStorage.getItem('nexus-reader-settings')` reads elsewhere.
108+
109+
## Format-Default Bug Fix
110+
111+
In `src/routes/books/read/[id]/+page.server.ts`, change the format-resolution logic:
112+
113+
```ts
114+
const requestedFormat = (url.searchParams.get('format') ?? 'epub').toLowerCase();
115+
const fallback = availableFormats[0] ?? 'epub';
116+
const format = availableFormats.includes(requestedFormat) ? requestedFormat : fallback;
117+
```
118+
119+
If the requested format isn't available, fall back to the first available format rather than always to `'epub'`. For a PDF-only book, this routes to the PDF reader cleanly. If `availableFormats` is empty (no formats reported), the existing 404 from `getItem` returning null still fires upstream of this code.
120+
121+
## Out of Scope
122+
123+
- Page curl animation (skeuomorphic page-turn): expensive to make non-cringe, easy to skip.
124+
- Per-zone tap action remapping (custom action mapping per zone): YAGNI.
125+
- Two-up cover handling (first page solo, then pairs in dual mode): can add later if it bugs anyone.
126+
- Per-book settings overrides (settings are global per user, not per book).
127+
128+
## Testing
129+
130+
- Vitest: `useReaderInputs` rune — tap zone hit detection, swipe threshold, RTL direction flip, keyboard mapping.
131+
- Vitest: `PaginatedViewport` — flow switching, spread auto-resolution at different viewport widths, animation class application.
132+
- Manual: `pnpm dev`, exercise both readers across desktop, an iPad-sized viewport, and a phone-sized viewport. Verify tap zones, swipe, keyboard. Verify `:dev` build behavior on jellyfin host with TKAMB.
133+
134+
## Migration / Rollout
135+
136+
Single PR. No DB migration. No feature flag — the new settings panel replaces the existing one in both readers atomically. Old `userReaderPrefs` rows continue to work; missing fields hit defaults.
137+
138+
## Open Questions
139+
140+
None at design time. Implementation plan will surface anything that needs a follow-up.

src/lib/components/books/BookReader.svelte

Lines changed: 57 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
import ReaderProgressBar from './ReaderProgressBar.svelte';
1313
import TimeEstimate from './TimeEstimate.svelte';
1414
import KeyboardShortcuts from './KeyboardShortcuts.svelte';
15+
import PaginatedViewport from './PaginatedViewport.svelte';
16+
import ReaderSettingsPanel from './ReaderSettingsPanel.svelte';
17+
import { loadReaderSettings, persistReaderSettings, DEFAULT_READER_SETTINGS, type ReaderSettings } from './reader-settings';
1518
1619
interface Props {
1720
epubUrl: string;
@@ -104,34 +107,38 @@
104107
{ label: 'Shortcuts', key: '?' }
105108
];
106109
107-
// Reader settings (persisted in localStorage)
108-
let readerTheme = $state<'dark' | 'light' | 'sepia' | 'oled'>('dark');
109-
let fontFamily = $state<'serif' | 'sans' | 'mono' | 'display'>('serif');
110-
let fontSize = $state(18);
111-
let lineHeight = $state(1.6);
112-
let margins = $state<'narrow' | 'medium' | 'wide'>('medium');
113-
let textAlign = $state<'start' | 'justify'>('start');
114-
let flow = $state<'paginated' | 'scrolled'>('paginated');
110+
// Reader settings (persisted in localStorage via shared module)
111+
let settings = $state<ReaderSettings>({ ...DEFAULT_READER_SETTINGS });
112+
113+
// Derived aliases — keeps existing template references working; all writes
114+
// go back into `settings` so there is a single source of truth.
115+
const readerTheme = $derived(settings.theme);
116+
const fontFamily = $derived(settings.fontFamily);
117+
const fontSize = $derived(settings.fontSize);
118+
const lineHeight = $derived(settings.lineHeight);
119+
const margins = $derived(settings.margins);
120+
const textAlign = $derived(settings.textAlign);
121+
const flow = $derived(settings.flow);
115122
116123
let hideTimer: ReturnType<typeof setTimeout> | null = null;
117124
118125
const anyPanelOpen = $derived(showToc || showSettings || showBookmarks || showSearch || showFormatMenu);
119126
120-
const themes: Record<string, { bg: string; text: string; link: string }> = {
127+
const themes: Record<ReaderSettings['theme'], { bg: string; text: string; link: string }> = {
121128
dark: { bg: '#181514', text: '#f0ebe3', link: '#d4a253' },
122129
light: { bg: '#faf8f5', text: '#1a1a1a', link: '#b8862e' },
123130
sepia: { bg: '#f4ecd8', text: '#5b4636', link: '#8b6914' },
124131
oled: { bg: '#000000', text: '#b0b0b0', link: '#d4a253' }
125132
};
126133
127-
const fonts: Record<string, string> = {
134+
const fonts: Record<ReaderSettings['fontFamily'], string> = {
128135
serif: "Georgia, 'Times New Roman', serif",
129136
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
130137
mono: "'JetBrains Mono', 'Fira Code', monospace",
131138
display: "'Playfair Display', Georgia, serif"
132139
};
133140
134-
const marginValues: Record<string, string> = {
141+
const marginValues: Record<ReaderSettings['margins'], string> = {
135142
narrow: '2%',
136143
medium: '6%',
137144
wide: '12%'
@@ -144,30 +151,15 @@
144151
pink: 'rgba(251, 113, 133, 0.3)'
145152
};
146153
147-
// ── Settings persistence ──
148-
function loadSettings() {
149-
if (!browser) return;
150-
try {
151-
const saved = localStorage.getItem('nexus-reader-settings');
152-
if (saved) {
153-
const s = JSON.parse(saved);
154-
if (s.theme && s.theme in themes) readerTheme = s.theme;
155-
if (s.fontFamily && s.fontFamily in fonts) fontFamily = s.fontFamily;
156-
if (s.fontSize) fontSize = s.fontSize;
157-
if (s.lineHeight) lineHeight = s.lineHeight;
158-
if (s.margins && s.margins in marginValues) margins = s.margins;
159-
if (s.textAlign === 'start' || s.textAlign === 'justify') textAlign = s.textAlign;
160-
if (s.flow) flow = s.flow;
161-
}
162-
} catch { /* ignore */ }
163-
}
154+
// ── Settings persistence (via shared module) ──
155+
// Writes are persisted via $effect below; reads happen lazily in
156+
// initFoliateReader so the reader hydrates with whatever's in localStorage.
164157
165-
function persistSettings() {
158+
$effect(() => {
159+
// Persist on every change. `persistReaderSettings` merges into stored state.
166160
if (!browser) return;
167-
localStorage.setItem('nexus-reader-settings', JSON.stringify({
168-
theme: readerTheme, fontFamily, fontSize, lineHeight, margins, textAlign, flow
169-
}));
170-
}
161+
persistReaderSettings(settings);
162+
});
171163
172164
// ── Build CSS for foliate-js renderer ──
173165
function getReaderCSS(): string {
@@ -218,7 +210,6 @@
218210
function applyStyles() {
219211
if (!view?.renderer?.setStyles) return;
220212
view.renderer.setStyles(getReaderCSS());
221-
persistSettings();
222213
}
223214
224215
// ── Flatten TOC for display ──
@@ -507,9 +498,8 @@
507498
function handleKeydown(e: KeyboardEvent) {
508499
if (showSearch && e.key !== 'Escape') return;
509500
if (showNoteInput && e.key !== 'Escape') return;
501+
// ArrowLeft/Right are owned by PaginatedViewport (gated by settings.inputs.keyboard).
510502
switch (e.key) {
511-
case 'ArrowLeft': e.preventDefault(); prevPage(); break;
512-
case 'ArrowRight': e.preventDefault(); nextPage(); break;
513503
case 'Escape':
514504
e.preventDefault();
515505
if (showAnnotationPopup) dismissAnnotationPopup();
@@ -528,7 +518,8 @@
528518
529519
// ── Svelte action for foliate-js lifecycle ──
530520
function initFoliateReader(node: HTMLElement) {
531-
loadSettings();
521+
// Hydrate settings from localStorage before the view opens
522+
settings = loadReaderSettings();
532523
533524
// Sync initial prop values
534525
currentProgress = initialProgress;
@@ -684,12 +675,11 @@
684675
if (view && ready) applyStyles();
685676
});
686677
687-
// Handle flow change
678+
// Handle flow + direction changes — drive the foliate renderer directly.
688679
$effect(() => {
689-
if (!view || !ready) return;
690-
void flow;
691-
view.renderer.setAttribute('flow', flow);
692-
persistSettings();
680+
if (!view?.renderer) return;
681+
view.renderer.setAttribute('flow', settings.flow);
682+
view.renderer.setAttribute('dir', settings.direction);
693683
});
694684
695685
const progressPercent = $derived(Math.round(currentProgress * 100));
@@ -790,12 +780,22 @@
790780
style="background-color: {themes[readerTheme].bg};"
791781
onmousemove={handleReaderMouseMove}
792782
>
793-
<!-- foliate-js container -->
794-
<div
795-
use:initFoliateReader
796-
class="absolute overflow-hidden"
797-
style="top: 0; bottom: 0; left: 0; right: 0;"
798-
></div>
783+
<!-- foliate-js container, wrapped by PaginatedViewport for shared input/animation behavior -->
784+
<div class="absolute" style="top: 0; bottom: 0; left: 0; right: 0;">
785+
<PaginatedViewport
786+
{settings}
787+
onPrev={() => view?.prev()}
788+
onNext={() => view?.next()}
789+
onToggleUI={() => { showSettings = !showSettings; }}
790+
>
791+
{#snippet children(_ctx: { effectiveSpread: 'single' | 'dual'; animationKey: number })}
792+
<div
793+
use:initFoliateReader
794+
class="h-full w-full overflow-hidden"
795+
></div>
796+
{/snippet}
797+
</PaginatedViewport>
798+
</div>
799799

800800
<!-- Click zone overlay for navigation (only outside the iframe) -->
801801
<button
@@ -912,7 +912,7 @@
912912
{#each [{ key: 'light', label: 'Light', bg: '#faf8f5', ring: '#ccc' }, { key: 'sepia', label: 'Sepia', bg: '#f4ecd8', ring: '#c4a96a' }, { key: 'dark', label: 'Dark', bg: '#181514', ring: '#555' }, { key: 'oled', label: 'OLED', bg: '#000000', ring: '#333' }] as { key, label, bg, ring } (key)}
913913
<button
914914
class="flex flex-col items-center justify-center gap-1.5 rounded-lg border px-2 py-2.5 text-[10px] transition-all {readerTheme === key ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/40 hover:border-cream/20 hover:text-cream/60'}"
915-
onclick={() => { readerTheme = key as typeof readerTheme; }}
915+
onclick={() => { settings.theme = key as ReaderSettings['theme']; }}
916916
>
917917
<span
918918
class="h-5 w-5 rounded-full border-2"
@@ -932,7 +932,7 @@
932932
<button
933933
class="rounded-lg border px-2 py-2 text-xs transition-all {fontFamily === key ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}"
934934
style="font-family: {font};"
935-
onclick={() => { fontFamily = key as typeof fontFamily; }}
935+
onclick={() => { settings.fontFamily = key as ReaderSettings['fontFamily']; }}
936936
>{label}</button>
937937
{/each}
938938
</div>
@@ -946,7 +946,7 @@
946946
</label>
947947
<div class="flex items-center gap-3">
948948
<Type size={12} class="shrink-0 text-cream/30" />
949-
<input id="reader-font-size" type="range" min="12" max="36" step="1" bind:value={fontSize} class="reader-range flex-1" />
949+
<input id="reader-font-size" type="range" min="12" max="36" step="1" bind:value={settings.fontSize} class="reader-range flex-1" />
950950
<Type size={20} class="shrink-0 text-cream/30" />
951951
</div>
952952
</div>
@@ -957,7 +957,7 @@
957957
<span>Line Height</span>
958958
<span class="normal-case tracking-normal text-cream/60">{lineHeight.toFixed(1)}</span>
959959
</label>
960-
<input id="reader-line-height" type="range" min="1.0" max="2.0" step="0.1" bind:value={lineHeight} class="reader-range w-full" />
960+
<input id="reader-line-height" type="range" min="1.0" max="2.0" step="0.1" bind:value={settings.lineHeight} class="reader-range w-full" />
961961
</div>
962962

963963
<!-- Margins -->
@@ -967,7 +967,7 @@
967967
{#each [{ key: 'narrow', label: 'Narrow' }, { key: 'medium', label: 'Medium' }, { key: 'wide', label: 'Wide' }] as { key, label } (key)}
968968
<button
969969
class="flex-1 rounded-lg border px-3 py-2 text-xs transition-all {margins === key ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}"
970-
onclick={() => { margins = key as typeof margins; }}
970+
onclick={() => { settings.margins = key as ReaderSettings['margins']; }}
971971
>{label}</button>
972972
{/each}
973973
</div>
@@ -979,33 +979,21 @@
979979
<div class="flex gap-2">
980980
<button
981981
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-all {textAlign === 'start' ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}"
982-
onclick={() => { textAlign = 'start'; }}
982+
onclick={() => { settings.textAlign = 'start'; }}
983983
>
984984
<AlignLeft size={13} strokeWidth={1.5} /> Left
985985
</button>
986986
<button
987987
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-all {textAlign === 'justify' ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}"
988-
onclick={() => { textAlign = 'justify'; }}
988+
onclick={() => { settings.textAlign = 'justify'; }}
989989
>
990990
<AlignJustify size={13} strokeWidth={1.5} /> Justified
991991
</button>
992992
</div>
993993
</div>
994994

995-
<!-- Reading Mode -->
996-
<div>
997-
<span class="settings-label">Reading Mode</span>
998-
<div class="flex gap-2">
999-
<button
1000-
class="flex-1 rounded-lg border px-3 py-2 text-xs transition-all {flow === 'paginated' ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}"
1001-
onclick={() => { flow = 'paginated'; }}
1002-
>Paginated</button>
1003-
<button
1004-
class="flex-1 rounded-lg border px-3 py-2 text-xs transition-all {flow === 'scrolled' ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}"
1005-
onclick={() => { flow = 'scrolled'; }}
1006-
>Scrolled</button>
1007-
</div>
1008-
</div>
995+
<!-- Shared paginated / flow / spread / inputs / direction panel -->
996+
<ReaderSettingsPanel bind:settings variant="epub" />
1009997
</div>
1010998
</div>
1011999
{/if}

0 commit comments

Comments
 (0)