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,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 5–10 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user