books reader: zoom, 1/2-page spreads, scroll vs paged mode
Some checks failed
Deploy production / deploy (push) Has been cancelled

- Zoom buttons + Ctrl/Cmd +/-/0, Ctrl+wheel/trackpad pinch, touch pinch on PDFs;
  PDF scales the pdf.js render (sharp re-render after it settles), EPUB scales text.
  Remembered per format, and per book.
- One/two pages side by side: PDF rows with the cover alone, foliate max-column-count.
- Scroll vs Pages mode for both engines; PDF paged shows one page/spread fitted to the
  screen, prev/next move a whole spread; spreads stack when scrolling.
- Reading position is kept across zoom/spread/mode changes; progress keeps saving.
- layout.ts helpers with unit tests; old flow/fontSize/pdfZoom settings migrate.
This commit is contained in:
2026-10-24 02:24:34 +00:00
parent a022c45499
commit faeb76a7e7
6 changed files with 693 additions and 156 deletions

View File

@@ -14,6 +14,14 @@
type TextSelection,
type TocItem
} from '#lib/books/reader/types.js';
import {
defaultLayouts,
loadLayout,
saveLayout,
stepZoom,
wheelZoom,
type ViewLayout
} from '#lib/books/reader/layout.js';
import {
highlightColors,
highlightCss,
@@ -32,12 +40,19 @@
import MessageSquarePlus from 'lucide-svelte/icons/message-square-plus';
import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus';
import ZoomIn from 'lucide-svelte/icons/zoom-in';
import ZoomOut from 'lucide-svelte/icons/zoom-out';
import Columns2 from 'lucide-svelte/icons/columns-2';
import RectangleVertical from 'lucide-svelte/icons/rectangle-vertical';
import ScrollText from 'lucide-svelte/icons/scroll-text';
import BookOpen from 'lucide-svelte/icons/book-open';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const SETTINGS_KEY = 'books-reader-settings';
const isPdf = $derived(data.file.engine === 'pdf');
const kind = $derived(isPdf ? 'pdf' : 'foliate');
const progressUrl = $derived(
resolve('/trackers/books/[id=integer]/progress', { id: String(data.book.id) })
);
@@ -48,6 +63,10 @@
let engine = $state<ReaderEngine>();
let settings = $state<ReaderSettings>({ ...defaultSettings });
// Zoom, 1/2 pages and scroll vs paged: remembered per format, zoom also per book.
let layout = $state<ViewLayout>({ ...defaultLayouts.foliate });
let fixedLayout = $state(false);
let bookEl = $state<HTMLElement>();
let siteDark = $state(true);
// Seeded from the load; the reader adds/edits/deletes in place (writable derived).
let highlights = $derived<Highlight[]>(data.highlights);
@@ -80,12 +99,21 @@
} catch {
// ignore broken settings
}
layout = loadLayout(localStorage, kind, data.book.id);
siteDark = !getComputedStyle(document.documentElement).colorScheme.includes('light');
// Ctrl+wheel and trackpad pinch zoom the book, not the whole page.
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setZoom(wheelZoom(layout.zoom, e.deltaY, kind));
};
bookEl?.addEventListener('wheel', onWheel, { passive: false });
const onHide = () => flush(true);
const onVisibility = () => document.visibilityState === 'hidden' && flush(true);
window.addEventListener('pagehide', onHide);
document.addEventListener('visibilitychange', onVisibility);
return () => {
bookEl?.removeEventListener('wheel', onWheel);
window.removeEventListener('pagehide', onHide);
document.removeEventListener('visibilitychange', onVisibility);
flush(true);
@@ -97,6 +125,16 @@
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
function setLayout(patch: Partial<ViewLayout>) {
layout = { ...layout, ...patch };
saveLayout(localStorage, kind, data.book.id, layout);
}
const setZoom = (zoom: number) => setLayout({ zoom });
const zoomBy = (dir: 1 | -1) => setZoom(stepZoom(layout.zoom, dir, kind));
// Spreads only apply to paged reflowable text; PDFs stack spreads when scrolling too.
const spreadAvailable = $derived(isPdf || (!fixedLayout && layout.mode === 'paged'));
const layoutAdjustable = $derived(isPdf || !fixedLayout);
// ---- progress saving: debounced while reading, flushed on leave ----
let sessionId: number | null = null;
let pending: Relocation | null = null;
@@ -213,6 +251,14 @@
function onkey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]')) return;
if ((e.metaKey || e.ctrlKey) && !e.altKey) {
if (e.key === '+' || e.key === '=') zoomBy(1);
else if (e.key === '-' || e.key === '_') zoomBy(-1);
else if (e.key === '0') setZoom(1);
else return;
e.preventDefault();
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowRight':
@@ -226,7 +272,7 @@
engine?.prev();
break;
case ' ':
if (isPdf) return;
if (isPdf && layout.mode === 'scroll') return; // native scrolling
if (e.shiftKey) engine?.prev();
else engine?.next();
break;
@@ -265,6 +311,55 @@
<p class="truncate text-sm font-medium">{data.book.title}</p>
<p class="truncate text-xs opacity-60">{tocLabel ?? data.book.authors.join(', ')}</p>
</div>
{#if layoutAdjustable}
<div class="view-tools">
<button
type="button"
class="icon-btn"
aria-label="Zoom out"
title="Zoom out (Ctrl −)"
onclick={() => zoomBy(-1)}><ZoomOut size={18} /></button
>
<button
type="button"
class="zoom-level"
title="Reset zoom (Ctrl 0)"
aria-label="Reset zoom"
onclick={() => setZoom(1)}>{Math.round(layout.zoom * 100)}%</button
>
<button
type="button"
class="icon-btn"
aria-label="Zoom in"
title="Zoom in (Ctrl +)"
onclick={() => zoomBy(1)}><ZoomIn size={18} /></button
>
{#if spreadAvailable}
<button
type="button"
class="icon-btn"
aria-label={layout.spread === 2 ? 'Show one page' : 'Show two pages'}
title={layout.spread === 2 ? 'Two pages (switch to one)' : 'One page (switch to two)'}
onclick={() => setLayout({ spread: layout.spread === 2 ? 1 : 2 })}
>{#if layout.spread === 2}<Columns2 size={18} />{:else}<RectangleVertical
size={18}
/>{/if}</button
>
{/if}
<button
type="button"
class="icon-btn"
aria-label={layout.mode === 'scroll' ? 'Switch to pages' : 'Switch to scrolling'}
title={layout.mode === 'scroll'
? 'Scrolling (switch to pages)'
: 'Pages (switch to scrolling)'}
onclick={() => setLayout({ mode: layout.mode === 'scroll' ? 'paged' : 'scroll' })}
>{#if layout.mode === 'scroll'}<ScrollText size={18} />{:else}<BookOpen
size={18}
/>{/if}</button
>
</div>
{/if}
<button
type="button"
class={['icon-btn', panel === 'toc' && 'on']}
@@ -289,7 +384,7 @@
</header>
<div class="stage">
<main class="book">
<main class="book" bind:this={bookEl}>
{#if isPdf}
<PdfReader
bind:this={engine}
@@ -299,9 +394,12 @@
startPercent={data.resume.percent}
{highlights}
{settings}
{layout}
{theme}
onready={(t) => {
onzoom={setZoom}
onready={(t, info) => {
toc = t;
fixedLayout = info?.fixedLayout ?? false;
ready = true;
}}
{onrelocate}
@@ -320,9 +418,12 @@
startPercent={data.resume.percent}
{highlights}
{settings}
{layout}
{theme}
onready={(t) => {
onzoom={setZoom}
onready={(t, info) => {
toc = t;
fixedLayout = info?.fixedLayout ?? false;
ready = true;
}}
{onrelocate}
@@ -332,20 +433,20 @@
onloading={(f) => (loading = f)}
onerror={(m) => (failure = m)}
/>
{#if ready && settings.flow === 'paginated'}
<button
type="button"
class="turn left"
aria-label="Previous page"
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
>
<button
type="button"
class="turn right"
aria-label="Next page"
onclick={() => engine?.next()}><ChevronRight size={22} /></button
>
{/if}
{/if}
{#if ready && (layout.mode === 'paged' || fixedLayout)}
<button
type="button"
class="turn left"
aria-label={layout.spread === 2 ? 'Previous pages' : 'Previous page'}
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
>
<button
type="button"
class="turn right"
aria-label={layout.spread === 2 ? 'Next pages' : 'Next page'}
onclick={() => engine?.next()}><ChevronRight size={22} /></button
>
{/if}
{#if failure}
@@ -444,50 +545,61 @@
{/each}
</div>
</fieldset>
{#if isPdf}
{#if layoutAdjustable}
<fieldset>
<legend>Zoom</legend>
<legend>{isPdf ? 'Zoom' : 'Zoom (text size)'}</legend>
<div class="stepper">
<button
type="button"
aria-label="Zoom out"
onclick={() =>
updateSettings({
pdfZoom: Math.max(0.5, +(settings.pdfZoom - 0.1).toFixed(2))
})}><Minus size={14} /></button
>
<span>{Math.round(settings.pdfZoom * 100)}%</span>
<button
type="button"
aria-label="Zoom in"
onclick={() =>
updateSettings({
pdfZoom: Math.min(3, +(settings.pdfZoom + 0.1).toFixed(2))
})}><Plus size={14} /></button
>
</div>
</fieldset>
{:else}
<fieldset>
<legend>Text size</legend>
<div class="stepper">
<button
type="button"
aria-label="Smaller text"
onclick={() =>
updateSettings({ fontSize: Math.max(70, settings.fontSize - 10) })}
<button type="button" aria-label="Zoom out" onclick={() => zoomBy(-1)}
><Minus size={14} /></button
>
<span>{settings.fontSize}%</span>
<button
type="button"
aria-label="Larger text"
onclick={() =>
updateSettings({ fontSize: Math.min(220, settings.fontSize + 10) })}
class="stepper-value"
title="Reset zoom"
onclick={() => setZoom(1)}>{Math.round(layout.zoom * 100)}%</button
>
<button type="button" aria-label="Zoom in" onclick={() => zoomBy(1)}
><Plus size={14} /></button
>
</div>
</fieldset>
<fieldset>
<legend>Reading</legend>
<div class="seg">
<button
type="button"
class={layout.mode === 'scroll' && 'on'}
onclick={() => setLayout({ mode: 'scroll' })}>Scroll</button
>
<button
type="button"
class={layout.mode === 'paged' && 'on'}
onclick={() => setLayout({ mode: 'paged' })}>Pages</button
>
</div>
</fieldset>
<fieldset>
<legend>Pages side by side</legend>
<div class="seg">
<button
type="button"
class={layout.spread === 1 && 'on'}
onclick={() => setLayout({ spread: 1 })}>One</button
>
<button
type="button"
class={layout.spread === 2 && 'on'}
onclick={() => setLayout({ spread: 2 })}>Two</button
>
</div>
{#if !spreadAvailable}
<p class="hint">Two pages apply in Pages mode; scrolling text is one column.</p>
{:else if isPdf}
<p class="hint">The cover stays on its own; narrow screens show one page.</p>
{/if}
</fieldset>
{/if}
{#if !isPdf}
<fieldset>
<legend>Font</legend>
<div class="seg">
@@ -513,24 +625,10 @@
{/each}
</div>
</fieldset>
<fieldset>
<legend>Layout</legend>
<div class="seg">
<button
type="button"
class={settings.flow === 'paginated' && 'on'}
onclick={() => updateSettings({ flow: 'paginated' })}>Pages</button
>
<button
type="button"
class={settings.flow === 'scrolled' && 'on'}
onclick={() => updateSettings({ flow: 'scrolled' })}>Scroll</button
>
</div>
</fieldset>
{/if}
<p class="text-xs opacity-60">
Keys: ← → or h l to turn pages, t for contents, Esc to close.
Keys: ← → / PgUp PgDn or h l to turn pages, Ctrl + / − / 0 to zoom (or Ctrl +
scroll, pinch), t for contents, Esc to close.
</p>
</div>
{/if}
@@ -640,7 +738,8 @@
}}
/>
<span class="w-24 text-right text-xs tabular-nums opacity-70">
{#if location?.page && location.pageCount}{location.page} / {location.pageCount}{:else if saveState === 'saving'}saving…{:else if saveState === 'error'}not
{#if location?.page && location.pageCount}{location.page}{#if location.pageEnd}–{location.pageEnd}{/if}
/ {location.pageCount}{:else if saveState === 'saving'}saving…{:else if saveState === 'error'}not
saved{:else if saveState === 'saved'}saved{/if}
</span>
</footer>
@@ -900,12 +999,45 @@
.seg button:hover {
background: var(--r-soft);
}
.stepper span {
.stepper .stepper-value {
flex: 1;
text-align: center;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.stepper .stepper-value {
width: auto;
}
.hint {
margin-top: 0.35rem;
font-size: 0.7rem;
opacity: 0.55;
}
.view-tools {
display: none;
align-items: center;
gap: 0.125rem;
padding-right: 0.375rem;
margin-right: 0.125rem;
border-right: 1px solid var(--r-line);
}
@media (min-width: 640px) {
.view-tools {
display: flex;
}
}
.zoom-level {
min-width: 3rem;
height: 2.25rem;
border-radius: 0.5rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.zoom-level:hover {
opacity: 1;
background: var(--r-soft);
}
.seg button {
flex: 1;
height: 2.25rem;