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
+29
View File
@@ -0,0 +1,29 @@
const PURGED_FLAG = 'chitai:locations-purged';
/** Keys the epub.js reader wrote: `${bookId}-locations`. */
const LEGACY_KEY = /^\d+-locations$/;
/**
* Drops the epub.js locations cache, once per browser.
*
* foliate computes progress from section byte sizes at open time, so there is no
* locations pre-pass and nothing to cache. The old entries are not small — a
* few hundred KB of JSON per long book against a 510 MB origin quota — and a
* heavy reader sitting near the cap would make the new settings write throw
* QuotaExceededError.
*
* Removable once deployments have had a release to run it; see TODO.md.
*/
export function purgeLegacyLocationCache() {
try {
if (localStorage.getItem(PURGED_FLAG)) return;
for (const key of Object.keys(localStorage)) {
if (LEGACY_KEY.test(key)) localStorage.removeItem(key);
}
localStorage.setItem(PURGED_FLAG, '1');
} catch (error) {
console.warn('Could not purge the legacy locations cache', error);
}
}
+114
View File
@@ -0,0 +1,114 @@
interface PendingProgress {
percentage: number;
epub_cfi: string;
completed: boolean;
}
/** Treat the last stretch as finished: fraction is a float and never lands on 1. */
const COMPLETE_AT = 0.99;
const SAVE_DEBOUNCE_MS = 3000;
/**
* Debounced reading-progress writer.
*
* Progress goes through the catch-all proxy rather than a remote function. That
* looks like a convention violation but is the documented exception: the browser
* itself must send this, and the closing write uses sendBeacon, which needs a
* plain URL and body rather than a remote command's envelope. The httpOnly
* authToken cookie rides along and the proxy attaches the bearer header.
*/
export class ProgressReporter {
#url: string;
#timer: ReturnType<typeof setTimeout> | undefined;
#pending: PendingProgress | null = null;
#listening = false;
constructor(bookId: string | number) {
this.#url = `/api/books/progress/${bookId}`;
}
/**
* Starts flushing on the way out.
*
* pagehide and a hidden visibilitychange, not beforeunload: mobile Safari
* fires beforeunload unreliably and it blocks the bfcache.
*/
listen() {
if (this.#listening || typeof document === 'undefined') return;
document.addEventListener('visibilitychange', this.#onVisibilityChange);
window.addEventListener('pagehide', this.#onPageHide);
this.#listening = true;
}
record(percentage: number, epubCfi: string) {
this.#pending = {
percentage,
epub_cfi: epubCfi,
completed: percentage >= COMPLETE_AT
};
clearTimeout(this.#timer);
this.#timer = setTimeout(() => void this.flush(), SAVE_DEBOUNCE_MS);
}
/** Sends anything outstanding and waits for it. */
async flush() {
const body = this.#take();
if (!body) return;
try {
const response = await fetch(this.#url, {
method: 'POST',
// Without this the browser stamps text/plain and the proxy forwards it
// verbatim. Litestar decodes it anyway, which is why it went unnoticed.
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
keepalive: true
});
// A failed save must not interrupt reading, but should not be silent either.
if (!response.ok) console.error('Could not save reading progress', response.status);
} catch (error) {
console.error('Could not save reading progress', error);
}
}
/**
* Fire-and-forget flush for teardown.
*
* The old reader cleared its debounce timer on destroy without flushing, so a
* page turn within three seconds of leaving was dropped — and leaving is
* exactly when someone turns a last page.
*/
flushSync() {
const body = this.#take();
if (!body) return;
const blob = new Blob([JSON.stringify(body)], { type: 'application/json' });
if (!navigator.sendBeacon?.(this.#url, blob)) void this.flush();
}
dispose() {
this.flushSync();
clearTimeout(this.#timer);
if (!this.#listening) return;
document.removeEventListener('visibilitychange', this.#onVisibilityChange);
window.removeEventListener('pagehide', this.#onPageHide);
this.#listening = false;
}
/** Claims the pending write so it cannot be sent twice. */
#take(): PendingProgress | null {
const body = this.#pending;
this.#pending = null;
clearTimeout(this.#timer);
return body;
}
#onVisibilityChange = () => {
if (document.visibilityState === 'hidden') this.flushSync();
};
#onPageHide = () => this.flushSync();
}
+5 -3
View File
@@ -16,9 +16,11 @@ export interface ReaderPalette {
* <style id="chitai-theme"> with the full palette into every page, so the tokens
* are resolved on documentElement before first paint.
*
* Call this after the dark class has landed, not in the same tick as a mode flip.
* `dark` is passed in rather than read off the class list, because mode-watcher's
* store is the authority and the class trails it by a frame. Call this after that
* frame, though — the token values themselves do come from the class.
*/
export function readReaderPalette(): ReaderPalette {
export function readReaderPalette(dark: boolean): ReaderPalette {
const styles = getComputedStyle(document.documentElement);
const token = (name: string, fallback: string) =>
styles.getPropertyValue(name).trim() || fallback;
@@ -29,7 +31,7 @@ export function readReaderPalette(): ReaderPalette {
fg: token('--foreground', '#000000'),
muted: token('--muted-foreground', '#666666'),
link: token('--primary', '#0066cc'),
dark: document.documentElement.classList.contains('dark')
dark
};
}