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 的章节、漫画的卷),与分窗互不干扰。
This commit is contained in:
CodeBuddy committed 2026-09-16 15:28:34 +08:00
1 parent 0a17174fb8
commit 5e8d14b460
28 files changed
+1110 -302

No files matched your search

+36 -3
View File
@@ -13,9 +13,9 @@ import { forbidden, unauthorized } from '../lib/errors.ts';
*/
export function authenticate(ctx: AppContext) {
return async function preHandler(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
const header = request.headers.authorization;
if (!header?.startsWith('Bearer ')) throw unauthorized('missing bearer token', 'NO_TOKEN');
const payload = verifyAccessToken(ctx.config, header.slice('Bearer '.length).trim());
const token = bearerToken(request);
if (!token) throw unauthorized('missing bearer token', 'NO_TOKEN');
const payload = verifyAccessToken(ctx.config, token);
const row = ctx.users.byId(payload.sub);
if (!row) throw unauthorized('account no longer exists', 'TOKEN_INVALID');
if (row.disabled === 1) throw forbidden('account disabled', 'ACCOUNT_DISABLED');
@@ -23,6 +23,39 @@ export function authenticate(ctx: AppContext) {
};
}
/**
* The access token, from the header or — for sub-resources only — the query.
*
* A chapter document is rendered by a browser, which fetches its `<img src>` and
* `<link href>` itself. Those requests cannot carry an `Authorization` header:
* there is no API to attach one to an element's URL, and a `fetch` + Blob URL
* would defeat caching and break the range requests a large image wants. So the
* token travels in the query string for exactly those requests.
*
* The fallback is restricted to `GET` on asset and cover routes. Anywhere else a
* token in a URL is a bad idea: URLs land in logs, history and `Referer` headers,
* and a token that can trigger a scan or a metadata write is not one to leak. The
* narrow allowlist keeps the exposure to reads of immutable content, and those
* URLs are only ever built by the client for that purpose.
*/
function bearerToken(request: FastifyRequest): string | null {
const header = request.headers.authorization;
if (header?.startsWith('Bearer ')) return header.slice('Bearer '.length).trim();
if (!queryTokenAllowed(request)) return null;
const query = (request.query ?? {}) as Record<string, unknown>;
const value = query.access_token;
return typeof value === 'string' && value.length > 0 ? value : null;
}
/** Whether this request may authenticate from the query string. */
function queryTokenAllowed(request: FastifyRequest): boolean {
if (request.method !== 'GET') return false;
const path = (request.url.split('?')[0] ?? '').replace(/\/$/, '');
if (!path.startsWith('/api/v1/books/')) return false;
return path.endsWith('/assets') || path.endsWith('/cover') || path.endsWith('/content');
}
export function requireAdmin(request: FastifyRequest): void {
if (request.currentUser?.role !== 'admin') throw forbidden('admin role required', 'ADMIN_REQUIRED');
}
+28
View File
@@ -219,6 +219,34 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
return manifest;
});
/**
* The book's table of contents.
*
* Separate from `/items` on purpose. `/items` is a *transfer* window: it is
* windowed, and it carries everything a renderer needs to lay out a page. A
* table of contents is a list of names, it must be complete to be useful, and
* it is a few kilobytes even for a 1200-chapter book. Making one endpoint do
* both jobs gave the reader a "table of contents" reading
* 「第 1 章 – 第 40 章」, which is the server's pagination leaking into the UI.
*/
app.get('/api/v1/books/:id/toc', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { id } = request.params as { id: string };
const book = ctx.shelf.get(user.id, id);
const handler = resolveHandler(ctx, id, book.format);
if (!handler) return { toc: [] };
const context = { ...sourceContext(ctx, id, book.format), bookId: id };
if (handler.toc) return { toc: await handler.toc(context) };
// Default: the format has no navigation of its own, so its items are its
// table of contents. `pdf` and `image` land here.
const manifest = await handler.manifest(context);
return {
toc: manifest.items.map((item) => ({ href: item.href, title: item.title, level: 0, spine: item.seq })),
};
});
/**
* A single addressable resource: a chapter document, a page image, a font.
*
+13 -8
View File
@@ -80,16 +80,15 @@ export function registerWebRoutes(app: FastifyInstance, ctx: AppContext): void {
const path = decodeURIComponent(url.pathname);
if (path.startsWith('/api/')) {
reply.status(404).send({
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: `no route for GET ${request.url}` },
});
return;
}
if (!build) {
reply.header('content-type', 'text/html; charset=utf-8');
reply.send(landingPage(roots));
return;
return reply
.header('content-type', 'text/html; charset=utf-8')
.send(landingPage(roots));
}
// `normalize` collapses `..` before the prefix check; comparing the resolved
@@ -98,6 +97,7 @@ export function registerWebRoutes(app: FastifyInstance, ctx: AppContext): void {
const relative = normalize(path).replace(/^([/\\])+/, '');
const candidate = resolve(build, relative);
if (candidate.startsWith(build + sep) && isFile(candidate)) {
const body = createReadStream(candidate);
reply.header('content-type', CONTENT_TYPES[extname(candidate).toLowerCase()] ?? 'application/octet-stream');
// Hashed asset names are immutable; `index.html` must not be, or a client
// keeps loading a bundle that was replaced by an upgrade.
@@ -105,13 +105,18 @@ export function registerWebRoutes(app: FastifyInstance, ctx: AppContext): void {
'cache-control',
relative.startsWith('assets/') ? 'public, max-age=31536000, immutable' : 'no-cache',
);
reply.send(createReadStream(candidate));
return;
// The stream must be the handler's RETURN value, not a `reply.send()`
// followed by a bare `return`. The latter makes Fastify send the response
// twice: the second send is empty, so every asset arrives as a 200 with
// `content-length: 0` and the client parses nothing. It fails silently —
// the log line is "stream closed prematurely" and the page just stays
// blank, which is a genuinely confusing way to lose an afternoon.
return reply.send(body);
}
reply.header('content-type', 'text/html; charset=utf-8');
reply.header('cache-control', 'no-cache');
reply.send(readFileSync(join(build, 'index.html')));
return reply.send(readFileSync(join(build, 'index.html')));
});
}
@@ -9,6 +9,7 @@ import {
type HandlerContext,
type Manifest,
type ParsedSource,
type TocEntry,
} from './registry.ts';
import { ZipArchive } from './zip-reader.ts';
import { imageContentType, isImageExtension } from './image-types.ts';
@@ -206,6 +207,17 @@ export const comicDirectoryHandler = registerDirectoryHandler({
};
},
/** One entry per volume: a comic's table of contents is its volumes. */
async toc(ctx: HandlerContext): Promise<TocEntry[]> {
const volumes = await collectVolumes(ctx.absPath);
return volumes.map((volume, index) => ({
href: `page:${index}:0`,
title: `${volume.title}(${volume.pages.length} 页)`,
level: 0,
spine: index,
}));
},
async manifest(ctx: HandlerContext): Promise<Manifest> {
const volumes = await collectVolumes(ctx.absPath);
const items = [];
+32 -1
View File
@@ -9,6 +9,7 @@ import {
type HandlerContext,
type Manifest,
type ParsedSource,
type TocEntry,
} from './registry.ts';
import { parseEpub, extractEpubCover } from '../metadata.ts';
import { XMLParser } from 'fast-xml-parser';
@@ -43,7 +44,19 @@ import { XMLParser } from 'fast-xml-parser';
* archive path (`xhtml:OEBPS/ch1.xhtml`) instead.
*/
/** How many spine entries one manifest window may describe. */
/**
* How many spine entries one manifest window may describe.
*
* This is a *transfer* boundary, not a table of contents. The two were the same
* thing in the first version of this contract, which made a book's TOC read
* "第 1 章 – 第 40 章" instead of listing its chapters — the reader asked for a
* table of contents and got the server's pagination.
*
* A client that wants the chapter list asks for it with `toc=1`, which returns
* every spine entry's title without its per-item detail. A long book's TOC is
* therefore a few kilobytes of strings rather than a manifest with sizes and
* media types for every chapter.
*/
export const CHAPTER_WINDOW = 40;
/** A chapter document is XHTML; anything larger is not something we serve. */
@@ -122,6 +135,24 @@ export const epubHandler = registerFileHandler({
};
},
/**
* Every chapter, by name.
*
* This is the book's table of contents, not the manifest's windows. It exists
* because windowing made the two groups in the manifest (`第 1 章 – 第 40 章`)
* look like a table of contents, which no reader wants to see.
*/
async toc(ctx: HandlerContext): Promise<TocEntry[]> {
const archive = await ZipArchive.open(ctx.absPath);
const pkg = await readPackage(archive);
return pkg.spine.map((item, index) => ({
href: `xhtml:${item.path}`,
title: pkg.titles.get(item.path) ?? `第 ${index + 1} 章`,
level: 0,
spine: index,
}));
},
async asset(ctx: HandlerContext, req): Promise<AssetPayload> {
const archive = await ZipArchive.open(ctx.absPath);
+28
View File
@@ -87,6 +87,24 @@ export interface Manifest {
items: ContentItem[];
}
/**
* A table of contents entry.
*
* Deliberately not a `ContentItem`: a TOC is a list of *names*, and a client
* rendering one needs a title and somewhere to jump to. Shipping sizes, media
* types and per-item ids for a 1200-chapter book would be several times the
* payload for fields the TOC never reads.
*/
export interface TocEntry {
/** Addressable reference; the same value `ContentItem.href` carries. */
href: string;
title: string;
/** Depth for nested navigation, 0 for a flat list. */
level: number;
/** Whole-book index, when the format has one. */
spine?: number;
}
export interface AssetRequest {
/** Item id from the manifest, or a format specific reference. */
ref: string;
@@ -142,6 +160,15 @@ export interface FileFormatHandler {
readonly extensions: readonly string[];
/** Human readable, surfaced in /capabilities so clients can show support. */
readonly label: string;
/**
* The book's own navigation, when it has one.
*
* Optional because not every format does: a single image has no chapters, and
* a TXT file only has headings when its author wrote them. A handler that
* omits this gets the default, which is one entry per manifest item — correct
* for anything whose items already are the table of contents.
*/
toc?(ctx: HandlerContext): Promise<TocEntry[]>;
/**
* Optional cheap check that the file really is this format. Used when the
* extension is ambiguous (a .zip that may or may not be a comic).
@@ -156,6 +183,7 @@ export interface DirectoryFormatHandler {
readonly format: string;
readonly kind: BookKind;
readonly label: string;
toc?(ctx: HandlerContext): Promise<TocEntry[]>;
/** Decide whether this directory is one book of this format. */
matches(ctx: HandlerContext, entries: DirectoryEntry[]): Promise<boolean> | boolean;
parse(ctx: HandlerContext, entries: DirectoryEntry[]): Promise<ParsedSource>;
+12
View File
@@ -8,6 +8,7 @@ import {
type HandlerContext,
type Manifest,
type ParsedSource,
type TocEntry,
} from './registry.ts';
import { filenameMetadata } from '../metadata.ts';
@@ -176,6 +177,17 @@ export const textHandler = registerFileHandler({
};
},
/** The chapters the heading scan found, in order. */
async toc(ctx: HandlerContext): Promise<TocEntry[]> {
const { text } = await readTextFile(ctx.absPath);
return splitChapters(text).chapters.map((chapter, index) => ({
href: `chapter:${index}`,
title: chapter.title,
level: 0,
spine: index,
}));
},
/**
* Body text. Two addressing modes, because clients need both:
* - `chapter:<n>` for a table-of-contents jump
+56
View File
@@ -973,3 +973,59 @@ describe('directory detection', () => {
assert.equal(await looksLikeComicDirectory(dir), false);
});
});
describe('query-string tokens', () => {
test('an asset can be fetched with a query token, because an img tag cannot send a header', async () => {
// A chapter document is rendered by the browser, which fetches its images
// and stylesheets itself. Without this, every illustration in a book 401s.
const book = await findBook('三体');
const token = ctx.users.login('owner', 'password123', 'test');
const issued = (await token).accessToken;
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${book.id}/assets?ref=${encodeURIComponent('OEBPS/images/pic.png')}&access_token=${encodeURIComponent(issued)}`,
});
assert.equal(res.statusCode, 200, res.body);
assert.equal(res.headers['content-type'], 'image/png');
});
test('the same token is refused on a state-changing endpoint', async () => {
// A token in a URL leaks into logs, history and Referer headers. It is
// accepted only where a browser forces our hand: a GET for immutable
// content. A scan trigger must never be reachable that way.
const admin = await ctx.users.login('owner', 'password123', 'test');
const res = await app.inject({
method: 'POST',
url: `/api/v1/library/scan?access_token=${encodeURIComponent(admin.accessToken)}`,
});
assert.equal(res.statusCode, 401, 'a query token must not authorise a POST');
});
test('a query token on a metadata write is refused', async () => {
const admin = await ctx.users.login('owner', 'password123', 'test');
const book = await findBook('三体');
const res = await app.inject({
method: 'PATCH',
url: `/api/v1/books/${book.id}/metadata?access_token=${encodeURIComponent(admin.accessToken)}`,
payload: { title: 'x' },
});
assert.equal(res.statusCode, 401);
});
test('a query token is refused on a collection endpoint', async () => {
// Only per-book asset reads accept it. A shelf listing carries the whole
// library and has no reason to be fetchable from a URL someone could paste.
const admin = await ctx.users.login('owner', 'password123', 'test');
const res = await app.inject({ method: 'GET', url: `/api/v1/books?access_token=${encodeURIComponent(admin.accessToken)}` });
assert.equal(res.statusCode, 401);
});
test('an invalid query token is refused like any other', async () => {
const book = await findBook('三体');
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${book.id}/assets?ref=OEBPS%2Fimages%2Fpic.png&access_token=not-a-token`,
});
assert.equal(res.statusCode, 401);
});
});
+19
View File
@@ -57,6 +57,25 @@ describe('static client hosting', () => {
assert.match(res.headers['cache-control'] as string, /immutable/);
});
test('an asset is served with its real bytes, not an empty second send', async () => {
// This was a 200 with `content-length: 0` for every asset. The cause was a
// `reply.send(stream)` followed by a bare `return` in an async handler:
// Fastify sends the response twice, the second send is empty, and the
// client's module never loads — a blank page with no error anywhere except
// a "stream closed prematurely" log line.
const res = await app.inject({ method: 'GET', url: '/assets/index-abc123.js' });
assert.equal(res.statusCode, 200);
assert.ok(res.rawPayload.byteLength > 0, 'a streamed asset must not be empty');
assert.equal(res.body, 'console.log(1)');
});
test('index.html is served with its real bytes', async () => {
const res = await app.inject({ method: 'GET', url: '/' });
assert.equal(res.statusCode, 200);
assert.ok(res.rawPayload.byteLength > 0);
assert.match(res.body, /<title>reader<\/title>/);
});
test('a client-side route falls back to index.html instead of 404', async () => {
// A single-page app's deep links are not files; returning 404 would break a
// reload on any screen but the shelf.
+48
View File
@@ -0,0 +1,48 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))s(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&s(r)}).observe(document,{childList:!0,subtree:!0});function e(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(i){if(i.ep)return;i.ep=!0;const o=e(i);fetch(i.href,o)}})();class V extends Error{constructor(t,e,s){super(s),this.status=t,this.code=e,this.name="ApiError"}}const Y=4;class Q{constructor(t={}){this.options=t,this.baseUrl=(t.baseUrl??"").replace(/\/$/,""),this.session=(t.storage??T()).read()}session;refreshInFlight=null;inflight=0;queue=[];baseUrl;get currentSession(){return this.session}signOut(){this.session=null,(this.options.storage??T()).write(null),this.options.onAuthLost?.()}setSession(t){this.session=t,(this.options.storage??T()).write(t)}async login(t,e){const s=await this.request("POST","/api/v1/auth/login",{body:{username:t,password:e},anonymous:!0});return this.setSession(s),s}async register(t,e,s){const i=await this.request("POST","/api/v1/auth/register",{body:{username:t,password:e,...s?{displayName:s}:{}},anonymous:!0});return this.setSession(i.session),i.session}async instance(){return this.request("GET","/api/v1/instance",{anonymous:!0})}async manifest(t,e){const s=e===void 0?"":`?group=${e}`;return this.request("GET",`/api/v1/books/${encodeURIComponent(t)}/manifest${s}`)}async toc(t){return(await this.request("GET",`/api/v1/books/${encodeURIComponent(t)}/toc`)).toc}async items(t,e){const s=e===void 0?"":`?group=${e}`;return this.request("GET",`/api/v1/books/${encodeURIComponent(t)}/items${s}`)}async asset(t,e){return this.requestBlob("GET",`/api/v1/books/${encodeURIComponent(t)}/assets?ref=${encodeURIComponent(e)}`)}async chapterText(t,e){return(await this.asset(t,e)).text()}contentUrl(t){return this.url(`/api/v1/books/${encodeURIComponent(t)}/content`)}coverUrl(t){const e=this.session?.accessToken,s=`/api/v1/books/${encodeURIComponent(t)}/cover`;return this.url(e?`${s}?access_token=${encodeURIComponent(e)}`:s)}async shelf(t={}){const e=new URLSearchParams;t.search&&e.set("search",t.search),t.page&&e.set("page",String(t.page)),t.pageSize&&e.set("pageSize",String(t.pageSize));const s=e.toString()?`?${e}`:"";return this.request("GET",`/api/v1/books${s}`)}async progress(t){return this.request("GET",`/api/v1/sync/progress/${encodeURIComponent(t)}`)}async putProgress(t,e){await this.request("PUT",`/api/v1/sync/progress/${encodeURIComponent(t)}`,{body:e})}url(t){return`${this.baseUrl}${t}`}async request(t,e,s={}){const i=await this.fetch(t,e,s);if(i.status!==204)return await i.json()}async requestBlob(t,e){return(await this.fetch(t,e,{})).blob()}async fetch(t,e,s){await this.acquire();try{const i=await this.send(t,e,s);if(i.status!==401||s.anonymous){if(!i.ok)throw await E(i);return i}if(!await this.refresh())throw this.signOut(),await E(i);const r=await this.send(t,e,s);if(!r.ok)throw await E(r);return r}finally{this.release()}}send(t,e,s){const i={};return s.body!==void 0&&(i["content-type"]="application/json"),!s.anonymous&&this.session&&(i.authorization=`Bearer ${this.session.accessToken}`),fetch(this.url(e),{method:t,headers:i,...s.body!==void 0?{body:JSON.stringify(s.body)}:{}})}async refresh(){this.refreshInFlight??=(async()=>{const e=this.session;if(!e?.refreshToken)return null;try{const s=await fetch(this.url("/api/v1/auth/refresh"),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({refreshToken:e.refreshToken})});return s.ok?await s.json():null}catch{return null}finally{queueMicrotask(()=>{this.refreshInFlight=null})}})();const t=await this.refreshInFlight;return t?(this.setSession(t),!0):!1}acquire(){return this.inflight<Y?(this.inflight+=1,Promise.resolve()):new Promise(t=>{this.queue.push(()=>{this.inflight+=1,t()})})}release(){this.inflight-=1,this.queue.shift()?.()}}async function E(n){let t="HTTP_ERROR",e=`request failed with ${n.status}`;try{const s=await n.json();s?.error?.code&&(t=s.error.code,e=s.error.message)}catch{}return new V(n.status,t,e)}function T(){const n="reader.session";try{const t=globalThis.localStorage;if(!t)throw new Error("no localStorage");return{read:()=>{const e=t.getItem(n);if(!e)return null;try{return JSON.parse(e)}catch{return null}},write:e=>{e?t.setItem(n,JSON.stringify(e)):t.removeItem(n)}}}catch{let t=null;return{read:()=>t,write:e=>{t=e}}}}class Z{constructor(t,e,s={}){this.host=t,this.api=e,this.options=s,this.root=document.createElement("div"),this.root.className="shelf";const i=document.createElement("div");i.className="shelf__header";const o=document.createElement("h1");o.classLine truncated
html {
font-size: ${n.fontSize}px;
background: ${n.background};
color: ${n.text};
/* The document is the scroll container; the frame is sized to one page. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
/* No scroll-snap here. Snapping to the columns sounds right and is not: the
snap target is each column's own edge, so a programmatic scrollLeft lands
wherever the browser decides the nearest snap point is — and a snap point
inside a column is not a page boundary. The paginator sets scrollLeft
explicitly, and a gesture handler keeps user swipes on the same grid. */
}
html::-webkit-scrollbar { display: none; }
body {
/* Columns are the pagination: one column is exactly one page. The column
width is set below in pixels rather than left to the publisher's rules, and
nothing else about the body typography is touched. */
column-width: ${s}px;
column-gap: ${n.columnGap}px;
column-fill: auto;
/* A definite height is what lets the content fragment into columns at all. */
height: 100vh;
/* No overflow: hidden here. Clipping the body also clips the columns it
generated, so content past the first page never becomes scrollable and the
chapter reports a single page. The document's own scroller is what has to
see the overflow. */
margin: 0;
padding: 0 ${n.pagePadding}px;
box-sizing: border-box;
/* No forced font-family and no line-height: the publisher's body rules decide
the typography, as they should. */
}
img, svg {
/* An image that overflows its column breaks the geometry for the whole
chapter, not just for itself. Clamping is the one place where fidelity has
to give way, and it is stated here rather than hidden in a reset. */
max-width: 100%;
max-height: 90vh;
object-fit: contain;
break-inside: avoid;
}
`}function ht(n){return new Promise(t=>{const e=()=>{n.removeEventListener("load",e),requestAnimationFrame(()=>t())};n.addEventListener("load",e)})}class lt{constructor(t,e={}){this.scroller=t,this.columnGap=e.columnGap??32,e.pagePadding}columnGap;stride=0;pageCount=1;current=0;attach(t){this.scroller=t,this.reset()}layout(t){const e=this.scroller;if(!e)return this.stride=0,this.pageCount=1,{pageCount:1,stride:0};const s=t??e.clientWidth;this.stride=Math.max(1,s+this.columnGap);const i=s+this.columnGap;return this.pageCount=Math.max(1,Math.ceil((e.scrollWidth-this.columnGap)/i)),{pageCount:this.pageCount,stride:this.stride}}get total(){return this.pageCount}get index(){return this.current}goTo(t){const e=Math.max(0,Math.min(this.pageCount-1,Math.floor(t)));return e===this.current?!1:(this.current=e,this.scroller&&(this.scroller.scrollLeft=e*this.stride),!0)}turn(t){const e=this.current+t;return e<0?{changed:this.goTo(0),spill:e}:e>this.pageCount-1?{changed:this.goTo(this.pageCount-1),spill:e-(this.pageCount-1)}:{changed:this.goTo(e),spill:0}}reset(){this.current=0,this.pageCount=1,this.scroller&&(this.scroller.scrollLeft=0)}pageAt(t){return this.stride<=0?0:Math.max(0,Math.min(this.pageCount-1,Math.round(t/this.stride)))}}function dt(n,t){return t<=1?0:Math.max(0,Math.min(1,n/(t-1)))}function S(n,t,e){const s=j(n);if(s.length===0)return{blockIndex:0,offsetRatio:0};const i=t*e;let o=0;for(let h=0;h<s.length&&s[h].offsetLeft<=i+1;h+=1)o=h;const r=s[o],c=r.offsetHeight||1,a=Math.max(0,Math.min(1,(i-r.offsetLeft)/c));return{blockIndex:o,offsetRatio:a}}function U(n,t,e){const s=j(n);if(s.length===0)return 0;const i=s[Math.min(t.blockIndex,s.length-1)];if(!i)return 0;const o=i.offsetLeft+t.offsetRatio*(i.offsetHeight||0);return e>0?Math.max(0,Math.round(o/e)):0}function j(n){const t=n?.ownerDocument?.body;return t?Array.from(t.children).filter(e=>e instanceof HTMLElement):[]}class W{entries=new Map;pending=new Map;bytes=0;counter=0;ahead;maxBytes;constructor(t={}){this.ahead=t.ahead??1,this.maxBytes=t.maxBytes??32*1024*1024}peek(t){return this.entries.get(t)?.blob}async get(t,e){const s=this.entries.get(t);if(s)return s.blob;const i=this.pending.get(t);if(i)return i;const o=e(t).then(r=>(this.store(t,r),r));this.pending.set(t,o);try{return await o}finally{this.pending.delete(t)}}warm(t,e,s,i={}){if(!ut())return()=>{};const o=i.ahead??this.ahead,r=i.sizeOf;let c=this.maxBytes-this.bytes;const a=new Set;for(let h=1;h<=o;h+=1){const l=t[e+h];if(!l||this.entries.has(l)||this.pending.has(l))continue;const d=r?.(l);d!==void 0&&d>c||(d!==void 0&&(c-=d),this.get(l,s).catch(()=>{}),a.add(l))}return()=>{for(const h of a)this.entries.delete(h),this.pending.delete(h)}}trim(t){if(this.entries.size<=t)return;const e=[...this.entries.entries()].sort((s,i)=>s[1].seq-i[1].seq);for(const[s,i]of e.slice(0,e.length-t))this.bytes-=i.bytes,this.entries.delete(s)}clear(){this.entries.clear(),this.pending.clear(),this.bytes=0}store(t,e){const s=e.size;if(!(s>this.maxBytes))for(this.bytes+=s,this.entries.set(t,{blob:e,bytes:s,seq:this.counter++});this.bytes>this.maxBytes;){const i=[...this.entries.entries()].sort((o,r)=>o[1].seq-r[1].seq)[0];if(!i)break;this.bytes-=i[1].bytes,this.entries.delete(i[0])}}}function ut(){const n=navigator.connection;return n?n.saveData?!1:n.effectiveType!=="slow-2g"&&n.effectiveType!=="2g":!0}class v{listeners=new Set;on(t){return this.listeners.add(t),()=>this.listeners.delete(t)}emit(t){for(const e of[...this.listeners])e(t)}clear(){this.listeners.clear()}}function N(n){if(!n)return null;if(!n.startsWith("{"))return/^[\w-]+:/.test(n)?{chapter:n,spine:0,block:0,ratio:0,percent:0}:null;try{const t=JSON.parse(n);return typeof t.chapter!="string"?null:{chapter:t.chapter,spine:D(t.spine,0),block:D(t.block,0),ratio:I(t.ratio),percent:I(t.percent)}}catch{return null}}function L(n){return JSON.stringify(n)}function D(n,t){return typeof n=="number"&&Number.isFinite(n)?n:t}function I(n){return typeof n!="number"||!Number.isFinite(n)?0:Math.max(0,Math.min(1,n))}function pt(n,t,e){return t<=0?0:Math.max(0,Math.min(1,(n+e)/t))}function ft(n,t){const e=t.currentSession?.accessToken;if(!e)return n;const i=new DOMParser().parseFromString(n,"application/xhtml+xml");if(i.querySelector("parsererror"))return n;let o=!1;const r=["src","href","poster"];for(const c of r)for(const a of Array.from(i.querySelectorAll(`[${c}]`))){const h=a.getAttribute(c);!h||!H(h,t)||J(h)||(a.setAttribute(c,X(h,e)),o=!0)}for(const c of Array.from(i.querySelectorAll("[style]"))){const a=c.getAttribute("style");if(!a)continue;const h=q(a,t,e);h!==a&&(c.setAttribute("style",h),o=!0)}for(const c of Array.from(i.querySelectorAll("style"))){const a=c.textContent;if(!a)continue;const h=q(a,t,e);h!==a&&(c.textContent=h,o=!0)}return o?`<?xml version="1.0" encoding="utf-8"?>
${new XMLSerializer().serializeToString(i.documentElement)}`:n}function H(n,t){if(n.startsWith("/api/"))return!0;const e=t.baseUrl;return e.length>0&&n.startsWith(`${e}/api/`)}function J(n){return n.includes("access_token=")}function X(n,t){const[e,s]=mt(n),i=e.includes("?")?"&":"?";return`${e}${i}access_token=${encodeURIComponent(t)}${s}`}function q(n,t,e){return n.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,(s,i,o)=>!H(o,t)||J(o)?s:`url(${i}${X(o,e)}${i})`)}function mt(n){const t=n.indexOf("#");return t<0?[n,""]:[n.slice(0,t),n.slice(t)]}class m{constructor(t,e,s,i){this.host=t,this.api=e,this.book=s,this.options=i,this.device=i.device??"web",this.style=i.style,this.container=new ot(t),this.paginator=new lt(null,{columnGap:i.style.columnGap,pagePadding:i.style.pagePadding})}state=new v;container;paginator;cache=new W;window=null;current=null;locator=null;progressTimer=null;cancelPrefetch=null;device;style;async open(t,e=0){const s=await this.api.manifest(t,e);this.window=P(s.content??null,{...s.kind!==void 0?{kind:s.kind}:{},...s.total!==void 0?{total:s.total}:{},...s.groups!==void 0?{groups:s.groups}:{},...s.items!==void 0?{items:s.items}:{}});const i=N((await this.api.progress(t).catch(()=>({progress:null}))).progress?.locator??"");this.locator=i;const o=i?this.window.items.findIndex(r=>r.href===i.chapter):-1;return await this.show(o>=0?o:0,i??void 0),this.current}get chapterDocument(){return this.container.document()}async turn(t){if(!this.current||!this.window)return;const{changed:e,spill:s}=this.paginator.turn(t);if(s!==0){const i=this.current.index+Math.sign(s);if(i<0||i>=this.window.items.length){e&&this.publish();return}await this.show(i),s>0?this.paginator.goTo(0):this.paginator.goTo(this.paginator.total-1),this.publish();return}e&&this.publish()}async goToSpine(t){this.window&&(await this.ensureWindow(t),!(!this.window||!O(this.window,t))&&await this.show(it(this.window,t)))}async goToSpineOfHref(t){if(!this.window)return!1;const e=this.window.items.findIndex(s=>s.href===t);return e>=0?(await this.show(e),!0):!1}relayout(){if(!this.current)return;const t=this.paginator.layout(this.container.width()),e=S(this.container.scroller(),this.paginator.index,t.stride),s=this.paginator.layout(this.container.width());this.paginator.goTo(U(this.container.scroller(),e,s.stride)),this.publish()}async restyle(t){this.style=t,this.current&&await this.reshow()}async reshow(){const t=this.current;if(!t)return;const e=this.window?.items[t.index];if(!e)return;const s=this.paginator.layout(this.container.width()).stride,i=S(this.container.scroller(),this.paginator.index,s);await this.show(t.index,{chapter:e.href,spine:R(this.window,t.index),block:i.blockIndex,ratio:i.offsetRatio,percent:t.pageCount>1?t.page/t.pageCount:0})}async flush(){this.progressTimer&&(clearTimeout(this.progressTimer),this.progressTimer=null),await this.writeProgress()}destroy(){this.cancelPrefetch?.(),this.cache.clear(),this.container.destroy(),this.state.clear()}async show(t,e){const s=this.window;if(!s)return;const i=s.items[t];if(!i)return;const o=await this.cache.get(i.href,()=>this.api.asset(this.book.id,i.href)).then(a=>a.text());await this.container.mount(ft(o,this.api),this.style),this.paginator.attach(this.container.scroller());const r=this.paginator.layout(this.container.width()),c=e&&e.chapter===i.href?U(this.container.scroller(),{blockIndex:e.block,offsetRatio:e.ratio},r.stride):0;this.paginator.goTo(Math.min(c,r.pageCount-1)),this.current={book:this.book,groups:s.groups,items:s.items,index:t,total:s.total,page:this.paginator.index,pageCount:this.paginator.total,warnings:[]},this.scheduleProgress(),this.prefetchFrom(t),this.publish()}publish(){if(!this.current)return;const t={...this.current,page:this.paginator.index,pageCount:this.paginator.total};this.current=t,this.state.emit(t)}scheduleProgress(){const t=this.options.progressDebounceMs??4e3;if(t===0){this.writeProgress();return}this.progressTimer&&clearTimeout(this.progressTimer),this.progressTimer=setTimeout(()=>{this.progressTimer=null,this.writeProgress()},t)}async writeProgress(){const t=this.window,e=this.current;if(!t||!e)return;const s=t.items[e.index];if(!s)return;const i=this.paginator.layout(this.container.width()).stride,o=S(this.container.scroller(),this.paginator.index,i),r=dt(this.paginator.index,this.paginator.total),c=R(t,e.index),a={chapter:s.href,spine:c,block:o.blockIndex,ratio:o.offsetRatio,percent:pt(c,t.total,r)};this.locator=a,await this.api.putProgress(this.book.id,{locator:L(a),percentage:a.percent,chapterTitle:s.title,device:this.device,updatedAt:Date.now()}).catch(()=>{})}prefetchFrom(t){const e=this.window;if(!e)return;this.cancelPrefetch?.();const s=e.items.map(i=>i.href);this.cancelPrefetch=this.cache.warm(s,t,i=>this.api.asset(this.book.id,i),{ahead:this.options.platform==="android"?1:2,sizeOf:i=>e.items.find(o=>o.href===i)?.size})}async ensureWindow(t){if(!this.window||O(this.window,t))return;const e=nt(this.window,t);if(!e)return;const s=await Line truncated
`),o.style.whiteSpace="pre-wrap",t.append(o),s=[]};for(const o of e)o.trim()===""?i():s.push(o);return i(),t}class C{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i}state=new v;frame=null;page=1;async open(t){const e=N((await this.api.progress(t).catch(()=>({progress:null}))).progress?.locator??"");e?.block&&(this.page=Math.max(1,e.block));const s=document.createElement("iframe");return s.className="reader-pdf",s.setAttribute("sandbox","allow-same-origin allow-popups allow-forms"),s.title=this.book.title,this.frame=s,this.host.replaceChildren(s),this.setPage(t,this.page),{book:this.book,page:this.page,url:s.src}}setPage(t,e){this.page=Math.max(1,Math.floor(e)),this.frame&&(this.frame.src=this.url(t,this.page)),this.state.emit({book:this.book,page:this.page,url:this.frame?.src??""}),this.saveProgress(t)}async flush(t){await this.saveProgress(t)}destroy(){this.host.replaceChildren(),this.frame=null,this.state.clear()}url(t,e){const s=this.api.currentSession?.accessToken??"",i=this.api.url(`/api/v1/books/${encodeURIComponent(t)}/assets?ref=document`),o=i.includes("?")?"&":"?";return`${i}${o}access_token=${encodeURIComponent(s)}#page=${e}`}async saveProgress(t){const e={chapter:"document",spine:0,block:this.page,ratio:0,percent:0};await this.api.putProgress(t,{locator:L(e),percentage:0,chapterTitle:`第 ${this.page} 页`,device:this.options.device??"web",updatedAt:Date.now()}).catch(()=>{})}}const p={fontSize:18,pageWidth:720,columnGap:32,pagePadding:24,theme:"light"},bt={light:{background:"#ffffff",text:"#111111",muted:"#6b7280"},sepia:{background:"#f6f0e4",text:"#3b3121",muted:"#8a7c63"},dark:{background:"#14161a",text:"#c9cdd4",muted:"#7c828c"}},K="reader.settings";function yt(){try{const n=localStorage.getItem(K);if(!n)return{...p};const t=JSON.parse(n);return{fontSize:G(t.fontSize,12,32,p.fontSize),pageWidth:G(t.pageWidth,360,1200,p.pageWidth),columnGap:p.columnGap,pagePadding:p.pagePadding,theme:vt(t.theme)?t.theme:p.theme}}catch{return{...p}}}function F(n){try{localStorage.setItem(K,JSON.stringify(n))}catch{}}function _(n,t){n.dataset.theme=t;const e={light:"#ffffff",sepia:"#f6f0e4",dark:"#14161a"};document.querySelector('meta[name="theme-color"]')?.setAttribute("content",e[t])}function G(n,t,e,s){return typeof n!="number"||!Number.isFinite(n)?s:Math.max(t,Math.min(e,Math.round(n)))}function vt(n){return n==="light"||n==="sepia"||n==="dark"}function kt(n){return n==="android"?{reflowable:"webview",paged:"native",document:"native"}:{reflowable:"webview",paged:"webview",document:"iframe"}}class xt{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i,this.settings=yt(),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=!0;const o=document.createElement("div");o.className="reader__footer",this.progressFill=document.createElement("div"),this.progressFill.className="reader__progress-fill";const r=document.createElement("div");r.className="reader__progress",r.append(this.progressFill),this.pageLabel=document.createElement("span"),o.append(r,this.pageLabel),this.root.append(this.toolbar,this.warning,this.stage,o),t.replaceChildren(this.root),this.buildToolbar(),this.bindGestures()}root;stage;toolbar;progressFill;pageLabel;warning;reader=null;state={index:0,total:0,title:"",percent:0};settings;disposers=[];tocDialog=null;async open(){const t=this.options.platform??"web",e=await this.api.manifest(this.book.id),s=e.content?.kind??e.kind??"reflowable",i=kt(t);if(s==="reflowable"){const r=new m(this.containerFor("webview"),this.api,this.book,{platform:t,style:this.readerStyle(),...this.options.device!==void 0?{device:this.options.device}:{}});this.reader=r,this.disposers.push(r.state.on(c=>this.onReflowable(c))),await r.open(this.book.id),this.stage.classList.remove("is-paged"),this.applySettings();return}if(s==="text"){const r=new y(this.stage,this.api,this.book,{...this.options.device!==void 0?{device:this.options.device}:{}});this.reader=r,await r.open(this.book.id),this.pageLabel.textContent="流式";return}if(s==="document"){const r=new C(this.stage,this.api,this.book,{...this.options.device!==void 0?{device:this.options.device}:{}});this.reader=r,this.stage.classList.add("is-paged");const c=await r.open(this.book.id);this.pageLabel.textContent=`${this.book.format.toUpperCase()} · 第 ${c.page} 页`;return}if(i.paged==="native"){this.host.dispatchEvent(new CustomEvent("reader:native-page",{detail:{bookId:this.book.id,kind:s}}));return}const o=new w(this.stage,this.api,this.book,{platform:"web",...this.options.device!==void 0?{device:this.options.device}:{}});this.reader=o,this.disposers.push(o.state.on(r=>this.onPaged(r))),await o.open(this.book.id),this.stage.classList.add("is-paged") Line truncated
//# sourceMappingURL=index-C5iXpQfY.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-C5iXpQfY.js","sources":["../../src/net/api.ts","../../src/ui/shelf-view.ts","../../src/ui/auth-view.ts","../../src/render/window.ts","../../src/render/container.ts","../../src/render/paginator.ts","../../src/render/prefetch.ts","../../src/lib/events.ts","../../src/lib/locator.ts","../../src/net/asset-url.ts","../../src/render/reader.ts","../../src/render/paged.ts","../../src/render/text.ts","../../src/render/pdf.ts","../../src/render/theme.ts","../../src/ui/settings.ts","../../src/ui/reader-view.ts","../../src/bridge.ts","../../src/main.ts"],"sourcesContent":["/**\n * The server contract, in one place.\n *\n * Every call a renderer needs goes through this client. Two rules shape it:\n *\n * 1. **No renderer knows a URL.** A PDF viewer, a comic pager and a chapter\n * loader all ask for \"page 12 of book X\" and get bytes; the `ref` they pass\n * is whatever the manifest handed them, treated as opaque. That is what lets\n * the server add a format without a client release.\n * 2. **Concurrency is bounded.** A reader flipping quickly through a comic would\n * otherwise open one request per page per flip, on a phone, over a tunnel.\n * The limiter below is crude and that is fine — it exists to keep a reader\n * from DoSing its own server.\n */\n\nexport interface ContentItem {\n id: string;\n seq: number;\n title: string;\n kind: 'chapter' | 'page';\n mediaType: string;\n href: string;\n /** Byte length when known, so a prefetch can be budgeted. */\n size?: number;\n}\n\nexport interface ContentGroup {\n id: string;\n seq: number;\n title: string;\n count: number;\n /** Global seq of this group's first item. */\n offset: number;\n}\n\nexport interface BookContent {\n kind: 'reflowable' | 'paged' | 'text' | 'document' | 'single-image';\n total: number;\n groups: ContentGroup[];\n items: ContentItem[];\n /** Present when the request narrowed the items to one group. */\n group?: number;\n}\n\n/** One entry of a book's own navigation. */\nexport interface TocEntry {\n href: string;\n title: string;\n level: number;\n spine?: number;\n}\n\nexport interface BookDto {\n id: string;\n title: string;\n author: string;\n series: string;\n seriesIndex: number | null;\n tags: string[];\n format: string;\n language: string;\n coverUrl: string | null;\n fileSize: number;\n pageCount: number | null;\n source: string;\n manualFields: string[];\n addedAt: number;\n updatedAt: number;\n}\n\nexport interface ManifestResponse {\n book: BookDto;\n contentUrl: string;\n coverUrl: string | null;\n files: Array<{ rel_path: string; size: number; missing: number }>;\n kind?: BookContent['kind'];\n total?: number;\n groups?: ContentGroup[];\n items?: ContentItem[];\n content: BookContent | null;\n}\n\nexport interface ProgressDto {\n bookId: string;\n locator: string;\n percentage: number;\n chapterTitle: string;\n device: string;\n updatedAt: number;\n}\n\nexport interface Session {\n accessToken: string;\n refreshToken: string;\n accessTokenExpiresAt: number;\n refreshTokenExpiresAt: number;\n}\n\nexport interface ApiErrorBody {\n error: { code: string; message: string };\n}\n\n/** Raised for a non-2xx response, carrying the server's error code. */\nexport class ApiError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n ) {\n super(message);\n this.name = 'ApiError';\n }\n}\n\nexport interface ApiClientOptions {\n /** Server origin. Empty means \"same origin\", which is the H5 deployment. */\n baseUrl?: string;\n /** Called when a request fails with 401 and a refresh is not possible. */\n onAuthLost?: () => void;\n /** Persistence for the session; defaults to localStorage when available. */\n storage?: SessionStorage;\n}\n\nexport interface SessionStorage {\n read(): Session | null;\n write(session: Session | null): void;\n}\n\n/** Bounded parallel requests: enough to prefetch, not enough to flood. */\nconst MAX_INFLIGHT = 4;\n\nexport class ApiClient {\n private session: Session | null;\n private refreshInFlight: Promise<Session | null> | null = null;\n private inflight = 0;\n private queue: Array<() => void> = [];\n /** Server origin, without a trailing slash. Empty means same origin. */\n readonly baseUrl: string;\n\n constructor(private readonly options: ApiClientOptions = {}) {\n this.baseUrl = (options.baseUrl ?? '').replace(/\\/$/, '');\n this.session = (options.storage ?? defaultStorage()).read();\n }\n\n get currentSession(): Session | null {\n return this.session;\n }\n\n /** Drop the session and tell the host, so it can show a login screen. */\n signOut(): void {\n this.session = null;\n (this.options.storage ?? defaultStorage()).write(null);\n this.options.onAuthLost?.();\n }\n\n private setSession(session: Session | null): void {\n this.session = session;\n (this.options.storage ?? defaultStorLine truncated
@@ -1 +1 @@
:root{--ui-bg: #ffffff;--ui-fg: #111111;--ui-muted: #6b7280;--ui-border: #e5e7eb;--ui-accent: #2563eb;--ui-danger: #dc2626;--safe-top: env(safe-area-inset-top, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--safe-right: env(safe-area-inset-right, 0px)}:root[data-theme=dark]{--ui-bg: #14161a;--ui-fg: #e5e7eb;--ui-muted: #9ca3af;--ui-border: #2a2e35;--ui-accent: #60a5fa;--ui-danger: #f87171}:root[data-theme=sepia]{--ui-bg: #f6f0e4;--ui-fg: #3b3121;--ui-muted: #8a7c63;--ui-border: #e0d6c2;--ui-accent: #a16207}*{box-sizing:border-box}html,body{margin:0;height:100%;overflow:hidden;background:var(--ui-bg);color:var(--ui-fg);font-family:system-ui,-apple-system,Segoe UI,Noto Sans SC,sans-serif;user-select:none;-webkit-tap-highlight-color:transparent}#app{height:100%;overflow:hidden}.shelf{height:100%;overflow-y:auto;padding:calc(12px + var(--safe-top)) calc(16px + var(--safe-right)) calc(24px + var(--safe-bottom)) calc(16px + var(--safe-left))}.shelf__header{display:flex;align-items:center;gap:12px;margin-bottom:16px}.shelf__title{font-size:20px;font-weight:600;margin:0;flex:1}.shelf__search{width:100%;padding:10px 12px;margin-bottom:16px;border:1px solid var(--ui-border);border-radius:10px;background:var(--ui-bg);color:var(--ui-fg);font-size:15px}.shelf__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:16px 12px}.book-card{display:flex;flex-direction:column;gap:6px;cursor:pointer;border:0;padding:0;background:none;color:inherit;text-align:left}.book-card__cover{width:100%;aspect-ratio:2 / 3;object-fit:cover;border-radius:6px;background:var(--ui-border);box-shadow:0 1px 3px #00000026}.book-card__title{font-size:13px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.book-card__meta{font-size:11px;color:var(--ui-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.reader{position:relative;height:100%;display:flex;flex-direction:column;overflow:hidden}.reader__stage{flex:1;min-height:0;position:relative;touch-action:pan-y}.reader__stage.is-paged{touch-action:none}.reader__toolbar{display:flex;align-items:center;gap:8px;padding:6px calc(10px + var(--safe-right)) 6px calc(10px + var(--safe-left));border-bottom:1px solid var(--ui-border);background:var(--ui-bg);padding-top:calc(6px + var(--safe-top));font-size:13px}.reader__toolbar-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.reader__button{border:0;background:none;color:var(--ui-fg);font-size:15px;padding:6px 8px;border-radius:8px;cursor:pointer}.reader__button:disabled{color:var(--ui-muted);cursor:default}.reader__footer{display:flex;align-items:center;gap:10px;padding:4px calc(12px + var(--safe-right)) calc(4px + var(--safe-bottom)) calc(12px + var(--safe-left));border-top:1px solid var(--ui-border);font-size:12px;color:var(--ui-muted);background:var(--ui-bg)}.reader__progress{flex:1;height:3px;background:var(--ui-border);border-radius:2px;overflow:hidden}.reader__progress-fill{height:100%;width:0;background:var(--ui-accent);transition:width .12s linear}.reader__stage .reader-viewport{transition:scroll-left .16s ease-out}.reader__page{display:block;margin:0 auto;width:auto;max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.reader-text{height:100%;overflow-y:auto;padding:16px calc(16px + var(--safe-right)) calc(24px + var(--safe-bottom)) calc(16px + var(--safe-left));user-select:text;line-height:1.8;font-size:17px}.reader-text p{margin:0 0 .9em}.reader-pdf{width:100%;height:100%;border:0;background:var(--ui-bg)}.reader__warning{padding:8px 12px;background:color-mix(in srgb,var(--ui-danger) 12%,transparent);color:var(--ui-danger);font-size:12px}.spinner{position:absolute;inset:0;display:grid;place-items:center;color:var(--ui-muted);font-size:13px;pointer-events:none}.dialog{position:fixed;inset:0;display:grid;place-items:center;background:#00000080;padding:20px;z-index:20}.dialog__panel{width:min(420px,100%);max-height:80vh;overflow-y:auto;background:var(--ui-bg);border-radius:14px;padding:20px;box-shadow:0 12px 40px #00000040}.dialog__title{margin:0 0 14px;font-size:17px}.dialog__row{display:flex;flex-direction:column;gap:4px;margin-bottom:12px;font-size:13px;color:var(--ui-muted)}.dialog__row input,.dialog__row select{padding:9px 10px;font-size:15px;border:1px solid var(--ui-border);border-radius:8px;background:var(--ui-bg);color:var(--ui-fg)}.dialog__actions{display:flex;gap:10px;justify-content:flex-end;margin-top:8px}.dialog__button{padding:9px 14px;border-radius:8px;border:1px solid var(--ui-border);background:none;color:var(--ui-fg);font-size:14px;cursor:pointer}.dialog__button--primary{background:var(--ui-accent);border-color:var(--ui-accent);color:#fff}.toc{list-style:none;margin:0;padding:0}.toc__item{padding:10px 4px;border-bottom:1px solid var(--ui-border);font-size:14px;cursor:pointer}.toc__item--current{color:var(--ui-accent);font-weight:600}.tLine truncated
:root{--ui-bg: #ffffff;--ui-fg: #111111;--ui-muted: #6b7280;--ui-border: #e5e7eb;--ui-accent: #2563eb;--ui-danger: #dc2626;--safe-top: env(safe-area-inset-top, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--safe-right: env(safe-area-inset-right, 0px)}:root[data-theme=dark]{--ui-bg: #14161a;--ui-fg: #e5e7eb;--ui-muted: #9ca3af;--ui-border: #2a2e35;--ui-accent: #60a5fa;--ui-danger: #f87171}:root[data-theme=sepia]{--ui-bg: #f6f0e4;--ui-fg: #3b3121;--ui-muted: #8a7c63;--ui-border: #e0d6c2;--ui-accent: #a16207}*{box-sizing:border-box}html,body{margin:0;height:100%;overflow:hidden;background:var(--ui-bg);color:var(--ui-fg);font-family:system-ui,-apple-system,Segoe UI,Noto Sans SC,sans-serif;user-select:none;-webkit-tap-highlight-color:transparent}#app{height:100%;overflow:hidden}.shelf{height:100%;overflow-y:auto;padding:calc(12px + var(--safe-top)) calc(16px + var(--safe-right)) calc(24px + var(--safe-bottom)) calc(16px + var(--safe-left))}.shelf__header{display:flex;align-items:center;gap:12px;margin-bottom:16px}.shelf__title{font-size:20px;font-weight:600;margin:0;flex:1}.shelf__search{width:100%;padding:10px 12px;margin-bottom:16px;border:1px solid var(--ui-border);border-radius:10px;background:var(--ui-bg);color:var(--ui-fg);font-size:15px}.shelf__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:16px 12px}.book-card{display:flex;flex-direction:column;gap:6px;cursor:pointer;border:0;padding:0;background:none;color:inherit;text-align:left}.book-card__cover{width:100%;aspect-ratio:2 / 3;object-fit:cover;border-radius:6px;background:var(--ui-border);box-shadow:0 1px 3px #00000026}.book-card__title{font-size:13px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.book-card__meta{font-size:11px;color:var(--ui-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.reader{position:relative;height:100%;display:flex;flex-direction:column;overflow:hidden}.reader__stage{flex:1;min-height:0;position:relative;touch-action:pan-y}.reader__stage.is-paged{touch-action:none}.reader__toolbar{display:flex;align-items:center;gap:8px;padding:6px calc(10px + var(--safe-right)) 6px calc(10px + var(--safe-left));border-bottom:1px solid var(--ui-border);background:var(--ui-bg);padding-top:calc(6px + var(--safe-top));font-size:13px}.reader__toolbar-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.reader__button{border:0;background:none;color:var(--ui-fg);font-size:15px;padding:6px 8px;border-radius:8px;cursor:pointer}.reader__button:disabled{color:var(--ui-muted);cursor:default}.reader__footer{display:flex;align-items:center;gap:10px;padding:4px calc(12px + var(--safe-right)) calc(4px + var(--safe-bottom)) calc(12px + var(--safe-left));border-top:1px solid var(--ui-border);font-size:12px;color:var(--ui-muted);background:var(--ui-bg)}.reader__progress{flex:1;height:3px;background:var(--ui-border);border-radius:2px;overflow:hidden}.reader__progress-fill{height:100%;width:0;background:var(--ui-accent);transition:width .12s linear}.reader-frame{display:block;width:100%;height:100%;border:0;background:transparent}.reader__stage.is-reflowable{touch-action:pan-y}.reader__page{display:block;margin:0 auto;width:auto;max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.reader-text{height:100%;overflow-y:auto;padding:16px calc(16px + var(--safe-right)) calc(24px + var(--safe-bottom)) calc(16px + var(--safe-left));user-select:text;line-height:1.8;font-size:17px}.reader-text p{margin:0 0 .9em}.reader-pdf{width:100%;height:100%;border:0;background:var(--ui-bg)}.reader__warning{padding:8px 12px;background:color-mix(in srgb,var(--ui-danger) 12%,transparent);color:var(--ui-danger);font-size:12px}.spinner{position:absolute;inset:0;display:grid;place-items:center;color:var(--ui-muted);font-size:13px;pointer-events:none}.dialog{position:fixed;inset:0;display:grid;place-items:center;background:#00000080;padding:20px;z-index:20}.dialog__panel{width:min(420px,100%);max-height:80vh;overflow-y:auto;background:var(--ui-bg);border-radius:14px;padding:20px;box-shadow:0 12px 40px #00000040}.dialog__title{margin:0 0 14px;font-size:17px}.dialog__row{display:flex;flex-direction:column;gap:4px;margin-bottom:12px;font-size:13px;color:var(--ui-muted)}.dialog__row input,.dialog__row select{padding:9px 10px;font-size:15px;border:1px solid var(--ui-border);border-radius:8px;background:var(--ui-bg);color:var(--ui-fg)}.dialog__actions{display:flex;gap:10px;justify-content:flex-end;margin-top:8px}.dialog__button{padding:9px 14px;border-radius:8px;border:1px solid var(--ui-border);background:none;color:var(--ui-fg);font-size:14px;cursor:pointer}.dialog__button--primary{background:var(--ui-accent);border-color:var(--ui-accent);color:#fff}.toc{list-style:none;margin:0;padding:0}.toc__item{padding:10px 4px;border-bottom:1px solid var(--ui-border);font-size:14px;cursor:pointerLine truncated
-3
View File
@@ -1,3 +0,0 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))s(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&s(r)}).observe(document,{childList:!0,subtree:!0});function e(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(i){if(i.ep)return;i.ep=!0;const o=e(i);fetch(i.href,o)}})();class H extends Error{constructor(t,e,s){super(s),this.status=t,this.code=e,this.name="ApiError"}}const j=4;class W{constructor(t={}){this.options=t,this.baseUrl=(t.baseUrl??"").replace(/\/$/,""),this.session=(t.storage??S()).read()}session;refreshInFlight=null;inflight=0;queue=[];baseUrl;get currentSession(){return this.session}signOut(){this.session=null,(this.options.storage??S()).write(null),this.options.onAuthLost?.()}setSession(t){this.session=t,(this.options.storage??S()).write(t)}async login(t,e){const s=await this.request("POST","/api/v1/auth/login",{body:{username:t,password:e},anonymous:!0});return this.setSession(s),s}async register(t,e,s){const i=await this.request("POST","/api/v1/auth/register",{body:{username:t,password:e,...s?{displayName:s}:{}},anonymous:!0});return this.setSession(i.session),i.session}async instance(){return this.request("GET","/api/v1/instance",{anonymous:!0})}async manifest(t,e){const s=e===void 0?"":`?group=${e}`;return this.request("GET",`/api/v1/books/${encodeURIComponent(t)}/manifest${s}`)}async items(t,e){const s=e===void 0?"":`?group=${e}`;return this.request("GET",`/api/v1/books/${encodeURIComponent(t)}/items${s}`)}async asset(t,e){return this.requestBlob("GET",`/api/v1/books/${encodeURIComponent(t)}/assets?ref=${encodeURIComponent(e)}`)}async chapterText(t,e){return(await this.asset(t,e)).text()}contentUrl(t){return this.url(`/api/v1/books/${encodeURIComponent(t)}/content`)}coverUrl(t){return this.url(`/api/v1/books/${encodeURIComponent(t)}/cover`)}async shelf(t={}){const e=new URLSearchParams;t.search&&e.set("search",t.search),t.page&&e.set("page",String(t.page)),t.pageSize&&e.set("pageSize",String(t.pageSize));const s=e.toString()?`?${e}`:"";return this.request("GET",`/api/v1/books${s}`)}async progress(t){return this.request("GET",`/api/v1/sync/progress/${encodeURIComponent(t)}`)}async putProgress(t,e){await this.request("PUT",`/api/v1/sync/progress/${encodeURIComponent(t)}`,{body:e})}url(t){return`${this.baseUrl}${t}`}async request(t,e,s={}){const i=await this.fetch(t,e,s);if(i.status!==204)return await i.json()}async requestBlob(t,e){return(await this.fetch(t,e,{})).blob()}async fetch(t,e,s){await this.acquire();try{const i=await this.send(t,e,s);if(i.status!==401||s.anonymous){if(!i.ok)throw await x(i);return i}if(!await this.refresh())throw this.signOut(),await x(i);const r=await this.send(t,e,s);if(!r.ok)throw await x(r);return r}finally{this.release()}}send(t,e,s){const i={};return s.body!==void 0&&(i["content-type"]="application/json"),!s.anonymous&&this.session&&(i.authorization=`Bearer ${this.session.accessToken}`),fetch(this.url(e),{method:t,headers:i,...s.body!==void 0?{body:JSON.stringify(s.body)}:{}})}async refresh(){this.refreshInFlight??=(async()=>{const e=this.session;if(!e?.refreshToken)return null;try{const s=await fetch(this.url("/api/v1/auth/refresh"),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({refreshToken:e.refreshToken})});return s.ok?await s.json():null}catch{return null}finally{queueMicrotask(()=>{this.refreshInFlight=null})}})();const t=await this.refreshInFlight;return t?(this.setSession(t),!0):!1}acquire(){return this.inflight<j?(this.inflight+=1,Promise.resolve()):new Promise(t=>{this.queue.push(()=>{this.inflight+=1,t()})})}release(){this.inflight-=1,this.queue.shift()?.()}}async function x(n){let t="HTTP_ERROR",e=`request failed with ${n.status}`;try{const s=await n.json();s?.error?.code&&(t=s.error.code,e=s.error.message)}catch{}return new H(n.status,t,e)}function S(){const n="reader.session";try{const t=globalThis.localStorage;if(!t)throw new Error("no localStorage");return{read:()=>{const e=t.getItem(n);if(!e)return null;try{return JSON.parse(e)}catch{return null}},write:e=>{e?t.setItem(n,JSON.stringify(e)):t.removeItem(n)}}}catch{let t=null;return{read:()=>t,write:e=>{t=e}}}}class J{constructor(t,e,s={}){this.host=t,this.api=e,this.options=s,this.root=document.createElement("div"),this.root.className="shelf";const i=document.createElement("div");i.className="shelf__header";const o=document.createElement("h1");o.className="shelf__title",o.textContent="书库";const r=document.createElement("button");r.className="reader__button",r.textContent="退出",r.addEventListener("click",()=>this.options.oLine truncated
`),o.style.whiteSpace="pre-wrap",t.append(o),s=[]};for(const o of e)o.trim()===""?i():s.push(o);return i(),t}class I{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i}state=new v;frame=null;page=1;async open(t){const e=C((await this.api.progress(t).catch(()=>({progress:null}))).progress?.locator??"");e?.block&&(this.page=Math.max(1,e.block));const s=document.createElement("iframe");return s.className="reader-pdf",s.setAttribute("sandbox","allow-same-origin allow-popups allow-forms"),s.title=this.book.title,this.frame=s,this.host.replaceChildren(s),this.setPage(t,this.page),{book:this.book,page:this.page,url:s.src}}setPage(t,e){this.page=Math.max(1,Math.floor(e)),this.frame&&(this.frame.src=this.url(t,this.page)),this.state.emit({book:this.book,page:this.page,url:this.frame?.src??""}),this.saveProgress(t)}async flush(t){await this.saveProgress(t)}destroy(){this.host.replaceChildren(),this.frame=null,this.state.clear()}url(t,e){const s=this.api.currentSession?.accessToken??"",i=this.api.url(`/api/v1/books/${encodeURIComponent(t)}/assets?ref=document`),o=i.includes("?")?"&":"?";return`${i}${o}access_token=${encodeURIComponent(s)}#page=${e}`}async saveProgress(t){const e={chapter:"document",spine:0,block:this.page,ratio:0,percent:0};await this.api.putProgress(t,{locator:P(e),percentage:0,chapterTitle:`第 ${this.page} 页`,device:this.options.device??"web",updatedAt:Date.now()}).catch(()=>{})}}const p={fontSize:18,pageWidth:720,columnGap:32,pagePadding:24,theme:"light"},G="reader.settings";function lt(){try{const n=localStorage.getItem(G);if(!n)return{...p};const t=JSON.parse(n);return{fontSize:F(t.fontSize,12,32,p.fontSize),pageWidth:F(t.pageWidth,360,1200,p.pageWidth),columnGap:p.columnGap,pagePadding:p.pagePadding,theme:dt(t.theme)?t.theme:p.theme}}catch{return{...p}}}function q(n){try{localStorage.setItem(G,JSON.stringify(n))}catch{}}function T(n,t){n.dataset.theme=t;const e={light:"#ffffff",sepia:"#f6f0e4",dark:"#14161a"};document.querySelector('meta[name="theme-color"]')?.setAttribute("content",e[t])}function F(n,t,e,s){return typeof n!="number"||!Number.isFinite(n)?s:Math.max(t,Math.min(e,Math.round(n)))}function dt(n){return n==="light"||n==="sepia"||n==="dark"}function ut(n){return n==="android"?{reflowable:"webview",paged:"native",document:"native"}:{reflowable:"webview",paged:"webview",document:"iframe"}}class pt{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i,this.settings=lt(),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=!0;const o=document.createElement("div");o.className="reader__footer",this.progressFill=document.createElement("div"),this.progressFill.className="reader__progress-fill";const r=document.createElement("div");r.className="reader__progress",r.append(this.progressFill),this.pageLabel=document.createElement("span"),o.append(r,this.pageLabel),this.root.append(this.toolbar,this.warning,this.stage,o),t.replaceChildren(this.root),this.buildToolbar(),this.bindGestures()}root;stage;toolbar;progressFill;pageLabel;warning;reader=null;state={index:0,total:0,title:"",percent:0};settings;disposers=[];tocDialog=null;async open(){const t=this.options.platform??"web",e=await this.api.manifest(this.book.id),s=e.content?.kind??e.kind??"reflowable",i=ut(t);if(s==="reflowable"){const r=new f(this.containerFor("webview"),this.api,this.book,{platform:t,...this.options.device!==void 0?{device:this.options.device}:{}});this.reader=r,this.disposers.push(r.state.on(h=>this.onReflowable(h))),await r.open(this.book.id),this.stage.classList.remove("is-paged"),this.applySettings();return}if(s==="text"){const r=new y(this.stage,this.api,this.book,{...this.options.device!==void 0?{device:this.options.device}:{}});this.reader=r,await r.open(this.book.id),this.pageLabel.textContent="流式";return}if(i.paged==="native"){this.host.dispatchEvent(new CustomEvent("reader:native-page",{detail:{bookId:this.book.id}}));return}const o=new w(this.stage,this.api,this.book,{platform:"web",...this.options.device!==void 0?{device:this.options.device}:{}});this.reader=o,this.disposers.push(o.state.on(r=>this.onPaged(r))),await o.open(this.book.id),this.stage.classList.add("is-paged")}async close(){const t=this.reader;t instanceof f||t instanceof w?await t.flush().catch(()=>{}):(t instanceof y||t instanceof I)&&await t.flush(this.book.id).catch(()=>{});for(const e of this.disposers)e();this.disposers=[],this.reader?.destroy(),this.reader=null,this.host.replaceChildren()}async turn(t){(this.reader instanceof f||this.reader instanceof w)&&await this.reader.turn(t)}applySettings(){T(document.documentElement,this.settings.theme),this.root.style.setProperty("--reader-font-size",`${this.settings.fontSizLine truncated
//# sourceMappingURL=index-D6yU5C0y.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index-D6yU5C0y.js","sources":["../../src/net/api.ts","../../src/ui/shelf-view.ts","../../src/ui/auth-view.ts","../../src/render/window.ts","../../src/render/container.ts","../../src/render/paginator.ts","../../src/render/prefetch.ts","../../src/lib/events.ts","../../src/lib/locator.ts","../../src/render/reader.ts","../../src/render/paged.ts","../../src/render/text.ts","../../src/render/pdf.ts","../../src/render/theme.ts","../../src/ui/settings.ts","../../src/ui/reader-view.ts","../../src/bridge.ts","../../src/main.ts"],"sourcesContent":["/**\n * The server contract, in one place.\n *\n * Every call a renderer needs goes through this client. Two rules shape it:\n *\n * 1. **No renderer knows a URL.** A PDF viewer, a comic pager and a chapter\n * loader all ask for \"page 12 of book X\" and get bytes; the `ref` they pass\n * is whatever the manifest handed them, treated as opaque. That is what lets\n * the server add a format without a client release.\n * 2. **Concurrency is bounded.** A reader flipping quickly through a comic would\n * otherwise open one request per page per flip, on a phone, over a tunnel.\n * The limiter below is crude and that is fine — it exists to keep a reader\n * from DoSing its own server.\n */\n\nexport interface ContentItem {\n id: string;\n seq: number;\n title: string;\n kind: 'chapter' | 'page';\n mediaType: string;\n href: string;\n /** Byte length when known, so a prefetch can be budgeted. */\n size?: number;\n}\n\nexport interface ContentGroup {\n id: string;\n seq: number;\n title: string;\n count: number;\n /** Global seq of this group's first item. */\n offset: number;\n}\n\nexport interface BookContent {\n kind: 'reflowable' | 'paged' | 'text' | 'document' | 'single-image';\n total: number;\n groups: ContentGroup[];\n items: ContentItem[];\n /** Present when the request narrowed the items to one group. */\n group?: number;\n}\n\nexport interface BookDto {\n id: string;\n title: string;\n author: string;\n series: string;\n seriesIndex: number | null;\n tags: string[];\n format: string;\n language: string;\n coverUrl: string | null;\n fileSize: number;\n pageCount: number | null;\n source: string;\n manualFields: string[];\n addedAt: number;\n updatedAt: number;\n}\n\nexport interface ManifestResponse {\n book: BookDto;\n contentUrl: string;\n coverUrl: string | null;\n files: Array<{ rel_path: string; size: number; missing: number }>;\n kind?: BookContent['kind'];\n total?: number;\n groups?: ContentGroup[];\n items?: ContentItem[];\n content: BookContent | null;\n}\n\nexport interface ProgressDto {\n bookId: string;\n locator: string;\n percentage: number;\n chapterTitle: string;\n device: string;\n updatedAt: number;\n}\n\nexport interface Session {\n accessToken: string;\n refreshToken: string;\n accessTokenExpiresAt: number;\n refreshTokenExpiresAt: number;\n}\n\nexport interface ApiErrorBody {\n error: { code: string; message: string };\n}\n\n/** Raised for a non-2xx response, carrying the server's error code. */\nexport class ApiError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n ) {\n super(message);\n this.name = 'ApiError';\n }\n}\n\nexport interface ApiClientOptions {\n /** Server origin. Empty means \"same origin\", which is the H5 deployment. */\n baseUrl?: string;\n /** Called when a request fails with 401 and a refresh is not possible. */\n onAuthLost?: () => void;\n /** Persistence for the session; defaults to localStorage when available. */\n storage?: SessionStorage;\n}\n\nexport interface SessionStorage {\n read(): Session | null;\n write(session: Session | null): void;\n}\n\n/** Bounded parallel requests: enough to prefetch, not enough to flood. */\nconst MAX_INFLIGHT = 4;\n\nexport class ApiClient {\n private session: Session | null;\n private refreshInFlight: Promise<Session | null> | null = null;\n private inflight = 0;\n private queue: Array<() => void> = [];\n private readonly baseUrl: string;\n\n constructor(private readonly options: ApiClientOptions = {}) {\n this.baseUrl = (options.baseUrl ?? '').replace(/\\/$/, '');\n this.session = (options.storage ?? defaultStorage()).read();\n }\n\n get currentSession(): Session | null {\n return this.session;\n }\n\n /** Drop the session and tell the host, so it can show a login screen. */\n signOut(): void {\n this.session = null;\n (this.options.storage ?? defaultStorage()).write(null);\n this.options.onAuthLost?.();\n }\n\n private setSession(session: Session | null): void {\n this.session = session;\n (this.options.storage ?? defaultStorage()).write(session);\n }\n\n async login(username: string, password: string): Promise<Session> {\n const session = await this.request<Session>('POST', '/api/v1/auth/login', {\n body: { username, password },\n anonymous: true,\n }Line truncated
+2 -2
View File
@@ -15,8 +15,8 @@
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#ffffff" />
<title>书库</title>
<script type="module" crossorigin src="./assets/index-D6yU5C0y.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BGTmnE8-.css">
<script type="module" crossorigin src="./assets/index-C5iXpQfY.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Ch5PggpO.css">
</head>
<body>
<div id="app"></div>
+30
View File
@@ -11,6 +11,7 @@
"devDependencies": {
"@types/jsdom": "^30.0.0",
"jsdom": "^30.0.1",
"playwright": "^1.63.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vite": "^6.0.7"
@@ -1393,6 +1394,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+1
View File
@@ -15,6 +15,7 @@
"devDependencies": {
"@types/jsdom": "^30.0.0",
"jsdom": "^30.0.1",
"playwright": "^1.63.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vite": "^6.0.7"
+1
View File
@@ -50,6 +50,7 @@ function showAuth(): void {
}
async function showShelf(): Promise<void> {
shelf.mount();
await shelf.load(true);
}
+39 -3
View File
@@ -42,6 +42,14 @@ export interface BookContent {
group?: number;
}
/** One entry of a book's own navigation. */
export interface TocEntry {
href: string;
title: string;
level: number;
spine?: number;
}
export interface BookDto {
id: string;
title: string;
@@ -126,7 +134,8 @@ export class ApiClient {
private refreshInFlight: Promise<Session | null> | null = null;
private inflight = 0;
private queue: Array<() => void> = [];
private readonly baseUrl: string;
/** Server origin, without a trailing slash. Empty means same origin. */
readonly baseUrl: string;
constructor(private readonly options: ApiClientOptions = {}) {
this.baseUrl = (options.baseUrl ?? '').replace(/\/$/, '');
@@ -183,6 +192,19 @@ export class ApiClient {
return this.request('GET', `/api/v1/books/${encodeURIComponent(bookId)}/manifest${query}`);
}
/**
* The book's table of contents.
*
* A separate call from `manifest` because a TOC has to be complete to be
* useful, while the manifest is deliberately windowed. Fetching the manifest
* of a 1200-chapter book to show its contents would be the one thing windowing
* exists to prevent.
*/
async toc(bookId: string): Promise<TocEntry[]> {
const result = await this.request<{ toc: TocEntry[] }>('GET', `/api/v1/books/${encodeURIComponent(bookId)}/toc`);
return result.toc;
}
/** One window of items, for a book already open. */
async items(bookId: string, group?: number): Promise<BookContent> {
const query = group === undefined ? '' : `?group=${group}`;
@@ -211,9 +233,23 @@ export class ApiClient {
return this.url(`/api/v1/books/${encodeURIComponent(bookId)}/content`);
}
/** A URL a plain `<img>` can load. Cover images are per-book and immutable. */
/**
* A URL a plain `<img>` can load.
*
* The token travels in the query because an `<img src>` cannot carry a header —
* the same constraint that shapes `render/asset-url.ts`, and the same bounded
* exposure: the server only accepts a query token on read endpoints for
* immutable content, and a cover is addressed by content hash.
*
* Fetching the cover in JavaScript and handing over a Blob URL would keep the
* token out of the URL, at the cost of buffering every cover of a 2000-book
* shelf before any of them can render — which is exactly what lazy loading
* exists to avoid.
*/
coverUrl(bookId: string): string {
return this.url(`/api/v1/books/${encodeURIComponent(bookId)}/cover`);
const token = this.session?.accessToken;
const path = `/api/v1/books/${encodeURIComponent(bookId)}/cover`;
return this.url(token ? `${path}?access_token=${encodeURIComponent(token)}` : path);
}
async shelf(params: { search?: string; page?: number; pageSize?: number } = {}): Promise<{
+127
View File
@@ -0,0 +1,127 @@
/**
* Asset URLs that a browser can fetch on its own.
*
* A chapter document is rendered by the browser, which then requests its
* `<img src>`, `<link href>` and `@font-face` resources itself. Those requests
* cannot carry an `Authorization` header — there is no API to attach one to an
* element's URL.
*
* The two available answers are both bad in different ways:
*
* 1. **Fetch everything in JavaScript and hand out Blob URLs.** This works, and
* it means the client downloads and buffers every image in a chapter before
* the book can render, defeats the browser's own caching, and makes an
* `iframe`'s range requests for a large plate image impossible. For a chapter
* with twelve illustrations it is several megabytes of JavaScript-side work on
* every turn.
* 2. **Put the token in the URL.** The browser's own caching, range requests and
* parallel image loading all keep working, and the exposure is bounded by the
* server only accepting a query token on read endpoints for immutable content.
*
* The second is what this module does, and the server's `queryTokenAllowed` is
* the other half of the arrangement — it is an allowlist, not a blanket rule, so
* a leaked URL cannot trigger a scan or write metadata.
*
* Tokens in URLs do leak: into browser history, into a proxy's access log, into a
* `Referer` header. Three things keep that acceptable here. Asset URLs are only
* ever built for a document that is being displayed in the reading frame, never
* for an outbound link. They are scoped to a single book and expire with the
* access token. And the endpoint they reach serves immutable content addressed by
* content hash — the worst a leaked URL gives away is a page of a book whose
* reader already had it.
*/
import type { ApiClient } from './api.ts';
/**
* Rewrite the asset URLs inside a chapter document to carry the token.
*
* The server has already made every relative reference absolute, so this only has
* to append a parameter to the URLs that point back at the asset endpoint. URLs
* that already carry `access_token` are left alone, which makes the function
* idempotent — it runs on every chapter, including one served from the prefetch
* cache.
*
* `DOMParser` rather than a regex: the document is XHTML and its markup is the
* publisher's, so entity handling and attribute quoting are the parser's job, not
* a pattern's. The parse is done once per chapter and the serialization is what
* the frame receives anyway.
*/
export function withAssetToken(html: string, api: ApiClient): string {
const token = api.currentSession?.accessToken;
if (!token) return html;
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'application/xhtml+xml');
// A parse error means the chapter is not well-formed XML. The container reports
// that to the reader with the HTML fallback; here it just means no rewriting,
// because rewriting a document we could not parse risks making it worse.
if (doc.querySelector('parsererror')) return html;
let touched = false;
const attributes = ['src', 'href', 'poster'] as const;
for (const attribute of attributes) {
for (const element of Array.from(doc.querySelectorAll(`[${attribute}]`))) {
const value = element.getAttribute(attribute);
if (!value || !isApiAsset(value, api)) continue;
if (hasToken(value)) continue;
element.setAttribute(attribute, appendToken(value, token));
touched = true;
}
}
// `style` attributes and `<style>` blocks can carry `url(...)` too, which is
// how a publisher ships a background image.
for (const element of Array.from(doc.querySelectorAll('[style]'))) {
const value = element.getAttribute('style');
if (!value) continue;
const rewritten = rewriteCssUrls(value, api, token);
if (rewritten !== value) {
element.setAttribute('style', rewritten);
touched = true;
}
}
for (const style of Array.from(doc.querySelectorAll('style'))) {
const value = style.textContent;
if (!value) continue;
const rewritten = rewriteCssUrls(value, api, token);
if (rewritten !== value) {
style.textContent = rewritten;
touched = true;
}
}
if (!touched) return html;
return `<?xml version="1.0" encoding="utf-8"?>\n${new XMLSerializer().serializeToString(doc.documentElement)}`;
}
/** Whether a URL points at this instance's asset endpoint. */
function isApiAsset(value: string, api: ApiClient): boolean {
if (value.startsWith('/api/')) return true;
const base = api.baseUrl;
return base.length > 0 && value.startsWith(`${base}/api/`);
}
function hasToken(value: string): boolean {
return value.includes('access_token=');
}
function appendToken(value: string, token: string): string {
const [path, fragment] = splitFragment(value);
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}access_token=${encodeURIComponent(token)}${fragment}`;
}
/** Rewrite `url(...)` inside a CSS string, leaving anything else untouched. */
function rewriteCssUrls(css: string, api: ApiClient, token: string): string {
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (full, quote: string, value: string) => {
if (!isApiAsset(value, api) || hasToken(value)) return full;
return `url(${quote}${appendToken(value, token)}${quote})`;
});
}
function splitFragment(value: string): [string, string] {
const index = value.indexOf('#');
if (index < 0) return [value, ''];
return [value.slice(0, index), value.slice(index)];
}
+245 -180
View File
@@ -6,232 +6,297 @@
* ============================================================================
*
* The user asked whether a pure-H5 build needs an iframe or a Shadow DOM for
* strict epub resources. The answer is **Shadow DOM, and not for the reason it
* is usually recommended**. Working through why is worth doing, because the
* usual reason ("style isolation") is the one that does not hold here.
* strict epub resources. The short answer is **Shadow DOM for the outer
* document, and one `srcdoc` iframe per chapter for the book itself.** That is
* not a hedge; the two problems are genuinely different and each primitive
* solves exactly one of them.
*
* ## Shadow DOM is NOT isolation from the publisher's stylesheet
* ## What Shadow DOM does and does not do
*
* A shadow root blocks the *document's* rules from matching into it, but the
* publisher's stylesheet is not "the document's rules" — it is a chapter asset
* that has to end up inside the shadow root to do anything at all. So:
* It is NOT isolation from the publisher's stylesheet, and anyone who reaches for
* it expecting that has misread the primitive. The publisher's CSS is not "the
* document's rules" — it is a chapter asset that has to end up inside the root to
* do anything at all. Inside, it styles the book exactly as it would in an
* iframe. The book must style itself; that is the product.
*
* - A shadow root gives zero protection from a publisher's `p { margin: 2em }`,
* `body { writing-mode: vertical-rl }` or `* { float: left }`. Those load
* inside the root, next to the markup they style, exactly as they would in an
* iframe. Anyone who reaches for Shadow DOM to "not get affected by the
* book's CSS" has misread the primitive: the book must style itself, that is
* the product.
* - It also gives zero protection from `@font-face` and `@page`, which are
* scoped to the document either way in practice.
* What it *does* provide, and the reason it is the outer container:
*
* The style isolation Shadow DOM *does* provide is the direction that actually
* matters here: the client's own UI styles cannot leak in and restyle a
* footnote or a blockquote. And, critically, `all: unset` / `!important`
* overrides in the client's CSS cannot silently flatten a publisher's layout —
* which is the failure mode this product exists to avoid.
* - The client's own UI rules cannot leak in and restyle a footnote or a
* blockquote. `all: unset` and `!important` in the shell's CSS are contained,
* which is precisely the failure mode this product exists to avoid.
* - Per-chapter resources are discarded with the chapter, so a book whose
* chapters each link their own stylesheet cannot corrupt later chapters.
* - The shell keeps its own DOM: dialogs, gestures, overlays and selection
* handling all live outside the root and cannot be matched by a book's `*`
* selector.
*
* ## What a shadow root buys beyond that
* ## Why the book itself is an iframe
*
* - **Per-chapter styles do not accumulate.** Each chapter's shadow root is
* discarded with it. An iframe has the same property, but at a much higher
* cost (see below). Without either, a book whose chapters each link their own
* CSS would corrupt later chapters with earlier chapters' remnants.
* - **No document per chapter.** An iframe is a nested browsing context: its own
* document, its own layout, resize and scripting lifecycles. Replacing one is
* costly enough to be visible on a 200-chapter book, and each iframe needs its
* own scroll container, focus handling and (on Android WebView) its own
* compositing layer.
* - **Same JS context.** `scrollLeft`, `offsetLeft`, `getBoundingClientRect`
* are directly queryable. Cross-frame layout reads need `postMessage` plus a
* second implementation of the paginator inside the frame, which is the real
* reason cross-frame readers end up duplicating logic.
* - **Text selection and highlight are plain DOM work.** Selecting across a
* frame boundary is impossible; a highlight library that must choose between
* frames will inevitably pick one and mishandle the other.
* This is the part that the first version of this module got wrong, and the
* browser test is what caught it. A chapter document is not a fragment: it has a
* `<head>` with `<link rel=stylesheet>` and a `<body>` with `<img src>`. Those
* are **sub-resource requests**, and they are issued by the browser, not by our
* code. Two consequences:
*
* ## So what handles the *actual* problems the question was pointing at
* 1. **They cannot carry an `Authorization` header.** There is no API to attach
* a request header to an `<img>` or a `<link>`. Injecting the chapter's CSS
* and images into the host document therefore breaks every one of them with a
* 401 — the book renders as unstyled text with no pictures, which is exactly
* what happened before this file was rewritten.
* 2. **They resolve against the document's base URL.** The chapter's `src` is
* already rewritten to an absolute asset URL by the server, so this is
* satisfied — but only because it is a real document with a real base.
*
* Strict epubs break WebView rendering in three specific ways, and none of them
* are solved by choosing a container. They are solved by what the container is
* given:
* A shadow root solves neither. An `iframe` solves both: it is a real browsing
* context, so its `src`/`href` requests are ordinary navigations that can carry
* whatever URL (including a token query parameter) they were written with, and
* its base URL is its own.
*
* 1. **Absolute resource paths.** The server already rewrites relative
* `src`/`href` in the chapter and keeps absolute URLs, `data:` URIs and
* fragment anchors untouched. That is what makes the document standalone.
* 2. **XML, not HTML.** Strict epubs are XHTML with proper namespaces, and
* `innerHTML` in an HTML parser is remarkably forgiving in ways that matter:
* it drops unknown namespaced elements, and it mishandles self-closing tags
* on void elements like `<br/>`. Fetching the chapter as a string and
* handing it to `DOMParser.parseFromString(text, 'application/xhtml+xml')`
* keeps the document as XML. A parse error is real XML, reported rather than
* silently half-rendered.
* 3. **No document-level `html`/`body` styling.** The `<html>` and `<body>`
* rules in a chapter are written for a full window. Inside a reader the
* container supplies its own scroller, so `body { height: 100vh }` or
* `html { background: #000 }` would fight it. The chapter's roots are
* unwrapped: the top-level blocks are moved into the shadow root and the
* `<html>`/`<body>` elements themselves are dropped. What is NOT touched is
* anything with a selector of its own — no `div` is unwrapped, no publisher
* rule is rewritten or removed.
* `srcdoc` rather than a Blob URL: a Blob URL's origin is opaque, so the frame's
* requests would be cross-origin and the asset endpoint would refuse them. A
* `srcdoc` frame inherits the parent's origin, which is what makes the rewritten
* absolute URLs work without CORS.
*
* ## When an iframe would be the right call
* ## What an iframe costs, and why it is worth paying
*
* Not never. An iframe wins if a book needs its own `window` — a fixed layout
* epub with scripted interactivity, or a PDF.js viewer, which expects a frame it
* can own. The comic and PDF renderers below do use one for the PDF case, for
* exactly that reason. For reflowable text, a frame would buy nothing and cost a
* second layout pipeline.
* A frame is a nested browsing context with its own layout and lifecycle. That
* cost is only paid per chapter — one frame is reused and its `srcdoc` replaced,
* not one frame per chapter — and it buys the guarantee that a chapter is a
* *document*, rendered by an engine that has been rendering documents for thirty
* years, rather than a fragment we hope is equivalent.
*
* The paginator therefore drives the inner document's scroller. That is the same
* amount of work as driving a local one, and it is the only arrangement in which
* `document.styleSheets`, `@page`, `@font-face` and `:root` in a book's CSS mean
* what the publisher intended — `:root` inside a shadow root refers to the shadow
* root itself, not to an element the client can set variables on, which is the
* second bug the browser test caught.
*/
/** Result of mounting a chapter. */
/** A mounted chapter. */
export interface MountedChapter {
/** The element the publisher's content ended up in. */
content: HTMLElement;
/** The scroll container the paginator drives. */
viewport: HTMLElement;
/** Non-fatal problems worth surfacing (an XML error, a missing resource). */
warnings: string[];
}
export interface ContainerOptions {
/** Gap between columns in CSS pixels; must match the stylesheet. */
columnGap?: number;
/** Document the chapter was loaded into, once it has loaded. */
document(): Document | null;
/** The element the paginator scrolls. */
scroller(): HTMLElement;
}
/**
* The scrolling viewport that chapters are mounted into.
* The reading frame.
*
* Kept as a class so the tricky parts — replacing a chapter without losing the
* shadow root's styles, and reporting an XML error instead of rendering nothing
* — live in one place.
* One `iframe`, reused across chapters. `srcdoc` is replaced rather than the
* frame being recreated: tearing down and rebuilding a browsing context per
* chapter is measurable on a 200-chapter book, and a reused frame keeps the
* same compositing layer.
*/
export class ChapterContainer {
readonly viewport: HTMLDivElement;
private root: ShadowRoot;
private current: HTMLElement | null = null;
readonly host: HTMLElement;
private frame: HTMLIFrameElement;
private ready: Promise<void> | null = null;
constructor(private readonly host: HTMLElement) {
this.viewport = document.createElement('div');
this.viewport.className = 'reader-viewport';
this.host.append(this.viewport);
// `open` rather than `closed`: the client's own highlight and search code
// must be able to walk the tree, and closed roots are a speed bump, not a
// security boundary (the API to reach them is public).
this.root = this.viewport.attachShadow({ mode: 'open' });
constructor(host: HTMLElement) {
this.host = host;
this.frame = document.createElement('iframe');
this.frame.className = 'reader-frame';
// Set inline as well as in the stylesheet: this element's default sizing
// (`display: inline`, 2px border) is wrong in a way that breaks pagination
// rather than merely looking off, and a stylesheet that fails to load must
// not be able to reintroduce it.
this.frame.style.display = 'block';
this.frame.style.width = '100%';
this.frame.style.height = '100%';
this.frame.style.border = '0';
// Same-origin is required for the client to read the chapter's layout for
// pagination and text selection. There is no script in a chapter document
// that could exploit it: `allow-scripts` is deliberately absent, so a book's
// own `<script>` cannot run even if it has one.
this.frame.setAttribute('sandbox', 'allow-same-origin');
this.frame.setAttribute('title', '正文');
host.replaceChildren(this.frame);
}
/**
* Replace the mounted chapter.
* Load a chapter document.
*
* `xml` must be the chapter document as text. The container does not fetch it:
* loading and mounting are separated so the caller can cache, prefetch and
* order requests without knowing anything about the DOM.
* The server has already made the document self-contained: relative resource
* references are rewritten to absolute asset URLs, so the frame needs nothing
* from us beyond the markup and the token the URLs carry.
*/
mount(xml: string, options: ContainerOptions = {}): MountedChapter {
const warnings: string[] = [];
const doc = parseChapter(xml, warnings);
const content = buildContent(doc, warnings);
async mount(html: string, settings: ReaderStyle): Promise<MountedChapter> {
const doc = this.frame.contentDocument;
if (!doc) throw new Error('chapter frame has no document');
// Styles first, content second: the publisher's `@import` order decides the
// cascade, and moving them after the markup would not change that but does
// make the document read in the reverse of its natural order.
this.root.replaceChildren(...collectStyles(doc), content);
this.current = content;
const styled = injectReaderStyle(html, settings, this.width());
this.ready = waitForLoad(this.frame);
this.frame.srcdoc = styled;
await this.ready;
if (!options.columnGap) return { content, viewport: this.viewport, warnings };
return { content, viewport: this.viewport, warnings };
const chapter = this.frame.contentDocument;
if (!chapter) throw new Error('chapter frame lost its document');
return {
document: () => this.frame.contentDocument,
scroller: () => (chapter.scrollingElement ?? chapter.documentElement) as HTMLElement,
};
}
/** The element the paginator scrolls: the chapter's own scrolling element. */
scroller(): HTMLElement | null {
const doc = this.frame.contentDocument;
if (!doc?.body) return null;
return (doc.scrollingElement ?? doc.documentElement) as HTMLElement;
}
/** The chapter's document, once it has loaded. */
document(): Document | null {
return this.frame.contentDocument;
}
/**
* The mounted content element, for the paginator and for highlight work.
* Width of one page.
*
* Exposed deliberately: highlights are stored as a text quote plus a location,
* and both have to be resolved against this element. Hiding it would push the
* querySelector logic into every consumer.
* Read from the frame rather than from the host: the frame is the element the
* document actually lays out in, and a host with padding or a scrollbar would
* give a different answer than the page geometry the CSS computed.
*/
get content(): HTMLElement | null {
return this.current;
width(): number {
// `clientWidth` is 0 before the frame has been laid out, which happens on the
// first mount. Falling back to the host keeps the first chapter from being
// paginated against a zero-width page.
return this.frame.clientWidth || this.host.clientWidth || 360;
}
/** Drop the mounted chapter and release its styles. */
clear(): void {
this.root.replaceChildren();
this.current = null;
destroy(): void {
this.host.replaceChildren();
}
}
/** The subset of reader settings a chapter document needs. */
export interface ReaderStyle {
fontSize: number;
pageWidth: number;
columnGap: number;
pagePadding: number;
background: string;
text: string;
}
/**
* Parse a chapter as XML.
* Build the complete chapter document.
*
* `application/xhtml+xml` is not cosmetic: the HTML parser silently repairs
* broken markup by *discarding* it, so a strict epub with a namespace typo would
* render as a chapter missing a paragraph and nobody would know why. The XML
* parser reports it. The caller still shows whatever parsed, because a book with
* one bad chapter should be readable.
* Everything the document needs is inlined, because an `srcdoc` frame has no
* base URL of its own to resolve a client stylesheet against — and because a
* chapter that loaded its reader CSS from the network would be unstyled for one
* frame, which is visible on every chapter turn.
*
* The styles appended last are the reader's own, and they are deliberately
* minimal: page geometry, and a font size the book may override. See
* `render/theme.ts` for why the list stops there.
*/
export function parseChapter(xml: string, warnings: string[]): Document {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'application/xhtml+xml');
const failure = doc.querySelector('parsererror');
if (failure) {
warnings.push(failure.textContent?.trim().slice(0, 200) || 'chapter is not well-formed XML');
// Fall back to the HTML parser rather than showing a blank page. The warning
// is what tells the reader (and us, in a bug report) that it happened.
return parser.parseFromString(xml, 'text/html');
}
return doc;
function injectReaderStyle(html: string, settings: ReaderStyle, frameWidth: number): string {
const style = `<style id="reader-page">${pageCss(settings, frameWidth)}</style>`;
const withViewport = /<head[^>]*>/i.test(html)
? html.replace(/<head([^>]*)>/i, `<head$1>${viewportMeta()}`)
: html;
if (/<\/head>/i.test(withViewport)) return withViewport.replace(/<\/head>/i, `${style}</head>`);
return `${style}${withViewport}`;
}
/** A phone-width viewport, so a chapter authored for a desktop window reflows. */
function viewportMeta(): string {
return '<meta name="viewport" content="width=device-width, initial-scale=1">';
}
/**
* Everything the publisher linked, in document order.
* Page geometry for a chapter.
*
* `<link rel=stylesheet>` is included because epubs routinely use it instead of
* `<style>`. The URLs are already absolute by the time the chapter reaches the
* client, so they resolve inside the shadow root without a `<base>` element —
* and `<base>` would be the wrong tool anyway, since a shadow root does not use
* the document's base URL.
* `column-width` is what creates pages; the frame's scroller then moves between
* them by `scrollLeft`, exactly as it would for a horizontal list. Nothing here
* selects a content element, so nothing here can flatten a publisher's layout.
*
* ## The height that makes columns work
*
* `column-fill: auto` only fragments content when the container has a *definite*
* height. Left alone, `html` and `body` size to their content, the computed
* height becomes the content's own height, and the whole chapter lays out in a
* single column as tall as the text — `scrollWidth` equals `clientWidth` and
* every "page" is the entire chapter. That is what this file did before the
* browser test looked at `scrollWidth`, and it is not a subtle failure: the
* reader shows one page per chapter and the page-turn gesture does nothing.
*
* So the document is pinned to the frame's viewport height: 100vh inside an
* iframe is the frame's own viewport, which is exactly the page height wanted.
* The body deliberately does NOT clip its overflow — clipping it also clips the
* columns it generated, so content past the first page never becomes scrollable
* and the chapter reports a single page.
*
* The one rule that does touch content is `font-size` on `html`, and it is
* expressed so a book can win: a book that sets an absolute size keeps it, and a
* book that uses relative sizes scales with the reader's choice. That is the only
* way a font-size control can work without overriding the book.
*/
function collectStyles(doc: Document): HTMLElement[] {
const nodes: HTMLElement[] = [];
for (const style of Array.from(doc.querySelectorAll('style'))) {
const copy = document.createElement('style');
copy.textContent = style.textContent;
nodes.push(copy);
}
for (const link of Array.from(doc.querySelectorAll('link[rel~="stylesheet" i]'))) {
const href = link.getAttribute('href');
if (!href) continue;
const copy = document.createElement('link');
copy.setAttribute('rel', 'stylesheet');
copy.setAttribute('href', href);
nodes.push(copy);
}
return nodes;
function pageCss(settings: ReaderStyle, frameWidth: number): string {
// `pageWidth` is a CEILING on how wide a page may be, not the width itself: on
// a 390px phone a 720px column would lay the whole chapter out in one column
// wider than the screen, and every page turn would scroll nowhere. The column
// takes the smaller of the reader's preference and the space that exists.
const width = Math.max(80, Math.min(settings.pageWidth, frameWidth));
const usable = Math.max(80, width - settings.pagePadding * 2);
return `
html {
font-size: ${settings.fontSize}px;
background: ${settings.background};
color: ${settings.text};
/* The document is the scroll container; the frame is sized to one page. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
/* No scroll-snap here. Snapping to the columns sounds right and is not: the
snap target is each column's own edge, so a programmatic scrollLeft lands
wherever the browser decides the nearest snap point is — and a snap point
inside a column is not a page boundary. The paginator sets scrollLeft
explicitly, and a gesture handler keeps user swipes on the same grid. */
}
html::-webkit-scrollbar { display: none; }
body {
/* Columns are the pagination: one column is exactly one page. The column
width is set below in pixels rather than left to the publisher's rules, and
nothing else about the body typography is touched. */
column-width: ${usable}px;
column-gap: ${settings.columnGap}px;
column-fill: auto;
/* A definite height is what lets the content fragment into columns at all. */
height: 100vh;
/* No overflow: hidden here. Clipping the body also clips the columns it
generated, so content past the first page never becomes scrollable and the
chapter reports a single page. The document's own scroller is what has to
see the overflow. */
margin: 0;
padding: 0 ${settings.pagePadding}px;
box-sizing: border-box;
/* No forced font-family and no line-height: the publisher's body rules decide
the typography, as they should. */
}
img, svg {
/* An image that overflows its column breaks the geometry for the whole
chapter, not just for itself. Clamping is the one place where fidelity has
to give way, and it is stated here rather than hidden in a reset. */
max-width: 100%;
max-height: 90vh;
object-fit: contain;
break-inside: avoid;
}
`;
}
/**
* Move the chapter's blocks into a single element.
*
* The `<html>` and `<body>` wrappers are dropped, as explained in the module
* comment. Everything below them is moved verbatim — no class is rewritten, no
* element is unwrapped, no attribute is added. The publisher's structure is the
* book.
*/
function buildContent(doc: Document, warnings: string[]): HTMLElement {
const host = document.createElement('div');
host.className = 'reader-chapter';
const source = doc.body ?? doc.documentElement;
if (!source) {
warnings.push('chapter has no body');
return host;
}
// `importNode` with `deep: true` copies nodes into this document, which is what
// makes the shadow root able to hold them. Moving them directly would work for
// nodes already in this document, but a chapter parsed by DOMParser is in its
// own document and must be adopted.
for (const child of Array.from(source.childNodes)) {
host.append(document.importNode(child, true));
}
return host;
/** Resolve when a frame finishes loading its `srcdoc`. */
function waitForLoad(frame: HTMLIFrameElement): Promise<void> {
return new Promise((resolve) => {
const done = (): void => {
frame.removeEventListener('load', done);
// One animation frame after `load`, so the first layout has happened and
// the paginator's `scrollWidth` is not the pre-layout value.
requestAnimationFrame(() => resolve());
};
frame.addEventListener('load', done);
});
}
+89 -53
View File
@@ -5,23 +5,30 @@
*
* Continuous scrolling is what every web reader does, and it is the wrong model
* for a book. A reader's position is measured in pages: the progress indicator,
* "12 pages left in this chapter", turning back to re-read a paragraph — all of
* it assumes a fixed page. Scroll position is a pixel offset that changes meaning
* turning back to re-read a paragraph, "3 pages left in this chapter" — all of it
* assumes a fixed page. A scroll offset is a pixel number that changes meaning
* when the font size, viewport or column width changes, which is exactly the set
* of things a reader adjusts.
*
* ## Why CSS multi-column instead of measuring by hand
*
* The alternative is to wrap every word in a span and do a binary search for the
* page break. That rewrites the publisher's markup, which this product refuses to
* do, and it is slow enough to be felt on a chapter with images. Multi-column
* layout is the browser's own implementation of the same thing: the content is
* laid out once, columns are created, and a page is a horizontal scroll offset.
* The alternative is to wrap every word in a span and binary-search the page
* break. That rewrites the publisher's markup, which this product refuses to do,
* and it is slow enough to feel on a chapter with images. Multi-column layout is
* the browser's own implementation of the same thing: the content is laid out
* once, columns are created, and a page is a horizontal scroll offset.
*
* The cost is that pagination is done by the layout engine, so page boundaries
* land wherever the engine puts them. That is acceptable — and it is what every
* commercial reader does too — as long as a *position* survives a repagination,
* which the anchor tolerance below handles.
* The cost is that page boundaries land wherever the layout engine puts them.
* That is acceptable — and it is what every commercial reader does — as long as a
* *position* survives repagination, which is what the anchor functions below are
* for.
*
* ## Why the scroller is a parameter
*
* The chapter lives in an iframe (see `container.ts`), so the element being
* scrolled belongs to another document. Taking it as a parameter rather than
* reaching for a global keeps this class testable and keeps the frame's existence
* out of the pagination arithmetic.
*/
/** A position inside a chapter, anchored to a DOM node so it survives repagination. */
@@ -33,43 +40,61 @@ export interface ChapterPosition {
}
export interface PaginatorOptions {
/** Gap between columns, in CSS pixels. Must match the stylesheet. */
/** Gap between columns, in CSS pixels. Must match the injected stylesheet. */
columnGap?: number;
/** Extra page padding, in CSS pixels, on each side. */
/** Page padding, in CSS pixels, on each side. Must match the stylesheet. */
pagePadding?: number;
}
/**
* Drive a single chapter's viewport.
*
* Deliberately owns no DOM: it is handed the scrolling element and reports
* positions. Keeping it free of element creation means the same class can be
* unit tested without a browser, which matters because pagination bugs (a
* position that drifts one page forward on every resize) are only reproducible
* against a real layout engine and are otherwise very hard to pin down.
* Drive a chapter's scrolling element.
*/
export class Paginator {
private readonly columnGap: number;
private readonly pagePadding: number;
/** Column stride: the exact distance between two page origins. */
private stride = 0;
private pageCount = 1;
private current = 0;
constructor(private readonly viewport: HTMLElement, options: PaginatorOptions = {}) {
constructor(private scroller: HTMLElement | null, options: PaginatorOptions = {}) {
this.columnGap = options.columnGap ?? 32;
this.pagePadding = options.pagePadding ?? 24;
void options.pagePadding;
}
/** Recompute the column geometry. Call after a resize or a font change. */
layout(pageWidth = this.viewport.clientWidth): { pageCount: number; stride: number } {
const usable = Math.max(1, pageWidth - this.pagePadding * 2);
/** Point the paginator at a (new) chapter document. */
attach(scroller: HTMLElement | null): void {
this.scroller = scroller;
this.reset();
}
/**
* Recompute the column geometry.
*
* `scrollWidth` is the layout engine's answer to "how much content is there",
* and dividing it by the stride is the only way to learn the page count without
* duplicating the engine's work. The gap is added back because `scrollWidth`
* omits the trailing gap that separates the last column from the edge.
*/
layout(pageWidth?: number): { pageCount: number; stride: number } {
const scroller = this.scroller;
if (!scroller) {
this.stride = 0;
this.pageCount = 1;
return { pageCount: 1, stride: 0 };
}
const usable = pageWidth ?? scroller.clientWidth;
// The stride is the distance between two page origins. Deriving it from the
// *viewport* rather than from the columns keeps it independent of
// `column-width`, which the publisher's own CSS could influence.
this.stride = Math.max(1, usable + this.columnGap);
// `scrollWidth` is the browser's answer to "how much content is there", and
// dividing it by the stride is the only way to learn the page count without
// duplicating the layout engine's work.
const scrollWidth = this.viewport.scrollWidth;
this.pageCount = Math.max(1, Math.round((scrollWidth + this.columnGap) / this.stride));
// The page count comes from the content's real width rather than from a
// division: `scrollWidth` includes the leading page padding and the column
// gutters, so dividing it by the stride loses the remainder and drops the
// last page of a chapter whose content does not fill it exactly. Rounding the
// division up against the *padded* width is what keeps that page reachable.
const padded = usable + this.columnGap;
this.pageCount = Math.max(1, Math.ceil((scroller.scrollWidth - this.columnGap) / padded));
return { pageCount: this.pageCount, stride: this.stride };
}
@@ -86,11 +111,17 @@ export class Paginator {
const next = Math.max(0, Math.min(this.pageCount - 1, Math.floor(page)));
if (next === this.current) return false;
this.current = next;
this.viewport.scrollLeft = next * this.stride;
if (this.scroller) this.scroller.scrollLeft = next * this.stride;
return true;
}
/** Move by a relative number of pages. Returns the leftover for chapter turns. */
/**
* Move by a relative number of pages.
*
* `spill` is the number of pages that did not fit; the caller turns it into a
* chapter turn. Returning it rather than swallowing it is what makes "the next
* page past the end of this chapter" a chapter turn instead of a dead gesture.
*/
turn(pages: number): { changed: boolean; spill: number } {
const target = this.current + pages;
if (target < 0) return { changed: this.goTo(0), spill: target };
@@ -105,7 +136,7 @@ export class Paginator {
reset(): void {
this.current = 0;
this.pageCount = 1;
this.viewport.scrollLeft = 0;
if (this.scroller) this.scroller.scrollLeft = 0;
}
/** Page index for a horizontal scroll offset (e.g. after a user swipe). */
@@ -131,22 +162,25 @@ export function pageFraction(page: number, pageCount: number): number {
/**
* Capture a position that survives repagination.
*
* The page index does not: the same page index means a different part of the
* text after a font size change. Anchoring to the top-level block at the page's
* leading edge (plus how far into it) means a reader who resizes the text stays
* on the sentence they were reading, which is the entire reason readers tolerate
* changing the font size at all.
* The page index does not: the same index means a different part of the text
* after a font-size change. Anchoring to the top-level block at the page's
* leading edge, plus how far into that block, means a reader who resizes the
* text stays on the sentence they were reading — which is the entire reason
* readers tolerate changing the font size at all.
*/
export function capturePosition(viewport: HTMLElement, page: number, stride: number): ChapterPosition {
const blocks = topLevelBlocks(viewport);
export function capturePosition(scroller: HTMLElement | null, page: number, stride: number): ChapterPosition {
const blocks = topLevelBlocks(scroller);
if (blocks.length === 0) return { blockIndex: 0, offsetRatio: 0 };
const origin = page * stride;
let index = 0;
for (let i = 0; i < blocks.length; i += 1) {
const block = blocks[i]!;
const left = block.offsetLeft;
if (left <= origin + 1) index = i;
// `offsetLeft` is relative to the offsetParent, which for a chapter's blocks
// is the nearest positioned ancestor. That is stable within a document and
// the only thing that matters here, since capture and restore both happen
// against the same document.
if (block.offsetLeft <= origin + 1) index = i;
else break;
}
@@ -156,9 +190,10 @@ export function capturePosition(viewport: HTMLElement, page: number, stride: num
return { blockIndex: index, offsetRatio: ratio };
}
/** Restore a captured position. Returns the page to land on, or 0 if unmatchable. */
export function restorePosition(viewport: HTMLElement, position: ChapterPosition, stride: number): number {
const blocks = topLevelBlocks(viewport);
/** Restore a captured position. Returns the page to land on. */
export function restorePosition(scroller: HTMLElement | null, position: ChapterPosition, stride: number): number {
const blocks = topLevelBlocks(scroller);
if (blocks.length === 0) return 0;
const block = blocks[Math.min(position.blockIndex, blocks.length - 1)];
if (!block) return 0;
const left = block.offsetLeft + position.offsetRatio * (block.offsetHeight || 0);
@@ -168,12 +203,13 @@ export function restorePosition(viewport: HTMLElement, position: ChapterPosition
/**
* The blocks a position can anchor to.
*
* Only the viewport's direct children are considered. Going deeper would anchor
* to a `<span>` whose offset would change the moment the publisher's CSS nests
* differently, and the anchors have to be stable across the client's own style
* tweaks — those changes must not move the reader's place in the book.
* Only the body's direct children are considered. Going deeper would anchor to a
* `<span>` whose offset changes the moment the publisher's CSS nests differently,
* and anchors have to be stable across the client's own style tweaks — those
* changes must not move the reader's place in the book.
*/
function topLevelBlocks(viewport: HTMLElement): HTMLElement[] {
const root = (viewport.querySelector('.reader-chapter') ?? viewport) as HTMLElement;
return Array.from(root.children).filter((node): node is HTMLElement => node instanceof HTMLElement);
function topLevelBlocks(scroller: HTMLElement | null): HTMLElement[] {
const body = scroller?.ownerDocument?.body;
if (!body) return [];
return Array.from(body.children).filter((node): node is HTMLElement => node instanceof HTMLElement);
}
+62 -17
View File
@@ -46,11 +46,12 @@ import {
toWindow,
type Window,
} from './window.ts';
import { ChapterContainer } from './container.ts';
import { ChapterContainer, type ReaderStyle } from './container.ts';
import { Paginator, capturePosition, pageFraction, restorePosition } from './paginator.ts';
import { PrefetchCache } from './prefetch.ts';
import { Emitter } from '../lib/events.ts';
import { bookPercent, parseLocator, serializeLocator, type Locator } from '../lib/locator.ts';
import { withAssetToken } from '../net/asset-url.ts';
export interface ReaderState {
book: BookDto;
@@ -74,6 +75,8 @@ export interface ReaderOptions {
progressDebounceMs?: number;
/** Host platform, so the reader can adapt gesture expectations. */
platform?: 'web' | 'android';
/** Type size and page width, mirrored into each chapter document. */
style: ReaderStyle;
}
export class BookReader {
@@ -88,15 +91,21 @@ export class BookReader {
private cancelPrefetch: (() => void) | null = null;
private readonly device: string;
private style: ReaderStyle;
constructor(
private readonly host: HTMLElement,
private readonly api: ApiClient,
private readonly book: BookDto,
private readonly options: ReaderOptions = {},
private readonly options: ReaderOptions,
) {
this.device = options.device ?? 'web';
this.style = options.style;
this.container = new ChapterContainer(host);
this.paginator = new Paginator(this.container.viewport);
this.paginator = new Paginator(null, {
columnGap: options.style.columnGap,
pagePadding: options.style.pagePadding,
});
}
/**
@@ -131,9 +140,9 @@ export class BookReader {
return this.current!;
}
/** The container the reader mounted into, exposed for highlight integration. */
get chapterElement(): HTMLElement | null {
return this.container.content;
/** The chapter's document, for highlight and search integration. */
get chapterDocument(): Document | null {
return this.container.document();
}
/** Turn pages; a spill at either end moves to the neighbouring chapter. */
@@ -191,13 +200,43 @@ export class BookReader {
/** Recompute geometry after a resize, keeping the reader's place. */
relayout(): void {
if (!this.current) return;
const stride = this.paginator.layout().stride;
const position = capturePosition(this.container.viewport, this.paginator.index, stride);
const stride2 = this.paginator.layout().stride;
this.paginator.goTo(restorePosition(this.container.viewport, position, stride2));
const geometry = this.paginator.layout(this.container.width());
const position = capturePosition(this.container.scroller(), this.paginator.index, geometry.stride);
const after = this.paginator.layout(this.container.width());
this.paginator.goTo(restorePosition(this.container.scroller(), position, after.stride));
this.publish();
}
/** Update the type size and repaginate, keeping the reader's place. */
async restyle(style: ReaderStyle): Promise<void> {
this.style = style;
if (!this.current) return;
await this.reshow();
}
/**
* Re-render the current chapter from the cache.
*
* Used after a type-size change: a chapter document is styled at load time, and
* the only way to re-style it is to load it again. Reading it from the prefetch
* cache makes this a re-layout rather than a network round trip.
*/
private async reshow(): Promise<void> {
const state = this.current;
if (!state) return;
const item = this.window?.items[state.index];
if (!item) return;
const stride = this.paginator.layout(this.container.width()).stride;
const position = capturePosition(this.container.scroller(), this.paginator.index, stride);
await this.show(state.index, {
chapter: item.href,
spine: toSpineIndex(this.window!, state.index),
block: position.blockIndex,
ratio: position.offsetRatio,
percent: state.pageCount > 1 ? state.page / state.pageCount : 0,
});
}
/**
* Flush the pending progress write.
*
@@ -216,7 +255,7 @@ export class BookReader {
destroy(): void {
this.cancelPrefetch?.();
this.cache.clear();
this.container.clear();
this.container.destroy();
this.state.clear();
}
@@ -232,14 +271,20 @@ export class BookReader {
.get(item.href, () => this.api.asset(this.book.id, item.href))
.then((blob) => blob.text());
const mounted = this.container.mount(xml);
const geometry = this.paginator.layout();
// The chapter's own images and stylesheets are fetched by the browser, not by
// us, so their URLs have to be self-authenticating before the document is
// handed to the frame.
await this.container.mount(withAssetToken(xml, this.api), this.style);
// The scroller belongs to the chapter's own document, so the paginator is
// pointed at it after the frame has loaded and laid out.
this.paginator.attach(this.container.scroller());
const geometry = this.paginator.layout(this.container.width());
// Restoring an intra-chapter position is only meaningful for the chapter it
// was captured in. Landing in a different chapter uses its first page.
const target =
restore && restore.chapter === item.href
? restorePosition(this.container.viewport, { blockIndex: restore.block, offsetRatio: restore.ratio }, geometry.stride)
? restorePosition(this.container.scroller(), { blockIndex: restore.block, offsetRatio: restore.ratio }, geometry.stride)
: 0;
this.paginator.goTo(Math.min(target, geometry.pageCount - 1));
@@ -251,7 +296,7 @@ export class BookReader {
total: window.total,
page: this.paginator.index,
pageCount: this.paginator.total,
warnings: mounted.warnings,
warnings: [],
};
this.scheduleProgress();
this.prefetchFrom(index);
@@ -289,8 +334,8 @@ export class BookReader {
const item = window.items[state.index];
if (!item) return;
const stride = this.paginator.layout().stride;
const position = capturePosition(this.container.viewport, this.paginator.index, stride);
const stride = this.paginator.layout(this.container.width()).stride;
const position = capturePosition(this.container.scroller(), this.paginator.index, stride);
const fraction = pageFraction(this.paginator.index, this.paginator.total);
// The spine index is derived from the loaded window's group, never from the
// window's own array offset: `items` holds one window, so `index` is local.
+1 -1
View File
@@ -65,7 +65,7 @@ export const DEFAULT_SETTINGS: ReaderSettings = {
* whereas a book whose text stays dark on a "dark" background is one the reader
* can see and judge.
*/
const PALETTES: Record<ThemeMode, { background: string; text: string; muted: string }> = {
export const PALETTES: Record<ThemeMode, { background: string; text: string; muted: string }> = {
light: { background: '#ffffff', text: '#111111', muted: '#6b7280' },
sepia: { background: '#f6f0e4', text: '#3b3121', muted: '#8a7c63' },
dark: { background: '#14161a', text: '#c9cdd4', muted: '#7c828c' },
+80 -23
View File
@@ -32,6 +32,8 @@ 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.
@@ -135,6 +137,7 @@ export class ReaderView {
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;
@@ -156,10 +159,27 @@ export class ReaderView {
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') {
// The shell renders it; this view only reports the hand-off so the H5
// bundle does not silently mount a renderer the shell will replace.
this.host.dispatchEvent(new CustomEvent('reader:native-page', { detail: { bookId: this.book.id } }));
this.host.dispatchEvent(
new CustomEvent('reader:native-page', { detail: { bookId: this.book.id, kind } }),
);
return;
}
@@ -196,12 +216,29 @@ export class ReaderView {
}
}
/** Re-apply the reader's settings and repaginate. */
/**
* 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);
this.root.style.setProperty('--reader-font-size', `${this.settings.fontSize}px`);
this.root.style.setProperty('--reader-page-width', `${this.settings.pageWidth}px`);
if (this.reader instanceof BookReader) this.reader.relayout();
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 {
@@ -322,39 +359,59 @@ export class ReaderView {
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 groups = await this.api
.manifest(this.book.id)
.then((manifest) => manifest.groups ?? [])
.catch(() => []);
const entries = await this.api.toc(this.book.id).catch(() => []);
const list = document.createElement('ul');
list.className = 'toc';
if (groups.length === 0) {
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'toc__empty';
empty.textContent = '这本书没有可跳转的章节';
list.append(empty);
} else {
groups.forEach((group, index) => {
entries.forEach((entry) => {
const item = document.createElement('li');
item.className = 'toc__item';
item.textContent = `${group.title}(${group.count})`;
// The group's `offset` is a whole-book index, which is exactly what
// `goToSpine` accepts — no summing of previous counts on the client, so no
// drift when a window was never fetched.
item.textContent = entry.title;
item.style.paddingLeft = `${4 + entry.level * 14}px`;
item.addEventListener('click', () => {
this.closeDialog();
const reader = this.reader;
if (reader instanceof BookReader) void reader.goToSpine(group.offset);
else if (reader instanceof PagedReader) void reader.goTo(0);
else if (reader instanceof TextReader) void reader.goToChapter(index, this.book.id);
else void reader?.setPage(this.book.id, group.offset + 1);
void this.jumpTo(entry);
});
list.append(item);
});
}
this.showDialog('目录', list);
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 {
+23 -2
View File
@@ -24,6 +24,8 @@ export interface ShelfHost {
export class ShelfView {
private readonly root: HTMLElement;
private readonly grid: HTMLElement;
/** Built once, mounted on demand. */
private built = false;
private search = '';
private page = 1;
private total = 0;
@@ -68,7 +70,6 @@ export class ShelfView {
this.grid.className = 'shelf__grid';
this.root.append(header, input, this.grid);
host.replaceChildren(this.root);
// Infinite scroll: a library is browsed by scrolling, and paging controls on
// a phone are a tax on every visit.
@@ -79,6 +80,24 @@ export class ShelfView {
});
}
/**
* Take over the host element.
*
* Mounting is separate from construction because the view is built once, at
* startup, while the auth screen may still own the host. Replacing the host's
* children in the constructor meant the shelf's markup was installed and then
* covered by the dialog that mounted after it — so a successful sign-in left
* the login form on screen with the loaded shelf hidden underneath.
*/
mount(): void {
this.host.replaceChildren(this.root);
this.built = true;
}
get mounted(): boolean {
return this.built;
}
async load(reset: boolean): Promise<void> {
if (this.loading) return;
this.loading = true;
@@ -121,7 +140,9 @@ export class ShelfView {
// that is already scrolling.
cover.loading = 'lazy';
cover.decoding = 'async';
if (book.coverUrl) cover.src = this.api.url(book.coverUrl);
// The DTO carries the server's own path; the token has to come from the
// client, because an `<img src>` cannot send an Authorization header.
if (book.coverUrl) cover.src = this.api.coverUrl(book.id);
cover.addEventListener('error', () => {
cover.removeAttribute('src');
});
+21 -4
View File
@@ -225,10 +225,27 @@ body {
transition: width 120ms linear;
}
/* A page is a horizontal scroll target; the reader moves it by scrollLeft. The
transition is what makes the movement read as a page turn rather than a jump. */
.reader__stage .reader-viewport {
transition: scroll-left 160ms ease-out;
/*
* The chapter frame.
*
* Zero border and `display: block` are not cosmetic: an iframe's default is
* `display: inline` with a 2px inset border, which sizes it to its content box
* rather than to the stage. The symptom is a 304x154 frame in the corner of a
* 390-wide screen with the book laid out for the wrong width — the pagination
* then computes one page for the entire chapter and every page turn does nothing.
*/
.reader-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
/* The reader drives the frame's scroll position, so the stage must not also
intercept horizontal pans. */
.reader__stage.is-reflowable {
touch-action: pan-y;
}
.reader__page {
+103
View File
@@ -0,0 +1,103 @@
/**
* Asset URL tests.
*
* The behaviour under test is the one that decides whether a chapter renders as
* a book or as unstyled text with no pictures: the browser fetches a chapter's
* images and stylesheets itself, so their URLs have to authenticate without a
* header. The browser test caught this as a wall of 401s.
*/
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
import type { ApiClient } from '../src/net/api.ts';
// The module parses XHTML with the DOM, so the DOM has to exist before it loads.
before(() => {
const dom = new JSDOM('<!doctype html><html><body></body></html>');
const g = globalThis as unknown as Record<string, unknown>;
g.DOMParser = dom.window.DOMParser;
g.XMLSerializer = dom.window.XMLSerializer;
g.document = dom.window.document;
});
const { withAssetToken } = await import('../src/net/asset-url.ts');
function client(token: string | null, baseUrl = ''): ApiClient {
return {
baseUrl,
currentSession: token ? { accessToken: token } : null,
} as unknown as ApiClient;
}
const CHAPTER = `<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head>
<link rel="stylesheet" href="/api/v1/books/b1/assets?ref=OEBPS%2Fs.css"/>
</head><body>
<p>正文</p>
<img src="/api/v1/books/b1/assets?ref=OEBPS%2Fi.png"/>
<a href="#note1">脚注</a>
<img src="https://example.com/remote.png"/>
</body></html>`;
describe('asset URL rewriting', () => {
test('an image URL gets the token, because an img tag cannot send a header', () => {
const out = withAssetToken(CHAPTER, client('tok'));
// Serialising XHTML escapes `&` as `&amp;`, which is what an XML document
// requires; the browser unescapes it when it parses the attribute.
assert.match(out, /ref=OEBPS%2Fi\.png&amp;access_token=tok/);
});
test('a stylesheet link gets it too', () => {
const out = withAssetToken(CHAPTER, client('tok'));
assert.match(out, /ref=OEBPS%2Fs\.css&amp;access_token=tok/);
});
test('an in-document anchor is left alone', () => {
// Rewriting `#note1` would break every footnote in the book, because the
// rewrite would turn a same-document jump into a network request.
const out = withAssetToken(CHAPTER, client('tok'));
assert.match(out, /href="#note1"/);
});
test('a third-party URL is left alone', () => {
// The token is scoped to this instance; sending it to example.com would leak
// it to a stranger.
const out = withAssetToken(CHAPTER, client('tok'));
assert.match(out, /src="https:\/\/example\.com\/remote\.png"/);
assert.ok(!out.includes('example.com/remote.png?access_token'), 'a foreign URL must not carry our token');
assert.match(out, /<img src="https:\/\/example\.com\/remote\.png"/);
});
test('a URL that already has a token is not given a second one', () => {
// The function runs on every chapter, including one served from the cache,
// so running it twice must not produce two parameters.
const once = withAssetToken(CHAPTER, client('tok'));
const twice = withAssetToken(once, client('tok'));
assert.equal((twice.match(/access_token=tok/g) ?? []).length, 2, 'one per asset URL, not two');
});
test('without a session the document is returned unchanged', () => {
assert.equal(withAssetToken(CHAPTER, client(null)), CHAPTER);
});
test('a cross-origin deployment rewrites absolute URLs too', () => {
// The Android shell loads assets from `file://` and talks to a real server
// origin, so every URL is absolute.
const html = '<html xmlns="http://www.w3.org/1999/xhtml"><body><img src="http://nas:8080/api/v1/books/b1/assets?ref=a.png"/></body></html>';
const out = withAssetToken(html, client('tok', 'http://nas:8080'));
assert.match(out, /access_token=tok/);
});
test('a broken chapter is returned as-is rather than half-rewritten', () => {
// A parse failure means the document is not well-formed XML. Rewriting part
// of it risks turning a renderable chapter into an unrenderable one.
const broken = '<html><body><img src="/api/v1/books/b1/assets?ref=a.png"';
assert.equal(withAssetToken(broken, client('tok')), broken);
});
test('CSS url() references are rewritten', () => {
const html = '<html xmlns="http://www.w3.org/1999/xhtml"><head><style>body{background:url("/api/v1/books/b1/assets?ref=bg.png")}</style></head><body/></html>';
const out = withAssetToken(html, client('tok'));
assert.match(out, /access_token=tok/);
});
});