Files
reader/web/src/app.ts
T
cnb.dQC5J6ohAGA dbaac99b45 feat(web): 界面加路由,首页重做
首页(书架)此前是纯命令式拼出来的,而整个客户端没有任何路由:URL 从头到尾不变,
返回键、刷新、分享、收藏一概不成立。

## 路由

`ui/router.ts` 把 fragment 解析成 route,shell 渲染 router 报上来的那个:

  #/shelf             书架
  #/book/<id>         阅读器
  #/library/<路径>    书库里的目录

用 fragment 而不是路径,是因为同一份产物要被两个宿主加载,而两个都服务不了任意路径:
Android 从 appassets.androidplatform.net 加载(那里没有服务端),H5 由 reader 服务端
托管(没有客户端路由回落)。路径式路由会在 vite dev 里正常、在两个生产宿主里 404。

返回走应用自己的屏幕轨迹,不走 history.back():应用里的「返回」是「上一个屏幕」,
而浏览器历史里可能有登录表单或别的站点。轨迹为空时(新标签页里粘贴的链接)替换成父级,
而不是压入——否则那个链接会永远压在书架后面。

走进书库子目录用 replace:书库是一个带面包屑的屏幕,走目录是同一个屏幕改自己的参数。
压栈的话,读者走过四个文件夹再按返回会逐层退出来。

带着链接进来而尚未登录时,route 存在 App.pending 里等在登录页后面,登录后直接落到
目标屏幕,而不是落到一个他从未见过的书架。

Android 外壳的返回手势不需要改动:它看的是 WebView 的 canGoBack(),而路由现在真的
往 WebView 历史里写——之前那段注释描述的行为,从这次起才成立。

## 首页

- 先看到书,再看到控件:标题和封面在最上面,搜索在后,排序与数量贴在它们作用的那一节上。
  旧版把搜索框、数量、四个排序 chip 依次排在封面之前,三行控件才轮到第一本书。
- 排序栏是唯一吸顶的东西,它承载的是读者会滚回去改的两件事。
- 空状态给一条出路(打开书库管理 / 刷新 / 清除搜索),而不是一句「书库还是空的」。
- 冷启动画骨架屏,且按当前密度画——密度不对的骨架屏会让读者看到布局动两次。
- 封面缺图时把书名画成封面,而不是一个灰色方块。

## 顺手修掉的 5 个真缺陷

1. mountUI 在 unmount 之后仍会渲染。屏幕是异步加载的,离开后那条迟到的响应会把
   整棵树画进一个刚被清空的容器;书架迟到的响应会把自己一屏封面画进书库管理器。
2. --safe-left/-right/-top/-bottom 从未在生效的样式表里定义过,只有一份没人 import 的
   死样式表定义过。未定义的自定义属性会让整个 calc() 失效并静默丢弃声明。
3. 封面在 flex 列里没有显式宽度,aspect-ratio 盒子因此收缩到内容宽度:只有书名的封面
   塌成 42px、有图的占满卡片,整排看起来参差不齐。
4. 阅读列在宽屏上被拉伸到 9 列 45px 的封面。现在内容列封顶 68rem,封面随断点变大。
5. 删掉 web/src/ui/styles.css——414 行从未被引用的旧样式,以及 .book-highlight 等
   已经没有任何代码产出的规则。

## 验证

- web 全量 284 项测试通过(新增 router 19 项 + 链接契约 4 项)。
- typecheck / build / check-shell.sh / build-web-assets.sh 均通过。
- 真浏览器实测:深链直开、刷新不丢、返回离开屏幕、登录后落回目标、书库走目录、
  空搜索、粘性工具栏、深浅色、390px 与 1440px 两档宽度。
- 体积:251KB → 252KB(gzip 79.1KB)。
2026-09-17 18:49:53 +08:00

596 lines
23 KiB
TypeScript

import { ReaderApi, type SessionStore } from './api/client.ts';
import { ApiError } from './api/errors.ts';
import type { Book, Session } from './api/types.ts';
import { createWebPlatform } from './core/web-platform.ts';
import {
androidPageHost,
androidSpeechBridge,
createAndroidPlatform,
detectAndroidBridge,
} from './core/android-platform.ts';
import type { SpeechBridge } from './android-bridge.ts';
import type { NativePageHost } from './ui/native-page.ts';
import type { Platform } from './core/platform.ts';
import { SyncEngine, type SyncStatus } from './core/sync.ts';
import { OfflineStore } from './store/offline.ts';
import { SettingsStore, DEFAULT_APP_SETTINGS, type AppSettings } from './store/settings.ts';
import { ShelfScreen } from './ui/shelf-screen.tsx';
import { ReaderScreen } from './ui/reader-screen.tsx';
import { LoginScreen } from './ui/login-screen.tsx';
import { ManagerScreen } from './ui/manager-screen.tsx';
import { el } from './ui/dom.ts';
import { Router, type Route, type RouteLocation } from './ui/router.ts';
/**
* Application shell: routing, lifecycle and the wiring between the layers.
*
* Intentionally tiny. Everything with a decision in it lives in a module with a
* single responsibility — the formats know how to unpack a book, the view knows
* how to lay it out, the sync engine knows when to talk to the server — and this
* file only connects them and manages what is on screen.
*
* ## What the routes changed here
*
* Every screen now has an address, and this class is what turns a route into a
* screen and back (`render` / `Router`). The shift is not cosmetic — three things
* that used to be separate pieces of code are now consequences of the same fact:
*
* - **Back.** The Android shell's back gesture and the browser's Back button both
* work, because each screen has a URL and a parent, and neither screen has to
* implement a back stack of its own.
* - **Reload.** A book, and a folder in the library, survive a reload. Before
* this, a reload always landed on the shelf and the reader lost their place.
* - **Deep links.** A link into a book with no session is *held* across the
* sign-in screen instead of being dropped, so a shared link works for the
* second reader as well as the first.
*
* The screens are still classes with an `element` and a `dispose()`; the router
* knows nothing about Preact and the screens know nothing about the hash. The one
* thing they do share is `RouteLocation`, through which a screen asks to leave.
*/
export interface AppOptions {
/** Baked-in server URL, used when nothing is stored yet. */
defaultServerUrl?: string;
root: HTMLElement;
}
export class App {
private readonly root: HTMLElement;
private readonly sessions: SessionStore;
private api!: ReaderApi;
private platform!: Platform;
private offline!: OfflineStore;
private settingsStore!: SettingsStore;
private sync!: SyncEngine;
private settings: AppSettings = { ...DEFAULT_APP_SETTINGS };
private shelf: ShelfScreen | null = null;
private reader: ReaderScreen | null = null;
/**
* The library file manager.
*
* A screen rather than a panel because it navigates: it has its own path, its
* own back action and its own selection state, and a panel over the shelf would
* have to reimplement all three badly. The path is a route now, so "its own
* path" is literal — `#/library/科幻/刘慈欣`.
*/
private manager: ManagerScreen | null = null;
/**
* Native fixed-layout renderer, present only in the Android shell.
*
* Held here rather than inside ReaderScreen so that the desktop of it — the
* bridge object — is touched in one place, and so that a reader screen can be
* constructed with or without one without either path special-casing.
*/
private pageHost: NativePageHost | null = null;
/**
* The shell's native speech engine, present only on Android shell 3+.
*
* Held alongside the page host because both are the same kind of thing: a
* capability the *shell* has that the shared layer uses when it is there and
* does without when it is not.
*/
private speechBridge: SpeechBridge | null = null;
/**
* The route the app is *trying* to show.
*
* Kept separately from the one on screen because of authentication: a reader who
* follows a link to a book while signed out must land on that book after signing
* in, not on the shelf. The router is not even constructed until there is a
* session — there is no route to have while the sign-in form is up — so this is
* where the link waits.
*/
private pending: Route | null = null;
/** The route currently rendered. `null` while the login screen is up. */
private route: Route | null = null;
/**
* The location that came with `route`, kept so a deferred paint can reuse it.
*
* A route that has to fetch before it can be painted (`showBook`) finishes after
* the router's callback has returned, and it needs the *same* `back()` the
* callback was given — not a re-derived one, which would be a second
* implementation of "where does back go" and would drift.
*/
private location: RouteLocation | null = null;
private router: Router | null = null;
/** The book the shell last resolved, reported by `currentBook()`. */
private book: Book | null = null;
/**
* True while a route is being rendered because the app is starting.
*
* `ReaderScreen.open()` is asynchronous and the shelf's first load is too, so a
* route that needs to *fetch* cannot be rendered synchronously inside the
* router's callback. This flag is what lets the callback hand work to a promise
* without the deep-link path and the click path diverging.
*/
private rendering = false;
constructor(private readonly options: AppOptions) {
this.root = options.root;
this.sessions = createSessionStore();
}
async start(): Promise<void> {
this.platform = await this.createPlatform();
this.api = new ReaderApi(this.platform, this.sessions);
this.offline = new OfflineStore(this.platform.kv);
this.settingsStore = new SettingsStore(this.platform.kv);
this.settings = await this.settingsStore.load();
this.sync = new SyncEngine(this.api, this.offline, this.platform);
await this.offline.load();
const storedUrl = await this.settingsStore.serverUrl();
const session = await this.api.restore();
const baseUrl = storedUrl || this.options.defaultServerUrl || inferDefaultUrl();
if (baseUrl) {
this.api.setBaseUrl(baseUrl);
// A session restored from storage is only usable if the URL it was minted
// against is the one that came back.
session?.user && void this.settingsStore.setServerUrl(baseUrl);
}
document.documentElement.dataset['theme'] = this.settings.theme;
if (session) {
// Verify before entering the app: a token that the server has since
// revoked would otherwise flash an empty library and then sign out. This is
// also the one place the session is checked, so a deep link into a book
// cannot get in ahead of it.
try {
await this.api.me();
this.enterApp();
this.sync.start();
} catch (err) {
if (err instanceof ApiError && err.isConnectivity) {
// Offline with a stored session is a legitimate state, not a failure.
this.enterApp();
this.sync.start();
} else {
await this.sessions.clear();
this.showLogin();
}
}
} else {
this.showLogin();
}
this.installLifecycleHooks();
}
// ---- routing ----
/**
* Starts the router, which paints the first route.
*
* Called exactly once per session. Signing out disposes it, because the login
* form is not a screen in the app's trail: it is the door, and a reader who
* signs back in should not find a stack of routes from the previous session
* waiting behind the shelf.
*/
private enterApp(): void {
if (this.router) return;
this.router = new Router({
fallback: fallbackHash(),
onChange: (route, location) => this.render(route, location),
});
}
/**
* Paints a route.
*
* The one place that decides which screen is on `#app`. Everything else asks for
* a route and lets this run: a click on a book, the Android back gesture, and a
* reload all arrive here identically, which is why none of them can disagree
* about what is on screen. A route that needs a `Book` the app does not have yet
* (a deep link, a reload) fetches it here and then paints; the flag is only to
* keep that fetch out of the synchronous callback.
*/
private render(route: Route, location: RouteLocation): void {
this.pending = route;
this.location = location;
if (this.rendering) return;
if (sameScreen(this.route, route)) return;
switch (route.name) {
case 'shelf':
this.showShelf();
return;
case 'library':
this.showManager(route, location);
return;
case 'book':
void this.showBook(route, location);
}
}
private clearScreens(): void {
this.reader?.dispose();
this.reader = null;
this.shelf?.dispose();
this.shelf = null;
this.manager?.dispose();
this.manager = null;
this.root.replaceChildren();
}
// ---- screens ----
/**
* The sign-in screen, and the first-run experience.
*
* Not a route, deliberately. A URL for "the login form" would be a URL for a
* *transient* state, and the one thing that must survive it is the route the
* reader was trying to reach — which is what `pending` holds. So the form is the
* absence of a router, and after `onAuthenticated` the router starts and paints
* the pending route.
*/
private showLogin(message?: string): void {
this.router?.dispose();
this.router = null;
this.route = null;
this.clearScreens();
const login = new LoginScreen({
api: this.api,
defaultServerUrl: this.api.baseUrl || inferDefaultUrl(),
onAuthenticated: () => {
// The deep link wins over the shelf: this is the whole point of holding
// it. `#/book/<id>` pasted by a friend lands on the book, not on a shelf
// the reader has never seen.
this.enterApp();
this.sync.start();
},
onServerUrlChange: (url) => this.settingsStore.setServerUrl(url),
});
this.root.append(login.element);
if (message) login.reset(message);
}
private showShelf(): void {
this.route = { name: 'shelf' };
this.clearScreens();
const shelf = new ShelfScreen({
api: this.api,
offline: this.offline,
platform: this.platform,
// The shelf's own preferences ride in the same per-device settings store as
// the reader's, so the two cannot disagree about where a preference lives.
settings: this.settings,
onOpenBook: (book) => this.openBook(book),
onOpenManager: (path) => this.router?.navigate({ name: 'library', path }),
onSignedOut: () => this.handleSignedOut(),
onSettingsChange: (patch) => {
void this.settingsStore.update(patch);
this.settings = { ...this.settings, ...patch };
},
});
this.shelf = shelf;
this.root.append(shelf.element);
void shelf.show();
}
/**
* A folder in the library.
*
* The path comes from the route, and walking into a folder *replaces* it rather
* than pushing: the manager is one screen with a breadcrumb, so a reader who
* walked four folders deep expects Back to leave the screen, not to walk out of
* it one folder at a time. The breadcrumb is what undoes a walk.
*/
private showManager(route: { name: 'library'; path: string }, location: RouteLocation): void {
const same = this.route?.name === 'library';
this.route = route;
if (same && this.manager) {
// Already here: tell the existing screen to walk, rather than rebuilding it
// and losing the selection the reader had in the folder they came from.
void this.manager.open(route.path);
return;
}
this.clearScreens();
const manager = new ManagerScreen({
api: this.api,
onClose: () => location.back(),
onSignedOut: () => this.handleSignedOut(),
onNavigate: (path) => this.router?.navigate({ name: 'library', path }, { replace: true }),
});
this.manager = manager;
this.root.append(manager.element);
void manager.open(route.path);
}
/**
* A book, by route.
*
* The book object is not in the URL — only its id — so this is the one screen
* that may need to fetch before it can be painted. It fetches through the same
* `ReaderApi` the shelf used, so a reload of a book link works offline: the
* offline mirror answers, and the book opens on the page the reader left it on.
*/
private async showBook(route: { name: 'book'; bookId: string }, location: RouteLocation): Promise<void> {
this.route = route;
this.rendering = true;
try {
const book = await this.resolveBook(route.bookId);
if (book === null) {
// A link to a book that is gone (rescan, deleted, another account). The
// shelf is the only honest answer, and the route follows it so Back does
// not return to the same dead link.
// A snackbar rather than a line on the shelf: the shelf may not be the
// screen the reader ends up on, and a silent redirect is how a dead link
// becomes "the app is broken".
showToast('这本书不在书架上了');
this.router?.navigate({ name: 'shelf' }, { replace: true });
return;
}
this.clearScreens();
const reader = new ReaderScreen({
api: this.api,
offline: this.offline,
sync: this.sync,
platform: this.platform,
settings: this.settings,
...(this.pageHost ? { pageHost: this.pageHost } : {}),
...(this.speechBridge ? { speechBridge: this.speechBridge } : {}),
onBack: () => location.back(),
onSettingsChange: (patch) => {
void this.settingsStore.update(patch);
this.settings = { ...this.settings, ...patch };
},
onSignedOut: () => this.handleSignedOut(),
});
this.reader = reader;
this.book = book;
this.root.append(reader.element);
await reader.open(book);
} finally {
this.rendering = false;
// A route that arrived *while this one was resolving* — a fast double Back,
// a link opened while the previous book was still loading — was recorded but
// not painted, because the flag above was set. Dropping it would leave the
// wrong screen up with the right URL; this re-runs the paint against
// whatever the router last reported, using the location it reported with.
const pending = this.pending;
const location = this.location;
if (pending && location && !sameScreen(this.route, pending)) this.render(pending, location);
}
}
/**
* Finds a book by id, locally first.
*
* The local mirror is the first source because it is instant and works offline,
* and because it is the *same* data the shelf drew: a reader who taps a cover and
* a reader who reloads a link must not get two different books with the same id.
* The server is consulted only when the mirror has never seen it.
*/
private async resolveBook(bookId: string): Promise<Book | null> {
const cached = this.offline.books().find((candidate) => candidate.id === bookId);
if (cached) return cached;
try {
return (await this.api.getBook(bookId)).book;
} catch (err) {
if (err instanceof ApiError && err.isAuthFailure) this.handleSignedOut();
return null;
}
}
private openBook(book: Book): void {
// The click path and the deep-link path converge here: one writes the URL and
// lets `render` build the screen, so both produce the same history and the same
// screen. A click used to construct the screen directly, which is exactly how
// the two paths drifted apart.
this.router?.navigate({ name: 'book', bookId: book.id });
}
private handleSignedOut(): void {
this.sync.stop();
void this.sessions.clear();
void this.offline.clear();
this.showLogin('登录已失效,请重新登录');
}
private async createPlatform(): Promise<Platform> {
const bridge = detectAndroidBridge();
if (!bridge) return createWebPlatform(this.options.defaultServerUrl ?? '');
// The native page renderer is resolved here, once, alongside the bridge that
// provides it, so no screen has to ask whether it is running on Android.
this.pageHost = androidPageHost(bridge);
this.speechBridge = androidSpeechBridge(bridge);
return createAndroidPlatform(this.options.defaultServerUrl ?? '', bridge);
}
/**
* Reports progress when the page is hidden or the app is backgrounded.
*
* This is the hook that makes "close the app right after reading" not lose the
* last few pages: the debounce in the reader would otherwise never fire.
*/
private installLifecycleHooks(): void {
if (typeof document === 'undefined') return;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') void this.sync.flush();
});
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => void this.sync.flush());
}
}
/**
* Writes everything pending to disk and to the server, best effort.
*
* Called when the app is backgrounded. Deliberately not awaited by the caller
* (the Android shell cannot await it either), so it must not throw: a failure
* here leaves the outbox intact for the next attempt rather than surfacing as
* an unhandled rejection.
*/
async flush(): Promise<void> {
try {
await this.offline.flush();
await this.sync.flush();
} catch {
// The outbox is durable; the next cycle retries.
}
}
// ---- diagnostics for the shell ----
syncStatus(): SyncStatus {
return this.sync.status();
}
currentBook(): Book | null {
return this.book;
}
settingsSnapshot(): Promise<AppSettings> {
return this.settingsStore.load();
}
}
/**
* Whether a repaint is needed, and whether the *screen* changes.
*
* Two routes that render the same screen but differ in their argument — two books,
* two folders — are different URLs, but repainting from scratch for them would
* throw away a reading position or a folder selection. The router decides whether
* the URL changed; this decides whether the screen did.
*/
function sameScreen(a: Route | null, b: Route): boolean {
if (a === null) return false;
if (a.name !== b.name) return false;
if (a.name === 'book' && b.name === 'book') return a.bookId === b.bookId;
if (a.name === 'library' && b.name === 'library') return a.path === b.path;
return true;
}
/**
* The URL to write when the app is opened without a fragment.
*
* Built from the document's own base so the hash is relative to wherever the
* bundle happens to live — `https://appassets.androidplatform.net/` on Android,
* the server root in the H5 build, `http://localhost:5174/` in dev. A hard-coded
* `#/shelf` happens to work in all three, but it also *looks* like it works by
* accident, and the next route (a fragment that carries state) would not.
*/
function fallbackHash(): string {
if (typeof location === 'undefined') return '#/shelf';
if (location.protocol === 'file:') return '#/shelf';
return `${location.origin}${location.pathname}#/shelf`;
}
/**
* A message that appears over whatever is on screen and leaves by itself.
*
* Built by hand rather than as a screen component because it outlives screens: it
* is appended to `document.body`, so a route change does not take it with it —
* which is the one case it exists for, a dead link that redirects to the shelf
* while the explanation is still being read. Purely presentational: it is
* `aria-live` so a screen reader announces it, and it takes no pointer events so
* it cannot eat a tap it is covering.
*/
function showToast(message: string): void {
if (typeof document === 'undefined') return;
const node = document.createElement('div');
node.className = 'app-toast';
node.setAttribute('role', 'status');
node.setAttribute('aria-live', 'polite');
node.textContent = message;
document.body.append(node);
// Two frames, so the entry transition actually runs: a class added in the same
// frame the node is created is coalesced and the toast appears without moving.
requestAnimationFrame(() => node.dataset['shown'] = 'true');
setTimeout(() => {
node.dataset['shown'] = 'false';
setTimeout(() => node.remove(), 250);
}, 3600);
}
/**
* Session persistence.
*
* Tokens live in the same key/value store as everything else. That means they
* are in IndexedDB (`localStorage` is the classic choice and is worse: it is
* synchronous, capped at a few MB, and shared across every script on the
* origin). On Android they end up in the WebView's storage inside the app's
* private data directory, which is the platform's own boundary for app secrets.
*/
function createSessionStore(): SessionStore {
const KEY = 'reader.session.v1';
return {
async load(): Promise<Session | null> {
const store = await sharedKv();
const raw = await store.get(KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Session;
if (!parsed?.accessToken || !parsed?.refreshToken) return null;
return parsed;
} catch {
return null;
}
},
async save(session: Session): Promise<void> {
const store = await sharedKv();
await store.set(KEY, JSON.stringify(session));
},
async clear(): Promise<void> {
const store = await sharedKv();
await store.remove(KEY);
},
};
}
interface KeyValueStoreLike {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
remove(key: string): Promise<void>;
}
let sharedKvPromise: Promise<KeyValueStoreLike> | null = null;
/**
* One shared store instance.
*
* Two separate IndexedDB connections to the same database in the same tab is
* legal but wasteful, and on a cold start it doubles the open cost.
*/
async function sharedKv(): Promise<KeyValueStoreLike> {
if (!sharedKvPromise) sharedKvPromise = createStoresLazy();
return sharedKvPromise;
}
async function createStoresLazy(): Promise<KeyValueStoreLike> {
const { createStores } = await import('./store/idb.ts');
const stores = await createStores();
return stores.kv;
}
/** Best-effort default so a fresh install does not start with an empty field. */
function inferDefaultUrl(): string {
if (typeof location === 'undefined') return '';
if (location.protocol === 'file:') return '';
// Same-origin when the H5 bundle is served by the reader server itself.
return `${location.origin}`;
}
export { el };