Files
reader/web/src/ui/reader-view.ts
T
CodeBuddy 5e8d14b460 fix(web): 用 iframe 承载章节文档,浏览器实测逼出来的四处修正
真机(Chromium,390x844)跑通整条链路后,之前"看起来对"的地方全都露馅了。
这一轮把渲染层按实测重做,四个都是必须修的:

1. **章节必须是真文档,不能是 shadow DOM 里的片段。**
   章节里的 <img src> 和 <link rel=stylesheet> 是浏览器自己去请求的,
   没法带 Authorization 头。把它们注入宿主文档的结果是每个插图、每个
   样式表都 401 —— 书渲染成无样式无图。iframe 是真浏览上下文,它的
   子资源请求是普通导航,URL 里带什么就发什么。所以:外壳用 shadow DOM
   (挡的是客户端样式往书里漏,这是它真正能挡的方向),书本身用 iframe。

2. **子资源的 token 只能走 query。**
   服务端新增 `queryTokenAllowed`,白名单式放行:仅 GET、仅
   /api/v1/books/*/assets|cover|content。触发扫描、写元数据这类端点一律
   不接受 URL 里的 token —— 那才是真正会泄到日志和 Referer 的地方。
   新增 5 个测试固定这条边界。

3. **`column-fill: auto` 需要确定高度,否则根本没有分页。**
   body 不设高度时它会撑到内容高度,整章排成一列,scrollWidth 等于
   clientWidth —— 翻页手势完全无效,一章只显示一页。修完 390px 宽的一章
   是 3732px / 9 页,翻页步长精确 422px(390 + 32 gutter)。
   同时去掉 scroll-snap:吸附目标是列边缘而不是我做出来的页边界,
   会导致 scrollLeft 落在两页之间、甚至一次跳两页。

4. **`pageWidth` 是上限不是实际宽度。**
   手机上 720px 的列比屏幕还宽,同样退化成"一章一页"。取
   min(读者设置, 实际宽度)。

另外发现并按格式分流修好的:PDF(kind=document)之前落进了图片翻页器,
读者看到的是一个破图;现在走 PdfReader。
封面 <img> 也漏了 token,一并改成 api.coverUrl()。

顺带把一个设计缺陷拆开了:目录不再从 manifest 的 groups 取。
groups 是传输分窗,它把目录显示成「第 1 章 – 第 40 章」——服务端的分页
边界漏进了 UI。新增 /books/:id/toc,按格式返回真正的目录
(epub 的 spine 标题、txt 的章节、漫画的卷),与分窗互不干扰。
2026-09-16 15:28:34 +08:00

558 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The reading surface, shared by the H5 build and the Android shell.
*
* Format routing lives here and is the answer to the second half of the user's
* question: **EPUB (and TXT) render through the WebView; everything else does
* not render here at all on Android.**
*
* The reasoning, because it drove the structure of this file:
*
* - **Reflowable text belongs in a WebView.** It is the only engine on the
* device that implements CSS multi-column, embedded fonts, vertical writing
* and ruby — the typography features this product is built on. A native text
* stack would have to reimplement all of it worse.
* - **Images do not.** A comic page is a JPEG: a native `ImageView` decodes it
* with hardware acceleration and recycles the bitmap, while a WebView holds a
* full compositing layer per page and decodes on the main thread. The user's
* own note — 「其他类型的书籍需要使用原生来渲染,提升性能」 — is correct, and
* the reason is memory rather than raw speed: a 300MB volume read in a WebView
* is an OOM on a mid-range phone, and the same volume read natively is fine.
* - **PDF has a viewer already.** On Android that is the platform's, on the H5
* build it is the browser's. Neither needs this client in between.
*
* So this view owns the *reflowable* path plus the H5 fallbacks, and the shell
* decides whether it is ever reached for paged/document content. `capabilities()`
* below is what the two builds negotiate with, so the split is declared in one
* place instead of being implied by which renderer happens to be imported.
*/
import { ApiClient, type BookDto } from '../net/api.ts';
import { BookReader } from '../render/reader.ts';
import { PagedReader } from '../render/paged.ts';
import { TextReader } from '../render/text.ts';
import { PdfReader } from '../render/pdf.ts';
import { applyTheme, loadSettings, saveSettings, type ReaderSettings } from './settings.ts';
import type { ReaderStyle } from '../render/container.ts';
import { PALETTES } from '../render/theme.ts';
/**
* Which renderer handles what, per platform.
*
* Declared rather than hardcoded because the Android shell overrides it: the
* shell answers 'native' for paged and document content and never mounts those
* renderers, so the same book opened on the phone takes the native path and the
* same book opened in a browser takes the H5 fallback.
*/
export interface RendererCapabilities {
/** Reflowable text always goes through the WebView. */
reflowable: 'webview';
/** Paged images: native on Android, H5 elsewhere. */
paged: 'native' | 'webview';
/** PDF: the platform viewer on Android, an iframe elsewhere. */
document: 'native' | 'iframe';
}
export function capabilities(platform: 'web' | 'android'): RendererCapabilities {
return platform === 'android'
? { reflowable: 'webview', paged: 'native', document: 'native' }
: { reflowable: 'webview', paged: 'webview', document: 'iframe' };
}
export interface ReaderHost {
/** Called when the user swipes or taps past the last page of a book. */
onExit?: () => void;
platform?: 'web' | 'android';
device?: string;
}
/**
* A reading session for one book.
*
* Owns whichever renderer the book's `kind` calls for, plus the chrome around it:
* the toolbar, the footer progress, the table of contents. Kept as a class because
* the four renderers share no base class on purpose — they have almost nothing in
* common beyond `turn` and `flush`, and a common interface would have to be wide
* enough to be meaningless.
*/
export class ReaderView {
private readonly root: HTMLElement;
private readonly stage: HTMLElement;
private readonly toolbar: HTMLElement;
private readonly progressFill: HTMLElement;
private readonly pageLabel: HTMLElement;
private readonly warning: HTMLElement;
private reader: BookReader | PagedReader | TextReader | PdfReader | null = null;
private state: { index: number; total: number; title: string; percent: number } = {
index: 0,
total: 0,
title: '',
percent: 0,
};
private settings: ReaderSettings;
private disposers: Array<() => void> = [];
private tocDialog: HTMLElement | null = null;
constructor(
private readonly host: HTMLElement,
private readonly api: ApiClient,
private readonly book: BookDto,
private readonly options: ReaderHost = {},
) {
this.settings = loadSettings();
this.root = document.createElement('div');
this.root.className = 'reader';
this.toolbar = document.createElement('div');
this.toolbar.className = 'reader__toolbar';
this.stage = document.createElement('div');
this.stage.className = 'reader__stage';
this.warning = document.createElement('div');
this.warning.className = 'reader__warning';
this.warning.hidden = true;
const footer = document.createElement('div');
footer.className = 'reader__footer';
this.progressFill = document.createElement('div');
this.progressFill.className = 'reader__progress-fill';
const progressTrack = document.createElement('div');
progressTrack.className = 'reader__progress';
progressTrack.append(this.progressFill);
this.pageLabel = document.createElement('span');
footer.append(progressTrack, this.pageLabel);
this.root.append(this.toolbar, this.warning, this.stage, footer);
host.replaceChildren(this.root);
this.buildToolbar();
this.bindGestures();
}
/** Open the book with whichever renderer its `kind` calls for. */
async open(): Promise<void> {
const platform = this.options.platform ?? 'web';
// One request to learn the shape of the book, then one renderer is built.
const manifest = await this.api.manifest(this.book.id);
const kind = manifest.content?.kind ?? manifest.kind ?? 'reflowable';
const caps = capabilities(platform);
if (kind === 'reflowable') {
const reader = new BookReader(this.containerFor('webview'), this.api, this.book, {
platform,
style: this.readerStyle(),
...(this.options.device !== undefined ? { device: this.options.device } : {}),
});
this.reader = reader;
this.disposers.push(reader.state.on((state) => this.onReflowable(state)));
const initial = await reader.open(this.book.id);
this.stage.classList.remove('is-paged');
this.applySettings();
void initial;
return;
}
if (kind === 'text') {
const reader = new TextReader(this.stage, this.api, this.book, {
...(this.options.device !== undefined ? { device: this.options.device } : {}),
});
this.reader = reader;
await reader.open(this.book.id);
this.pageLabel.textContent = '流式';
return;
}
// A PDF is a `document`: an opaque file the client does not interpret. It
// must be routed BEFORE the paged branch, or a `.pdf` lands in the image
// pager and the reader sees a broken image instead of the viewer.
if (kind === 'document') {
const pdf = new PdfReader(this.stage, this.api, this.book, {
...(this.options.device !== undefined ? { device: this.options.device } : {}),
});
this.reader = pdf;
this.stage.classList.add('is-paged');
const state = await pdf.open(this.book.id);
this.pageLabel.textContent = `${this.book.format.toUpperCase()} · 第 ${state.page} 页`;
return;
}
// Comics and single images: paged content. On Android the shell takes this
// over (see `capabilities`), because a native pager decodes and recycles a
// bitmap where a WebView holds a compositing layer per page.
if (caps.paged === 'native') {
this.host.dispatchEvent(
new CustomEvent('reader:native-page', { detail: { bookId: this.book.id, kind } }),
);
return;
}
const paged = new PagedReader(this.stage, this.api, this.book, {
platform: 'web',
...(this.options.device !== undefined ? { device: this.options.device } : {}),
});
this.reader = paged;
this.disposers.push(paged.state.on((state) => this.onPaged(state)));
await paged.open(this.book.id);
this.stage.classList.add('is-paged');
return;
}
/** Persist the position before the surface goes away. */
async close(): Promise<void> {
const reader = this.reader;
if (reader instanceof BookReader || reader instanceof PagedReader) {
await reader.flush().catch(() => undefined);
} else if (reader instanceof TextReader || reader instanceof PdfReader) {
await reader.flush(this.book.id).catch(() => undefined);
}
for (const dispose of this.disposers) dispose();
this.disposers = [];
this.reader?.destroy();
this.reader = null;
this.host.replaceChildren();
}
/** Turn pages from a hardware key or an external control (the shell's). */
async turn(pages: number): Promise<void> {
if (this.reader instanceof BookReader || this.reader instanceof PagedReader) {
await this.reader.turn(pages);
}
}
/**
* Re-apply the reader's settings.
*
* A type-size change is not a CSS variable update: the chapter document was
* styled when it loaded, so it has to be rendered again. That is why this
* awaits — but the chapter comes out of the prefetch cache, so it is a
* re-layout rather than a network round trip. A page-width change is the same,
* because the column geometry lives in that document too.
*/
applySettings(): void {
applyTheme(document.documentElement, this.settings.theme);
if (this.reader instanceof BookReader) void this.reader.restyle(this.readerStyle());
}
/** The type settings a chapter document is rendered with. */
private readerStyle(): ReaderStyle {
return {
fontSize: this.settings.fontSize,
pageWidth: this.settings.pageWidth,
columnGap: this.settings.columnGap,
pagePadding: this.settings.pagePadding,
...PALETTES[this.settings.theme],
};
}
updateSettings(patch: Partial<ReaderSettings>): void {
this.settings = { ...this.settings, ...patch };
saveSettings(this.settings);
applyTheme(document.documentElement, this.settings.theme);
this.applySettings();
if (this.tocDialog) this.closeDialog();
this.openSettings();
}
// ---------------------------------------------------------------- chrome
private buildToolbar(): void {
const back = document.createElement('button');
back.className = 'reader__button';
back.textContent = '←';
back.setAttribute('aria-label', '返回');
back.addEventListener('click', () => void this.exit());
const title = document.createElement('div');
title.className = 'reader__toolbar-title';
title.textContent = this.book.title;
const toc = document.createElement('button');
toc.className = 'reader__button';
toc.textContent = '目录';
toc.addEventListener('click', () => void this.openToc());
const settings = document.createElement('button');
settings.className = 'reader__button';
settings.textContent = 'Aa';
settings.setAttribute('aria-label', '阅读设置');
settings.addEventListener('click', () => this.openSettings());
this.toolbar.append(back, title, toc, settings);
}
/**
* Swipe and tap gestures.
*
* The reflowable reader's viewport scrolls natively, so only the *ends* of a
* chapter need intervention: a swipe that arrives there should turn the chapter,
* not rubber-band. Paged content is different — there is no scroll surface at
* all — so its horizontal swipes are read directly.
*/
private bindGestures(): void {
let startX = 0;
let startY = 0;
let startAt = 0;
let tracking = false;
const down = (event: PointerEvent): void => {
startX = event.clientX;
startY = event.clientY;
startAt = event.timeStamp;
tracking = true;
};
const up = (event: PointerEvent): void => {
if (!tracking) return;
tracking = false;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
const elapsed = event.timeStamp - startAt;
// A swipe needs both distance and speed: a slow drag across half the screen
// is usually a text selection, not a page turn.
if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (elapsed > 800) return;
void this.turn(dx < 0 ? 1 : -1);
};
this.stage.addEventListener('pointerdown', down);
this.stage.addEventListener('pointerup', up);
this.stage.addEventListener('pointercancel', () => {
tracking = false;
});
const key = (event: KeyboardEvent): void => {
if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') {
event.preventDefault();
void this.turn(1);
} else if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
event.preventDefault();
void this.turn(-1);
} else if (event.key === 'Escape') {
void this.exit();
}
};
window.addEventListener('keydown', key);
this.disposers.push(() => window.removeEventListener('keydown', key));
// A locked phone or a backgrounded tab may never run another timer, so the
// debounced progress write has to be flushed on the transition itself.
const flush = (): void => {
const reader = this.reader;
if (reader instanceof BookReader || reader instanceof PagedReader) void reader.flush();
else if (reader instanceof TextReader) void reader.flush(this.book.id);
else if (reader instanceof PdfReader) void reader.flush(this.book.id);
};
document.addEventListener('visibilitychange', flush);
window.addEventListener('pagehide', flush);
this.disposers.push(() => {
document.removeEventListener('visibilitychange', flush);
window.removeEventListener('pagehide', flush);
});
}
private async exit(): Promise<void> {
await this.close();
this.options.onExit?.();
}
private containerFor(_mode: 'webview'): HTMLElement {
const holder = document.createElement('div');
holder.style.height = '100%';
this.stage.replaceChildren(holder);
return holder;
}
/**
* The book's own table of contents.
*
* Fetched from `/toc`, not derived from the manifest's groups. The manifest is
* windowed, so its groups are a transfer boundary: showing them gave the reader
* 「第 1 章 – 第 40 章」 where a chapter list belongs.
*/
private async openToc(): Promise<void> {
const entries = await this.api.toc(this.book.id).catch(() => []);
const list = document.createElement('ul');
list.className = 'toc';
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'toc__empty';
empty.textContent = '这本书没有可跳转的章节';
list.append(empty);
} else {
entries.forEach((entry) => {
const item = document.createElement('li');
item.className = 'toc__item';
item.textContent = entry.title;
item.style.paddingLeft = `${4 + entry.level * 14}px`;
item.addEventListener('click', () => {
this.closeDialog();
void this.jumpTo(entry);
});
list.append(item);
});
}
this.showDialog(`目录 · ${this.book.title}`, list);
}
/** Jump to a table-of-contents entry with whichever reader is loaded. */
private async jumpTo(entry: { href: string; spine?: number }): Promise<void> {
const reader = this.reader;
if (reader instanceof BookReader) {
// `spine` is the whole-book index the server computed, so a jump lands on
// the right chapter even when the window holding it has not been fetched.
if (entry.spine !== undefined) await reader.goToSpine(entry.spine);
else await reader.goToSpineOfHref(entry.href);
return;
}
if (reader instanceof PagedReader) {
await reader.goTo(entry.spine ?? 0);
return;
}
if (reader instanceof TextReader) {
const index = Number.parseInt(entry.href.replace('chapter:', ''), 10);
await reader.goToChapter(Number.isFinite(index) ? index : 0, this.book.id);
return;
}
void reader?.setPage(this.book.id, (entry.spine ?? 0) + 1);
}
private openSettings(): void {
const form = document.createElement('div');
const fontSize = this.slider('字号', this.settings.fontSize, 12, 32, 1, (value) => {
this.settings = { ...this.settings, fontSize: value };
this.applySettings();
});
// Committed on release rather than on every input event: repaginating a
// chapter on every pixel of slider travel makes the slider itself unusable.
fontSize.input.addEventListener('change', () => this.commitSettings());
const width = this.slider('页宽', this.settings.pageWidth, 360, 1200, 20, (value) => {
this.settings = { ...this.settings, pageWidth: value };
this.applySettings();
});
width.input.addEventListener('change', () => this.commitSettings());
const theme = document.createElement('select');
for (const [value, label] of [
['light', '浅色'],
['sepia', '羊皮纸'],
['dark', '深色'],
] as const) {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
option.selected = this.settings.theme === value;
theme.append(option);
}
theme.addEventListener('change', () => {
this.settings = { ...this.settings, theme: theme.value as ReaderSettings['theme'] };
this.commitSettings();
});
const themeRow = document.createElement('label');
themeRow.className = 'dialog__row';
themeRow.append('主题', theme);
form.append(fontSize.row, width.row, themeRow);
this.showDialog('阅读设置', form);
}
private commitSettings(): void {
saveSettings(this.settings);
applyTheme(document.documentElement, this.settings.theme);
this.applySettings();
}
private slider(
label: string,
value: number,
min: number,
max: number,
step: number,
onChange: (value: number) => void,
): { row: HTMLElement; input: HTMLInputElement } {
const row = document.createElement('label');
row.className = 'dialog__row';
const input = document.createElement('input');
input.type = 'range';
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(value);
input.addEventListener('input', () => onChange(Number(input.value)));
row.append(label, input);
return { row, input };
}
private showDialog(title: string, body: HTMLElement): void {
this.closeDialog();
const dialog = document.createElement('div');
dialog.className = 'dialog';
const panel = document.createElement('div');
panel.className = 'dialog__panel';
const heading = document.createElement('h2');
heading.className = 'dialog__title';
heading.textContent = title;
const actions = document.createElement('div');
actions.className = 'dialog__actions';
const close = document.createElement('button');
close.className = 'dialog__button dialog__button--primary';
close.textContent = '关闭';
close.addEventListener('click', () => this.closeDialog());
actions.append(close);
panel.append(heading, body, actions);
dialog.append(panel);
dialog.addEventListener('click', (event) => {
if (event.target === dialog) this.closeDialog();
});
this.root.append(dialog);
this.tocDialog = dialog;
}
private closeDialog(): void {
this.tocDialog?.remove();
this.tocDialog = null;
}
// ---------------------------------------------------------------- state
private onReflowable(state: {
page: number;
pageCount: number;
index: number;
items: Array<{ title: string }>;
total: number;
warnings: string[];
}): void {
this.state = {
index: state.index,
total: state.total,
title: state.items[state.index]?.title ?? '',
percent: (state.index + (state.pageCount > 1 ? state.page / state.pageCount : 0)) / Math.max(1, state.total),
};
this.renderProgress();
if (state.warnings.length > 0) {
this.warning.hidden = false;
// Surfaced rather than swallowed: a chapter that failed to parse renders as
// something, and the reader deserves to know it is not what the book said.
this.warning.textContent = `本章有 ${state.warnings.length} 处问题,可能显示不完整`;
}
}
private onPaged(state: { index: number; total: number; loading: boolean }): void {
this.state = {
index: state.index,
total: state.total,
title: '',
percent: state.index / Math.max(1, state.total),
};
this.renderProgress();
}
private renderProgress(): void {
this.progressFill.style.width = `${Math.round(this.state.percent * 100)}%`;
this.pageLabel.textContent =
this.state.index >= 0 ? `${this.state.percent * 100 < 1 ? 0 : Math.round(this.state.percent * 100)}%` : '';
this.toolbar.querySelector('.reader__toolbar-title')!.textContent =
this.state.title ? `${this.book.title} · ${this.state.title}` : this.book.title;
}
}