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:
@@ -1,329 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
|
||||
import { Book, type Rendition } from 'epubjs';
|
||||
import type { DisplayedLocation } from 'epubjs/types/rendition';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { ChevronLeft, ChevronRight, PanelLeft, RotateCcw, TriangleAlert } from '@lucide/svelte';
|
||||
|
||||
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 { 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 {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
PanelLeft,
|
||||
RotateCcw,
|
||||
TriangleAlert
|
||||
} from '@lucide/svelte';
|
||||
import FoliateView from './foliate-view.svelte';
|
||||
import ReaderToc from './reader-toc.svelte';
|
||||
|
||||
let {
|
||||
bookUrl,
|
||||
bookId,
|
||||
filename,
|
||||
title = '',
|
||||
initialProgress = 0,
|
||||
initialEpubLoc = null
|
||||
}: {
|
||||
bookUrl: string;
|
||||
bookId: string | number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
initialProgress?: number;
|
||||
initialEpubLoc?: string | null;
|
||||
} = $props();
|
||||
|
||||
let epubViewer = $state<HTMLElement>();
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
let isReaderVisible = $state(false);
|
||||
const settingsState = setReaderSettingsState();
|
||||
|
||||
/** 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 isReady = $state(false);
|
||||
|
||||
let hasNextPage = $state(true);
|
||||
let hasPrevPage = $state(false);
|
||||
|
||||
let book: Book | undefined = $state();
|
||||
let rendition = $state<Rendition>();
|
||||
let chapters = $state<any[]>([]);
|
||||
|
||||
let currentLocation = $state(untrack(() => initialEpubLoc));
|
||||
let currentProgress = $state(untrack(() => initialProgress));
|
||||
|
||||
let toc = $state<FoliateTocItem[]>([]);
|
||||
let activeTocId = $state<number | null>(null);
|
||||
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() {
|
||||
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();
|
||||
}
|
||||
const percent = $derived(Math.round(progress * 100));
|
||||
|
||||
/**
|
||||
* Push the book's own page onto the theme, so reading in dark mode is not a
|
||||
* white slab. epub.js renders into an iframe, so app CSS cannot reach it —
|
||||
* the values have to be handed over explicitly.
|
||||
* Fetches the book ourselves rather than handing foliate the URL.
|
||||
*
|
||||
* 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() {
|
||||
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() {
|
||||
async function loadFile() {
|
||||
loadError = null;
|
||||
isReaderVisible = false;
|
||||
isReady = false;
|
||||
file = undefined;
|
||||
|
||||
try {
|
||||
updateDimensions();
|
||||
|
||||
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,
|
||||
// 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;
|
||||
file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
|
||||
} catch (error) {
|
||||
console.error('Error loading EPUB', error);
|
||||
loadError = error instanceof Error ? error.message : 'The file could not be opened.';
|
||||
console.error('Could not download the book', error);
|
||||
loadError = error instanceof Error ? error.message : 'The file could not be downloaded.';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!browser) return;
|
||||
window.addEventListener('resize', handleResize);
|
||||
await loadBook();
|
||||
});
|
||||
function handleRelocate(detail: FoliateRelocateDetail) {
|
||||
progress = detail.fraction;
|
||||
activeTocId = detail.tocItem?.id ?? null;
|
||||
reporter.record(detail.fraction, detail.cfi);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (!browser) return;
|
||||
window.removeEventListener('resize', handleResize);
|
||||
clearTimeout(debounceTimeout);
|
||||
// Guarded: a failed load leaves `book` undefined, and this threw on the
|
||||
// way out, replacing the real error with a second one.
|
||||
book?.destroy();
|
||||
function navigateToChapter(href: string) {
|
||||
void viewer?.goTo(href);
|
||||
isSidebarOpen = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
purgeLegacyLocationCache();
|
||||
reporter.listen();
|
||||
void loadFile();
|
||||
|
||||
return () => reporter.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={handleKeydown} />
|
||||
<svelte:head>
|
||||
<title>{title ? `${title} — Chitai` : 'Reader — Chitai'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<Sidebar.Provider bind:open={isSidebarOpen}>
|
||||
<Sidebar.Root class={isReaderVisible ? '' : 'hidden'}>
|
||||
<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 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>
|
||||
<Sidebar.Provider bind:open={isSidebarOpen} class="min-h-0">
|
||||
{#if isReady}
|
||||
<ReaderToc {toc} activeId={activeTocId} onnavigate={navigateToChapter} />
|
||||
{/if}
|
||||
|
||||
<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 -->
|
||||
@@ -333,7 +115,7 @@
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8"
|
||||
onclick={() => (isSidebarOpen = !isSidebarOpen)}
|
||||
disabled={!isReaderVisible}
|
||||
disabled={!isReady || toc.length === 0}
|
||||
>
|
||||
<PanelLeft class="size-4" />
|
||||
</Tooltip.Trigger>
|
||||
@@ -344,17 +126,17 @@
|
||||
<a
|
||||
href="/book/{bookId}"
|
||||
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
|
||||
title={title}
|
||||
{title}
|
||||
>
|
||||
{title || 'Reader'}
|
||||
</a>
|
||||
|
||||
{#if isReaderVisible}
|
||||
{#if isReady}
|
||||
<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="block h-full bg-flag" style="width: {percent}%;"></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>
|
||||
{/if}
|
||||
|
||||
@@ -362,14 +144,13 @@
|
||||
</header>
|
||||
|
||||
{#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 max-w-sm flex-col items-center gap-3 text-center">
|
||||
<TriangleAlert class="size-8 text-destructive" />
|
||||
<h2 class="font-serif text-lg">This book wouldn't open</h2>
|
||||
<p class="text-sm text-muted-foreground">{loadError}</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<Button onclick={loadBook}>
|
||||
<Button onclick={loadFile}>
|
||||
<RotateCcw class="size-4" />
|
||||
Try again
|
||||
</Button>
|
||||
@@ -379,63 +160,53 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !isReaderVisible}
|
||||
<div class="flex flex-1 items-center justify-center gap-3">
|
||||
<Spinner />
|
||||
<span class="text-sm text-muted-foreground">Opening…</span>
|
||||
{:else}
|
||||
{#if !isReady}
|
||||
<div class="flex flex-1 items-center justify-center gap-3">
|
||||
<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>
|
||||
{/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>
|
||||
</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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,15 @@
|
||||
const fileId = page.params.fileId!;
|
||||
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}`;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title} — Chitai</title>
|
||||
</svelte:head>
|
||||
|
||||
<EpubReader
|
||||
{bookUrl}
|
||||
{bookId}
|
||||
title={data.title}
|
||||
filename={data.filename}
|
||||
initialProgress={data.initialProgress}
|
||||
initialEpubLoc={data.initialEpubLoc}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user