books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests
This commit is contained in:
228
src/lib/books/reader/EpubReader.svelte
Normal file
228
src/lib/books/reader/EpubReader.svelte
Normal file
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
// Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js.
|
||||
import { onMount } from 'svelte';
|
||||
import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js';
|
||||
import {
|
||||
fetchWithProgress,
|
||||
themeColors,
|
||||
type EngineProps,
|
||||
type ReaderSettings,
|
||||
type ReaderTheme,
|
||||
type TocItem
|
||||
} from './types.js';
|
||||
|
||||
type FoliateToc = { label?: string; href?: string; subitems?: FoliateToc[] | null };
|
||||
type Annotation = { value: string; color?: string };
|
||||
type Renderer = HTMLElement & {
|
||||
setStyles(css: string): void;
|
||||
getContents(): { doc: Document; index: number }[];
|
||||
};
|
||||
interface FoliateView extends HTMLElement {
|
||||
book: { toc?: FoliateToc[] };
|
||||
renderer: Renderer;
|
||||
open(file: File): Promise<void>;
|
||||
init(options: { lastLocation?: unknown; showTextStart?: boolean }): Promise<void>;
|
||||
close(): void;
|
||||
goTo(target: string | number): Promise<unknown>;
|
||||
goToFraction(fraction: number): Promise<void>;
|
||||
goLeft(): Promise<void>;
|
||||
goRight(): Promise<void>;
|
||||
addAnnotation(annotation: Annotation): Promise<unknown>;
|
||||
deleteAnnotation(annotation: Annotation): Promise<unknown>;
|
||||
getCFI(index: number, range: Range): string;
|
||||
}
|
||||
type DrawFn = (rects: unknown, options: Record<string, unknown>) => SVGElement;
|
||||
|
||||
let {
|
||||
url,
|
||||
name,
|
||||
start,
|
||||
startPercent,
|
||||
highlights,
|
||||
settings,
|
||||
theme,
|
||||
onready,
|
||||
onrelocate,
|
||||
onselection,
|
||||
onhighlightclick,
|
||||
onkey,
|
||||
onloading,
|
||||
onerror
|
||||
}: EngineProps = $props();
|
||||
|
||||
let host = $state<FoliateView>();
|
||||
let view = $state<FoliateView>();
|
||||
let highlightDraw: DrawFn | undefined;
|
||||
|
||||
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) {
|
||||
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; }
|
||||
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; }
|
||||
p { hyphens: auto; -webkit-hyphens: auto; widows: 2; orphans: 2; }
|
||||
a:any-link { color: ${c.link} !important; }
|
||||
${t === 'light' ? '' : 'body *:not(a, a *) { color: inherit !important; background-color: transparent !important; border-color: currentColor; }'}
|
||||
img, svg, video { max-width: 100%; height: auto; }
|
||||
aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
|
||||
::selection { background: rgba(250, 204, 21, 0.45); }`;
|
||||
}
|
||||
|
||||
function apply(v: FoliateView) {
|
||||
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));
|
||||
v.style.background = themeColors[theme].bg;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (view) apply(view);
|
||||
});
|
||||
|
||||
const color = (h: Highlight) =>
|
||||
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
|
||||
const toToc = (items: FoliateToc[] = []): TocItem[] =>
|
||||
items
|
||||
.filter((i) => i.href)
|
||||
.map((i) => ({
|
||||
label: (i.label ?? '').trim() || 'Untitled',
|
||||
target: i.href!,
|
||||
children: toToc(i.subitems ?? [])
|
||||
}));
|
||||
|
||||
export async function goTo(target: string | number) {
|
||||
await view?.goTo(target);
|
||||
}
|
||||
export async function goToPercent(percent: number) {
|
||||
await view?.goToFraction(Math.min(0.9999, Math.max(0, percent / 100)));
|
||||
}
|
||||
export function next() {
|
||||
void view?.goRight();
|
||||
}
|
||||
export function prev() {
|
||||
void view?.goLeft();
|
||||
}
|
||||
export function addHighlight(h: Highlight) {
|
||||
if (h.locator) void view?.addAnnotation({ value: h.locator, color: color(h) }).catch(() => {});
|
||||
}
|
||||
export function removeHighlight(h: Highlight) {
|
||||
if (h.locator) void view?.deleteAnnotation({ value: h.locator }).catch(() => {});
|
||||
}
|
||||
export function clearSelection() {
|
||||
for (const { doc } of view?.renderer.getContents() ?? []) doc.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
let el: FoliateView | undefined;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const base = '/vendor/foliate-js';
|
||||
const [, overlayer] = await Promise.all([
|
||||
import(/* @vite-ignore */ `${base}/view.js`),
|
||||
import(/* @vite-ignore */ `${base}/overlayer.js`)
|
||||
]);
|
||||
highlightDraw = overlayer.Overlayer.highlight;
|
||||
onloading(0);
|
||||
const blob = await fetchWithProgress(url, onloading, abort.signal);
|
||||
if (disposed) return;
|
||||
onloading(null);
|
||||
|
||||
el = host;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener('relocate', (e) => {
|
||||
const d = (e as CustomEvent).detail;
|
||||
onrelocate({
|
||||
percent: Math.max(0, Math.min(100, (d.fraction ?? 0) * 100)),
|
||||
locator: d.cfi,
|
||||
page: null,
|
||||
pageCount: null,
|
||||
label: d.tocItem?.label?.trim() || null
|
||||
});
|
||||
});
|
||||
el.addEventListener('load', (e) => {
|
||||
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
|
||||
doc.addEventListener('keydown', onkey);
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
doc.addEventListener('selectionchange', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const sel = doc.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
if (!sel || sel.isCollapsed || !sel.rangeCount || !text) return onselection(null);
|
||||
try {
|
||||
onselection({ text, locator: el!.getCFI(index, sel.getRangeAt(0)), page: null });
|
||||
} catch {
|
||||
onselection(null);
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
});
|
||||
el.addEventListener('create-overlay', () => {
|
||||
for (const h of highlights) addHighlight(h);
|
||||
});
|
||||
el.addEventListener('draw-annotation', (e) => {
|
||||
const { draw, annotation } = (e as CustomEvent).detail as {
|
||||
draw: (fn: DrawFn, opts: Record<string, unknown>) => void;
|
||||
annotation: Annotation;
|
||||
};
|
||||
if (highlightDraw)
|
||||
draw(highlightDraw, { color: annotation.color ?? highlightCss.yellow });
|
||||
});
|
||||
el.addEventListener('show-annotation', (e) => {
|
||||
const value = (e as CustomEvent).detail.value as string;
|
||||
const h = highlights.find((x) => x.locator === value);
|
||||
if (h) onhighlightclick(h.id);
|
||||
});
|
||||
|
||||
await el.open(new File([blob], name, { type: blob.type }));
|
||||
if (disposed) return;
|
||||
apply(el);
|
||||
view = el;
|
||||
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
|
||||
await el.init({ lastLocation, showTextStart: !lastLocation });
|
||||
onready(toToc(el.book.toc));
|
||||
} catch (e) {
|
||||
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
|
||||
onerror(e instanceof Error ? e.message : 'Could not open this book');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
el?.close?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="host">
|
||||
<foliate-view bind:this={host}></foliate-view>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.host :global(foliate-view) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
538
src/lib/books/reader/PdfReader.svelte
Normal file
538
src/lib/books/reader/PdfReader.svelte
Normal file
@@ -0,0 +1,538 @@
|
||||
<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';
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import {
|
||||
highlightCss,
|
||||
parsePdfLocator,
|
||||
pdfLocator,
|
||||
type Highlight,
|
||||
type HighlightColor,
|
||||
type Rect
|
||||
} from '#lib/books/shared.js';
|
||||
import { type EngineProps, type TocItem } from './types.js';
|
||||
|
||||
let {
|
||||
url,
|
||||
start,
|
||||
startPercent,
|
||||
highlights,
|
||||
settings,
|
||||
theme,
|
||||
onready,
|
||||
onrelocate,
|
||||
onselection,
|
||||
onhighlightclick,
|
||||
onloading,
|
||||
onerror
|
||||
}: EngineProps = $props();
|
||||
|
||||
type PageInfo = { num: number; width: number; height: number }; // at scale 1
|
||||
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement };
|
||||
|
||||
let scroller: HTMLDivElement;
|
||||
let pdf: PDFDocumentProxy | undefined;
|
||||
let pdfjs: typeof import('pdfjs-dist') | undefined;
|
||||
let pages = $state<PageInfo[]>([]);
|
||||
let width = $state(0);
|
||||
let current = 1;
|
||||
// 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 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 byPage = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
|
||||
const map = new Map<number, { h: Highlight; rects: Rect[] }[]>();
|
||||
for (const h of highlights) {
|
||||
const loc = parsePdfLocator(h.locator);
|
||||
if (!loc || !loc.rects.length) continue;
|
||||
const list = map.get(loc.page) ?? [];
|
||||
list.push({ h, rects: loc.rects });
|
||||
map.set(loc.page, list);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const color = (h: Highlight) =>
|
||||
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
|
||||
|
||||
async function render(num: number) {
|
||||
const el = pageEls.get(num);
|
||||
if (!pdf || !pdfjs || !el || live.has(num)) return;
|
||||
const layer = el.querySelector<HTMLElement>('.layer')!;
|
||||
const entry: Live = { el, layer };
|
||||
live.set(num, entry);
|
||||
const page: PDFPageProxy = await pdf.getPage(num);
|
||||
if (live.get(num) !== entry) return;
|
||||
const s = scale;
|
||||
const viewport = page.getViewport({ scale: s });
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.floor(viewport.width * dpr);
|
||||
canvas.height = Math.floor(viewport.height * dpr);
|
||||
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,
|
||||
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined
|
||||
});
|
||||
try {
|
||||
await entry.task.promise;
|
||||
if (live.get(num) !== entry) return;
|
||||
await new pdfjs.TextLayer({
|
||||
textContentSource: page.streamTextContent(),
|
||||
container: text,
|
||||
viewport
|
||||
}).render();
|
||||
} catch {
|
||||
// cancelled (scrolled away or re-zoomed)
|
||||
}
|
||||
}
|
||||
|
||||
function unrender(num: number) {
|
||||
const entry = live.get(num);
|
||||
if (!entry) return;
|
||||
entry.task?.cancel();
|
||||
entry.layer.replaceChildren();
|
||||
live.delete(num);
|
||||
}
|
||||
|
||||
function page(node: HTMLElement, num: number) {
|
||||
pageEls.set(num, node);
|
||||
observer?.observe(node);
|
||||
return {
|
||||
destroy() {
|
||||
observer?.unobserve(node);
|
||||
pageEls.delete(num);
|
||||
unrender(num);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Re-render everything when the zoom or the width changes, keeping the reading position.
|
||||
let lastScale = 0;
|
||||
$effect(() => {
|
||||
const s = scale;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function position() {
|
||||
if (!scroller) return { page: 1, fraction: 0 };
|
||||
const anchor = scroller.scrollTop + scroller.clientHeight * 0.3;
|
||||
for (const p of pages) {
|
||||
const node = pageEls.get(p.num);
|
||||
if (!node) continue;
|
||||
const top = node.offsetTop;
|
||||
const bottom = top + node.offsetHeight;
|
||||
if (anchor < bottom + 8)
|
||||
return {
|
||||
page: p.num,
|
||||
fraction: Math.max(0, Math.min(1, (anchor - top) / node.offsetHeight))
|
||||
};
|
||||
}
|
||||
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;
|
||||
scroller.scrollTop =
|
||||
node.offsetTop + node.offsetHeight * fraction - (fraction ? scroller.clientHeight * 0.3 : 12);
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
function onscroll() {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const { page: num, fraction } = position();
|
||||
current = num;
|
||||
onrelocate({
|
||||
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
|
||||
locator: pdfLocator(num),
|
||||
page: num,
|
||||
pageCount: pages.length,
|
||||
label: labelFor(num)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let flatToc: { page: number; label: string }[] = [];
|
||||
const labelFor = (num: number) => {
|
||||
let label: string | null = null;
|
||||
for (const item of flatToc) if (item.page <= num) label = item.label;
|
||||
return label;
|
||||
};
|
||||
|
||||
export async function goTo(target: string | number) {
|
||||
if (typeof target === 'number') return scrollToPosition(target);
|
||||
const loc = parsePdfLocator(target);
|
||||
if (!loc) return;
|
||||
const y = loc.rects[0]?.y;
|
||||
if (y != null) scrollToPosition(loc.page, Math.max(0, y - 0.05) || 0.0001);
|
||||
else scrollToPosition(loc.page);
|
||||
}
|
||||
export async function goToPercent(percent: number) {
|
||||
const p = (percent / 100) * pages.length;
|
||||
scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
|
||||
}
|
||||
export function next() {
|
||||
scrollToPosition(current + 1);
|
||||
}
|
||||
export function prev() {
|
||||
const { fraction } = position();
|
||||
scrollToPosition(fraction > 0.1 ? current : current - 1);
|
||||
}
|
||||
export function addHighlight() {
|
||||
// Highlights are derived from props; nothing to draw imperatively.
|
||||
}
|
||||
export function removeHighlight() {}
|
||||
export function clearSelection() {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
/** Selection rects as fractions of the page, merged per line. */
|
||||
function selectionRects(range: Range, pageEl: HTMLElement): Rect[] {
|
||||
const box = pageEl.getBoundingClientRect();
|
||||
const rects = [...range.getClientRects()]
|
||||
.filter((r) => r.width > 1 && r.height > 1)
|
||||
.map((r) => ({
|
||||
x: (r.left - box.left) / box.width,
|
||||
y: (r.top - box.top) / box.height,
|
||||
w: r.width / box.width,
|
||||
h: r.height / box.height
|
||||
}))
|
||||
.filter((r) => r.x >= -0.01 && r.y >= -0.01 && r.x < 1 && r.y < 1)
|
||||
.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const merged: Rect[] = [];
|
||||
for (const r of rects) {
|
||||
const last = merged.at(-1);
|
||||
if (last && Math.abs(last.y - r.y) < last.h * 0.5 && r.x <= last.x + last.w + 0.02) {
|
||||
const right = Math.max(last.x + last.w, r.x + r.w);
|
||||
const bottom = Math.max(last.y + last.h, r.y + r.h);
|
||||
last.x = Math.min(last.x, r.x);
|
||||
last.y = Math.min(last.y, r.y);
|
||||
last.w = right - last.x;
|
||||
last.h = bottom - last.y;
|
||||
} else merged.push({ ...r });
|
||||
}
|
||||
return merged.slice(0, 60);
|
||||
}
|
||||
|
||||
function hitHighlight(e: MouseEvent, num: number) {
|
||||
if (!document.getSelection()?.isCollapsed) return;
|
||||
const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = (e.clientX - box.left) / box.width;
|
||||
const y = (e.clientY - box.top) / box.height;
|
||||
const hit = byPage
|
||||
.get(num)
|
||||
?.find(({ rects }) =>
|
||||
rects.some((r) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h)
|
||||
);
|
||||
if (hit) onhighlightclick(hit.h.id);
|
||||
}
|
||||
|
||||
async function resolveToc(doc: PDFDocumentProxy): Promise<TocItem[]> {
|
||||
const outline = await doc.getOutline().catch(() => null);
|
||||
if (!outline) return [];
|
||||
let budget = 600;
|
||||
const walk = async (items: typeof outline): Promise<TocItem[]> => {
|
||||
const out: TocItem[] = [];
|
||||
for (const item of items) {
|
||||
if (budget-- <= 0) break;
|
||||
let num: number | null = null;
|
||||
try {
|
||||
const dest =
|
||||
typeof item.dest === 'string' ? await doc.getDestination(item.dest) : item.dest;
|
||||
const ref = dest?.[0];
|
||||
if (ref && typeof ref === 'object') num = (await doc.getPageIndex(ref)) + 1;
|
||||
else if (typeof ref === 'number') num = ref + 1;
|
||||
} catch {
|
||||
num = null;
|
||||
}
|
||||
const children = item.items?.length ? await walk(item.items) : [];
|
||||
if (num) {
|
||||
out.push({ label: item.title.trim() || `Page ${num}`, target: num, children });
|
||||
flatToc.push({ page: num, label: item.title.trim() });
|
||||
} else out.push(...children);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const toc = await walk(outline);
|
||||
flatToc = flatToc.sort((a, b) => a.page - b.page);
|
||||
return toc;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
const resize = new ResizeObserver(() => (width = scroller.clientWidth));
|
||||
resize.observe(scroller);
|
||||
width = scroller.clientWidth;
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const num = Number((entry.target as HTMLElement).dataset.page);
|
||||
if (entry.isIntersecting) void render(num);
|
||||
else unrender(num);
|
||||
}
|
||||
},
|
||||
{ root: scroller, rootMargin: '150% 0px' }
|
||||
);
|
||||
|
||||
let selTimer: ReturnType<typeof setTimeout>;
|
||||
const onSelectionChange = () => {
|
||||
clearTimeout(selTimer);
|
||||
selTimer = setTimeout(() => {
|
||||
const sel = document.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
const node = sel?.anchorNode;
|
||||
const pageEl = (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(
|
||||
'.pdf-page'
|
||||
);
|
||||
if (!sel || sel.isCollapsed || !text || !pageEl || !scroller.contains(pageEl))
|
||||
return onselection(null);
|
||||
const num = Number(pageEl.dataset.page);
|
||||
const rects = selectionRects(sel.getRangeAt(0), pageEl);
|
||||
if (!rects.length) return onselection(null);
|
||||
onselection({ text, locator: pdfLocator(num, rects), page: num });
|
||||
}, 250);
|
||||
};
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
pdfjs = await import('pdfjs-dist');
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
onloading(0);
|
||||
const task = pdfjs.getDocument({
|
||||
url,
|
||||
withCredentials: true,
|
||||
rangeChunkSize: 1 << 20
|
||||
});
|
||||
task.onProgress = ({ loaded, total }: { loaded: number; total: number }) =>
|
||||
onloading(total ? Math.min(1, loaded / total) : null);
|
||||
const doc = await task.promise;
|
||||
if (disposed) return void doc.destroy();
|
||||
pdf = doc;
|
||||
onloading(null);
|
||||
// Use the first page's size for every page up front; real sizes fill in quickly.
|
||||
const first = (await doc.getPage(1)).getViewport({ scale: 1 });
|
||||
pages = Array.from({ length: doc.numPages }, (_, i) => ({
|
||||
num: i + 1,
|
||||
width: first.width,
|
||||
height: first.height
|
||||
}));
|
||||
void (async () => {
|
||||
for (let i = 2; i <= Math.min(doc.numPages, 2000) && !disposed; i++) {
|
||||
const vp = (await doc.getPage(i)).getViewport({ scale: 1 });
|
||||
if (vp.width !== first.width || vp.height !== first.height)
|
||||
pages[i - 1] = { num: i, width: vp.width, height: vp.height };
|
||||
}
|
||||
})();
|
||||
requestAnimationFrame(async () => {
|
||||
const loc = parsePdfLocator(start);
|
||||
if (loc) scrollToPosition(loc.page, loc.rects[0]?.y ?? 0);
|
||||
else if (startPercent > 0) await goToPercent(startPercent);
|
||||
onscroll();
|
||||
onready(await resolveToc(doc));
|
||||
});
|
||||
} catch (e) {
|
||||
if (!disposed) onerror(e instanceof Error ? e.message : 'Could not open this PDF');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
resize.disconnect();
|
||||
observer?.disconnect();
|
||||
for (const num of [...live.keys()]) unrender(num);
|
||||
void pdf?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={['scroller', `t-${theme}`]}
|
||||
style:background={theme === 'light' ? '#e9e9e7' : theme === 'sepia' ? '#e6dcc4' : '#0b0b0c'}
|
||||
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}
|
||||
{/each}
|
||||
<span class="num">{p.num}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroller {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 12px 96px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
.pdf-page {
|
||||
position: relative;
|
||||
background: white;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 0.12),
|
||||
0 8px 24px rgb(0 0 0 / 0.08);
|
||||
flex: none;
|
||||
}
|
||||
.t-sepia .pdf-page :global(canvas) {
|
||||
filter: sepia(0.35) brightness(0.97);
|
||||
}
|
||||
.t-dark .pdf-page {
|
||||
background: #1a1a1c;
|
||||
}
|
||||
.t-dark .pdf-page :global(canvas) {
|
||||
filter: invert(0.9) hue-rotate(180deg);
|
||||
}
|
||||
.layer,
|
||||
.layer :global(canvas) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.hl {
|
||||
position: absolute;
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.45;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.t-dark .hl {
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.num {
|
||||
position: absolute;
|
||||
right: -2.25rem;
|
||||
bottom: 0;
|
||||
font-size: 0.7rem;
|
||||
color: rgb(128 128 128 / 0.8);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.num {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* pdf.js text layer (from pdfjs-dist/web/pdf_viewer.css, trimmed) */
|
||||
.pdf-page :global(.textLayer) {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
inset: 0;
|
||||
overflow: clip;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
text-size-adjust: none;
|
||||
forced-color-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
caret-color: CanvasText;
|
||||
z-index: 2;
|
||||
--min-font-size: 1;
|
||||
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
--scale-round-x: 1px;
|
||||
--scale-round-y: 1px;
|
||||
}
|
||||
.pdf-page :global(.textLayer :is(span, br)) {
|
||||
color: transparent;
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
user-select: text;
|
||||
}
|
||||
.pdf-page :global(.textLayer > :not(.markedContent)),
|
||||
.pdf-page :global(.textLayer .markedContent span:not(.markedContent)) {
|
||||
z-index: 1;
|
||||
--font-height: 0;
|
||||
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
||||
--scale-x: 1;
|
||||
--rotate: 0deg;
|
||||
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
|
||||
}
|
||||
.pdf-page :global(.textLayer .markedContent) {
|
||||
display: contents;
|
||||
}
|
||||
.pdf-page :global(.textLayer ::selection) {
|
||||
background: rgb(250 204 21 / 0.4);
|
||||
color: transparent;
|
||||
}
|
||||
.pdf-page :global(.textLayer br::selection) {
|
||||
background: transparent;
|
||||
}
|
||||
.pdf-page :global(.textLayer .endOfContent) {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 100% 0 0;
|
||||
z-index: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
89
src/lib/books/reader/types.ts
Normal file
89
src/lib/books/reader/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Contract between the reader shell (the /read page) and the EPUB/PDF engines.
|
||||
import type { Highlight } from '#lib/books/shared.js';
|
||||
|
||||
export type TocItem = { label: string; target: string | number; children: TocItem[] };
|
||||
|
||||
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
|
||||
pageCount: number | null;
|
||||
label: string | null; // current chapter
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
|
||||
light: { bg: '#fdfdfb', fg: '#1c1c1c', link: '#2563eb' },
|
||||
sepia: { bg: '#f4ecd8', fg: '#433422', link: '#8a4b14' },
|
||||
dark: { bg: '#121214', fg: '#d8d8d8', link: '#7aa8ff' }
|
||||
};
|
||||
|
||||
/** What both engines expose to the shell through bind:this. */
|
||||
export interface ReaderEngine {
|
||||
goTo(target: string | number): Promise<void>;
|
||||
goToPercent(percent: number): Promise<void>;
|
||||
next(): void;
|
||||
prev(): void;
|
||||
addHighlight(h: Highlight): void;
|
||||
removeHighlight(h: Highlight): void;
|
||||
clearSelection(): void;
|
||||
}
|
||||
|
||||
export type EngineProps = {
|
||||
url: string;
|
||||
name: string;
|
||||
start: string | null;
|
||||
startPercent: number;
|
||||
highlights: Highlight[];
|
||||
settings: ReaderSettings;
|
||||
theme: ReaderTheme;
|
||||
onready: (toc: TocItem[]) => void;
|
||||
onrelocate: (r: Relocation) => void;
|
||||
onselection: (s: TextSelection | null) => void;
|
||||
onhighlightclick: (id: number) => void;
|
||||
onkey: (e: KeyboardEvent) => void;
|
||||
onloading: (fraction: number | null) => void;
|
||||
onerror: (message: string) => void;
|
||||
};
|
||||
|
||||
/** Downloads a file with progress, so big books show a loading bar instead of a blank page. */
|
||||
export async function fetchWithProgress(
|
||||
url: string,
|
||||
onprogress: (fraction: number | null) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(url, { signal, credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'File not found' : `HTTP ${res.status}`);
|
||||
const total = Number(res.headers.get('content-length')) || 0;
|
||||
if (!res.body || !total) return res.blob();
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
let loaded = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
loaded += value.byteLength;
|
||||
onprogress(Math.min(1, loaded / total));
|
||||
}
|
||||
return new Blob(chunks, { type: res.headers.get('content-type') ?? '' });
|
||||
}
|
||||
Reference in New Issue
Block a user