Files
reader/web/src/ui/reader-view.ts
T

659 lines
25 KiB
TypeScript

import type { BookDoc, Section } from '../formats/types.ts';
import { ResourceResolver, hydrateResources } from './resources.ts';
import { createBookHost, extractBody, extractInlineStyles, sanitiseInjectedContent, type BookShadowHost } from './shadow.ts';
import { bookPercentage, formatLocator, parseLocator } from './locator.ts';
import { collectSpokenChunks, type SpokenChunk } from '../render/tts-text.ts';
export interface ReaderViewOptions {
container: HTMLElement;
doc: BookDoc;
/** Called on every position change, throttled by the caller via the view. */
onPositionChange?: (position: Position) => void;
onChapterChange?: (index: number, section: Section) => void;
}
export interface Position {
sectionIndex: number;
sectionId: string;
within: number;
percentage: number;
locator: string;
chapterTitle: string;
}
export type TextAlign = 'inherit' | 'start' | 'justify';
export type PageAnimation = 'none' | 'slide' | 'fade';
export type TapZone = 'standard' | 'reversed';
export interface ViewSettings {
/** 'scroll' keeps the author's continuous flow; 'paged' paginates by column. */
mode: 'scroll' | 'paged';
fontScale: number;
lineHeight: string;
theme: 'light' | 'sepia' | 'dark';
/** Fixed-layout only. */
fit: 'contain' | 'width';
direction: 'ltr' | 'rtl';
/** A font stack offered before the book's own, for books that ship no stack. */
fontFamily: string;
/**
* Horizontal padding inside the reading column, in rem.
*
* A number rather than a keyword because the useful range differs per device:
* a phone wants 0.75rem to fit a sentence per line, a tablet wants 3rem to stop
* the line becoming unreadably long.
*/
pageMargin: number;
/** `inherit` keeps whatever the book asked for. */
textAlign: TextAlign;
/** Darkens the page without changing the theme; for reading in bed. */
brightness: number;
pageAnimation: PageAnimation;
/** Which side of the screen turns forward; handedness is a real preference. */
tapZone: TapZone;
}
const DEFAULT_SETTINGS: ViewSettings = {
mode: 'scroll',
fontScale: 1,
lineHeight: 'inherit',
theme: 'light',
fit: 'contain',
direction: 'ltr',
fontFamily: 'inherit',
pageMargin: 1.5,
textAlign: 'inherit',
brightness: 1,
pageAnimation: 'slide',
tapZone: 'standard',
};
/**
* Renders a `BookDoc` and owns everything about the current position.
*
* Split from the reader controller on purpose: this class knows about DOM,
* layout and gestures and nothing about the network; the controller knows about
* sync and history and nothing about CSS columns. The boundary is what lets the
* same view be driven by the H5 app and by the Android shell without either one
* duplicating layout logic.
*/
export class ReaderView {
private readonly doc: BookDoc;
private readonly container: HTMLElement;
private readonly host: BookShadowHost;
private readonly resolver: ResourceResolver;
private settings: ViewSettings;
private sectionIndex = 0;
private sectionOffset = 0;
private lastEmitted = '';
private readonly options: ReaderViewOptions;
private resizeObserver: ResizeObserver | null = null;
private currentObjectUrl: string | null = null;
private animationTimer: ReturnType<typeof setTimeout> | null = null;
private spokenChunks: SpokenChunk[] = [];
private speechHighlight: HTMLElement | null = null;
constructor(options: ReaderViewOptions) {
this.options = options;
this.doc = options.doc;
this.container = options.container;
this.settings = { ...DEFAULT_SETTINGS, direction: options.doc.direction };
this.host = createBookHost();
this.host.setAttribute('data-layout', options.doc.layout);
this.host.className = 'book-host';
this.resolver = new ResourceResolver(options.doc.resources);
this.container.append(this.host);
this.applySettings();
}
get settingsSnapshot(): ViewSettings {
return { ...this.settings };
}
get sectionCount(): number {
return this.doc.sections.length;
}
currentSectionIndex(): number {
return this.sectionIndex;
}
chapterLabels(): Array<{ id: string; label: string; depth: number }> {
return this.doc.toc;
}
applySettings(next?: Partial<ViewSettings>): void {
const previous = this.settings;
if (next) this.settings = { ...this.settings, ...next };
document.documentElement.dataset['theme'] = this.settings.theme;
this.host.setAttribute('data-paginated', String(this.settings.mode === 'paged'));
this.container.style.setProperty('--reader-font-scale', String(this.settings.fontScale));
// `inherit` is what keeps the author's line-height; only an explicit user
// choice replaces it.
this.container.style.setProperty('--reader-line-height', this.settings.lineHeight);
// Same reasoning for the font stack: the default is `inherit`, so a book
// that ships its own stack keeps it. The reader's choice is offered first so
// a book that ships *nothing* looks like the rest of their library.
this.container.style.setProperty('--reader-font-family', this.settings.fontFamily);
this.container.style.setProperty('--reader-text-align', this.settings.textAlign);
this.container.style.setProperty('--reader-page-margin', `${this.settings.pageMargin}rem`);
this.container.style.setProperty('--reader-brightness', String(this.settings.brightness));
this.host.style.direction = this.settings.direction;
// Toggling pagination changes the column count, so the stored offset has to
// be re-applied after the browser has laid out the new column box.
requestAnimationFrame(() => this.restoreOffset());
// A page turn is the only place an animation is wanted, and only the two
// settings that change where a page *is* can start one.
if (next && previous.mode !== this.settings.mode) this.animatePage('none');
}
/**
* Flashes a page-turn animation.
*
* On the host rather than on the flow, because the flow's own transform would
* fight the column layout in paged mode. `data-animating` is cleared by a
* timer rather than by `animationend`: the same animation may be requested
* twice in a row (a fast double tap), and `animationend` would then never fire
* for the second one, leaving the host stuck in an animating state.
*/
animatePage(direction: 'next' | 'previous' | 'none'): void {
if (this.settings.pageAnimation === 'none' || direction === 'none') return;
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const value = `${this.settings.pageAnimation}-${direction}`;
this.host.setAttribute('data-animating', value);
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
this.animationTimer = setTimeout(() => {
this.animationTimer = null;
this.host.removeAttribute('data-animating');
}, 220);
}
/** Opens a section by index, optionally at a fractional offset within it. */
async open(index: number, offset = 0): Promise<void> {
const section = this.doc.sections[index];
if (!section) return;
this.sectionIndex = index;
this.sectionOffset = Math.min(1, Math.max(0, offset));
this.releaseObjectUrl();
if (this.doc.layout === 'fixed') {
await this.renderFixed(section);
} else {
await this.renderReflowable(section);
}
this.options.onChapterChange?.(index, section);
this.refreshSpokenChunks();
this.restoreOffset();
this.emitPosition();
this.attachResizeObserver();
}
/** Resumes at a locator produced by `position()`, tolerating an unknown one. */
async openLocator(locator: string): Promise<boolean> {
const parsed = parseLocator(locator, this.doc);
if (!parsed) return false;
const index = this.doc.sections.findIndex((section) => section.id === parsed.sectionId);
if (index === -1) return false;
await this.open(index, parsed.offset);
return true;
}
position(): Position {
const section = this.doc.sections[this.sectionIndex];
const within = this.measureWithin();
return {
sectionIndex: this.sectionIndex,
sectionId: section?.id ?? '',
within,
percentage: bookPercentage(this.doc, this.sectionIndex, within),
locator: formatLocator(section?.id ?? '', within),
chapterTitle: section?.label ?? '',
};
}
/** Advances one unit: a page when paged, a screenful when scrolling. */
async next(): Promise<boolean> {
if (this.doc.layout === 'fixed') return this.stepFixed(1);
if (this.settings.mode === 'paged') {
const advanced = this.stepColumn(1);
if (advanced) return true;
return this.stepSection(1);
}
const flow = this.host.flow;
const limit = flow.scrollHeight - flow.clientHeight;
if (flow.scrollTop < limit - 8) {
flow.scrollTop = Math.min(limit, flow.scrollTop + flow.clientHeight * 0.9);
return true;
}
return this.stepSection(1);
}
async previous(): Promise<boolean> {
if (this.doc.layout === 'fixed') return this.stepFixed(-1);
if (this.settings.mode === 'paged') {
const moved = this.stepColumn(-1);
if (moved) return true;
return this.stepSection(-1, true);
}
const flow = this.host.flow;
if (flow.scrollTop > 8) {
flow.scrollTop = Math.max(0, flow.scrollTop - flow.clientHeight * 0.9);
return true;
}
return this.stepSection(-1, true);
}
/** Jumps to a fraction of the whole book, used by the progress slider. */
async seekPercentage(percentage: number): Promise<void> {
const clamped = Math.min(1, Math.max(0, percentage));
const index = Math.min(
this.doc.sections.length - 1,
Math.floor(clamped * this.doc.sections.length),
);
const remainder = clamped * this.doc.sections.length - index;
await this.open(index, remainder);
}
dispose(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
this.animationTimer = null;
this.clearSpeechHighlight();
this.releaseObjectUrl();
this.resolver.dispose();
this.host.remove();
}
// ---- rendering per layout ----
private async renderReflowable(section: Section): Promise<void> {
const raw = section.html ?? '';
const body = extractBody(raw);
const inlineStyles = extractInlineStyles(raw);
// The chapter's own <style> blocks are prepended so the book's link-level
// stylesheets (already in `doc.styles`) can still override them, matching
// the cascade the book was authored against.
this.host.setContent(body, [...inlineStyles, ...this.doc.styles]);
sanitiseInjectedContent(this.host.shadow);
await hydrateResources(this.host.shadow, this.resolver);
// Images load asynchronously and change the flow height, which invalidates
// any offset measured before they arrive.
this.waitForImages().then(() => {
this.restoreOffset();
this.refreshSpokenChunks();
this.emitPosition();
});
}
private async renderFixed(section: Section): Promise<void> {
const wrapper = document.createElement('div');
wrapper.className = 'fixed-page';
wrapper.setAttribute('data-fit', this.settings.fit);
if (section.image) {
const url = this.objectUrl(section.image.bytes, section.image.mediaType);
const img = document.createElement('img');
img.src = url;
img.alt = section.label;
// The page must be decodable before it is shown, otherwise the reader
// sees a blank screen for a beat on every turn of a large scan.
img.decoding = 'async';
img.draggable = false;
wrapper.append(img);
} else if (section.html) {
const frame = document.createElement('iframe');
frame.className = 'pdf-frame';
frame.setAttribute('title', section.label);
wrapper.append(frame);
// The PDF viewer owns its own rendering; the reader only supplies bytes.
queueMicrotask(() => {
frame.srcdoc = section.html ?? '';
});
}
this.host.setContent('', []);
this.host.flow.replaceChildren(wrapper);
}
// ---- position measurement ----
/**
* Fraction scrolled inside the current section.
*
* Measured against the section's own scroll extent, never against absolute
* document pixels, so the value is portable across screen sizes.
*/
private measureWithin(): number {
// Fixed layout has no sub-section position: a comic page is atomic, so the
// position is entirely expressed by the section index.
if (this.doc.layout === 'fixed') return 0;
const flow = this.host.flow;
if (this.settings.mode === 'paged') {
const width = flow.scrollWidth;
if (width <= 0) return this.sectionOffset;
return Math.min(1, Math.max(0, flow.scrollLeft / Math.max(1, width - flow.clientWidth)));
}
const limit = flow.scrollHeight - flow.clientHeight;
if (limit <= 0) return 0;
return Math.min(1, Math.max(0, flow.scrollTop / limit));
}
private restoreOffset(): void {
const flow = this.host.flow;
if (this.doc.layout === 'fixed') return;
if (this.settings.mode === 'paged') {
const max = Math.max(0, flow.scrollWidth - flow.clientWidth);
flow.scrollLeft = max * this.sectionOffset;
return;
}
const limit = Math.max(0, flow.scrollHeight - flow.clientHeight);
flow.scrollTop = limit * this.sectionOffset;
}
private emitPosition(): void {
const position = this.position();
// Scroll events fire per frame; only report when the position meaningfully
// moved, so a lazy reader does not generate a sync write every frame.
const key = `${position.sectionId}:${position.within.toFixed(3)}`;
if (key === this.lastEmitted) return;
this.lastEmitted = key;
this.options.onPositionChange?.(position);
}
/**
* Re-anchors the position when the viewport changes.
*
* Without this, rotating the device or opening the on-screen keyboard leaves
* the reader at a scroll offset measured against the old column height, which
* lands them a page or two away from where they were.
*/
private attachResizeObserver(): void {
this.resizeObserver?.disconnect();
if (typeof ResizeObserver === 'undefined') return;
this.resizeObserver = new ResizeObserver(() => this.restoreOffset());
this.resizeObserver.observe(this.host.flow);
}
private async stepFixed(delta: number): Promise<boolean> {
const next = this.sectionIndex + delta;
if (next < 0 || next >= this.doc.sections.length) return false;
await this.open(next, 0);
return true;
}
private stepSection(delta: number, toEnd = false): boolean {
const next = this.sectionIndex + delta;
if (next < 0 || next >= this.doc.sections.length) return false;
void this.open(next, toEnd ? 1 : 0);
return true;
}
/** Moves by one CSS column, which is one screen in paged mode. */
private stepColumn(delta: number): boolean {
const flow = this.host.flow;
const step = Math.max(1, flow.clientWidth);
const max = Math.max(0, flow.scrollWidth - flow.clientWidth);
const target = flow.scrollLeft + step * delta;
if (target < -1 || target > max + 1) return false;
flow.scrollLeft = Math.min(max, Math.max(0, target));
this.emitPosition();
return true;
}
// ---- read aloud ----
/**
* Refreshes the speakable sentence list for the chapter on screen.
*
* Called after every render rather than on demand, because the caller (the TTS
* panel) may be opened *while* a chapter is loading, and a queue built from the
* previous chapter would highlight the wrong paragraph.
*/
refreshSpokenChunks(): SpokenChunk[] {
this.spokenChunks = collectSpokenChunks(this.host.flow, (node) => this.blockIndexOf(node));
return this.spokenChunks;
}
get speechChunks(): readonly SpokenChunk[] {
return this.spokenChunks;
}
/**
* Paints the sentence being spoken and scrolls it into view.
*
* A `Range` is used instead of wrapping the sentence in a `<span>`: wrapping
* would rewrite the publisher's markup, and it is exactly the kind of edit that
* makes an author's stylesheet stop applying to a phrase in the middle of a
* paragraph. A range is painted into an overlay that sits *behind* the text, so
* line boxes do not move and pagination does not change when playback starts.
*/
highlightSpokenChunk(chunk: SpokenChunk | null): void {
this.clearSpeechHighlight();
if (!chunk || !chunk.node || !chunk.node.isConnected) return;
const shadow = this.host.shadow;
const range = shadow.ownerDocument.createRange();
const end = Math.min(chunk.node.data.length, chunk.start + chunk.text.length);
try {
range.setStart(chunk.node, chunk.start);
range.setEnd(chunk.node, end);
} catch {
return;
}
const rect = rangeRect(range);
if (!rect) return;
const hostRect = this.host.flow.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const mark = document.createElement('div');
mark.className = 'reader-speech-highlight';
mark.style.cssText = [
'position:absolute',
`left:${rect.left - hostRect.left + this.host.flow.scrollLeft}px`,
`top:${rect.top - hostRect.top + this.host.flow.scrollTop}px`,
`width:${rect.width}px`,
`height:${rect.height}px`,
].join(';');
// The overlay lives inside the shadow root's flow so it scrolls with the
// text; `position: relative` on the flow is what anchors it.
this.host.flow.append(mark);
this.speechHighlight = mark;
this.scrollIntoView(chunk);
}
/**
* The first sentence inside the reader's current text selection.
*
* A selected paragraph is how a reader says "this bit" on a phone: tapping to
* place a caret is not reliable over a shadow root, and long-press selection
* already exists for copying. Reading the selection is therefore the natural
* "read this paragraph" gesture, and it costs one call to `getSelection`.
*/
speechAnchorFromSelection(): SpokenChunk | null {
// `ShadowRoot.getSelection` exists on the Chromium/WebKit that ships on
// Android but is not in the DOM typings, hence the structural check rather
// than a direct call the type system would reject.
const shadowSelection = (this.host.shadow as ShadowRoot & { getSelection?: () => Selection | null }).getSelection;
const selection = typeof shadowSelection === 'function' ? shadowSelection.call(this.host.shadow) : null;
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
for (const chunk of this.spokenChunks) {
if (!chunk.node) continue;
// A sentence starts inside the selection. `intersectsNode` on the sentence
// range would also match a sentence that merely *ends* in the selection,
// which would make a one-word selection read the preceding sentence too.
if (range.comparePoint(chunk.node, chunk.start) === 0 || this.rangeContains(range, chunk)) return chunk;
}
return null;
}
private rangeContains(selection: Range, chunk: SpokenChunk): boolean {
if (!chunk.node) return false;
const start = this.host.shadow.ownerDocument.createRange();
try {
start.setStart(chunk.node, chunk.start);
start.setEnd(chunk.node, Math.min(chunk.node.data.length, chunk.start + Math.max(1, chunk.text.length)));
} catch {
return false;
}
return selection.isPointInRange(start.startContainer, start.startOffset);
}
/** Brings the spoken sentence back on screen, one screen at a time. */
scrollIntoView(chunk: SpokenChunk): void {
if (!chunk.node || !chunk.node.isConnected) return;
const flow = this.host.flow;
const range = this.host.shadow.ownerDocument.createRange();
try {
range.setStart(chunk.node, chunk.start);
range.setEnd(chunk.node, Math.min(chunk.node.data.length, chunk.start + Math.max(1, chunk.text.length)));
} catch {
return;
}
const rect = rangeRect(range);
if (!rect) return;
if (this.settings.mode === 'paged') {
// In paged mode the page is found by measurement, not by `scrollIntoView`,
// because the browser's idea of "into view" is a horizontal jump to a
// column boundary that ignores the chapter's own padding.
const target = flow.scrollLeft + rect.left - flow.getBoundingClientRect().left - flow.clientWidth / 2;
flow.scrollLeft = Math.max(0, target);
return;
}
const hostRect = flow.getBoundingClientRect();
if (rect.top < hostRect.top + 8 || rect.bottom > hostRect.bottom - 8) {
flow.scrollTop += rect.top - hostRect.top - hostRect.height / 3;
}
}
clearSpeechHighlight(): void {
this.speechHighlight?.remove();
this.speechHighlight = null;
}
/**
* The sentence to start reading from, for "read from here".
*
* Found by asking the flow which block is at the top of the reading area and
* then taking the first sentence inside it. Measuring the viewport rather than
* scaling the position fraction matters: in paged mode the leading edge of a
* page is a column boundary, and a fraction of the chapter maps to a sentence
* that can be a whole paragraph away from what the reader is looking at.
*/
speechAnchor(): SpokenChunk | null {
if (this.spokenChunks.length === 0) return null;
const flow = this.host.flow;
const hostRect = flow.getBoundingClientRect();
if (hostRect.width === 0 && hostRect.height === 0) return this.spokenChunks[0] ?? null;
const edge = this.settings.mode === 'paged' ? hostRect.left + 4 : hostRect.top + 4;
const first = this.spokenChunks[0] ?? null;
for (const chunk of this.spokenChunks) {
const rect = this.chunkRect(chunk);
if (!rect) return first;
const start = this.settings.mode === 'paged' ? rect.left : rect.top;
if (start >= edge) return chunk;
}
return this.spokenChunks[this.spokenChunks.length - 1] ?? first;
}
/** Bounding box of a sentence, measured through a Range rather than a wrapper. */
private chunkRect(chunk: SpokenChunk): DOMRect | null {
if (!chunk.node || !chunk.node.isConnected) return null;
const range = this.host.shadow.ownerDocument.createRange();
try {
range.setStart(chunk.node, chunk.start);
range.setEnd(chunk.node, Math.min(chunk.node.data.length, chunk.start + Math.max(1, chunk.text.length)));
} catch {
return null;
}
return rangeRect(range);
}
/** Index of the top-level block containing a node, for sentence addressing. */
private blockIndexOf(node: Node): number {
const children = [...this.host.flow.children];
let current: Node | null = node;
while (current && current.parentNode !== this.host.flow) current = current.parentNode;
return current ? children.indexOf(current as Element) : 0;
}
/**
* Scrolls to a top-level block, used when resuming aloud after a chapter jump.
*/
revealBlock(index: number): void {
const block = this.host.flow.children[index];
if (!(block instanceof HTMLElement)) return;
if (this.settings.mode === 'paged') {
const flow = this.host.flow;
flow.scrollLeft = Math.max(0, block.offsetLeft - flow.clientWidth / 2);
return;
}
const flow = this.host.flow;
const target = block.offsetTop - flow.clientHeight / 3;
flow.scrollTop = Math.max(0, target);
}
private objectUrl(bytes: Uint8Array, mediaType: string): string {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
const url = URL.createObjectURL(new Blob([copy], { type: mediaType }));
this.currentObjectUrl = url;
return url;
}
private releaseObjectUrl(): void {
if (!this.currentObjectUrl) return;
try {
URL.revokeObjectURL(this.currentObjectUrl);
} catch {
// Already revoked.
}
this.currentObjectUrl = null;
}
private async waitForImages(): Promise<void> {
const images = [...this.host.shadow.querySelectorAll('img')];
if (images.length === 0) return;
await Promise.all(
images.map(
(img) =>
new Promise<void>((resolve) => {
if (img.complete) {
resolve();
return;
}
// Bounded: a book referencing a missing asset must not stall the
// position restore forever.
const timer = setTimeout(resolve, 3000);
img.addEventListener('load', () => {
clearTimeout(timer);
resolve();
}, { once: true });
img.addEventListener('error', () => {
clearTimeout(timer);
resolve();
}, { once: true });
}),
),
);
}
}
/**
* A range's client rect, or null when the host cannot measure one.
*
* `Range.getBoundingClientRect` is missing in jsdom and in an embedded WebView
* whose document has no layout box yet. Returning null rather than throwing keeps
* read-aloud working as *audio* on such a host — the highlight is an enhancement,
* and an enhancement that takes the feature down with it is not one.
*/
function rangeRect(range: Range): DOMRect | null {
if (typeof range.getBoundingClientRect !== 'function') return null;
try {
return range.getBoundingClientRect();
} catch {
return null;
}
}