books reader: zoom, 1/2-page spreads, scroll vs paged mode
Some checks failed
Deploy production / deploy (push) Has been cancelled
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user