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:
@@ -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>
|
||||
Reference in New Issue
Block a user