Files
chitai/frontend/src/lib/components/reader/foliate-view.svelte
T
patrick 92ffa4f7c2 fix: forward app shortcuts pressed inside the book
Key events do not cross document boundaries, so shortcuts bound on the window
never fired while the book had focus and the browser default won instead —
ctrl+B opened bookmarks rather than the chapter sidebar. Replay them on the
window, cancelling the original only if a handler claimed it so ctrl+C still
copies.
2026-08-11 23:12:00 -04:00

306 lines
9.0 KiB
Svelte

<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);
let hostWidth = $state(0);
/**
* How many columns the paginator will actually lay out.
*
* It decides this internally and exposes it only as a custom property inside
* its shadow root, so the formula is mirrored here to place the spread
* divider. Kept in step with paginator.js's `divisor`.
*/
const columns = $derived(
settings.flow === 'scrolled' || hostWidth === 0
? 1
: Math.min(settings.maxColumnCount, Math.ceil(hostWidth / settings.maxInlineSize))
);
/**
* 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);
}
/**
* Replays a shortcut pressed inside the book on the host window.
*
* Key events do not cross document boundaries, so a shortcut pressed while
* the book has focus never reaches the app's handlers — which are bound on
* the window, as the sidebar's ctrl+B is — and the browser's own default runs
* instead. That is why ctrl+B opened bookmarks in the book but toggled the
* chapter sidebar everywhere else.
*
* The original is only cancelled if an app handler actually claimed the
* replay, so combinations the app does not use — ctrl+C above all — keep
* their normal browser behaviour.
*/
function forwardShortcut(event: KeyboardEvent) {
const source = (event.target as Node | null)?.ownerDocument;
// isTrusted rules out the replay itself, which would otherwise recurse.
if (!event.isTrusted || !source || source === document) return;
const claimed = !window.dispatchEvent(
new KeyboardEvent('keydown', {
key: event.key,
code: event.code,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
bubbles: true,
cancelable: true
})
);
if (claimed) event.preventDefault();
}
function onKeydown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey) {
forwardShortcut(event);
return;
}
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;
const resizeObserver = new ResizeObserver(([entry]) => {
hostWidth = entry.contentRect.width;
});
if (container) resizeObserver.observe(container);
(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;
resizeObserver.disconnect();
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>
<!--
Not {onkeydown}: that shorthand resolves to the global window.onkeydown
property, which TypeScript accepts and which is null at runtime, so the
handler silently never ran and arrows only worked once the book had focus.
-->
<svelte:window onkeydown={onKeydown} />
<div class="relative h-full w-full">
<div bind:this={container} class="foliate-host"></div>
{#if ready && columns === 2}
<!-- The gutter between the two pages of a spread. Sits in the column gap,
so it never crosses text. -->
<div
aria-hidden="true"
class="pointer-events-none absolute inset-y-[6%] left-1/2 w-px -translate-x-1/2 bg-border"
></div>
{/if}
</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>