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
@@ -1,329 +1,111 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { onMount, untrack } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { Book, type Rendition } from 'epubjs'; import { ChevronLeft, ChevronRight, PanelLeft, RotateCcw, TriangleAlert } from '@lucide/svelte';
import type { DisplayedLocation } from 'epubjs/types/rendition';
import { mode } from 'mode-watcher'; import type { FoliateRelocateDetail, FoliateTocItem } from '$foliate/view.js';
import { ProgressReporter } from '$lib/reader/progress';
import { purgeLegacyLocationCache } from '$lib/reader/legacy-cache';
import { setReaderSettingsState } from '$lib/state/reader-settings.svelte';
import '../../../app.css';
import * as Sidebar from '$lib/components/ui/sidebar/index';
import * as Collapsible from '$lib/components/ui/collapsible/index';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js'; import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import { Spinner } from '$lib/components/ui/spinner/index'; import { Spinner } from '$lib/components/ui/spinner/index';
import * as Sidebar from '$lib/components/ui/sidebar/index';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte'; import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
import FoliateView from './foliate-view.svelte';
import { import ReaderToc from './reader-toc.svelte';
ChevronDown,
ChevronLeft,
ChevronRight,
PanelLeft,
RotateCcw,
TriangleAlert
} from '@lucide/svelte';
let { let {
bookUrl, bookUrl,
bookId, bookId,
filename,
title = '', title = '',
initialProgress = 0, initialProgress = 0,
initialEpubLoc = null initialEpubLoc = null
}: { }: {
bookUrl: string; bookUrl: string;
bookId: string | number; bookId: string | number;
filename: string;
title?: string; title?: string;
initialProgress?: number; initialProgress?: number;
initialEpubLoc?: string | null; initialEpubLoc?: string | null;
} = $props(); } = $props();
let epubViewer = $state<HTMLElement>(); const settingsState = setReaderSettingsState();
let containerWidth = $state(0);
let containerHeight = $state(0);
let isReaderVisible = $state(false);
/** Set when loading fails, so the spinner gives way to something actionable. */ // Read once: the reader is remounted per file, so these are fixed for its
// lifetime, and tracking them would restart the load mid-read.
const reporter = new ProgressReporter(untrack(() => bookId));
const initialCfi = untrack(() => initialEpubLoc);
const initialFraction = untrack(() => initialProgress);
let file = $state<File>();
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
let isReady = $state(false);
let hasNextPage = $state(true); let toc = $state<FoliateTocItem[]>([]);
let hasPrevPage = $state(false); let activeTocId = $state<number | null>(null);
let book: Book | undefined = $state();
let rendition = $state<Rendition>();
let chapters = $state<any[]>([]);
let currentLocation = $state(untrack(() => initialEpubLoc));
let currentProgress = $state(untrack(() => initialProgress));
let isSidebarOpen = $state(false); let isSidebarOpen = $state(false);
let debounceTimeout = $state<NodeJS.Timeout>();
const percent = $derived(Math.round((currentProgress ?? 0) * 100)); let progress = $state(initialFraction);
let viewer = $state<ReturnType<typeof FoliateView>>();
function updateDimensions() { const percent = $derived(Math.round(progress * 100));
if (!epubViewer) return;
const parent = epubViewer.parentElement;
containerWidth = parent?.clientWidth! * 0.9 - 240;
containerHeight = window.innerHeight * 0.8;
}
function handleResize() {
updateDimensions();
rendition?.resize(containerWidth, containerHeight);
}
async function handleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') await prevPage();
else if (e.key === 'ArrowRight') await nextPage();
}
async function nextPage() {
if (hasNextPage) await rendition?.next();
}
async function prevPage() {
if (hasPrevPage) await rendition?.prev();
}
/** /**
* Push the book's own page onto the theme, so reading in dark mode is not a * Fetches the book ourselves rather than handing foliate the URL.
* white slab. epub.js renders into an iframe, so app CSS cannot reach it — *
* the values have to be handed over explicitly. * view.open(url) would route through foliate's fetchFile, which names the File
* after the URL path — "34", with no extension — and makeBook's CBZ/FB2 checks
* are filename-based. Doing it here also keeps the response.ok check: the proxy
* answers a failure with a SvelteKit error page, and fetch resolves on a 404,
* so without it a missing file surfaced only as an opaque parse error.
*/ */
function applyReaderTheme() { async function loadFile() {
if (!rendition) return;
try {
const styles = getComputedStyle(document.documentElement);
const bg = styles.getPropertyValue('--card').trim();
const fg = styles.getPropertyValue('--foreground').trim();
if (!bg || !fg) return;
// A registered theme rather than bare overrides: most EPUBs ship their
// own body background and colour, and an unprioritised override loses
// to them — which is why the page stayed white against a dark UI.
rendition.themes.register('chitai', {
body: { background: `${bg} !important`, color: `${fg} !important` },
'p, div, span, li, td, th, h1, h2, h3, h4, h5, h6': { color: `${fg} !important` },
a: { color: 'inherit !important' }
});
rendition.themes.select('chitai');
} catch (error) {
// Theming is a nicety; never let it take the reader down.
console.warn('Could not apply reader theme', error);
}
}
/**
* Runs when the rendition appears and again on every light/dark flip. It
* deliberately does not wait for isReaderVisible: at first paint that gate
* meant the theme was read before ModeWatcher had put `.dark` on <html>, so
* the book was styled from the light palette while the UI went dark.
*/
$effect(() => {
mode.current;
if (rendition) applyReaderTheme();
});
async function setUserBookProgress() {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/books/progress/${bookId}`, {
method: 'POST',
body: JSON.stringify({
percentage: currentProgress,
epub_cfi: currentLocation,
completed: currentProgress === 1
})
});
// A failed save must not interrupt reading, but it should not be
// invisible either.
if (!response.ok) console.error('Could not save reading progress', response.status);
} catch (error) {
console.error('Could not save reading progress', error);
}
}, 3000);
}
const getChapters = async (book: Book) => {
await book.ready;
const spineItems = book.spine.items.map((item, index) => ({
id: item.idref,
href: item.href,
index,
label: item.label || `Chapter ${index + 1}`,
cfi: book.spine.get(index).cfiBase
}));
const toc = await book.loaded.navigation;
return toc.toc.map((chapter) => {
const spineItem = spineItems.find((item: any) => item.href === chapter.href);
return {
...spineItem,
label: chapter.label || spineItem?.label,
subitems: chapter.subitems ?? [],
href: chapter.href,
cfi: spineItem?.cfi || book.spine.get(chapter.href)?.cfiBase
};
});
};
async function navigateToChapter(chapter: any) {
try {
if (!rendition || !book || !chapter.href) return;
await book.ready;
await rendition.display(chapter.href);
} catch (error) {
console.error('Error navigating to chapter:', error);
}
}
async function loadBook() {
loadError = null; loadError = null;
isReaderVisible = false; isReady = false;
file = undefined;
try { try {
updateDimensions();
const response = await fetch(bookUrl); const response = await fetch(bookUrl);
if (!response.ok) throw new Error(`The server returned ${response.status} for this file.`);
// fetch only rejects on network failure — a 404 or 500 still resolves, file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
// and arrayBuffer() happily returns the error page. Without this check
// the failure only surfaced as an epub parse error, caught below and
// logged, leaving the spinner running forever.
if (!response.ok) {
throw new Error(`The server returned ${response.status} for this file.`);
}
const arrayBuffer = await response.arrayBuffer();
book = new Book();
await book.open(arrayBuffer, 'binary');
// Locations are expensive to generate, so they are cached per book
const cached = localStorage.getItem(`${bookId}-locations`);
if (cached) {
book.locations.load(JSON.parse(cached));
} else {
const locations = await book.locations.generate(1600);
localStorage.setItem(`${bookId}-locations`, JSON.stringify(locations));
}
await book.ready;
rendition = book.renderTo('epub-viewer', {
width: containerWidth,
height: containerHeight,
spread: 'auto',
snap: true,
manager: 'continuous',
flow: 'paginated'
});
rendition.on('keydown', async (e: any) => {
if ((e.keyCode || e.which) === 37) await prevPage();
if ((e.keyCode || e.which) === 39) await nextPage();
});
rendition.on('locationChanged', async (location: DisplayedLocation) => {
if (!location?.start) return;
const cfi = rendition!.currentLocation().start.cfi;
currentLocation = cfi;
currentProgress = book?.locations.percentageFromCfi(cfi) ?? 0;
await setUserBookProgress();
hasNextPage = !rendition!.location.atEnd;
hasPrevPage = !rendition!.location.atStart;
});
chapters = await getChapters(book);
const startAt = currentLocation || book.locations.cfiFromPercentage(currentProgress);
await (startAt ? rendition.display(startAt) : rendition.display());
applyReaderTheme();
isReaderVisible = true;
} catch (error) { } catch (error) {
console.error('Error loading EPUB', error); console.error('Could not download the book', error);
loadError = error instanceof Error ? error.message : 'The file could not be opened.'; loadError = error instanceof Error ? error.message : 'The file could not be downloaded.';
} }
} }
onMount(async () => { function handleRelocate(detail: FoliateRelocateDetail) {
if (!browser) return; progress = detail.fraction;
window.addEventListener('resize', handleResize); activeTocId = detail.tocItem?.id ?? null;
await loadBook(); reporter.record(detail.fraction, detail.cfi);
}); }
onDestroy(() => { function navigateToChapter(href: string) {
if (!browser) return; void viewer?.goTo(href);
window.removeEventListener('resize', handleResize); isSidebarOpen = false;
clearTimeout(debounceTimeout); }
// Guarded: a failed load leaves `book` undefined, and this threw on the
// way out, replacing the real error with a second one. onMount(() => {
book?.destroy(); purgeLegacyLocationCache();
reporter.listen();
void loadFile();
return () => reporter.dispose();
}); });
</script> </script>
<svelte:document onkeydown={handleKeydown} /> <svelte:head>
<title>{title ? `${title} Chitai` : 'Reader — Chitai'}</title>
</svelte:head>
<Sidebar.Provider bind:open={isSidebarOpen}> <Sidebar.Provider bind:open={isSidebarOpen} class="min-h-0">
<Sidebar.Root class={isReaderVisible ? '' : 'hidden'}> {#if isReady}
<Sidebar.Header class="px-4 py-3"> <ReaderToc {toc} activeId={activeTocId} onnavigate={navigateToChapter} />
<p class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Chapters</p> {/if}
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each chapters as chapter (chapter.href)}
{#if chapter.subitems.length > 0}
<Collapsible.Root class="group/collapsible">
<div class="flex w-full items-center gap-1">
<Sidebar.MenuItem class="min-w-0 flex-1">
<Sidebar.MenuButton class="w-full" onclick={() => navigateToChapter(chapter)}>
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Collapsible.Trigger class="flex-shrink-0 p-2">
<ChevronDown
class="size-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
/>
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each chapter.subitems as subchapter (subchapter.href)}
<Sidebar.MenuSubItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
onclick={() => navigateToChapter(subchapter)}
>
<span class="block truncate" title={subchapter.label}>
{subchapter.label}
</span>
</Sidebar.MenuButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
{:else}
<Sidebar.MenuItem class="min-w-0">
<Sidebar.MenuButton class="w-full" onclick={() => navigateToChapter(chapter)}>
<span class="block truncate">{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Content>
</Sidebar.Root>
<main class="flex h-screen w-full flex-col overflow-hidden bg-background"> <main class="flex h-screen w-full flex-col overflow-hidden bg-background">
<!-- Reader chrome: somewhere to go back to, what you are reading, how far in --> <!-- Reader chrome: somewhere to go back to, what you are reading, how far in -->
@@ -333,7 +115,7 @@
<Tooltip.Trigger <Tooltip.Trigger
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8" class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8"
onclick={() => (isSidebarOpen = !isSidebarOpen)} onclick={() => (isSidebarOpen = !isSidebarOpen)}
disabled={!isReaderVisible} disabled={!isReady || toc.length === 0}
> >
<PanelLeft class="size-4" /> <PanelLeft class="size-4" />
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -344,17 +126,17 @@
<a <a
href="/book/{bookId}" href="/book/{bookId}"
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline" class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
title={title} {title}
> >
{title || 'Reader'} {title || 'Reader'}
</a> </a>
{#if isReaderVisible} {#if isReady}
<span class="hidden items-center gap-2 sm:flex"> <span class="hidden items-center gap-2 sm:flex">
<span class="h-1 w-24 overflow-hidden rounded-full bg-muted-foreground/25"> <span class="h-1 w-24 overflow-hidden rounded-full bg-muted-foreground/25">
<span class="block h-full bg-flag" style="width: {percent}%;"></span> <span class="block h-full bg-flag" style="width: {percent}%;"></span>
</span> </span>
<span class="font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span> <span class="font-mono text-xs text-muted-foreground tabular-nums">{percent}%</span>
</span> </span>
{/if} {/if}
@@ -362,14 +144,13 @@
</header> </header>
{#if loadError} {#if loadError}
<!-- Previously this state was a console.error and a spinner that never stopped -->
<div class="flex flex-1 items-center justify-center p-6"> <div class="flex flex-1 items-center justify-center p-6">
<div class="flex max-w-sm flex-col items-center gap-3 text-center"> <div class="flex max-w-sm flex-col items-center gap-3 text-center">
<TriangleAlert class="size-8 text-destructive" /> <TriangleAlert class="size-8 text-destructive" />
<h2 class="font-serif text-lg">This book wouldn't open</h2> <h2 class="font-serif text-lg">This book wouldn't open</h2>
<p class="text-sm text-muted-foreground">{loadError}</p> <p class="text-sm text-muted-foreground">{loadError}</p>
<div class="mt-2 flex gap-2"> <div class="mt-2 flex gap-2">
<Button onclick={loadBook}> <Button onclick={loadFile}>
<RotateCcw class="size-4" /> <RotateCcw class="size-4" />
Try again Try again
</Button> </Button>
@@ -379,63 +160,53 @@
</div> </div>
</div> </div>
</div> </div>
{:else if !isReaderVisible} {:else}
<div class="flex flex-1 items-center justify-center gap-3"> {#if !isReady}
<Spinner /> <div class="flex flex-1 items-center justify-center gap-3">
<span class="text-sm text-muted-foreground">Opening…</span> <Spinner />
<span class="text-sm text-muted-foreground">Opening…</span>
</div>
{/if}
<div class="flex min-h-0 flex-1 items-stretch {isReady ? '' : 'hidden'}">
<Button
variant="ghost"
size="icon"
class="my-auto size-10 shrink-0"
onclick={() => viewer?.goBack()}
aria-label="Previous page"
>
<ChevronLeft class="size-5" />
</Button>
<div class="min-w-0 flex-1">
{#if file}
<FoliateView
bind:this={viewer}
{file}
{initialCfi}
{initialFraction}
settings={settingsState.settings}
onrelocate={handleRelocate}
onerror={(message) => (loadError = message)}
onready={(detail) => {
toc = detail.toc;
isReady = true;
}}
/>
{/if}
</div>
<Button
variant="ghost"
size="icon"
class="my-auto size-10 shrink-0"
onclick={() => viewer?.goForward()}
aria-label="Next page"
>
<ChevronRight class="size-5" />
</Button>
</div> </div>
{/if} {/if}
<!-- Kept mounted even while loading: epub.js renders into #epub-viewer -->
<div class="flex flex-1 items-center justify-center {isReaderVisible ? '' : 'hidden'}">
<Button
variant="ghost"
size="icon"
class="size-10 shrink-0"
disabled={!hasPrevPage}
onclick={prevPage}
aria-label="Previous page"
>
<ChevronLeft class="size-5" />
</Button>
<div
id="epub-viewer"
bind:this={epubViewer}
class="epub-content mx-4 rounded-lg border bg-card p-8 shadow-sm"
></div>
<Button
variant="ghost"
size="icon"
class="size-10 shrink-0"
disabled={!hasNextPage}
onclick={nextPage}
aria-label="Next page"
>
<ChevronRight class="size-5" />
</Button>
</div>
</main> </main>
</Sidebar.Provider> </Sidebar.Provider>
<style>
.epub-content {
position: relative;
}
/* Gutter between the two pages when the spread is side by side */
@media (min-width: 1209px) {
.epub-content::after {
position: absolute;
width: 1px;
border-right: 1px solid var(--border);
height: 90%;
z-index: 1;
left: 50%;
transform: translateX(-50%);
top: 5%;
content: '';
}
}
</style>
@@ -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>
@@ -0,0 +1,86 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import type { FoliateTocItem } from '$foliate/view.js';
import * as Collapsible from '$lib/components/ui/collapsible/index';
import * as Sidebar from '$lib/components/ui/sidebar/index';
let {
toc,
activeId = null,
onnavigate
}: {
toc: FoliateTocItem[];
/** From relocate.tocItem.id — foliate assigns these, books do not carry them. */
activeId?: number | null;
onnavigate: (href: string) => void;
} = $props();
/** True when this item or any descendant is the current chapter. */
function contains(item: FoliateTocItem, id: number | null): boolean {
if (id === null) return false;
if (item.id === id) return true;
return item.subitems?.some((sub) => contains(sub, id)) ?? false;
}
</script>
<Sidebar.Root>
<Sidebar.Header class="px-4 py-3">
<p class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Chapters</p>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each toc as chapter (chapter.id)}
{#if chapter.subitems?.length}
<Collapsible.Root class="group/collapsible" open={contains(chapter, activeId)}>
<div class="flex w-full items-center gap-1">
<Sidebar.MenuItem class="min-w-0 flex-1">
<Sidebar.MenuButton
class="w-full"
isActive={chapter.id === activeId}
onclick={() => onnavigate(chapter.href)}
>
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Collapsible.Trigger class="flex-shrink-0 p-2">
<ChevronDown
class="size-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
/>
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each chapter.subitems ?? [] as subchapter (subchapter.id)}
<Sidebar.MenuSubItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
isActive={subchapter.id === activeId}
onclick={() => onnavigate(subchapter.href)}
>
<span class="block truncate" title={subchapter.label}>
{subchapter.label}
</span>
</Sidebar.MenuButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
{:else}
<Sidebar.MenuItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
isActive={chapter.id === activeId}
onclick={() => onnavigate(chapter.href)}
>
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Content>
</Sidebar.Root>
+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 * <style id="chitai-theme"> with the full palette into every page, so the tokens
* are resolved on documentElement before first paint. * 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 styles = getComputedStyle(document.documentElement);
const token = (name: string, fallback: string) => const token = (name: string, fallback: string) =>
styles.getPropertyValue(name).trim() || fallback; styles.getPropertyValue(name).trim() || fallback;
@@ -29,7 +31,7 @@ export function readReaderPalette(): ReaderPalette {
fg: token('--foreground', '#000000'), fg: token('--foreground', '#000000'),
muted: token('--muted-foreground', '#666666'), muted: token('--muted-foreground', '#666666'),
link: token('--primary', '#0066cc'), link: token('--primary', '#0066cc'),
dark: document.documentElement.classList.contains('dark') dark
}; };
} }
@@ -7,18 +7,15 @@
const fileId = page.params.fileId!; const fileId = page.params.fileId!;
const bookId = page.params.bookId!; const bookId = page.params.bookId!;
// Streams the file through the proxy so the browser fetches it directly // Fetched by the browser through the proxy, which attaches the auth header
const bookUrl = `/api/books/download/${bookId}/${fileId}`; const bookUrl = `/api/books/download/${bookId}/${fileId}`;
</script> </script>
<svelte:head>
<title>{data.title} — Chitai</title>
</svelte:head>
<EpubReader <EpubReader
{bookUrl} {bookUrl}
{bookId} {bookId}
title={data.title} title={data.title}
filename={data.filename}
initialProgress={data.initialProgress} initialProgress={data.initialProgress}
initialEpubLoc={data.initialEpubLoc} initialEpubLoc={data.initialEpubLoc}
/> />