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

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
// Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js. // Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js.
import { onMount } from 'svelte'; import { onMount, untrack } from 'svelte';
import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js'; import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js';
import { wheelZoom, type ViewLayout } from './layout.js';
import { import {
fetchWithProgress, fetchWithProgress,
themeColors, themeColors,
@@ -40,7 +41,9 @@
startPercent, startPercent,
highlights, highlights,
settings, settings,
layout,
theme, theme,
onzoom,
onready, onready,
onrelocate, onrelocate,
onselection, onselection,
@@ -53,18 +56,25 @@
let host = $state<FoliateView>(); let host = $state<FoliateView>();
let view = $state<FoliateView>(); let view = $state<FoliateView>();
let highlightDraw: DrawFn | undefined; let highlightDraw: DrawFn | undefined;
// Zoom re-lays out the whole section, so follow fast changes (Ctrl+wheel, pinch) lazily.
let zoom = $state(untrack(() => layout.zoom));
$effect(() => {
const z = layout.zoom;
const t = setTimeout(() => (zoom = z), 120);
return () => clearTimeout(t);
});
const fonts = { const fonts = {
serif: 'Charter, "Iowan Old Style", Georgia, "Palatino Linotype", serif', serif: 'Charter, "Iowan Old Style", Georgia, "Palatino Linotype", serif',
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
}; };
function css(s: ReaderSettings, t: ReaderTheme) { function css(s: ReaderSettings, t: ReaderTheme, z: number) {
const c = themeColors[t]; const c = themeColors[t];
const family = s.font === 'publisher' ? null : fonts[s.font]; const family = s.font === 'publisher' ? null : fonts[s.font];
return ` return `
@namespace epub "http://www.idpf.org/2007/ops"; @namespace epub "http://www.idpf.org/2007/ops";
html { color-scheme: ${t === 'dark' ? 'dark' : 'light'}; background: ${c.bg} !important; color: ${c.fg} !important; font-size: ${s.fontSize}% !important; } html { color-scheme: ${t === 'dark' ? 'dark' : 'light'}; background: ${c.bg} !important; color: ${c.fg} !important; font-size: ${Math.round(z * 100)}% !important; }
body { background: transparent !important; color: inherit !important; ${family ? `font-family: ${family} !important;` : ''} } body { background: transparent !important; color: inherit !important; ${family ? `font-family: ${family} !important;` : ''} }
${family ? 'body :not(pre, code, kbd, samp, tt, pre *, code *) { font-family: inherit !important; }' : ''} ${family ? 'body :not(pre, code, kbd, samp, tt, pre *, code *) { font-family: inherit !important; }' : ''}
p, li, blockquote, dd, dt { line-height: ${s.lineHeight} !important; } p, li, blockquote, dd, dt { line-height: ${s.lineHeight} !important; }
@@ -76,19 +86,26 @@ aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
::selection { background: rgba(250, 204, 21, 0.45); }`; ::selection { background: rgba(250, 204, 21, 0.45); }`;
} }
function apply(v: FoliateView) { // Fixed-layout books (comics, FXL EPUB) use foliate's own page fitting and spreads.
const isFixed = (v: FoliateView) => v.renderer?.tagName === 'FOLIATE-FXL';
function apply(v: FoliateView, l: Pick<ViewLayout, 'mode' | 'spread'>, z: number) {
const r = v.renderer; const r = v.renderer;
r.setAttribute('flow', settings.flow); if (!isFixed(v)) {
r.setAttribute('flow', l.mode === 'scroll' ? 'scrolled' : 'paginated');
r.setAttribute('margin', '48px'); r.setAttribute('margin', '48px');
r.setAttribute('gap', '6%'); r.setAttribute('gap', '6%');
r.setAttribute('max-inline-size', '720px'); // Wider columns as the text grows, so a zoomed spread falls back to one column
r.setAttribute('max-column-count', '2'); // instead of squeezing a few words per line.
r.setStyles(css(settings, theme)); r.setAttribute('max-inline-size', `${Math.round(720 * Math.max(1, z))}px`);
r.setAttribute('max-column-count', String(l.spread));
}
r.setStyles?.(css(settings, theme, z));
v.style.background = themeColors[theme].bg; v.style.background = themeColors[theme].bg;
} }
$effect(() => { $effect(() => {
if (view) apply(view); if (view) apply(view, { mode: layout.mode, spread: layout.spread }, zoom);
}); });
const color = (h: Highlight) => const color = (h: Highlight) =>
@@ -158,6 +175,16 @@ aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
el.addEventListener('load', (e) => { el.addEventListener('load', (e) => {
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number }; const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
doc.addEventListener('keydown', onkey); doc.addEventListener('keydown', onkey);
// Ctrl+wheel / trackpad pinch inside the book text zooms instead of the page.
doc.addEventListener(
'wheel',
(we) => {
if (!we.ctrlKey && !we.metaKey) return;
we.preventDefault();
onzoom(wheelZoom(layout.zoom, we.deltaY, 'foliate'));
},
{ passive: false }
);
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
doc.addEventListener('selectionchange', () => { doc.addEventListener('selectionchange', () => {
clearTimeout(timer); clearTimeout(timer);
@@ -192,11 +219,11 @@ aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
await el.open(new File([blob], name, { type: blob.type })); await el.open(new File([blob], name, { type: blob.type }));
if (disposed) return; if (disposed) return;
apply(el); apply(el, layout, layout.zoom);
view = el; view = el;
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null); const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
await el.init({ lastLocation, showTextStart: !lastLocation }); await el.init({ lastLocation, showTextStart: !lastLocation });
onready(toToc(el.book.toc)); onready(toToc(el.book.toc), { fixedLayout: isFixed(el) });
} catch (e) { } catch (e) {
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError')) if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
onerror(e instanceof Error ? e.message : 'Could not open this book'); onerror(e instanceof Error ? e.message : 'Could not open this book');

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
// PDFs via pdf.js: continuous scroll, pages rendered lazily near the viewport, a text layer // PDFs via pdf.js: continuous scroll or one page/spread at a time, 1 or 2 pages per row,
// for selection, and highlights stored as page-relative rectangles. // pages rendered lazily near the viewport, a text layer for selection, and highlights stored
import { onMount } from 'svelte'; // as page-relative rectangles.
import { onMount, tick, untrack } from 'svelte';
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist'; import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import { import {
@@ -12,6 +13,7 @@
type HighlightColor, type HighlightColor,
type Rect type Rect
} from '#lib/books/shared.js'; } from '#lib/books/shared.js';
import { clampZoom, pdfRows, rowIndexOf, type Spread } from './layout.js';
import { type EngineProps, type TocItem } from './types.js'; import { type EngineProps, type TocItem } from './types.js';
let { let {
@@ -19,8 +21,9 @@
start, start,
startPercent, startPercent,
highlights, highlights,
settings, layout,
theme, theme,
onzoom,
onready, onready,
onrelocate, onrelocate,
onselection, onselection,
@@ -30,25 +33,37 @@
}: EngineProps = $props(); }: EngineProps = $props();
type PageInfo = { num: number; width: number; height: number }; // at scale 1 type PageInfo = { num: number; width: number; height: number }; // at scale 1
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement }; type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement; scale: number };
const GAP = 12;
let scroller: HTMLDivElement; let scroller: HTMLDivElement;
let pdf: PDFDocumentProxy | undefined; let pdf: PDFDocumentProxy | undefined;
let pdfjs: typeof import('pdfjs-dist') | undefined; let pdfjs: typeof import('pdfjs-dist') | undefined;
let pages = $state<PageInfo[]>([]); let pages = $state<PageInfo[]>([]);
let width = $state(0); let width = $state(0);
let height = $state(0);
let current = 1; let current = 1;
let currentRow = $state(0); // paged mode: the row (page or spread) on screen
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state // eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const live = new Map<number, Live>(); const live = new Map<number, Live>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state // eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const pageEls = new Map<number, HTMLElement>(); const pageEls = new Map<number, HTMLElement>();
let observer: IntersectionObserver | undefined; let observer: IntersectionObserver | undefined;
const paged = $derived(layout.mode === 'paged');
// Two pages side by side only when there's room for them.
const spread = $derived<Spread>(layout.spread === 2 && width >= 640 ? 2 : 1);
const rows = $derived(pdfRows(pages.length, spread));
const shownRows = $derived(paged ? rows.slice(currentRow, currentRow + 1) : rows);
// Zoom 1 = fit the row's width (scroll) or the whole page/spread (paged).
const scale = $derived.by(() => { const scale = $derived.by(() => {
const first = pages[0]; const first = pages[0];
if (!first || !width) return 1; if (!first || !width) return 1;
const fit = Math.min(width - 24, 920) / first.width; const avail = width - 2 * GAP - (spread - 1) * GAP;
return Math.max(0.3, fit * settings.pdfZoom); let fit = Math.min(avail, 920 * spread) / (first.width * spread);
if (paged && height > 2 * GAP) fit = Math.min(fit, (height - 2 * GAP) / first.height);
return Math.max(0.2, fit * layout.zoom);
}); });
const byPage = $derived.by(() => { const byPage = $derived.by(() => {
@@ -70,11 +85,12 @@
const el = pageEls.get(num); const el = pageEls.get(num);
if (!pdf || !pdfjs || !el || live.has(num)) return; if (!pdf || !pdfjs || !el || live.has(num)) return;
const layer = el.querySelector<HTMLElement>('.layer')!; const layer = el.querySelector<HTMLElement>('.layer')!;
const entry: Live = { el, layer }; const entry: Live = { el, layer, scale };
live.set(num, entry); live.set(num, entry);
const page: PDFPageProxy = await pdf.getPage(num); const page: PDFPageProxy = await pdf.getPage(num);
if (live.get(num) !== entry) return; if (live.get(num) !== entry) return;
const s = scale; const s = scale;
entry.scale = s;
const viewport = page.getViewport({ scale: s }); const viewport = page.getViewport({ scale: s });
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -83,8 +99,6 @@
const text = document.createElement('div'); const text = document.createElement('div');
text.className = 'textLayer'; text.className = 'textLayer';
layer.replaceChildren(canvas, text); layer.replaceChildren(canvas, text);
el.style.setProperty('--scale-factor', String(s));
el.style.setProperty('--total-scale-factor', String(s));
entry.task = page.render({ entry.task = page.render({
canvas, canvas,
viewport, viewport,
@@ -123,30 +137,53 @@
}; };
} }
// Re-render everything when the zoom or the width changes, keeping the reading position. // When the zoom, width, spread or mode changes: keep the reading position, resize the page
let lastScale = 0; // boxes right away (old canvases stretch meanwhile) and re-render sharp once it settles.
const layoutKey = $derived(`${layout.mode}|${spread}|${scale.toFixed(4)}`);
let lastKey = '';
let keep: { page: number; fraction: number } | null = null;
$effect.pre(() => {
const key = layoutKey;
untrack(() => {
// The DOM still has the old layout here, so this reads the position the reader sees.
if (lastKey && key !== lastKey && pages.length) keep ??= position();
});
});
$effect(() => { $effect(() => {
const s = scale; const key = layoutKey;
// The first scale with pages loaded is the baseline; the initial jump to the saved untrack(() => {
// position happens in onMount and must not be undone by a "keep position" here. if (!pages.length || !width) return void (keep = null);
if (!pages.length || !width) return; // The first layout with pages is the baseline; the initial jump to the saved position
if (!lastScale) { // happens in onMount and must not be undone by a "keep position" here.
lastScale = s; if (!lastKey || key === lastKey) return void ((lastKey = key), (keep = null));
return; lastKey = key;
const k = keep;
keep = null;
if (k) {
if (paged) currentRow = rowIndexOf(rows, k.page);
void tick().then(() => {
place(k.page, k.fraction);
onscroll();
});
} }
if (Math.abs(s - lastScale) < 0.001) return; scheduleRerender();
lastScale = s; });
const keep = position(); });
for (const num of [...live.keys()]) unrender(num);
requestAnimationFrame(() => { let rerenderTimer: ReturnType<typeof setTimeout> | undefined;
scrollToPosition(keep.page, keep.fraction); function scheduleRerender() {
clearTimeout(rerenderTimer);
rerenderTimer = setTimeout(() => {
for (const [num, entry] of [...live])
if (Math.abs(entry.scale - scale) > 0.001) unrender(num);
if (!scroller) return;
const v = scroller.getBoundingClientRect();
for (const [num, node] of pageEls) { for (const [num, node] of pageEls) {
const r = node.getBoundingClientRect(); const r = node.getBoundingClientRect();
const v = scroller.getBoundingClientRect();
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num); if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
} }
}); }, 160);
}); }
function position() { function position() {
if (!scroller) return { page: 1, fraction: 0 }; if (!scroller) return { page: 1, fraction: 0 };
@@ -165,23 +202,54 @@
return { page: pages.length, fraction: 1 }; return { page: pages.length, fraction: 1 };
} }
function scrollToPosition(num: number, fraction = 0) { /** Scroll so `num` (at `fraction` down the page) is in view; it must be in the DOM. */
const node = pageEls.get(Math.max(1, Math.min(pages.length, num))); function place(num: number, fraction = 0) {
if (!node) return; const node = pageEls.get(num);
if (!node || !scroller) return;
scroller.scrollTop = scroller.scrollTop =
node.offsetTop + node.offsetHeight * fraction - (fraction ? scroller.clientHeight * 0.3 : 12); node.offsetTop +
node.offsetHeight * fraction -
(fraction ? scroller.clientHeight * 0.3 : GAP);
}
function scrollToPosition(num: number, fraction = 0) {
if (!pages.length) return;
num = Math.max(1, Math.min(pages.length, num));
if (paged) {
const r = rowIndexOf(rows, num);
if (r !== currentRow) {
currentRow = r;
void tick().then(() => {
place(num, fraction);
onscroll();
});
return;
}
place(num, fraction);
onscroll(); // the scroll position may not change between rows
return;
}
place(num, fraction);
}
function showRow(i: number) {
const row = rows[Math.max(0, Math.min(rows.length - 1, i))];
if (row) scrollToPosition(row[0]);
} }
let frame = 0; let frame = 0;
function onscroll() { function onscroll() {
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => {
if (!pages.length) return;
const { page: num, fraction } = position(); const { page: num, fraction } = position();
current = num; current = num;
const row = rows[rowIndexOf(rows, num)];
onrelocate({ onrelocate({
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100), percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
locator: pdfLocator(num), locator: pdfLocator(num),
page: num, page: num,
pageEnd: row && row.length > 1 ? row[row.length - 1] : null,
pageCount: pages.length, pageCount: pages.length,
label: labelFor(num) label: labelFor(num)
}); });
@@ -207,12 +275,18 @@
const p = (percent / 100) * pages.length; const p = (percent / 100) * pages.length;
scrollToPosition(Math.floor(p) + 1, p % 1 || 0); scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
} }
// Next/prev move a whole row: one page, or two in spread mode.
export function next() { export function next() {
scrollToPosition(current + 1); if (paged) return showRow(currentRow + 1);
showRow(rowIndexOf(rows, current) + 1);
} }
export function prev() { export function prev() {
const { fraction } = position(); if (paged) return showRow(currentRow - 1);
scrollToPosition(fraction > 0.1 ? current : current - 1); // Scrolled into the current row: back to its top first, else the previous row.
const r = rowIndexOf(rows, current);
const node = pageEls.get(rows[r]?.[0] ?? 1);
const into = node && scroller ? scroller.scrollTop - (node.offsetTop - GAP) : 0;
showRow(into > 24 ? r : r - 1);
} }
export function addHighlight() { export function addHighlight() {
// Highlights are derived from props; nothing to draw imperatively. // Highlights are derived from props; nothing to draw imperatively.
@@ -296,9 +370,50 @@
onMount(() => { onMount(() => {
let disposed = false; let disposed = false;
const resize = new ResizeObserver(() => (width = scroller.clientWidth)); const measure = () => {
resize.observe(scroller);
width = scroller.clientWidth; width = scroller.clientWidth;
height = scroller.clientHeight;
};
const resize = new ResizeObserver(measure);
resize.observe(scroller);
measure();
// Ctrl+wheel / trackpad pinch is handled by the shell; here: wheel turns pages in paged
// mode when the page fits, and two-finger pinch zooms on touch screens.
let wheelAcc = 0;
let wheelLock = 0;
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey || !paged) return;
if (scroller.scrollHeight > scroller.clientHeight + 1) return;
e.preventDefault();
if (Date.now() < wheelLock) return;
wheelAcc += e.deltaY;
if (Math.abs(wheelAcc) < 60) return;
if (wheelAcc > 0) next();
else prev();
wheelAcc = 0;
wheelLock = Date.now() + 350;
};
let pinch: { d: number; z: number } | null = null;
const dist = (t: TouchList) =>
Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY) || 1;
const onTouchStart = (e: TouchEvent) => {
pinch = e.touches.length === 2 ? { d: dist(e.touches), z: layout.zoom } : null;
};
const onTouchMove = (e: TouchEvent) => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
const z = clampZoom((pinch.z * dist(e.touches)) / pinch.d, 'pdf');
if (Math.abs(z - layout.zoom) > 0.01) onzoom(z);
};
const onTouchEnd = (e: TouchEvent) => {
if (e.touches.length < 2) pinch = null;
};
scroller.addEventListener('wheel', onWheel, { passive: false });
scroller.addEventListener('touchstart', onTouchStart, { passive: true });
scroller.addEventListener('touchmove', onTouchMove, { passive: false });
scroller.addEventListener('touchend', onTouchEnd);
scroller.addEventListener('touchcancel', onTouchEnd);
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
for (const entry of entries) { for (const entry of entries) {
@@ -375,6 +490,12 @@
return () => { return () => {
disposed = true; disposed = true;
document.removeEventListener('selectionchange', onSelectionChange); document.removeEventListener('selectionchange', onSelectionChange);
scroller.removeEventListener('wheel', onWheel);
scroller.removeEventListener('touchstart', onTouchStart);
scroller.removeEventListener('touchmove', onTouchMove);
scroller.removeEventListener('touchend', onTouchEnd);
scroller.removeEventListener('touchcancel', onTouchEnd);
clearTimeout(rerenderTimer);
resize.disconnect(); resize.disconnect();
observer?.disconnect(); observer?.disconnect();
for (const num of [...live.keys()]) unrender(num); for (const num of [...live.keys()]) unrender(num);
@@ -389,14 +510,19 @@
bind:this={scroller} bind:this={scroller}
{onscroll} {onscroll}
> >
<div class="pages"> <div class={['pages', paged && 'paged', spread === 2 && 'spread']}>
{#each pages as p (p.num)} {#each shownRows as row (row[0])}
<div class="row">
{#each row as num (num)}
{@const p = pages[num - 1]}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div <div
class="pdf-page" class="pdf-page"
data-page={p.num} data-page={p.num}
style:width={`${Math.floor(p.width * scale)}px`} style:width={`${Math.floor(p.width * scale)}px`}
style:height={`${Math.floor(p.height * scale)}px`} style:height={`${Math.floor(p.height * scale)}px`}
style:--scale-factor={scale}
style:--total-scale-factor={scale}
use:page={p.num} use:page={p.num}
onclick={(e) => hitHighlight(e, p.num)} onclick={(e) => hitHighlight(e, p.num)}
> >
@@ -417,6 +543,8 @@
</div> </div>
{/each} {/each}
</div> </div>
{/each}
</div>
</div> </div>
<style> <style>
@@ -434,6 +562,21 @@
padding: 12px 12px 96px; padding: 12px 12px 96px;
min-width: fit-content; min-width: fit-content;
} }
.pages.paged {
min-height: 100%;
justify-content: center;
padding: 12px;
}
/* rows are not positioned, so page offsetTop stays relative to the scroller */
.row {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 12px;
}
.spread .num {
display: none;
}
.pdf-page { .pdf-page {
position: relative; position: relative;
background: white; background: white;

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import {
clampZoom,
defaultLayouts,
loadLayout,
normalizeLayout,
pdfRows,
rowIndexOf,
saveLayout,
stepZoom,
wheelZoom
} from './layout.js';
function memoryStore(init: Record<string, string> = {}) {
const data = new Map(Object.entries(init));
return {
data,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v)
};
}
describe('zoom helpers', () => {
it('clamps per format and repairs junk', () => {
expect(clampZoom(10, 'pdf')).toBe(4);
expect(clampZoom(0.1, 'pdf')).toBe(0.5);
expect(clampZoom(10, 'foliate')).toBe(2.5);
expect(clampZoom(Number.NaN, 'pdf')).toBe(1);
expect(clampZoom(-2, 'foliate')).toBe(1);
});
it('steps through presets from any value', () => {
expect(stepZoom(1, 1, 'pdf')).toBe(1.1);
expect(stepZoom(1, -1, 'pdf')).toBe(0.9);
expect(stepZoom(1.03, 1, 'pdf')).toBe(1.1);
expect(stepZoom(1.03, -1, 'pdf')).toBe(1);
expect(stepZoom(4, 1, 'pdf')).toBe(4);
expect(stepZoom(0.5, -1, 'pdf')).toBe(0.5);
expect(stepZoom(2.5, 1, 'foliate')).toBe(2.5);
expect(stepZoom(0.6, -1, 'foliate')).toBe(0.6);
});
it('wheel zooms smoothly in the right direction', () => {
expect(wheelZoom(1, -100, 'pdf')).toBeGreaterThan(1);
expect(wheelZoom(1, 100, 'pdf')).toBeLessThan(1);
// small trackpad deltas still move
expect(wheelZoom(1, -2, 'pdf')).not.toBe(1);
expect(wheelZoom(4, -100, 'pdf')).toBe(4);
});
});
describe('pdf rows', () => {
it('single pages', () => {
expect(pdfRows(3, 1)).toEqual([[1], [2], [3]]);
expect(pdfRows(0, 2)).toEqual([]);
});
it('spreads keep the cover alone', () => {
expect(pdfRows(1, 2)).toEqual([[1]]);
expect(pdfRows(4, 2)).toEqual([[1], [2, 3], [4]]);
expect(pdfRows(5, 2)).toEqual([[1], [2, 3], [4, 5]]);
});
it('finds the row of a page', () => {
const rows = pdfRows(5, 2);
expect(rowIndexOf(rows, 1)).toBe(0);
expect(rowIndexOf(rows, 3)).toBe(1);
expect(rowIndexOf(rows, 4)).toBe(2);
expect(rowIndexOf(rows, 99)).toBe(2);
expect(rowIndexOf(rows, -1)).toBe(0);
expect(rowIndexOf([], 3)).toBe(0);
});
});
describe('layout persistence', () => {
it('defaults per format', () => {
const s = memoryStore();
expect(loadLayout(s, 'pdf', 1)).toEqual(defaultLayouts.pdf);
expect(loadLayout(s, 'foliate', 1)).toEqual(defaultLayouts.foliate);
});
it('remembers layout per format and zoom per book', () => {
const s = memoryStore();
saveLayout(s, 'pdf', 7, { mode: 'paged', spread: 2, zoom: 1.5 });
expect(loadLayout(s, 'pdf', 7)).toEqual({ mode: 'paged', spread: 2, zoom: 1.5 });
// another PDF inherits the format's last layout
expect(loadLayout(s, 'pdf', 8)).toEqual({ mode: 'paged', spread: 2, zoom: 1.5 });
saveLayout(s, 'pdf', 8, { mode: 'paged', spread: 2, zoom: 0.75 });
expect(loadLayout(s, 'pdf', 7).zoom).toBe(1.5);
// formats are independent
expect(loadLayout(s, 'foliate', 7)).toEqual(defaultLayouts.foliate);
});
it('migrates the old settings once', () => {
const s = memoryStore({
'books-reader-settings': JSON.stringify({ flow: 'scrolled', fontSize: 120, pdfZoom: 1.3 })
});
expect(loadLayout(s, 'foliate', 1)).toEqual({ mode: 'scroll', spread: 2, zoom: 1.2 });
expect(loadLayout(s, 'pdf', 1)).toEqual({ mode: 'scroll', spread: 1, zoom: 1.3 });
});
it('survives broken storage', () => {
const s = memoryStore({ 'books-reader-layout:pdf': '{nope', 'books-reader-zoom:pdf:1': '"x"' });
expect(loadLayout(s, 'pdf', 1)).toEqual(defaultLayouts.pdf);
expect(normalizeLayout({ mode: 'x', spread: 3, zoom: 99 }, 'foliate')).toEqual({
mode: 'paged',
spread: 2,
zoom: 2.5
});
});
});

View File

@@ -0,0 +1,124 @@
// View layout for the reader: zoom, 1/2-page spreads and scroll vs paged reading.
// Pure helpers (unit tested) plus localStorage persistence: mode/spread/zoom are remembered
// per format (pdf vs reflowable), and the zoom is also remembered per book.
export type EngineKind = 'pdf' | 'foliate';
export type ViewMode = 'scroll' | 'paged';
export type Spread = 1 | 2;
export type ViewLayout = { mode: ViewMode; spread: Spread; zoom: number };
export const defaultLayouts: Record<EngineKind, ViewLayout> = {
pdf: { mode: 'scroll', spread: 1, zoom: 1 },
foliate: { mode: 'paged', spread: 2, zoom: 1 }
};
/** Zoom limits: PDF zoom scales the page render (1 = fit), foliate zoom scales the text. */
export const zoomRange: Record<EngineKind, [min: number, max: number]> = {
pdf: [0.5, 4],
foliate: [0.6, 2.5]
};
const ZOOM_STEPS = [0.5, 0.6, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4];
export function clampZoom(zoom: number, kind: EngineKind): number {
const [min, max] = zoomRange[kind];
const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
}
/** Next preset zoom level up (dir 1) or down (dir -1) from wherever the zoom is now. */
export function stepZoom(zoom: number, dir: 1 | -1, kind: EngineKind): number {
const [min, max] = zoomRange[kind];
const z = clampZoom(zoom, kind);
const next =
dir > 0
? (ZOOM_STEPS.find((s) => s > z + 0.001) ?? max)
: (ZOOM_STEPS.findLast((s) => s < z - 0.001) ?? min);
return clampZoom(next, kind);
}
/** Smooth zoom for Ctrl+wheel and trackpad pinch (which browsers report as Ctrl+wheel). */
export function wheelZoom(zoom: number, deltaY: number, kind: EngineKind): number {
// A mouse notch (~100) is one ~10% step; pinch deltas are small and smooth.
const d = Math.max(-50, Math.min(50, deltaY));
return clampZoom(zoom * Math.exp(-d * 0.002), kind);
}
/** PDF rows: one page per row, or book spreads with the cover on its own ([1], [2,3], [4,5]…). */
export function pdfRows(count: number, spread: Spread): number[][] {
const rows: number[][] = [];
if (count <= 0) return rows;
if (spread === 1) {
for (let n = 1; n <= count; n++) rows.push([n]);
return rows;
}
rows.push([1]);
for (let n = 2; n <= count; n += 2) rows.push(n + 1 <= count ? [n, n + 1] : [n]);
return rows;
}
/** Index of the row containing `page` (clamped into range). */
export function rowIndexOf(rows: number[][], page: number): number {
if (!rows.length) return 0;
const idx = rows.findIndex((r) => r.includes(page));
if (idx >= 0) return idx;
return page < rows[0][0] ? 0 : rows.length - 1;
}
type Store = Pick<Storage, 'getItem' | 'setItem'>;
const formatKey = (kind: EngineKind) => `books-reader-layout:${kind}`;
const bookKey = (kind: EngineKind, bookId: number) => `books-reader-zoom:${kind}:${bookId}`;
const LEGACY_KEY = 'books-reader-settings';
function read(store: Store, key: string): unknown {
try {
return JSON.parse(store.getItem(key) ?? 'null');
} catch {
return null;
}
}
/** Sanitises anything (old/broken storage) into a valid layout. */
export function normalizeLayout(raw: unknown, kind: EngineKind): ViewLayout {
const base = defaultLayouts[kind];
const o = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
return {
mode: o.mode === 'scroll' || o.mode === 'paged' ? o.mode : base.mode,
spread: o.spread === 1 || o.spread === 2 ? o.spread : base.spread,
zoom: typeof o.zoom === 'number' ? clampZoom(o.zoom, kind) : base.zoom
};
}
export function loadLayout(store: Store, kind: EngineKind, bookId: number): ViewLayout {
let saved = read(store, formatKey(kind));
if (!saved) {
// Carry over the pre-layout settings (flow, fontSize, pdfZoom) once.
const legacy = read(store, LEGACY_KEY) as Record<string, unknown> | null;
if (legacy && typeof legacy === 'object') {
saved =
kind === 'pdf'
? { zoom: legacy.pdfZoom }
: {
mode:
legacy.flow === 'scrolled'
? 'scroll'
: legacy.flow === 'paginated'
? 'paged'
: null,
zoom: typeof legacy.fontSize === 'number' ? legacy.fontSize / 100 : null
};
}
}
const layout = normalizeLayout(saved, kind);
const bookZoom = read(store, bookKey(kind, bookId));
if (typeof bookZoom === 'number') layout.zoom = clampZoom(bookZoom, kind);
return layout;
}
export function saveLayout(store: Store, kind: EngineKind, bookId: number, layout: ViewLayout) {
try {
store.setItem(formatKey(kind), JSON.stringify(layout));
store.setItem(bookKey(kind, bookId), JSON.stringify(layout.zoom));
} catch {
// storage full or disabled: the layout still applies for this visit
}
}

View File

@@ -1,5 +1,6 @@
// Contract between the reader shell (the /read page) and the EPUB/PDF engines. // Contract between the reader shell (the /read page) and the EPUB/PDF engines.
import type { Highlight } from '#lib/books/shared.js'; import type { Highlight } from '#lib/books/shared.js';
import type { ViewLayout } from './layout.js';
export type TocItem = { label: string; target: string | number; children: TocItem[] }; export type TocItem = { label: string; target: string | number; children: TocItem[] };
@@ -7,6 +8,7 @@ export type Relocation = {
percent: number; // 0-100 percent: number; // 0-100
locator: string; // EPUB CFI or `page:N` locator: string; // EPUB CFI or `page:N`
page: number | null; // PDF page (1-based), or null for reflowable books page: number | null; // PDF page (1-based), or null for reflowable books
pageEnd?: number | null; // last page shown when a PDF spread shows two
pageCount: number | null; pageCount: number | null;
label: string | null; // current chapter label: string | null; // current chapter
}; };
@@ -16,19 +18,13 @@ export type TextSelection = { text: string; locator: string; page: number | null
export type ReaderTheme = 'light' | 'sepia' | 'dark'; export type ReaderTheme = 'light' | 'sepia' | 'dark';
export type ReaderSettings = { export type ReaderSettings = {
theme: 'auto' | ReaderTheme; theme: 'auto' | ReaderTheme;
fontSize: number; // percent
lineHeight: number; lineHeight: number;
font: 'publisher' | 'serif' | 'sans'; font: 'publisher' | 'serif' | 'sans';
flow: 'paginated' | 'scrolled';
pdfZoom: number; // 1 = fit width
}; };
export const defaultSettings: ReaderSettings = { export const defaultSettings: ReaderSettings = {
theme: 'auto', theme: 'auto',
fontSize: 100,
lineHeight: 1.5, lineHeight: 1.5,
font: 'publisher', font: 'publisher'
flow: 'paginated',
pdfZoom: 1
}; };
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = { export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
@@ -55,8 +51,12 @@ export type EngineProps = {
startPercent: number; startPercent: number;
highlights: Highlight[]; highlights: Highlight[];
settings: ReaderSettings; settings: ReaderSettings;
/** Zoom, 1/2-page spread and scroll vs paged (see layout.ts). */
layout: ViewLayout;
theme: ReaderTheme; theme: ReaderTheme;
onready: (toc: TocItem[]) => void; /** Ctrl+wheel / pinch inside the engine asks the shell for a new zoom. */
onzoom: (zoom: number) => void;
onready: (toc: TocItem[], info?: { fixedLayout?: boolean }) => void;
onrelocate: (r: Relocation) => void; onrelocate: (r: Relocation) => void;
onselection: (s: TextSelection | null) => void; onselection: (s: TextSelection | null) => void;
onhighlightclick: (id: number) => void; onhighlightclick: (id: number) => void;

View File

@@ -14,6 +14,14 @@
type TextSelection, type TextSelection,
type TocItem type TocItem
} from '#lib/books/reader/types.js'; } from '#lib/books/reader/types.js';
import {
defaultLayouts,
loadLayout,
saveLayout,
stepZoom,
wheelZoom,
type ViewLayout
} from '#lib/books/reader/layout.js';
import { import {
highlightColors, highlightColors,
highlightCss, highlightCss,
@@ -32,12 +40,19 @@
import MessageSquarePlus from 'lucide-svelte/icons/message-square-plus'; import MessageSquarePlus from 'lucide-svelte/icons/message-square-plus';
import Minus from 'lucide-svelte/icons/minus'; import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus'; 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'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
const SETTINGS_KEY = 'books-reader-settings'; const SETTINGS_KEY = 'books-reader-settings';
const isPdf = $derived(data.file.engine === 'pdf'); const isPdf = $derived(data.file.engine === 'pdf');
const kind = $derived(isPdf ? 'pdf' : 'foliate');
const progressUrl = $derived( const progressUrl = $derived(
resolve('/trackers/books/[id=integer]/progress', { id: String(data.book.id) }) resolve('/trackers/books/[id=integer]/progress', { id: String(data.book.id) })
); );
@@ -48,6 +63,10 @@
let engine = $state<ReaderEngine>(); let engine = $state<ReaderEngine>();
let settings = $state<ReaderSettings>({ ...defaultSettings }); 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); let siteDark = $state(true);
// Seeded from the load; the reader adds/edits/deletes in place (writable derived). // Seeded from the load; the reader adds/edits/deletes in place (writable derived).
let highlights = $derived<Highlight[]>(data.highlights); let highlights = $derived<Highlight[]>(data.highlights);
@@ -80,12 +99,21 @@
} catch { } catch {
// ignore broken settings // ignore broken settings
} }
layout = loadLayout(localStorage, kind, data.book.id);
siteDark = !getComputedStyle(document.documentElement).colorScheme.includes('light'); 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 onHide = () => flush(true);
const onVisibility = () => document.visibilityState === 'hidden' && flush(true); const onVisibility = () => document.visibilityState === 'hidden' && flush(true);
window.addEventListener('pagehide', onHide); window.addEventListener('pagehide', onHide);
document.addEventListener('visibilitychange', onVisibility); document.addEventListener('visibilitychange', onVisibility);
return () => { return () => {
bookEl?.removeEventListener('wheel', onWheel);
window.removeEventListener('pagehide', onHide); window.removeEventListener('pagehide', onHide);
document.removeEventListener('visibilitychange', onVisibility); document.removeEventListener('visibilitychange', onVisibility);
flush(true); flush(true);
@@ -97,6 +125,16 @@
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); 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 ---- // ---- progress saving: debounced while reading, flushed on leave ----
let sessionId: number | null = null; let sessionId: number | null = null;
let pending: Relocation | null = null; let pending: Relocation | null = null;
@@ -213,6 +251,14 @@
function onkey(e: KeyboardEvent) { function onkey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]')) return; 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; if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) { switch (e.key) {
case 'ArrowRight': case 'ArrowRight':
@@ -226,7 +272,7 @@
engine?.prev(); engine?.prev();
break; break;
case ' ': case ' ':
if (isPdf) return; if (isPdf && layout.mode === 'scroll') return; // native scrolling
if (e.shiftKey) engine?.prev(); if (e.shiftKey) engine?.prev();
else engine?.next(); else engine?.next();
break; break;
@@ -265,6 +311,55 @@
<p class="truncate text-sm font-medium">{data.book.title}</p> <p class="truncate text-sm font-medium">{data.book.title}</p>
<p class="truncate text-xs opacity-60">{tocLabel ?? data.book.authors.join(', ')}</p> <p class="truncate text-xs opacity-60">{tocLabel ?? data.book.authors.join(', ')}</p>
</div> </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 <button
type="button" type="button"
class={['icon-btn', panel === 'toc' && 'on']} class={['icon-btn', panel === 'toc' && 'on']}
@@ -289,7 +384,7 @@
</header> </header>
<div class="stage"> <div class="stage">
<main class="book"> <main class="book" bind:this={bookEl}>
{#if isPdf} {#if isPdf}
<PdfReader <PdfReader
bind:this={engine} bind:this={engine}
@@ -299,9 +394,12 @@
startPercent={data.resume.percent} startPercent={data.resume.percent}
{highlights} {highlights}
{settings} {settings}
{layout}
{theme} {theme}
onready={(t) => { onzoom={setZoom}
onready={(t, info) => {
toc = t; toc = t;
fixedLayout = info?.fixedLayout ?? false;
ready = true; ready = true;
}} }}
{onrelocate} {onrelocate}
@@ -320,9 +418,12 @@
startPercent={data.resume.percent} startPercent={data.resume.percent}
{highlights} {highlights}
{settings} {settings}
{layout}
{theme} {theme}
onready={(t) => { onzoom={setZoom}
onready={(t, info) => {
toc = t; toc = t;
fixedLayout = info?.fixedLayout ?? false;
ready = true; ready = true;
}} }}
{onrelocate} {onrelocate}
@@ -332,21 +433,21 @@
onloading={(f) => (loading = f)} onloading={(f) => (loading = f)}
onerror={(m) => (failure = m)} onerror={(m) => (failure = m)}
/> />
{#if ready && settings.flow === 'paginated'} {/if}
{#if ready && (layout.mode === 'paged' || fixedLayout)}
<button <button
type="button" type="button"
class="turn left" class="turn left"
aria-label="Previous page" aria-label={layout.spread === 2 ? 'Previous pages' : 'Previous page'}
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
> >
<button <button
type="button" type="button"
class="turn right" class="turn right"
aria-label="Next page" aria-label={layout.spread === 2 ? 'Next pages' : 'Next page'}
onclick={() => engine?.next()}><ChevronRight size={22} /></button onclick={() => engine?.next()}><ChevronRight size={22} /></button
> >
{/if} {/if}
{/if}
{#if failure} {#if failure}
<div class="overlay"> <div class="overlay">
@@ -444,50 +545,61 @@
{/each} {/each}
</div> </div>
</fieldset> </fieldset>
{#if isPdf} {#if layoutAdjustable}
<fieldset> <fieldset>
<legend>Zoom</legend> <legend>{isPdf ? 'Zoom' : 'Zoom (text size)'}</legend>
<div class="stepper"> <div class="stepper">
<button <button type="button" aria-label="Zoom out" onclick={() => zoomBy(-1)}
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) })}
><Minus size={14} /></button ><Minus size={14} /></button
> >
<span>{settings.fontSize}%</span>
<button <button
type="button" type="button"
aria-label="Larger text" class="stepper-value"
onclick={() => title="Reset zoom"
updateSettings({ fontSize: Math.min(220, settings.fontSize + 10) })} onclick={() => setZoom(1)}>{Math.round(layout.zoom * 100)}%</button
>
<button type="button" aria-label="Zoom in" onclick={() => zoomBy(1)}
><Plus size={14} /></button ><Plus size={14} /></button
> >
</div> </div>
</fieldset> </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> <fieldset>
<legend>Font</legend> <legend>Font</legend>
<div class="seg"> <div class="seg">
@@ -513,24 +625,10 @@
{/each} {/each}
</div> </div>
</fieldset> </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} {/if}
<p class="text-xs opacity-60"> <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> </p>
</div> </div>
{/if} {/if}
@@ -640,7 +738,8 @@
}} }}
/> />
<span class="w-24 text-right text-xs tabular-nums opacity-70"> <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} saved{:else if saveState === 'saved'}saved{/if}
</span> </span>
</footer> </footer>
@@ -900,12 +999,45 @@
.seg button:hover { .seg button:hover {
background: var(--r-soft); background: var(--r-soft);
} }
.stepper span { .stepper .stepper-value {
flex: 1; flex: 1;
text-align: center; text-align: center;
font-size: 0.85rem; font-size: 0.85rem;
font-variant-numeric: tabular-nums; 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 { .seg button {
flex: 1; flex: 1;
height: 2.25rem; height: 2.25rem;