Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
050e489
spec(reader): paginated mode parity for PDF + EPUB (#61)
PetalCat Apr 19, 2026
8b98f6c
plan(reader): implementation plan for paginated mode parity (#61)
PetalCat Apr 19, 2026
e5af12f
test(infra): localStorage shim for component tests under Node 25
PetalCat Apr 19, 2026
caf61df
feat(reader): shared ReaderSettings module with localStorage persiste…
PetalCat Apr 19, 2026
fdb2ac9
chore: restore pnpm-lock.yaml to pre-T1 state
PetalCat Apr 19, 2026
86d9c4e
feat(reader): useReaderInputs rune for tap/swipe/keyboard (#61)
PetalCat Apr 19, 2026
8df5b82
feat(reader): PaginatedViewport wrapper component (#61)
PetalCat Apr 19, 2026
0ba0f67
feat(reader): ReaderSettingsPanel shared settings UI (#61)
PetalCat Apr 19, 2026
5ba0292
feat(reader): wire BookReader to shared settings + viewport (#61)
PetalCat Apr 19, 2026
83052b7
fix(reader): preserve original theme/font/margin keys in settings
PetalCat Apr 19, 2026
7cfe4e5
feat(reader): add paginated mode to PDF reader (#61)
PetalCat Apr 19, 2026
e24053f
feat(reader): PDF toolbar gear button opens reader settings (#61)
PetalCat Apr 19, 2026
6918c49
refactor(reader): remove duplicate ArrowLeft/Right handling in BookRe…
PetalCat Apr 19, 2026
3ec9679
fix(reader): stop killing the EPUB iframe on every navigation (#61)
PetalCat Apr 19, 2026
9f9d26a
fix(pdf): trigger render on canvas mount + fit-to-viewport in paginat…
PetalCat Apr 19, 2026
fc83454
fix(pdf): DOM fallback when canvasRefs[idx] is empty
PetalCat Apr 19, 2026
36fbed2
debug(pdf): instrument render flow with console.log
PetalCat Apr 19, 2026
c62d1f4
fix(pdf): use queueMicrotask not RAF in onCanvasMount
PetalCat Apr 19, 2026
177cc3a
chore(pdf): remove debug logs after queueMicrotask fix landed
PetalCat Apr 19, 2026
c82b3d5
fix(pdf): zoom in/out work in paginated mode (#61)
PetalCat Apr 19, 2026
c4e47ef
debug: zoom + render trace
PetalCat Apr 19, 2026
299575d
fix(pdf): invalidateAllPages must also cancel in-flight renders
PetalCat Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,190 changes: 1,190 additions & 0 deletions docs/superpowers/plans/2026-04-19-reader-paginated-mode.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-04-19-reader-paginated-mode-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
Status: in-flight
Closes: #61
---

# Reader Paginated Mode — Design

## Goal

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.

Also: a small bug fix so the EPUB reader doesn't load for books that have no EPUB format.

## Background

`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.

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.

## Requirements

1. PDF reader gains `paginated` flow mode.
2. EPUB reader gains the missing controls (spread, animation, per-input toggles, direction).
3. Both readers share one settings model and one settings UI pattern.
4. Settings persist per-user (extending the existing `userReaderPrefs` storage).
5. Touch-friendly on phones, comfortable on tablets, fast on desktop.
6. Bug fix: pick a sensible default format when EPUB is unavailable.

## Settings Model

A shared `ReaderSettings` shape, persisted per user:

```ts
interface ReaderSettings {
flow: 'paginated' | 'scrolled'; // default: 'paginated'
spread: 'auto' | 'single' | 'dual'; // default: 'auto'
pageAnimation: 'slide' | 'fade' | 'none'; // default: 'slide'
inputs: {
tapZones: boolean; // default: true
swipe: boolean; // default: true
keyboard: boolean; // default: true
};
direction: 'ltr' | 'rtl'; // default: 'ltr'
}
```

`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.

## Components

### `PaginatedViewport.svelte` (new)

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.

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.

### `useReaderInputs.svelte.ts` (new)

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.

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.

Swipe threshold: 50px minimum horizontal travel, must exceed vertical travel by 2x (avoids fighting vertical scroll in scrolled mode).

Keyboard: ←/→ and PageUp/PageDown for prev/next, Space for next, Esc to close drawers. Already wired for EPUB; this consolidates.

`direction: 'rtl'` flips the prev/next mapping for tap zones, swipe, and keyboard.

### `KeyboardShortcuts.svelte`

Extend the existing component to be format-agnostic; PDF reader picks it up.

### `PdfReader.svelte` (modify)

- Wrap rendered pages in `PaginatedViewport`.
- 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.
- Existing `spreadMode` state becomes the `spread` setting; `auto` resolves at render time.
- Page index continues to flow through `play_sessions.position` (already does for scrolled mode).

### `BookReader.svelte` (modify)

- Pull existing `flow` state out into the shared settings model.
- Add the spread/animation/input/direction controls to its settings drawer.
- Pass everything to `PaginatedViewport`.
- foliate-js's `renderer.setAttribute('flow', ...)` continues to drive the underlying paginated layout; the wrapper just owns the chrome and inputs.

### Settings UI

Both readers' right-side drawers render the same `<ReaderSettingsPanel>` component. Single source of truth for layout and copy.

## Animation

Implemented via CSS on the viewport's page container:

- `slide` (default): `transform: translateX(±100%)` with `transition: transform 220ms ease-out`. Two-buffer approach (current + incoming) so slide direction matches navigation direction.
- `fade`: `opacity 0 → 1` over 150ms.
- `none`: no transition.

All three driven by the single `pageAnimation` setting; no per-format implementations.

## Persistence

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.

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.

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.

## Format-Default Bug Fix

In `src/routes/books/read/[id]/+page.server.ts`, change the format-resolution logic:

```ts
const requestedFormat = (url.searchParams.get('format') ?? 'epub').toLowerCase();
const fallback = availableFormats[0] ?? 'epub';
const format = availableFormats.includes(requestedFormat) ? requestedFormat : fallback;
```

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.

## Out of Scope

- Page curl animation (skeuomorphic page-turn): expensive to make non-cringe, easy to skip.
- Per-zone tap action remapping (custom action mapping per zone): YAGNI.
- Two-up cover handling (first page solo, then pairs in dual mode): can add later if it bugs anyone.
- Per-book settings overrides (settings are global per user, not per book).

## Testing

- Vitest: `useReaderInputs` rune — tap zone hit detection, swipe threshold, RTL direction flip, keyboard mapping.
- Vitest: `PaginatedViewport` — flow switching, spread auto-resolution at different viewport widths, animation class application.
- 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.

## Migration / Rollout

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.

## Open Questions

None at design time. Implementation plan will surface anything that needs a follow-up.
126 changes: 57 additions & 69 deletions src/lib/components/books/BookReader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
import ReaderProgressBar from './ReaderProgressBar.svelte';
import TimeEstimate from './TimeEstimate.svelte';
import KeyboardShortcuts from './KeyboardShortcuts.svelte';
import PaginatedViewport from './PaginatedViewport.svelte';
import ReaderSettingsPanel from './ReaderSettingsPanel.svelte';
import { loadReaderSettings, persistReaderSettings, DEFAULT_READER_SETTINGS, type ReaderSettings } from './reader-settings';

interface Props {
epubUrl: string;
Expand Down Expand Up @@ -104,34 +107,38 @@
{ label: 'Shortcuts', key: '?' }
];

// Reader settings (persisted in localStorage)
let readerTheme = $state<'dark' | 'light' | 'sepia' | 'oled'>('dark');
let fontFamily = $state<'serif' | 'sans' | 'mono' | 'display'>('serif');
let fontSize = $state(18);
let lineHeight = $state(1.6);
let margins = $state<'narrow' | 'medium' | 'wide'>('medium');
let textAlign = $state<'start' | 'justify'>('start');
let flow = $state<'paginated' | 'scrolled'>('paginated');
// Reader settings (persisted in localStorage via shared module)
let settings = $state<ReaderSettings>({ ...DEFAULT_READER_SETTINGS });

// Derived aliases — keeps existing template references working; all writes
// go back into `settings` so there is a single source of truth.
const readerTheme = $derived(settings.theme);
const fontFamily = $derived(settings.fontFamily);
const fontSize = $derived(settings.fontSize);
const lineHeight = $derived(settings.lineHeight);
const margins = $derived(settings.margins);
const textAlign = $derived(settings.textAlign);
const flow = $derived(settings.flow);

let hideTimer: ReturnType<typeof setTimeout> | null = null;

const anyPanelOpen = $derived(showToc || showSettings || showBookmarks || showSearch || showFormatMenu);

const themes: Record<string, { bg: string; text: string; link: string }> = {
const themes: Record<ReaderSettings['theme'], { bg: string; text: string; link: string }> = {
dark: { bg: '#181514', text: '#f0ebe3', link: '#d4a253' },
light: { bg: '#faf8f5', text: '#1a1a1a', link: '#b8862e' },
sepia: { bg: '#f4ecd8', text: '#5b4636', link: '#8b6914' },
oled: { bg: '#000000', text: '#b0b0b0', link: '#d4a253' }
};

const fonts: Record<string, string> = {
const fonts: Record<ReaderSettings['fontFamily'], string> = {
serif: "Georgia, 'Times New Roman', serif",
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
mono: "'JetBrains Mono', 'Fira Code', monospace",
display: "'Playfair Display', Georgia, serif"
};

const marginValues: Record<string, string> = {
const marginValues: Record<ReaderSettings['margins'], string> = {
narrow: '2%',
medium: '6%',
wide: '12%'
Expand All @@ -144,30 +151,15 @@
pink: 'rgba(251, 113, 133, 0.3)'
};

// ── Settings persistence ──
function loadSettings() {
if (!browser) return;
try {
const saved = localStorage.getItem('nexus-reader-settings');
if (saved) {
const s = JSON.parse(saved);
if (s.theme && s.theme in themes) readerTheme = s.theme;
if (s.fontFamily && s.fontFamily in fonts) fontFamily = s.fontFamily;
if (s.fontSize) fontSize = s.fontSize;
if (s.lineHeight) lineHeight = s.lineHeight;
if (s.margins && s.margins in marginValues) margins = s.margins;
if (s.textAlign === 'start' || s.textAlign === 'justify') textAlign = s.textAlign;
if (s.flow) flow = s.flow;
}
} catch { /* ignore */ }
}
// ── Settings persistence (via shared module) ──
// Writes are persisted via $effect below; reads happen lazily in
// initFoliateReader so the reader hydrates with whatever's in localStorage.

function persistSettings() {
$effect(() => {
// Persist on every change. `persistReaderSettings` merges into stored state.
if (!browser) return;
localStorage.setItem('nexus-reader-settings', JSON.stringify({
theme: readerTheme, fontFamily, fontSize, lineHeight, margins, textAlign, flow
}));
}
persistReaderSettings(settings);
});

// ── Build CSS for foliate-js renderer ──
function getReaderCSS(): string {
Expand Down Expand Up @@ -218,7 +210,6 @@
function applyStyles() {
if (!view?.renderer?.setStyles) return;
view.renderer.setStyles(getReaderCSS());
persistSettings();
}

// ── Flatten TOC for display ──
Expand Down Expand Up @@ -507,9 +498,8 @@
function handleKeydown(e: KeyboardEvent) {
if (showSearch && e.key !== 'Escape') return;
if (showNoteInput && e.key !== 'Escape') return;
// ArrowLeft/Right are owned by PaginatedViewport (gated by settings.inputs.keyboard).
switch (e.key) {
case 'ArrowLeft': e.preventDefault(); prevPage(); break;
case 'ArrowRight': e.preventDefault(); nextPage(); break;
case 'Escape':
e.preventDefault();
if (showAnnotationPopup) dismissAnnotationPopup();
Expand All @@ -528,7 +518,8 @@

// ── Svelte action for foliate-js lifecycle ──
function initFoliateReader(node: HTMLElement) {
loadSettings();
// Hydrate settings from localStorage before the view opens
settings = loadReaderSettings();

// Sync initial prop values
currentProgress = initialProgress;
Expand Down Expand Up @@ -684,12 +675,11 @@
if (view && ready) applyStyles();
});

// Handle flow change
// Handle flow + direction changes — drive the foliate renderer directly.
$effect(() => {
if (!view || !ready) return;
void flow;
view.renderer.setAttribute('flow', flow);
persistSettings();
if (!view?.renderer) return;
view.renderer.setAttribute('flow', settings.flow);
view.renderer.setAttribute('dir', settings.direction);
});

const progressPercent = $derived(Math.round(currentProgress * 100));
Expand Down Expand Up @@ -790,12 +780,22 @@
style="background-color: {themes[readerTheme].bg};"
onmousemove={handleReaderMouseMove}
>
<!-- foliate-js container -->
<div
use:initFoliateReader
class="absolute overflow-hidden"
style="top: 0; bottom: 0; left: 0; right: 0;"
></div>
<!-- foliate-js container, wrapped by PaginatedViewport for shared input/animation behavior -->
<div class="absolute" style="top: 0; bottom: 0; left: 0; right: 0;">
<PaginatedViewport
{settings}
onPrev={() => view?.prev()}
onNext={() => view?.next()}
onToggleUI={() => { showSettings = !showSettings; }}
>
{#snippet children(_ctx: { effectiveSpread: 'single' | 'dual'; animationKey: number })}
<div
use:initFoliateReader
class="h-full w-full overflow-hidden"
></div>
{/snippet}
</PaginatedViewport>
</div>

<!-- Click zone overlay for navigation (only outside the iframe) -->
<button
Expand Down Expand Up @@ -912,7 +912,7 @@
{#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)}
<button
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'}"
onclick={() => { readerTheme = key as typeof readerTheme; }}
onclick={() => { settings.theme = key as ReaderSettings['theme']; }}
>
<span
class="h-5 w-5 rounded-full border-2"
Expand All @@ -932,7 +932,7 @@
<button
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'}"
style="font-family: {font};"
onclick={() => { fontFamily = key as typeof fontFamily; }}
onclick={() => { settings.fontFamily = key as ReaderSettings['fontFamily']; }}
>{label}</button>
{/each}
</div>
Expand All @@ -946,7 +946,7 @@
</label>
<div class="flex items-center gap-3">
<Type size={12} class="shrink-0 text-cream/30" />
<input id="reader-font-size" type="range" min="12" max="36" step="1" bind:value={fontSize} class="reader-range flex-1" />
<input id="reader-font-size" type="range" min="12" max="36" step="1" bind:value={settings.fontSize} class="reader-range flex-1" />
<Type size={20} class="shrink-0 text-cream/30" />
</div>
</div>
Expand All @@ -957,7 +957,7 @@
<span>Line Height</span>
<span class="normal-case tracking-normal text-cream/60">{lineHeight.toFixed(1)}</span>
</label>
<input id="reader-line-height" type="range" min="1.0" max="2.0" step="0.1" bind:value={lineHeight} class="reader-range w-full" />
<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" />
</div>

<!-- Margins -->
Expand All @@ -967,7 +967,7 @@
{#each [{ key: 'narrow', label: 'Narrow' }, { key: 'medium', label: 'Medium' }, { key: 'wide', label: 'Wide' }] as { key, label } (key)}
<button
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'}"
onclick={() => { margins = key as typeof margins; }}
onclick={() => { settings.margins = key as ReaderSettings['margins']; }}
>{label}</button>
{/each}
</div>
Expand All @@ -979,33 +979,21 @@
<div class="flex gap-2">
<button
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'}"
onclick={() => { textAlign = 'start'; }}
onclick={() => { settings.textAlign = 'start'; }}
>
<AlignLeft size={13} strokeWidth={1.5} /> Left
</button>
<button
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'}"
onclick={() => { textAlign = 'justify'; }}
onclick={() => { settings.textAlign = 'justify'; }}
>
<AlignJustify size={13} strokeWidth={1.5} /> Justified
</button>
</div>
</div>

<!-- Reading Mode -->
<div>
<span class="settings-label">Reading Mode</span>
<div class="flex gap-2">
<button
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'}"
onclick={() => { flow = 'paginated'; }}
>Paginated</button>
<button
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'}"
onclick={() => { flow = 'scrolled'; }}
>Scrolled</button>
</div>
</div>
<!-- Shared paginated / flow / spread / inputs / direction panel -->
<ReaderSettingsPanel bind:settings variant="epub" />
</div>
</div>
{/if}
Expand Down
Loading
Loading