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.
306 lines
9.0 KiB
Svelte
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>
|