229 lines
7.2 KiB
Svelte
229 lines
7.2 KiB
Svelte
<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>
|