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:
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
// 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 { wheelZoom, type ViewLayout } from './layout.js';
|
||||
import {
|
||||
fetchWithProgress,
|
||||
themeColors,
|
||||
@@ -40,7 +41,9 @@
|
||||
startPercent,
|
||||
highlights,
|
||||
settings,
|
||||
layout,
|
||||
theme,
|
||||
onzoom,
|
||||
onready,
|
||||
onrelocate,
|
||||
onselection,
|
||||
@@ -53,18 +56,25 @@
|
||||
let host = $state<FoliateView>();
|
||||
let view = $state<FoliateView>();
|
||||
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 = {
|
||||
serif: 'Charter, "Iowan Old Style", Georgia, "Palatino Linotype", 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 family = s.font === 'publisher' ? null : fonts[s.font];
|
||||
return `
|
||||
@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;` : ''} }
|
||||
${family ? 'body :not(pre, code, kbd, samp, tt, pre *, code *) { font-family: inherit !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); }`;
|
||||
}
|
||||
|
||||
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;
|
||||
r.setAttribute('flow', settings.flow);
|
||||
r.setAttribute('margin', '48px');
|
||||
r.setAttribute('gap', '6%');
|
||||
r.setAttribute('max-inline-size', '720px');
|
||||
r.setAttribute('max-column-count', '2');
|
||||
r.setStyles(css(settings, theme));
|
||||
if (!isFixed(v)) {
|
||||
r.setAttribute('flow', l.mode === 'scroll' ? 'scrolled' : 'paginated');
|
||||
r.setAttribute('margin', '48px');
|
||||
r.setAttribute('gap', '6%');
|
||||
// Wider columns as the text grows, so a zoomed spread falls back to one column
|
||||
// instead of squeezing a few words per line.
|
||||
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;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (view) apply(view);
|
||||
if (view) apply(view, { mode: layout.mode, spread: layout.spread }, zoom);
|
||||
});
|
||||
|
||||
const color = (h: Highlight) =>
|
||||
@@ -158,6 +175,16 @@ aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
|
||||
el.addEventListener('load', (e) => {
|
||||
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
|
||||
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>;
|
||||
doc.addEventListener('selectionchange', () => {
|
||||
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 }));
|
||||
if (disposed) return;
|
||||
apply(el);
|
||||
apply(el, layout, layout.zoom);
|
||||
view = el;
|
||||
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
|
||||
await el.init({ lastLocation, showTextStart: !lastLocation });
|
||||
onready(toToc(el.book.toc));
|
||||
onready(toToc(el.book.toc), { fixedLayout: isFixed(el) });
|
||||
} catch (e) {
|
||||
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
|
||||
onerror(e instanceof Error ? e.message : 'Could not open this book');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
// PDFs via pdf.js: continuous scroll, pages rendered lazily near the viewport, a text layer
|
||||
// for selection, and highlights stored as page-relative rectangles.
|
||||
import { onMount } from 'svelte';
|
||||
// PDFs via pdf.js: continuous scroll or one page/spread at a time, 1 or 2 pages per row,
|
||||
// pages rendered lazily near the viewport, a text layer for selection, and highlights stored
|
||||
// as page-relative rectangles.
|
||||
import { onMount, tick, untrack } from 'svelte';
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import {
|
||||
@@ -12,6 +13,7 @@
|
||||
type HighlightColor,
|
||||
type Rect
|
||||
} from '#lib/books/shared.js';
|
||||
import { clampZoom, pdfRows, rowIndexOf, type Spread } from './layout.js';
|
||||
import { type EngineProps, type TocItem } from './types.js';
|
||||
|
||||
let {
|
||||
@@ -19,8 +21,9 @@
|
||||
start,
|
||||
startPercent,
|
||||
highlights,
|
||||
settings,
|
||||
layout,
|
||||
theme,
|
||||
onzoom,
|
||||
onready,
|
||||
onrelocate,
|
||||
onselection,
|
||||
@@ -30,25 +33,37 @@
|
||||
}: EngineProps = $props();
|
||||
|
||||
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 pdf: PDFDocumentProxy | undefined;
|
||||
let pdfjs: typeof import('pdfjs-dist') | undefined;
|
||||
let pages = $state<PageInfo[]>([]);
|
||||
let width = $state(0);
|
||||
let height = $state(0);
|
||||
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
|
||||
const live = new Map<number, Live>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
|
||||
const pageEls = new Map<number, HTMLElement>();
|
||||
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 first = pages[0];
|
||||
if (!first || !width) return 1;
|
||||
const fit = Math.min(width - 24, 920) / first.width;
|
||||
return Math.max(0.3, fit * settings.pdfZoom);
|
||||
const avail = width - 2 * GAP - (spread - 1) * GAP;
|
||||
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(() => {
|
||||
@@ -70,11 +85,12 @@
|
||||
const el = pageEls.get(num);
|
||||
if (!pdf || !pdfjs || !el || live.has(num)) return;
|
||||
const layer = el.querySelector<HTMLElement>('.layer')!;
|
||||
const entry: Live = { el, layer };
|
||||
const entry: Live = { el, layer, scale };
|
||||
live.set(num, entry);
|
||||
const page: PDFPageProxy = await pdf.getPage(num);
|
||||
if (live.get(num) !== entry) return;
|
||||
const s = scale;
|
||||
entry.scale = s;
|
||||
const viewport = page.getViewport({ scale: s });
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -83,8 +99,6 @@
|
||||
const text = document.createElement('div');
|
||||
text.className = 'textLayer';
|
||||
layer.replaceChildren(canvas, text);
|
||||
el.style.setProperty('--scale-factor', String(s));
|
||||
el.style.setProperty('--total-scale-factor', String(s));
|
||||
entry.task = page.render({
|
||||
canvas,
|
||||
viewport,
|
||||
@@ -123,30 +137,53 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Re-render everything when the zoom or the width changes, keeping the reading position.
|
||||
let lastScale = 0;
|
||||
$effect(() => {
|
||||
const s = scale;
|
||||
// The first scale with pages loaded is the baseline; the initial jump to the saved
|
||||
// position happens in onMount and must not be undone by a "keep position" here.
|
||||
if (!pages.length || !width) return;
|
||||
if (!lastScale) {
|
||||
lastScale = s;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(s - lastScale) < 0.001) return;
|
||||
lastScale = s;
|
||||
const keep = position();
|
||||
for (const num of [...live.keys()]) unrender(num);
|
||||
requestAnimationFrame(() => {
|
||||
scrollToPosition(keep.page, keep.fraction);
|
||||
for (const [num, node] of pageEls) {
|
||||
const r = node.getBoundingClientRect();
|
||||
const v = scroller.getBoundingClientRect();
|
||||
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
|
||||
}
|
||||
// When the zoom, width, spread or mode changes: keep the reading position, resize the page
|
||||
// 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(() => {
|
||||
const key = layoutKey;
|
||||
untrack(() => {
|
||||
if (!pages.length || !width) return void (keep = null);
|
||||
// The first layout with pages is the baseline; the initial jump to the saved position
|
||||
// happens in onMount and must not be undone by a "keep position" here.
|
||||
if (!lastKey || key === lastKey) return void ((lastKey = key), (keep = null));
|
||||
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();
|
||||
});
|
||||
}
|
||||
scheduleRerender();
|
||||
});
|
||||
});
|
||||
|
||||
let rerenderTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
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) {
|
||||
const r = node.getBoundingClientRect();
|
||||
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
|
||||
}
|
||||
}, 160);
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (!scroller) return { page: 1, fraction: 0 };
|
||||
@@ -165,23 +202,54 @@
|
||||
return { page: pages.length, fraction: 1 };
|
||||
}
|
||||
|
||||
function scrollToPosition(num: number, fraction = 0) {
|
||||
const node = pageEls.get(Math.max(1, Math.min(pages.length, num)));
|
||||
if (!node) return;
|
||||
/** Scroll so `num` (at `fraction` down the page) is in view; it must be in the DOM. */
|
||||
function place(num: number, fraction = 0) {
|
||||
const node = pageEls.get(num);
|
||||
if (!node || !scroller) return;
|
||||
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;
|
||||
function onscroll() {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (!pages.length) return;
|
||||
const { page: num, fraction } = position();
|
||||
current = num;
|
||||
const row = rows[rowIndexOf(rows, num)];
|
||||
onrelocate({
|
||||
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
|
||||
locator: pdfLocator(num),
|
||||
page: num,
|
||||
pageEnd: row && row.length > 1 ? row[row.length - 1] : null,
|
||||
pageCount: pages.length,
|
||||
label: labelFor(num)
|
||||
});
|
||||
@@ -207,12 +275,18 @@
|
||||
const p = (percent / 100) * pages.length;
|
||||
scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
|
||||
}
|
||||
// Next/prev move a whole row: one page, or two in spread mode.
|
||||
export function next() {
|
||||
scrollToPosition(current + 1);
|
||||
if (paged) return showRow(currentRow + 1);
|
||||
showRow(rowIndexOf(rows, current) + 1);
|
||||
}
|
||||
export function prev() {
|
||||
const { fraction } = position();
|
||||
scrollToPosition(fraction > 0.1 ? current : current - 1);
|
||||
if (paged) return showRow(currentRow - 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() {
|
||||
// Highlights are derived from props; nothing to draw imperatively.
|
||||
@@ -296,9 +370,50 @@
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
const resize = new ResizeObserver(() => (width = scroller.clientWidth));
|
||||
const measure = () => {
|
||||
width = scroller.clientWidth;
|
||||
height = scroller.clientHeight;
|
||||
};
|
||||
const resize = new ResizeObserver(measure);
|
||||
resize.observe(scroller);
|
||||
width = scroller.clientWidth;
|
||||
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(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
@@ -375,6 +490,12 @@
|
||||
return () => {
|
||||
disposed = true;
|
||||
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();
|
||||
observer?.disconnect();
|
||||
for (const num of [...live.keys()]) unrender(num);
|
||||
@@ -389,31 +510,38 @@
|
||||
bind:this={scroller}
|
||||
{onscroll}
|
||||
>
|
||||
<div class="pages">
|
||||
{#each pages as p (p.num)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="pdf-page"
|
||||
data-page={p.num}
|
||||
style:width={`${Math.floor(p.width * scale)}px`}
|
||||
style:height={`${Math.floor(p.height * scale)}px`}
|
||||
use:page={p.num}
|
||||
onclick={(e) => hitHighlight(e, p.num)}
|
||||
>
|
||||
<div class="layer"></div>
|
||||
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
|
||||
{#each rects as r, i (i)}
|
||||
<span
|
||||
class="hl"
|
||||
style:left={`${r.x * 100}%`}
|
||||
style:top={`${r.y * 100}%`}
|
||||
style:width={`${r.w * 100}%`}
|
||||
style:height={`${r.h * 100}%`}
|
||||
style:background={color(h)}
|
||||
></span>
|
||||
{/each}
|
||||
<div class={['pages', paged && 'paged', spread === 2 && 'spread']}>
|
||||
{#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 -->
|
||||
<div
|
||||
class="pdf-page"
|
||||
data-page={p.num}
|
||||
style:width={`${Math.floor(p.width * scale)}px`}
|
||||
style:height={`${Math.floor(p.height * scale)}px`}
|
||||
style:--scale-factor={scale}
|
||||
style:--total-scale-factor={scale}
|
||||
use:page={p.num}
|
||||
onclick={(e) => hitHighlight(e, p.num)}
|
||||
>
|
||||
<div class="layer"></div>
|
||||
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
|
||||
{#each rects as r, i (i)}
|
||||
<span
|
||||
class="hl"
|
||||
style:left={`${r.x * 100}%`}
|
||||
style:top={`${r.y * 100}%`}
|
||||
style:width={`${r.w * 100}%`}
|
||||
style:height={`${r.h * 100}%`}
|
||||
style:background={color(h)}
|
||||
></span>
|
||||
{/each}
|
||||
{/each}
|
||||
<span class="num">{p.num}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<span class="num">{p.num}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -434,6 +562,21 @@
|
||||
padding: 12px 12px 96px;
|
||||
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 {
|
||||
position: relative;
|
||||
background: white;
|
||||
|
||||
111
src/lib/books/reader/layout.test.ts
Normal file
111
src/lib/books/reader/layout.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
124
src/lib/books/reader/layout.ts
Normal file
124
src/lib/books/reader/layout.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Contract between the reader shell (the /read page) and the EPUB/PDF engines.
|
||||
import type { Highlight } from '#lib/books/shared.js';
|
||||
import type { ViewLayout } from './layout.js';
|
||||
|
||||
export type TocItem = { label: string; target: string | number; children: TocItem[] };
|
||||
|
||||
@@ -7,6 +8,7 @@ export type Relocation = {
|
||||
percent: number; // 0-100
|
||||
locator: string; // EPUB CFI or `page:N`
|
||||
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;
|
||||
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 ReaderSettings = {
|
||||
theme: 'auto' | ReaderTheme;
|
||||
fontSize: number; // percent
|
||||
lineHeight: number;
|
||||
font: 'publisher' | 'serif' | 'sans';
|
||||
flow: 'paginated' | 'scrolled';
|
||||
pdfZoom: number; // 1 = fit width
|
||||
};
|
||||
export const defaultSettings: ReaderSettings = {
|
||||
theme: 'auto',
|
||||
fontSize: 100,
|
||||
lineHeight: 1.5,
|
||||
font: 'publisher',
|
||||
flow: 'paginated',
|
||||
pdfZoom: 1
|
||||
font: 'publisher'
|
||||
};
|
||||
|
||||
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
|
||||
@@ -55,8 +51,12 @@ export type EngineProps = {
|
||||
startPercent: number;
|
||||
highlights: Highlight[];
|
||||
settings: ReaderSettings;
|
||||
/** Zoom, 1/2-page spread and scroll vs paged (see layout.ts). */
|
||||
layout: ViewLayout;
|
||||
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;
|
||||
onselection: (s: TextSelection | null) => void;
|
||||
onhighlightclick: (id: number) => void;
|
||||
|
||||
@@ -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