feat: replace the epub reader with foliate-js

Drops epub.js's 1600-location pre-pass and its localStorage cache: foliate
hands back a CFI and an overall fraction on every relocate, so first open no
longer freezes.

Also fixes resuming (pre-resolves the stored CFI, falling back to the stored
percentage rather than silently resetting to page one), progress saving
(missing Content-Type, and no flush on the way out), and arrow keys inside
the book iframe.
This commit is contained in:
2026-08-11 22:21:15 -04:00
parent 783f4d226d
commit 190e7af76d
7 changed files with 581 additions and 352 deletions
@@ -0,0 +1,230 @@
<script lang="ts">
import { onMount } from 'svelte';
import { mode } from 'mode-watcher';
import type {
FoliateLoadDetail,
FoliateRelocateDetail,
FoliateTarget,
FoliateTocItem,
View
} from '$foliate/view.js';
import { createFoliateView, loadFoliate } from '$lib/reader/foliate';
import type { ReaderSettings } from '$lib/schema/reader';
import { toRendererAttributes } from '$lib/reader/settings';
import { buildReaderStyles, readReaderPalette } from '$lib/reader/stylesheet';
interface ReadyDetail {
toc: FoliateTocItem[];
isFixedLayout: boolean;
rtl: boolean;
}
let {
file,
initialCfi = null,
initialFraction = 0,
settings,
onready,
onrelocate,
onerror
}: {
file: File;
initialCfi?: string | null;
initialFraction?: number;
settings: ReaderSettings;
onready?: (detail: ReadyDetail) => void;
onrelocate?: (detail: FoliateRelocateDetail) => void;
onerror?: (message: string) => void;
} = $props();
let container = $state<HTMLDivElement>();
/** Not $state: nothing in the markup reads it, and it must not be proxied. */
let view: View | undefined;
let rtl = false;
// Reactive so the effects below re-run once the book is open — otherwise a
// theme flip while it was still loading would never reach the book.
let ready = $state(false);
/**
* Where to resume from.
*
* foliate's resolveNavigation logs and swallows its failures, returning
* undefined — and init() then falls through to next(), i.e. page one. So an
* epub.js-authored CFI that does not resolve would silently reset the reader.
* Resolve it up front and fall back to the percentage the backend has stored
* all along, which lands within a page or two.
*/
function resolveStart(v: View): FoliateTarget | null {
if (initialCfi) {
const resolved = v.resolveNavigation(initialCfi);
if (resolved && Number.isInteger(resolved.index) && resolved.index >= 0) return initialCfi;
console.warn('Stored CFI did not resolve; falling back to percentage', initialCfi);
}
return initialFraction > 0 ? { fraction: initialFraction } : null;
}
function applyAttributes(current: ReaderSettings) {
if (!view?.renderer) return;
for (const [name, value] of Object.entries(toRendererAttributes(current))) {
view.renderer.setAttribute(name, value);
}
}
function applyStyles(current: ReaderSettings, dark: boolean) {
// Absent on the fixed-layout renderer, which has no reflowable text.
view?.renderer?.setStyles?.(buildReaderStyles(current, readReaderPalette(dark)));
}
/**
* Sections render inside iframes, which swallow key events before they reach
* the document — so arrow keys only worked with focus in the app chrome. The
* old reader used epub.js's rendition.on('keydown'); foliate has no equivalent,
* so bind on the section document each time one loads.
*/
function handleLoad(event: Event) {
const { doc } = (event as CustomEvent<FoliateLoadDetail>).detail;
doc.addEventListener('keydown', onKeydown);
}
function onKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowLeft':
case 'PageUp':
event.preventDefault();
void goLeft();
break;
case 'ArrowRight':
case 'PageDown':
case ' ':
event.preventDefault();
void goRight();
break;
}
}
export function goLeft() {
return view?.goLeft();
}
export function goRight() {
return view?.goRight();
}
/** Direction-aware, so the chevrons stay literal on right-to-left books. */
export function goForward() {
return rtl ? view?.goLeft() : view?.goRight();
}
export function goBack() {
return rtl ? view?.goRight() : view?.goLeft();
}
export function goTo(target: FoliateTarget) {
return view?.goTo(target);
}
onMount(() => {
let disposed = false;
(async () => {
try {
await loadFoliate();
if (disposed || !container) return;
view = createFoliateView();
// Appended before open(): the paginator measures its host, and a
// detached element has no size to measure.
//
// Deliberately outside Svelte's control. <foliate-view> is a custom
// element that renders its own iframes; writing it in markup would
// have SSR emit an unknown tag and leave Svelte trying to hydrate a
// subtree the paginator owns.
// eslint-disable-next-line svelte/no-dom-manipulating
container.append(view);
await view.open(file);
if (disposed) return;
rtl = view.book?.dir === 'rtl';
// Both before init(): the paginator stores the styles and re-applies
// them on every section load, so the first page paints already themed
// rather than flashing the book's own colours first.
applyAttributes(settings);
applyStyles(settings, mode.current === 'dark');
view.addEventListener('load', handleLoad);
view.addEventListener('relocate', (event) => {
onrelocate?.((event as CustomEvent<FoliateRelocateDetail>).detail);
});
await view.init({ lastLocation: resolveStart(view) });
if (disposed) return;
ready = true;
onready?.({
toc: view.book?.toc ?? [],
isFixedLayout: view.isFixedLayout,
rtl
});
} catch (error) {
console.error('Could not open the book', error);
onerror?.(error instanceof Error ? error.message : 'The file could not be opened.');
}
})();
return () => {
disposed = true;
for (const { doc } of view?.renderer?.getContents?.() ?? []) {
doc.removeEventListener('keydown', onKeydown);
}
view?.close();
view?.remove();
view = undefined;
};
});
// Re-applies on any settings change and on a light/dark flip.
//
// setStyles re-paginates internally and waits on document.fonts.ready, so no
// manual resize is needed — unlike epub.js, which required a window resize
// listener and rendition.resize().
//
// The styles are deferred by a frame on purpose: mode.current flips before
// ModeWatcher writes .dark onto <html>, so reading the tokens in this tick
// would style the book from the outgoing palette. That is the bug the old
// reader shipped with — a white page against a dark UI.
$effect(() => {
const current = settings;
const dark = mode.current === 'dark';
if (!ready) return;
applyAttributes(current);
const frame = requestAnimationFrame(() => applyStyles(current, dark));
return () => cancelAnimationFrame(frame);
});
</script>
<svelte:window {onkeydown} />
<div bind:this={container} class="foliate-host"></div>
<style>
/* foliate-view extends bare HTMLElement, so it has no default display. */
.foliate-host {
height: 100%;
width: 100%;
}
.foliate-host :global(foliate-view) {
display: block;
height: 100%;
width: 100%;
}
</style>