mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +08:00
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:
1 parent
0a17174fb8
commit
5e8d14b460
28 files changed
+1110
-302
No files matched your search
+36
-3
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Vendored
+48
@@ -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
@@ -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
|
||||
Vendored
-3
@@ -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
@@ -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
|
||||
Vendored
+2
-2
@@ -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>
|
||||
|
||||
Generated
+30
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -50,6 +50,7 @@ function showAuth(): void {
|
||||
}
|
||||
|
||||
async function showShelf(): Promise<void> {
|
||||
shelf.mount();
|
||||
await shelf.load(true);
|
||||
}
|
||||
|
||||
|
||||
+39
-3
@@ -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<{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 `&`, which is what an XML document
|
||||
// requires; the browser unescapes it when it parses the attribute.
|
||||
assert.match(out, /ref=OEBPS%2Fi\.png&access_token=tok/);
|
||||
});
|
||||
|
||||
test('a stylesheet link gets it too', () => {
|
||||
const out = withAssetToken(CHAPTER, client('tok'));
|
||||
assert.match(out, /ref=OEBPS%2Fs\.css&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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user