diff --git a/server/src/http/auth.ts b/server/src/http/auth.ts index be63c58..7fe8912 100644 --- a/server/src/http/auth.ts +++ b/server/src/http/auth.ts @@ -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 { - 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 `` and + * `` 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; + 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'); } diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index eb60c88..385cc61 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -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. * diff --git a/server/src/http/routes/web.ts b/server/src/http/routes/web.ts index ceb4536..c93235a 100644 --- a/server/src/http/routes/web.ts +++ b/server/src/http/routes/web.ts @@ -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'))); }); } diff --git a/server/src/indexer/formats/comic-directory.ts b/server/src/indexer/formats/comic-directory.ts index 7c6a304..4b98422 100644 --- a/server/src/indexer/formats/comic-directory.ts +++ b/server/src/indexer/formats/comic-directory.ts @@ -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 { + 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 { const volumes = await collectVolumes(ctx.absPath); const items = []; diff --git a/server/src/indexer/formats/epub.ts b/server/src/indexer/formats/epub.ts index de585af..435448a 100644 --- a/server/src/indexer/formats/epub.ts +++ b/server/src/indexer/formats/epub.ts @@ -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 { + 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 { const archive = await ZipArchive.open(ctx.absPath); diff --git a/server/src/indexer/formats/registry.ts b/server/src/indexer/formats/registry.ts index 7fd72e6..8412514 100644 --- a/server/src/indexer/formats/registry.ts +++ b/server/src/indexer/formats/registry.ts @@ -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; /** * 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; /** Decide whether this directory is one book of this format. */ matches(ctx: HandlerContext, entries: DirectoryEntry[]): Promise | boolean; parse(ctx: HandlerContext, entries: DirectoryEntry[]): Promise; diff --git a/server/src/indexer/formats/text.ts b/server/src/indexer/formats/text.ts index 434d959..d34b54e 100644 --- a/server/src/indexer/formats/text.ts +++ b/server/src/indexer/formats/text.ts @@ -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 { + 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:` for a table-of-contents jump diff --git a/server/test/formats.test.ts b/server/test/formats.test.ts index 09bd695..1b6ad24 100644 --- a/server/test/formats.test.ts +++ b/server/test/formats.test.ts @@ -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); + }); +}); diff --git a/server/test/web.test.ts b/server/test/web.test.ts index 9316625..06d251a 100644 --- a/server/test/web.test.ts +++ b/server/test/web.test.ts @@ -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, /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. diff --git a/web/dist/assets/index-C5iXpQfY.js b/web/dist/assets/index-C5iXpQfY.js new file mode 100644 index 0000000..b5637ae --- /dev/null +++ b/web/dist/assets/index-C5iXpQfY.js @@ -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.className="shelf__title",o.textContent="书库";const r=document.createElement("button");r.className="reader__button",r.textContent="退出",r.addEventListener("click",()=>this.options.onSignOut?.()),i.append(o,r);const c=document.createElement("input");c.className="shelf__search",c.type="search",c.placeholder="搜索书名、作者、系列",c.addEventListener("input",()=>{this.search=c.value.trim(),this.searchTimer&&clearTimeout(this.searchTimer),this.searchTimer=setTimeout(()=>{this.page=1,this.load(!0)},250)}),this.grid=document.createElement("div"),this.grid.className="shelf__grid",this.root.append(i,c,this.grid),this.root.addEventListener("scroll",()=>{if(this.loading||this.grid.childElementCount>=this.total)return;this.root.scrollHeight-this.root.scrollTop-this.root.clientHeight<600&&this.load(!1)})}root;grid;built=!1;search="";page=1;total=0;loading=!1;searchTimer=null;mount(){this.host.replaceChildren(this.root),this.built=!0}get mounted(){return this.built}async load(t){if(!this.loading){this.loading=!0,t&&this.grid.replaceChildren();try{const e=await this.api.shelf({page:this.page,pageSize:60,...this.search?{search:this.search}:{}});if(this.total=e.total,e.items.length===0&&t){const s=document.createElement("div");s.className="shelf__empty",s.textContent=this.search?"没有匹配的书":"书库是空的,检查挂载目录",this.grid.append(s)}for(const s of e.items)this.grid.append(this.card(s));this.page+=1}catch(e){const s=document.createElement("div");s.className="shelf__empty",s.textContent=e instanceof Error?e.message:"加载失败",this.grid.replaceChildren(s)}finally{this.loading=!1}}}card(t){const e=document.createElement("button");e.className="book-card",e.type="button",e.addEventListener("click",()=>this.options.onOpenBook?.(t));const s=document.createElement("img");s.className="book-card__cover",s.alt="",s.loading="lazy",s.decoding="async",t.coverUrl&&(s.src=this.api.coverUrl(t.id)),s.addEventListener("error",()=>{s.removeAttribute("src")});const i=document.createElement("div");i.className="book-card__title",i.textContent=t.title;const o=document.createElement("div");return o.className="book-card__meta",o.textContent=[t.author,tt(t.format)].filter(Boolean).join(" · "),e.append(s,i,o),e}}function tt(n){return{epub:"EPUB",pdf:"PDF",cbz:"漫画","comic-dir":"漫画",txt:"TXT",image:"图片"}[n]??n.toUpperCase()}class et{constructor(t,e,s={}){this.host=t,this.api=e,this.options=s,this.root=document.createElement("div"),this.root.className="dialog",t.replaceChildren(this.root)}root;async render(){let t=!0,e=0;try{const f=await this.api.instance();t=f.registrationOpen,e=f.userCount}catch{}const s=t&&e===0?"register":"login",i=document.createElement("div");i.className="dialog__panel";const o=document.createElement("h2");o.className="dialog__title",o.textContent=s==="register"?"创建第一个账号":"登录";const r=document.createElement("p");r.className="dialog__row",r.textContent=s==="register"?"第一个注册的账号会自动成为管理员。":"请用管理员创建的账号登录。";const c=document.createElement("input");c.autocomplete="username",c.placeholder="用户名";const a=document.createElement("input");a.type="password",a.autocomplete=s==="register"?"new-password":"current-password",a.placeholder="口令";const h=document.createElement("label");h.className="dialog__row",h.append("用户名",c);const l=document.createElement("label");l.className="dialog__row",l.append("口令",a);const d=document.createElement("div");d.className="reader__warning",d.hidden=!0;const u=document.createElement("button");u.className="dialog__button dialog__button--primary",u.textContent=s==="register"?"创建并登录":"登录";const x=document.createElement("div");x.className="dialog__actions",x.append(u),i.append(o,r,h,l,d,x),this.root.replaceChildren(i);const A=async()=>{d.hidden=!0,u.disabled=!0;try{s==="register"?await this.api.register(c.value.trim(),a.value):await this.api.login(c.value.trim(),a.value),this.options.onSignedIn?.()}catch(f){d.hidden=!1,d.textContent=st(f)}finally{u.disabled=!1}};u.addEventListener("click",()=>void A()),a.addEventListener("keydown",f=>{f.key==="Enter"&&A()}),c.focus()}}function st(n){switch(n.code){case"BAD_CREDENTIALS":return"用户名或口令错误";case"REGISTRATION_DISABLED":return"这个实例已关闭公开注册,请让管理员创建账号";case"PASSWORD_TOO_SHORT":return"口令至少 8 位";case"USERNAME_TAKEN":return"用户名已被占用";case"ACCOUNT_DISABLED":return"账号已被停用";case"HTTP_ERROR":case void 0:return"无法连接服务端,检查地址与网络";default:return n instanceof Error?n.message:"操作失败"}}function P(n,t){const e=n?.items??t?.items??[],s=n?.groups??t?.groups??[],i=n?.total??t?.total??e.length,o=n?.group??0;return{items:e,group:s[o]??null,groups:s,total:i,kind:n?.kind??t?.kind??"document"}}function R(n,t){return n.group?n.group.offset+Math.max(0,Math.min(n.group.count-1,t)):t}function it(n,t){return n.group?t-n.group.offset:t}function O(n,t){return n.group?t>=n.group.offset&&t<n.group.offset+n.group.count:t>=0&&t<n.items.length}function nt(n,t){return n.groups.find(e=>t>=e.offset&&t<e.offset+e.count)??null}class ot{host;frame;ready=null;constructor(t){this.host=t,this.frame=document.createElement("iframe"),this.frame.className="reader-frame",this.frame.style.display="block",this.frame.style.width="100%",this.frame.style.height="100%",this.frame.style.border="0",this.frame.setAttribute("sandbox","allow-same-origin"),this.frame.setAttribute("title","正文"),t.replaceChildren(this.frame)}async mount(t,e){if(!this.frame.contentDocument)throw new Error("chapter frame has no document");const i=rt(t,e,this.width());this.ready=ht(this.frame),this.frame.srcdoc=i,await this.ready;const o=this.frame.contentDocument;if(!o)throw new Error("chapter frame lost its document");return{document:()=>this.frame.contentDocument,scroller:()=>o.scrollingElement??o.documentElement}}scroller(){const t=this.frame.contentDocument;return t?.body?t.scrollingElement??t.documentElement:null}document(){return this.frame.contentDocument}width(){return this.frame.clientWidth||this.host.clientWidth||360}destroy(){this.host.replaceChildren()}}function rt(n,t,e){const s=`<style id="reader-page">${ct(t,e)}</style>`,i=/<head[^>]*>/i.test(n)?n.replace(/<head([^>]*)>/i,`<head$1>${at()}`):n;return/<\/head>/i.test(i)?i.replace(/<\/head>/i,`${s}</head>`):`${s}${i}`}function at(){return'<meta name="viewport" content="width=device-width, initial-scale=1">'}function ct(n,t){const e=Math.max(80,Math.min(n.pageWidth,t)),s=Math.max(80,e-n.pagePadding*2);return` +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 this.api.items(this.book.id,e.seq);this.window=P(s)}}class w{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i}state=new v;cache=new W({ahead:2,maxBytes:24*1024*1024});window=null;index=0;objectUrl=null;progressTimer=null;cancelPrefetch=null;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??""),o=i?this.window.items.findIndex(r=>r.href===i.chapter):-1;return await this.show(o>=0?o:0),this.currentState()}get total(){return this.window?.items.length??0}async turn(t){await this.show(this.index+t)}async goTo(t){await this.show(t)}async flush(){this.progressTimer&&(clearTimeout(this.progressTimer),this.progressTimer=null),await this.writeProgress()}destroy(){this.cancelPrefetch?.(),this.cache.clear(),this.releaseUrl(),this.state.clear()}async show(t){const e=this.window;if(!e||e.items.length===0)return;const s=Math.max(0,Math.min(e.items.length-1,t)),i=e.items[s];if(!i)return;this.index=s,this.emit(!0);const o=await this.cache.get(i.href,h=>this.api.asset(this.book.id,h));if(this.index!==s)return;const r=URL.createObjectURL(o),c=this.objectUrl;this.objectUrl=r;const a=document.createElement("img");a.className="reader-page",a.alt=i.title,a.decoding="async",await a.decode().catch(()=>{}),this.host.replaceChildren(a),a.src=r,c&&URL.revokeObjectURL(c),this.emit(!1),this.emitProgress(),this.prefetch(s)}prefetch(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),{sizeOf:i=>e.items.find(o=>o.href===i)?.size})}emitProgress(){const t=this.options.progressDebounceMs??2e3;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;if(!t)return;const e=t.items[this.index];if(!e)return;const s={chapter:e.href,spine:(t.group?.offset??0)+this.index,block:0,ratio:0,percent:this.index/Math.max(1,t.items.length)};await this.api.putProgress(this.book.id,{locator:L(s),percentage:s.percent,chapterTitle:e.title,device:this.options.device??"web",updatedAt:Date.now()}).catch(()=>{})}emit(t){this.state.emit({book:this.book,index:this.index,total:this.total,loading:t,warnings:[]})}currentState(){return{book:this.book,index:this.index,total:this.total,loading:!1,warnings:[]}}releaseUrl(){this.objectUrl&&(URL.revokeObjectURL(this.objectUrl),this.objectUrl=null)}}const gt=256*1024,wt=1200;class y{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i,this.scroll=document.createElement("div"),this.scroll.className="reader-text",this.host.replaceChildren(this.scroll)}state=new v;offset=0;lastLength=0;loading=!1;finished=!1;scroll;async open(t){await this.append(),this.scroll.addEventListener("scroll",()=>this.maybeAppend())}async goToChapter(t,e){const s=await this.api.asset(e,`chapter:${t}`);this.scroll.replaceChildren(z(await s.text())),this.offset=0,this.scroll.scrollTop=0,this.emit(!1),await this.saveProgress(e,t)}async flush(t){await this.saveProgress(t,null)}destroy(){this.host.replaceChildren(),this.state.clear()}maybeAppend(){this.finished||this.loading||this.scroll.scrollHeight-this.scroll.scrollTop-this.scroll.clientHeight>wt||this.append()}async append(){if(!(this.loading||this.finished)){this.loading=!0,this.emit(!0);try{const e=await(await this.api.asset(this.book.id,`chunk:${this.offset}`)).text();if(e.length===0){this.finished=!0;return}this.offset+=e.length,this.scroll.append(z(e)),e.length<gt&&(this.finished=!0)}finally{this.loading=!1,this.emit(!1)}}}async saveProgress(t,e){const s=this.scroll.scrollHeight||1,i=Math.max(0,Math.min(1,this.scroll.scrollTop/s)),o=JSON.stringify(e!==null?{chapter:`chapter:${e}`,spine:e,block:0,ratio:i,percent:i}:{chapter:`chunk:${Math.round(this.offset*i)}`,spine:0,block:0,ratio:i,percent:i});await this.api.putProgress(t,{locator:o,percentage:i,chapterTitle:e!==null?`第 ${e+1} 章`:"",device:this.options.device??"web",updatedAt:Date.now()}).catch(()=>{})}emit(t){this.state.emit({book:this.book,offset:this.offset,chapters:0,hasMore:!this.finished,loading:t})}}function z(n){const t=document.createDocumentFragment(),e=n.split(/\r\n|\r|\n/);let s=[];const i=()=>{if(s.length===0)return;const o=document.createElement("p");o.textContent=s.join(` +`),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")}async close(){const t=this.reader;t instanceof m||t instanceof w?await t.flush().catch(()=>{}):(t instanceof y||t instanceof C)&&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 m||this.reader instanceof w)&&await this.reader.turn(t)}applySettings(){_(document.documentElement,this.settings.theme),this.reader instanceof m&&this.reader.restyle(this.readerStyle())}readerStyle(){return{fontSize:this.settings.fontSize,pageWidth:this.settings.pageWidth,columnGap:this.settings.columnGap,pagePadding:this.settings.pagePadding,...bt[this.settings.theme]}}updateSettings(t){this.settings={...this.settings,...t},F(this.settings),_(document.documentElement,this.settings.theme),this.applySettings(),this.tocDialog&&this.closeDialog(),this.openSettings()}buildToolbar(){const t=document.createElement("button");t.className="reader__button",t.textContent="←",t.setAttribute("aria-label","返回"),t.addEventListener("click",()=>void this.exit());const e=document.createElement("div");e.className="reader__toolbar-title",e.textContent=this.book.title;const s=document.createElement("button");s.className="reader__button",s.textContent="目录",s.addEventListener("click",()=>void this.openToc());const i=document.createElement("button");i.className="reader__button",i.textContent="Aa",i.setAttribute("aria-label","阅读设置"),i.addEventListener("click",()=>this.openSettings()),this.toolbar.append(t,e,s,i)}bindGestures(){let t=0,e=0,s=0,i=!1;const o=h=>{t=h.clientX,e=h.clientY,s=h.timeStamp,i=!0},r=h=>{if(!i)return;i=!1;const l=h.clientX-t,d=h.clientY-e,u=h.timeStamp-s;Math.abs(l)<40||Math.abs(l)<Math.abs(d)*1.5||u>800||this.turn(l<0?1:-1)};this.stage.addEventListener("pointerdown",o),this.stage.addEventListener("pointerup",r),this.stage.addEventListener("pointercancel",()=>{i=!1});const c=h=>{h.key==="ArrowRight"||h.key==="PageDown"||h.key===" "?(h.preventDefault(),this.turn(1)):h.key==="ArrowLeft"||h.key==="PageUp"?(h.preventDefault(),this.turn(-1)):h.key==="Escape"&&this.exit()};window.addEventListener("keydown",c),this.disposers.push(()=>window.removeEventListener("keydown",c));const a=()=>{const h=this.reader;h instanceof m||h instanceof w?h.flush():h instanceof y?h.flush(this.book.id):h instanceof C&&h.flush(this.book.id)};document.addEventListener("visibilitychange",a),window.addEventListener("pagehide",a),this.disposers.push(()=>{document.removeEventListener("visibilitychange",a),window.removeEventListener("pagehide",a)})}async exit(){await this.close(),this.options.onExit?.()}containerFor(t){const e=document.createElement("div");return e.style.height="100%",this.stage.replaceChildren(e),e}async openToc(){const t=await this.api.toc(this.book.id).catch(()=>[]),e=document.createElement("ul");if(e.className="toc",t.length===0){const s=document.createElement("div");s.className="toc__empty",s.textContent="这本书没有可跳转的章节",e.append(s)}else t.forEach(s=>{const i=document.createElement("li");i.className="toc__item",i.textContent=s.title,i.style.paddingLeft=`${4+s.level*14}px`,i.addEventListener("click",()=>{this.closeDialog(),this.jumpTo(s)}),e.append(i)});this.showDialog(`目录 · ${this.book.title}`,e)}async jumpTo(t){const e=this.reader;if(e instanceof m){t.spine!==void 0?await e.goToSpine(t.spine):await e.goToSpineOfHref(t.href);return}if(e instanceof w){await e.goTo(t.spine??0);return}if(e instanceof y){const s=Number.parseInt(t.href.replace("chapter:",""),10);await e.goToChapter(Number.isFinite(s)?s:0,this.book.id);return}e?.setPage(this.book.id,(t.spine??0)+1)}openSettings(){const t=document.createElement("div"),e=this.slider("字号",this.settings.fontSize,12,32,1,r=>{this.settings={...this.settings,fontSize:r},this.applySettings()});e.input.addEventListener("change",()=>this.commitSettings());const s=this.slider("页宽",this.settings.pageWidth,360,1200,20,r=>{this.settings={...this.settings,pageWidth:r},this.applySettings()});s.input.addEventListener("change",()=>this.commitSettings());const i=document.createElement("select");for(const[r,c]of[["light","浅色"],["sepia","羊皮纸"],["dark","深色"]]){const a=document.createElement("option");a.value=r,a.textContent=c,a.selected=this.settings.theme===r,i.append(a)}i.addEventListener("change",()=>{this.settings={...this.settings,theme:i.value},this.commitSettings()});const o=document.createElement("label");o.className="dialog__row",o.append("主题",i),t.append(e.row,s.row,o),this.showDialog("阅读设置",t)}commitSettings(){F(this.settings),_(document.documentElement,this.settings.theme),this.applySettings()}slider(t,e,s,i,o,r){const c=document.createElement("label");c.className="dialog__row";const a=document.createElement("input");return a.type="range",a.min=String(s),a.max=String(i),a.step=String(o),a.value=String(e),a.addEventListener("input",()=>r(Number(a.value))),c.append(t,a),{row:c,input:a}}showDialog(t,e){this.closeDialog();const s=document.createElement("div");s.className="dialog";const i=document.createElement("div");i.className="dialog__panel";const o=document.createElement("h2");o.className="dialog__title",o.textContent=t;const r=document.createElement("div");r.className="dialog__actions";const c=document.createElement("button");c.className="dialog__button dialog__button--primary",c.textContent="关闭",c.addEventListener("click",()=>this.closeDialog()),r.append(c),i.append(o,e,r),s.append(i),s.addEventListener("click",a=>{a.target===s&&this.closeDialog()}),this.root.append(s),this.tocDialog=s}closeDialog(){this.tocDialog?.remove(),this.tocDialog=null}onReflowable(t){this.state={index:t.index,total:t.total,title:t.items[t.index]?.title??"",percent:(t.index+(t.pageCount>1?t.page/t.pageCount:0))/Math.max(1,t.total)},this.renderProgress(),t.warnings.length>0&&(this.warning.hidden=!1,this.warning.textContent=`本章有 ${t.warnings.length} 处问题,可能显示不完整`)}onPaged(t){this.state={index:t.index,total:t.total,title:"",percent:t.index/Math.max(1,t.total)},this.renderProgress()}renderProgress(){this.progressFill.style.width=`${Math.round(this.state.percent*100)}%`,this.pageLabel.textContent=this.state.index>=0?`${this.state.percent*100<1?0:Math.round(this.state.percent*100)}%`:"",this.toolbar.querySelector(".reader__toolbar-title").textContent=this.state.title?`${this.book.title} · ${this.state.title}`:this.book.title}}function Et(){const n=window.readerBridge;if(n)return{...n,platform:"android"}}const k=document.getElementById("app");if(!k)throw new Error("missing #app");const g=Et(),Tt=g?.serverUrl??"",b=new Q({baseUrl:Tt,onAuthLost:()=>$()}),B=new Z(k,b,{onOpenBook:n=>void St(n),onSignOut:()=>{b.signOut(),$()}});function $(){new et(k,b,{onSignedIn:()=>void M()}).render()}async function M(){B.mount(),await B.load(!0)}async function St(n){const t=new xt(k,b,n,{platform:g?.platform??"web",...g?.deviceName!==void 0?{device:g.deviceName}:{},onExit:()=>void M()});await t.open(),g?.onBookOpened?.(n.id),g?.onTurnPage?.(e=>void t.turn(e))}b.currentSession?M():$(); +//# sourceMappingURL=index-C5iXpQfY.js.map diff --git a/web/dist/assets/index-C5iXpQfY.js.map b/web/dist/assets/index-C5iXpQfY.js.map new file mode 100644 index 0000000..0c276c4 --- /dev/null +++ b/web/dist/assets/index-C5iXpQfY.js.map @@ -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 ?? 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 });\n this.setSession(session);\n return session;\n }\n\n async register(username: string, password: string, displayName?: string): Promise<Session> {\n const result = await this.request<{ session: Session }>('POST', '/api/v1/auth/register', {\n body: { username, password, ...(displayName ? { displayName } : {}) },\n anonymous: true,\n });\n this.setSession(result.session);\n return result.session;\n }\n\n /** Instance info, used to decide whether to offer registration. */\n async instance(): Promise<{ name: string; apiVersion: number; registrationOpen: boolean; userCount: number }> {\n return this.request('GET', '/api/v1/instance', { anonymous: true });\n }\n\n /**\n * Everything needed to open a book, in one round trip.\n *\n * `group` narrows the items to one window (a comic volume, a chapter range) so\n * opening a 40-volume series transfers one volume's worth of entries.\n */\n async manifest(bookId: string, group?: number): Promise<ManifestResponse> {\n const query = group === undefined ? '' : `?group=${group}`;\n return this.request('GET', `/api/v1/books/${encodeURIComponent(bookId)}/manifest${query}`);\n }\n\n /**\n * The book's table of contents.\n *\n * A separate call from `manifest` because a TOC has to be complete to be\n * useful, while the manifest is deliberately windowed. Fetching the manifest\n * of a 1200-chapter book to show its contents would be the one thing windowing\n * exists to prevent.\n */\n async toc(bookId: string): Promise<TocEntry[]> {\n const result = await this.request<{ toc: TocEntry[] }>('GET', `/api/v1/books/${encodeURIComponent(bookId)}/toc`);\n return result.toc;\n }\n\n /** One window of items, for a book already open. */\n async items(bookId: string, group?: number): Promise<BookContent> {\n const query = group === undefined ? '' : `?group=${group}`;\n return this.request('GET', `/api/v1/books/${encodeURIComponent(bookId)}/items${query}`);\n }\n\n /**\n * A single addressable resource, as bytes.\n *\n * Returns a Blob rather than text: the same method serves a chapter document, a\n * comic page and a PDF, and the caller decides how to interpret it. `ref` is\n * opaque and must be passed through unchanged.\n */\n async asset(bookId: string, ref: string): Promise<Blob> {\n return this.requestBlob('GET', `/api/v1/books/${encodeURIComponent(bookId)}/assets?ref=${encodeURIComponent(ref)}`);\n }\n\n /** A chapter document as text, which is what the container mounts. */\n async chapterText(bookId: string, ref: string): Promise<string> {\n const blob = await this.asset(bookId, ref);\n return blob.text();\n }\n\n /** The raw book file; a client uses this to cache a book for offline reading. */\n contentUrl(bookId: string): string {\n return this.url(`/api/v1/books/${encodeURIComponent(bookId)}/content`);\n }\n\n /**\n * A URL a plain `<img>` can load.\n *\n * The token travels in the query because an `<img src>` cannot carry a header —\n * the same constraint that shapes `render/asset-url.ts`, and the same bounded\n * exposure: the server only accepts a query token on read endpoints for\n * immutable content, and a cover is addressed by content hash.\n *\n * Fetching the cover in JavaScript and handing over a Blob URL would keep the\n * token out of the URL, at the cost of buffering every cover of a 2000-book\n * shelf before any of them can render — which is exactly what lazy loading\n * exists to avoid.\n */\n coverUrl(bookId: string): string {\n const token = this.session?.accessToken;\n const path = `/api/v1/books/${encodeURIComponent(bookId)}/cover`;\n return this.url(token ? `${path}?access_token=${encodeURIComponent(token)}` : path);\n }\n\n async shelf(params: { search?: string; page?: number; pageSize?: number } = {}): Promise<{\n items: BookDto[];\n total: number;\n page: number;\n pageSize: number;\n }> {\n const query = new URLSearchParams();\n if (params.search) query.set('search', params.search);\n if (params.page) query.set('page', String(params.page));\n if (params.pageSize) query.set('pageSize', String(params.pageSize));\n const suffix = query.toString() ? `?${query}` : '';\n return this.request('GET', `/api/v1/books${suffix}`);\n }\n\n async progress(bookId: string): Promise<{ progress: ProgressDto | null }> {\n return this.request('GET', `/api/v1/sync/progress/${encodeURIComponent(bookId)}`);\n }\n\n async putProgress(bookId: string, progress: Omit<ProgressDto, 'bookId'>): Promise<void> {\n await this.request('PUT', `/api/v1/sync/progress/${encodeURIComponent(bookId)}`, { body: progress });\n }\n\n /** Absolute URL for an API path, including the origin when one is configured. */\n url(path: string): string {\n return `${this.baseUrl}${path}`;\n }\n\n // ---------------------------------------------------------------- plumbing\n\n private async request<T>(\n method: string,\n path: string,\n options: { body?: unknown; anonymous?: boolean } = {},\n ): Promise<T> {\n const response = await this.fetch(method, path, options);\n if (response.status === 204) return undefined as T;\n return (await response.json()) as T;\n }\n\n private async requestBlob(method: string, path: string): Promise<Blob> {\n const response = await this.fetch(method, path, {});\n return response.blob();\n }\n\n /**\n * One request, with auth, one retry after a token refresh, and a queue slot.\n *\n * The refresh is deduplicated: a comic prefetching four pages at once would\n * otherwise revoke its own refresh token three times, because the server\n * rotates them on use.\n */\n private async fetch(\n method: string,\n path: string,\n options: { body?: unknown; anonymous?: boolean },\n ): Promise<Response> {\n await this.acquire();\n try {\n const response = await this.send(method, path, options);\n if (response.status !== 401 || options.anonymous) {\n if (!response.ok) throw await toApiError(response);\n return response;\n }\n\n const refreshed = await this.refresh();\n if (!refreshed) {\n this.signOut();\n throw await toApiError(response);\n }\n const retry = await this.send(method, path, options);\n if (!retry.ok) throw await toApiError(retry);\n return retry;\n } finally {\n this.release();\n }\n }\n\n private send(method: string, path: string, options: { body?: unknown; anonymous?: boolean }): Promise<Response> {\n const headers: Record<string, string> = {};\n if (options.body !== undefined) headers['content-type'] = 'application/json';\n if (!options.anonymous && this.session) headers.authorization = `Bearer ${this.session.accessToken}`;\n return fetch(this.url(path), {\n method,\n headers,\n ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),\n });\n }\n\n private async refresh(): Promise<boolean> {\n this.refreshInFlight ??= (async () => {\n const current = this.session;\n if (!current?.refreshToken) return null;\n try {\n const response = await fetch(this.url('/api/v1/auth/refresh'), {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ refreshToken: current.refreshToken }),\n });\n if (!response.ok) return null;\n const session = (await response.json()) as Session;\n return session;\n } catch {\n return null;\n } finally {\n // Cleared in a microtask so concurrent callers awaited on this same\n // promise still observe its result.\n queueMicrotask(() => {\n this.refreshInFlight = null;\n });\n }\n })();\n\n const session = await this.refreshInFlight;\n if (!session) return false;\n this.setSession(session);\n return true;\n }\n\n private acquire(): Promise<void> {\n if (this.inflight < MAX_INFLIGHT) {\n this.inflight += 1;\n return Promise.resolve();\n }\n return new Promise((resolve) => {\n this.queue.push(() => {\n this.inflight += 1;\n resolve();\n });\n });\n }\n\n private release(): void {\n this.inflight -= 1;\n this.queue.shift()?.();\n }\n}\n\nasync function toApiError(response: Response): Promise<ApiError> {\n let code = 'HTTP_ERROR';\n let message = `request failed with ${response.status}`;\n try {\n const body = (await response.json()) as ApiErrorBody;\n if (body?.error?.code) {\n code = body.error.code;\n message = body.error.message;\n }\n } catch {\n // A non-JSON error body (a proxy's HTML error page) is not worth a second\n // failure; the status code is the useful part.\n }\n return new ApiError(response.status, code, message);\n}\n\n/** localStorage when it exists; an in-memory stub in a worker or a test. */\nfunction defaultStorage(): SessionStorage {\n const key = 'reader.session';\n try {\n const probe = globalThis.localStorage;\n if (!probe) throw new Error('no localStorage');\n return {\n read: () => {\n const raw = probe.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as Session;\n } catch {\n return null;\n }\n },\n write: (session) => {\n if (session) probe.setItem(key, JSON.stringify(session));\n else probe.removeItem(key);\n },\n };\n } catch {\n let memory: Session | null = null;\n return {\n read: () => memory,\n write: (session) => {\n memory = session;\n },\n };\n }\n}\n","/**\n * The bookshelf.\n *\n * A grid of covers, which is the only browse surface a personal library needs.\n * Two choices worth naming:\n *\n * - **Covers are loaded eagerly and lazily at the same time.** The first screen\n * of covers is requested immediately so the shelf is not a wall of grey; the\n * rest are `loading=\"lazy\"`, so scrolling a 2000-book library does not open\n * 2000 requests on the way down.\n * - **Search is debounced and server-side.** Filtering 2000 books in the client\n * would mean holding all of them in memory, and the server already indexes\n * title, author, series and ISBN — a client-side filter would silently miss\n * fields the user expects to match.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\n\nexport interface ShelfHost {\n onOpenBook?: (book: BookDto) => void;\n onSignOut?: () => void;\n}\n\nexport class ShelfView {\n private readonly root: HTMLElement;\n private readonly grid: HTMLElement;\n /** Built once, mounted on demand. */\n private built = false;\n private search = '';\n private page = 1;\n private total = 0;\n private loading = false;\n private searchTimer: ReturnType<typeof setTimeout> | null = null;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly options: ShelfHost = {},\n ) {\n this.root = document.createElement('div');\n this.root.className = 'shelf';\n\n const header = document.createElement('div');\n header.className = 'shelf__header';\n const title = document.createElement('h1');\n title.className = 'shelf__title';\n title.textContent = '书库';\n const signOut = document.createElement('button');\n signOut.className = 'reader__button';\n signOut.textContent = '退出';\n signOut.addEventListener('click', () => this.options.onSignOut?.());\n header.append(title, signOut);\n\n const input = document.createElement('input');\n input.className = 'shelf__search';\n input.type = 'search';\n input.placeholder = '搜索书名、作者、系列';\n input.addEventListener('input', () => {\n this.search = input.value.trim();\n // Debounced: a request per keystroke against a self-hosted server on a NAS\n // is a request per keystroke against a spinning disk.\n if (this.searchTimer) clearTimeout(this.searchTimer);\n this.searchTimer = setTimeout(() => {\n this.page = 1;\n void this.load(true);\n }, 250);\n });\n\n this.grid = document.createElement('div');\n this.grid.className = 'shelf__grid';\n\n this.root.append(header, input, this.grid);\n\n // Infinite scroll: a library is browsed by scrolling, and paging controls on\n // a phone are a tax on every visit.\n this.root.addEventListener('scroll', () => {\n if (this.loading || this.grid.childElementCount >= this.total) return;\n const remaining = this.root.scrollHeight - this.root.scrollTop - this.root.clientHeight;\n if (remaining < 600) void this.load(false);\n });\n }\n\n /**\n * Take over the host element.\n *\n * Mounting is separate from construction because the view is built once, at\n * startup, while the auth screen may still own the host. Replacing the host's\n * children in the constructor meant the shelf's markup was installed and then\n * covered by the dialog that mounted after it — so a successful sign-in left\n * the login form on screen with the loaded shelf hidden underneath.\n */\n mount(): void {\n this.host.replaceChildren(this.root);\n this.built = true;\n }\n\n get mounted(): boolean {\n return this.built;\n }\n\n async load(reset: boolean): Promise<void> {\n if (this.loading) return;\n this.loading = true;\n if (reset) this.grid.replaceChildren();\n try {\n const result = await this.api.shelf({ page: this.page, pageSize: 60, ...(this.search ? { search: this.search } : {}) });\n this.total = result.total;\n if (result.items.length === 0 && reset) {\n const empty = document.createElement('div');\n empty.className = 'shelf__empty';\n empty.textContent = this.search ? '没有匹配的书' : '书库是空的,检查挂载目录';\n this.grid.append(empty);\n }\n for (const book of result.items) this.grid.append(this.card(book));\n this.page += 1;\n } catch (error) {\n const message = document.createElement('div');\n message.className = 'shelf__empty';\n message.textContent = error instanceof Error ? error.message : '加载失败';\n this.grid.replaceChildren(message);\n } finally {\n this.loading = false;\n }\n }\n\n private card(book: BookDto): HTMLElement {\n const card = document.createElement('button');\n card.className = 'book-card';\n card.type = 'button';\n // The whole card is the hit target: on a phone a tap on a 108px cover is\n // already at the edge of the 44px recommendation, and a separate title link\n // would be worse.\n card.addEventListener('click', () => this.options.onOpenBook?.(book));\n\n const cover = document.createElement('img');\n cover.className = 'book-card__cover';\n cover.alt = '';\n // `loading=lazy` plus an explicit aspect ratio means the browser reserves the\n // space before the image arrives, so the grid does not reflow under a thumb\n // that is already scrolling.\n cover.loading = 'lazy';\n cover.decoding = 'async';\n // The DTO carries the server's own path; the token has to come from the\n // client, because an `<img src>` cannot send an Authorization header.\n if (book.coverUrl) cover.src = this.api.coverUrl(book.id);\n cover.addEventListener('error', () => {\n cover.removeAttribute('src');\n });\n\n const title = document.createElement('div');\n title.className = 'book-card__title';\n title.textContent = book.title;\n\n const meta = document.createElement('div');\n meta.className = 'book-card__meta';\n meta.textContent = [book.author, formatLabel(book.format)].filter(Boolean).join(' · ');\n\n card.append(cover, title, meta);\n return card;\n }\n}\n\nfunction formatLabel(format: string): string {\n const labels: Record<string, string> = {\n epub: 'EPUB',\n pdf: 'PDF',\n cbz: '漫画',\n 'comic-dir': '漫画',\n txt: 'TXT',\n image: '图片',\n };\n return labels[format] ?? format.toUpperCase();\n}\n","/**\n * Sign-in.\n *\n * The first account on a fresh instance becomes the admin, so this screen has to\n * know whether the instance is empty — showing a login form to the person setting\n * the server up for the first time is the most common way a self-hosted product\n * loses a user in the first two minutes.\n *\n * The instance probe is unauthenticated by design, so it can run before anyone\n * has a token.\n */\n\nimport { ApiClient } from '../net/api.ts';\n\nexport interface AuthHost {\n onSignedIn?: () => void;\n}\n\nexport class AuthView {\n private readonly root: HTMLElement;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly options: AuthHost = {},\n ) {\n this.root = document.createElement('div');\n this.root.className = 'dialog';\n host.replaceChildren(this.root);\n }\n\n async render(): Promise<void> {\n let registrationOpen = true;\n let userCount = 0;\n try {\n const instance = await this.api.instance();\n registrationOpen = instance.registrationOpen;\n userCount = instance.userCount;\n } catch {\n // An unreachable server is reported by the submit handler with a real\n // message; guessing here would only duplicate it worse.\n }\n // The first account is always allowed, whatever the instance says.\n const mode: 'register' | 'login' = registrationOpen && userCount === 0 ? 'register' : 'login';\n\n const panel = document.createElement('div');\n panel.className = 'dialog__panel';\n const title = document.createElement('h2');\n title.className = 'dialog__title';\n title.textContent = mode === 'register' ? '创建第一个账号' : '登录';\n\n const hint = document.createElement('p');\n hint.className = 'dialog__row';\n hint.textContent =\n mode === 'register' ? '第一个注册的账号会自动成为管理员。' : '请用管理员创建的账号登录。';\n\n const username = document.createElement('input');\n username.autocomplete = 'username';\n username.placeholder = '用户名';\n const password = document.createElement('input');\n password.type = 'password';\n password.autocomplete = mode === 'register' ? 'new-password' : 'current-password';\n password.placeholder = '口令';\n\n const userRow = document.createElement('label');\n userRow.className = 'dialog__row';\n userRow.append('用户名', username);\n const passRow = document.createElement('label');\n passRow.className = 'dialog__row';\n passRow.append('口令', password);\n\n const error = document.createElement('div');\n error.className = 'reader__warning';\n error.hidden = true;\n\n const submit = document.createElement('button');\n submit.className = 'dialog__button dialog__button--primary';\n submit.textContent = mode === 'register' ? '创建并登录' : '登录';\n\n const actions = document.createElement('div');\n actions.className = 'dialog__actions';\n actions.append(submit);\n panel.append(title, hint, userRow, passRow, error, actions);\n this.root.replaceChildren(panel);\n\n const run = async (): Promise<void> => {\n error.hidden = true;\n submit.disabled = true;\n try {\n if (mode === 'register') await this.api.register(username.value.trim(), password.value);\n else await this.api.login(username.value.trim(), password.value);\n this.options.onSignedIn?.();\n } catch (failure) {\n error.hidden = false;\n error.textContent = describe(failure);\n } finally {\n submit.disabled = false;\n }\n };\n\n submit.addEventListener('click', () => void run());\n password.addEventListener('keydown', (event) => {\n if (event.key === 'Enter') void run();\n });\n username.focus();\n }\n}\n\n/**\n * Turn a server error into something a person can act on.\n *\n * The codes are part of the API's compatibility promise, so mapping them here is\n * safe — and the server's own messages are English, while this UI is not.\n */\nfunction describe(failure: unknown): string {\n const code = (failure as { code?: string }).code;\n switch (code) {\n case 'BAD_CREDENTIALS':\n return '用户名或口令错误';\n case 'REGISTRATION_DISABLED':\n return '这个实例已关闭公开注册,请让管理员创建账号';\n case 'PASSWORD_TOO_SHORT':\n return '口令至少 8 位';\n case 'USERNAME_TAKEN':\n return '用户名已被占用';\n case 'ACCOUNT_DISABLED':\n return '账号已被停用';\n case 'HTTP_ERROR':\n case undefined:\n return '无法连接服务端,检查地址与网络';\n default:\n return failure instanceof Error ? failure.message : '操作失败';\n }\n}\n","/**\n * The loaded window of a book.\n *\n * When the server returns a manifest narrowed to `?group=N`, `items` holds only\n * that group's entries. Every translation between \"whole-book spine index\" (what\n * progress and the table of contents use) and \"index into the array in memory\"\n * (what the DOM code uses) has to go through one place, or the two drift and the\n * reader opens the wrong chapter.\n *\n * This was the bug that made windowed loading look unsafe at first: two\n * different call sites each computed the offset slightly differently and got\n * different answers for the same chapter. There is now one function.\n */\n\nimport type { BookContent, ContentGroup, ContentItem } from '../net/api.ts';\n\nexport interface Window {\n /** The window's items, in reading order. */\n items: ContentItem[];\n /** The window's first group, when the format has groups. */\n group: ContentGroup | null;\n /** All groups of the book, so a table of contents stays complete. */\n groups: ContentGroup[];\n /** Chapters/pages in the whole book, not just this window. */\n total: number;\n kind: BookContent['kind'];\n}\n\n/** Build a window from a manifest response that may or may not be narrowed. */\nexport function toWindow(content: BookContent | null, fallback?: Partial<BookContent>): Window {\n const items = content?.items ?? fallback?.items ?? [];\n const groups = content?.groups ?? fallback?.groups ?? [];\n const total = content?.total ?? fallback?.total ?? items.length;\n\n // A narrowed response is the one that carries a `group` index; an unnarrowed\n // one starts at the book's first item by definition.\n const index = content?.group ?? 0;\n return {\n items,\n group: groups[index] ?? null,\n groups,\n total,\n kind: content?.kind ?? fallback?.kind ?? 'document',\n };\n}\n\n/** Whole-book index of a local item index. */\nexport function toSpineIndex(window: Window, localIndex: number): number {\n if (!window.group) return localIndex;\n return window.group.offset + Math.max(0, Math.min(window.group.count - 1, localIndex));\n}\n\n/** Local item index of a whole-book index, when that index is in this window. */\nexport function toLocalIndex(window: Window, spine: number): number {\n if (!window.group) return spine;\n return spine - window.group.offset;\n}\n\n/** Whether a whole-book index is inside the loaded window. */\nexport function contains(window: Window, spine: number): boolean {\n if (!window.group) return spine >= 0 && spine < window.items.length;\n return spine >= window.group.offset && spine < window.group.offset + window.group.count;\n}\n\n/** Whose group holds a whole-book index. */\nexport function groupForSpine(window: Window, spine: number): ContentGroup | null {\n return window.groups.find((group) => spine >= group.offset && spine < group.offset + group.count) ?? null;\n}\n\n/** Chapter title for a whole-book index, from the groups when items are absent. */\nexport function titleForSpine(window: Window, spine: number): string {\n const group = groupForSpine(window, spine);\n if (!group) return '';\n const local = group.seq === window.group?.seq && window.group ? spine - group.offset : -1;\n const item = local >= 0 ? window.items[local] : undefined;\n return item?.title ?? group.title;\n}\n","/**\n * Where a chapter document lives inside the reader.\n *\n * ============================================================================\n * The iframe vs Shadow DOM decision\n * ============================================================================\n *\n * The user asked whether a pure-H5 build needs an iframe or a Shadow DOM for\n * strict epub resources. The short answer is **Shadow DOM for the outer\n * document, and one `srcdoc` iframe per chapter for the book itself.** That is\n * not a hedge; the two problems are genuinely different and each primitive\n * solves exactly one of them.\n *\n * ## What Shadow DOM does and does not do\n *\n * It is NOT isolation from the publisher's stylesheet, and anyone who reaches for\n * it expecting that has misread the primitive. The publisher's CSS is not \"the\n * document's rules\" — it is a chapter asset that has to end up inside the root to\n * do anything at all. Inside, it styles the book exactly as it would in an\n * iframe. The book must style itself; that is the product.\n *\n * What it *does* provide, and the reason it is the outer container:\n *\n * - The client's own UI rules cannot leak in and restyle a footnote or a\n * blockquote. `all: unset` and `!important` in the shell's CSS are contained,\n * which is precisely the failure mode this product exists to avoid.\n * - Per-chapter resources are discarded with the chapter, so a book whose\n * chapters each link their own stylesheet cannot corrupt later chapters.\n * - The shell keeps its own DOM: dialogs, gestures, overlays and selection\n * handling all live outside the root and cannot be matched by a book's `*`\n * selector.\n *\n * ## Why the book itself is an iframe\n *\n * This is the part that the first version of this module got wrong, and the\n * browser test is what caught it. A chapter document is not a fragment: it has a\n * `<head>` with `<link rel=stylesheet>` and a `<body>` with `<img src>`. Those\n * are **sub-resource requests**, and they are issued by the browser, not by our\n * code. Two consequences:\n *\n * 1. **They cannot carry an `Authorization` header.** There is no API to attach\n * a request header to an `<img>` or a `<link>`. Injecting the chapter's CSS\n * and images into the host document therefore breaks every one of them with a\n * 401 — the book renders as unstyled text with no pictures, which is exactly\n * what happened before this file was rewritten.\n * 2. **They resolve against the document's base URL.** The chapter's `src` is\n * already rewritten to an absolute asset URL by the server, so this is\n * satisfied — but only because it is a real document with a real base.\n *\n * A shadow root solves neither. An `iframe` solves both: it is a real browsing\n * context, so its `src`/`href` requests are ordinary navigations that can carry\n * whatever URL (including a token query parameter) they were written with, and\n * its base URL is its own.\n *\n * `srcdoc` rather than a Blob URL: a Blob URL's origin is opaque, so the frame's\n * requests would be cross-origin and the asset endpoint would refuse them. A\n * `srcdoc` frame inherits the parent's origin, which is what makes the rewritten\n * absolute URLs work without CORS.\n *\n * ## What an iframe costs, and why it is worth paying\n *\n * A frame is a nested browsing context with its own layout and lifecycle. That\n * cost is only paid per chapter — one frame is reused and its `srcdoc` replaced,\n * not one frame per chapter — and it buys the guarantee that a chapter is a\n * *document*, rendered by an engine that has been rendering documents for thirty\n * years, rather than a fragment we hope is equivalent.\n *\n * The paginator therefore drives the inner document's scroller. That is the same\n * amount of work as driving a local one, and it is the only arrangement in which\n * `document.styleSheets`, `@page`, `@font-face` and `:root` in a book's CSS mean\n * what the publisher intended — `:root` inside a shadow root refers to the shadow\n * root itself, not to an element the client can set variables on, which is the\n * second bug the browser test caught.\n */\n\n/** A mounted chapter. */\nexport interface MountedChapter {\n /** Document the chapter was loaded into, once it has loaded. */\n document(): Document | null;\n /** The element the paginator scrolls. */\n scroller(): HTMLElement;\n}\n\n/**\n * The reading frame.\n *\n * One `iframe`, reused across chapters. `srcdoc` is replaced rather than the\n * frame being recreated: tearing down and rebuilding a browsing context per\n * chapter is measurable on a 200-chapter book, and a reused frame keeps the\n * same compositing layer.\n */\nexport class ChapterContainer {\n readonly host: HTMLElement;\n private frame: HTMLIFrameElement;\n private ready: Promise<void> | null = null;\n\n constructor(host: HTMLElement) {\n this.host = host;\n this.frame = document.createElement('iframe');\n this.frame.className = 'reader-frame';\n // Set inline as well as in the stylesheet: this element's default sizing\n // (`display: inline`, 2px border) is wrong in a way that breaks pagination\n // rather than merely looking off, and a stylesheet that fails to load must\n // not be able to reintroduce it.\n this.frame.style.display = 'block';\n this.frame.style.width = '100%';\n this.frame.style.height = '100%';\n this.frame.style.border = '0';\n // Same-origin is required for the client to read the chapter's layout for\n // pagination and text selection. There is no script in a chapter document\n // that could exploit it: `allow-scripts` is deliberately absent, so a book's\n // own `<script>` cannot run even if it has one.\n this.frame.setAttribute('sandbox', 'allow-same-origin');\n this.frame.setAttribute('title', '正文');\n host.replaceChildren(this.frame);\n }\n\n /**\n * Load a chapter document.\n *\n * The server has already made the document self-contained: relative resource\n * references are rewritten to absolute asset URLs, so the frame needs nothing\n * from us beyond the markup and the token the URLs carry.\n */\n async mount(html: string, settings: ReaderStyle): Promise<MountedChapter> {\n const doc = this.frame.contentDocument;\n if (!doc) throw new Error('chapter frame has no document');\n\n const styled = injectReaderStyle(html, settings, this.width());\n this.ready = waitForLoad(this.frame);\n this.frame.srcdoc = styled;\n await this.ready;\n\n const chapter = this.frame.contentDocument;\n if (!chapter) throw new Error('chapter frame lost its document');\n return {\n document: () => this.frame.contentDocument,\n scroller: () => (chapter.scrollingElement ?? chapter.documentElement) as HTMLElement,\n };\n }\n\n /** The element the paginator scrolls: the chapter's own scrolling element. */\n scroller(): HTMLElement | null {\n const doc = this.frame.contentDocument;\n if (!doc?.body) return null;\n return (doc.scrollingElement ?? doc.documentElement) as HTMLElement;\n }\n\n /** The chapter's document, once it has loaded. */\n document(): Document | null {\n return this.frame.contentDocument;\n }\n\n /**\n * Width of one page.\n *\n * Read from the frame rather than from the host: the frame is the element the\n * document actually lays out in, and a host with padding or a scrollbar would\n * give a different answer than the page geometry the CSS computed.\n */\n width(): number {\n // `clientWidth` is 0 before the frame has been laid out, which happens on the\n // first mount. Falling back to the host keeps the first chapter from being\n // paginated against a zero-width page.\n return this.frame.clientWidth || this.host.clientWidth || 360;\n }\n\n destroy(): void {\n this.host.replaceChildren();\n }\n}\n\n/** The subset of reader settings a chapter document needs. */\nexport interface ReaderStyle {\n fontSize: number;\n pageWidth: number;\n columnGap: number;\n pagePadding: number;\n background: string;\n text: string;\n}\n\n/**\n * Build the complete chapter document.\n *\n * Everything the document needs is inlined, because an `srcdoc` frame has no\n * base URL of its own to resolve a client stylesheet against — and because a\n * chapter that loaded its reader CSS from the network would be unstyled for one\n * frame, which is visible on every chapter turn.\n *\n * The styles appended last are the reader's own, and they are deliberately\n * minimal: page geometry, and a font size the book may override. See\n * `render/theme.ts` for why the list stops there.\n */\nfunction injectReaderStyle(html: string, settings: ReaderStyle, frameWidth: number): string {\n const style = `<style id=\"reader-page\">${pageCss(settings, frameWidth)}</style>`;\n const withViewport = /<head[^>]*>/i.test(html)\n ? html.replace(/<head([^>]*)>/i, `<head$1>${viewportMeta()}`)\n : html;\n if (/<\\/head>/i.test(withViewport)) return withViewport.replace(/<\\/head>/i, `${style}</head>`);\n return `${style}${withViewport}`;\n}\n\n/** A phone-width viewport, so a chapter authored for a desktop window reflows. */\nfunction viewportMeta(): string {\n return '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">';\n}\n\n/**\n * Page geometry for a chapter.\n *\n * `column-width` is what creates pages; the frame's scroller then moves between\n * them by `scrollLeft`, exactly as it would for a horizontal list. Nothing here\n * selects a content element, so nothing here can flatten a publisher's layout.\n *\n * ## The height that makes columns work\n *\n * `column-fill: auto` only fragments content when the container has a *definite*\n * height. Left alone, `html` and `body` size to their content, the computed\n * height becomes the content's own height, and the whole chapter lays out in a\n * single column as tall as the text — `scrollWidth` equals `clientWidth` and\n * every \"page\" is the entire chapter. That is what this file did before the\n * browser test looked at `scrollWidth`, and it is not a subtle failure: the\n * reader shows one page per chapter and the page-turn gesture does nothing.\n *\n * So the document is pinned to the frame's viewport height: 100vh inside an\n * iframe is the frame's own viewport, which is exactly the page height wanted.\n * The body deliberately does NOT clip its overflow — clipping it also clips the\n * columns it generated, so content past the first page never becomes scrollable\n * and the chapter reports a single page.\n *\n * The one rule that does touch content is `font-size` on `html`, and it is\n * expressed so a book can win: a book that sets an absolute size keeps it, and a\n * book that uses relative sizes scales with the reader's choice. That is the only\n * way a font-size control can work without overriding the book.\n */\nfunction pageCss(settings: ReaderStyle, frameWidth: number): string {\n // `pageWidth` is a CEILING on how wide a page may be, not the width itself: on\n // a 390px phone a 720px column would lay the whole chapter out in one column\n // wider than the screen, and every page turn would scroll nowhere. The column\n // takes the smaller of the reader's preference and the space that exists.\n const width = Math.max(80, Math.min(settings.pageWidth, frameWidth));\n const usable = Math.max(80, width - settings.pagePadding * 2);\n return `\nhtml {\n font-size: ${settings.fontSize}px;\n background: ${settings.background};\n color: ${settings.text};\n /* The document is the scroll container; the frame is sized to one page. */\n overflow-x: auto;\n overflow-y: hidden;\n scrollbar-width: none;\n /* No scroll-snap here. Snapping to the columns sounds right and is not: the\n snap target is each column's own edge, so a programmatic scrollLeft lands\n wherever the browser decides the nearest snap point is — and a snap point\n inside a column is not a page boundary. The paginator sets scrollLeft\n explicitly, and a gesture handler keeps user swipes on the same grid. */\n}\nhtml::-webkit-scrollbar { display: none; }\nbody {\n /* Columns are the pagination: one column is exactly one page. The column\n width is set below in pixels rather than left to the publisher's rules, and\n nothing else about the body typography is touched. */\n column-width: ${usable}px;\n column-gap: ${settings.columnGap}px;\n column-fill: auto;\n /* A definite height is what lets the content fragment into columns at all. */\n height: 100vh;\n /* No overflow: hidden here. Clipping the body also clips the columns it\n generated, so content past the first page never becomes scrollable and the\n chapter reports a single page. The document's own scroller is what has to\n see the overflow. */\n margin: 0;\n padding: 0 ${settings.pagePadding}px;\n box-sizing: border-box;\n /* No forced font-family and no line-height: the publisher's body rules decide\n the typography, as they should. */\n}\nimg, svg {\n /* An image that overflows its column breaks the geometry for the whole\n chapter, not just for itself. Clamping is the one place where fidelity has\n to give way, and it is stated here rather than hidden in a reset. */\n max-width: 100%;\n max-height: 90vh;\n object-fit: contain;\n break-inside: avoid;\n}\n`;\n}\n\n/** Resolve when a frame finishes loading its `srcdoc`. */\nfunction waitForLoad(frame: HTMLIFrameElement): Promise<void> {\n return new Promise((resolve) => {\n const done = (): void => {\n frame.removeEventListener('load', done);\n // One animation frame after `load`, so the first layout has happened and\n // the paginator's `scrollWidth` is not the pre-layout value.\n requestAnimationFrame(() => resolve());\n };\n frame.addEventListener('load', done);\n });\n}\n","/**\n * Column-based pagination for reflowable content.\n *\n * ## Why not `scroll` mode\n *\n * Continuous scrolling is what every web reader does, and it is the wrong model\n * for a book. A reader's position is measured in pages: the progress indicator,\n * turning back to re-read a paragraph, \"3 pages left in this chapter\" — all of it\n * assumes a fixed page. A scroll offset is a pixel number that changes meaning\n * when the font size, viewport or column width changes, which is exactly the set\n * of things a reader adjusts.\n *\n * ## Why CSS multi-column instead of measuring by hand\n *\n * The alternative is to wrap every word in a span and binary-search the page\n * break. That rewrites the publisher's markup, which this product refuses to do,\n * and it is slow enough to feel on a chapter with images. Multi-column layout is\n * the browser's own implementation of the same thing: the content is laid out\n * once, columns are created, and a page is a horizontal scroll offset.\n *\n * The cost is that page boundaries land wherever the layout engine puts them.\n * That is acceptable — and it is what every commercial reader does — as long as a\n * *position* survives repagination, which is what the anchor functions below are\n * for.\n *\n * ## Why the scroller is a parameter\n *\n * The chapter lives in an iframe (see `container.ts`), so the element being\n * scrolled belongs to another document. Taking it as a parameter rather than\n * reaching for a global keeps this class testable and keeps the frame's existence\n * out of the pagination arithmetic.\n */\n\n/** A position inside a chapter, anchored to a DOM node so it survives repagination. */\nexport interface ChapterPosition {\n /** Index of the top-level block the reader was in. */\n blockIndex: number;\n /** How far into that block, as a fraction of its height. */\n offsetRatio: number;\n}\n\nexport interface PaginatorOptions {\n /** Gap between columns, in CSS pixels. Must match the injected stylesheet. */\n columnGap?: number;\n /** Page padding, in CSS pixels, on each side. Must match the stylesheet. */\n pagePadding?: number;\n}\n\n/**\n * Drive a chapter's scrolling element.\n */\nexport class Paginator {\n private readonly columnGap: number;\n /** Column stride: the exact distance between two page origins. */\n private stride = 0;\n private pageCount = 1;\n private current = 0;\n\n constructor(private scroller: HTMLElement | null, options: PaginatorOptions = {}) {\n this.columnGap = options.columnGap ?? 32;\n void options.pagePadding;\n }\n\n /** Point the paginator at a (new) chapter document. */\n attach(scroller: HTMLElement | null): void {\n this.scroller = scroller;\n this.reset();\n }\n\n /**\n * Recompute the column geometry.\n *\n * `scrollWidth` is the layout engine's answer to \"how much content is there\",\n * and dividing it by the stride is the only way to learn the page count without\n * duplicating the engine's work. The gap is added back because `scrollWidth`\n * omits the trailing gap that separates the last column from the edge.\n */\n layout(pageWidth?: number): { pageCount: number; stride: number } {\n const scroller = this.scroller;\n if (!scroller) {\n this.stride = 0;\n this.pageCount = 1;\n return { pageCount: 1, stride: 0 };\n }\n const usable = pageWidth ?? scroller.clientWidth;\n // The stride is the distance between two page origins. Deriving it from the\n // *viewport* rather than from the columns keeps it independent of\n // `column-width`, which the publisher's own CSS could influence.\n this.stride = Math.max(1, usable + this.columnGap);\n\n // The page count comes from the content's real width rather than from a\n // division: `scrollWidth` includes the leading page padding and the column\n // gutters, so dividing it by the stride loses the remainder and drops the\n // last page of a chapter whose content does not fill it exactly. Rounding the\n // division up against the *padded* width is what keeps that page reachable.\n const padded = usable + this.columnGap;\n this.pageCount = Math.max(1, Math.ceil((scroller.scrollWidth - this.columnGap) / padded));\n return { pageCount: this.pageCount, stride: this.stride };\n }\n\n get total(): number {\n return this.pageCount;\n }\n\n get index(): number {\n return this.current;\n }\n\n /** Move to a page, clamped to the chapter. Returns true if the page changed. */\n goTo(page: number): boolean {\n const next = Math.max(0, Math.min(this.pageCount - 1, Math.floor(page)));\n if (next === this.current) return false;\n this.current = next;\n if (this.scroller) this.scroller.scrollLeft = next * this.stride;\n return true;\n }\n\n /**\n * Move by a relative number of pages.\n *\n * `spill` is the number of pages that did not fit; the caller turns it into a\n * chapter turn. Returning it rather than swallowing it is what makes \"the next\n * page past the end of this chapter\" a chapter turn instead of a dead gesture.\n */\n turn(pages: number): { changed: boolean; spill: number } {\n const target = this.current + pages;\n if (target < 0) return { changed: this.goTo(0), spill: target };\n if (target > this.pageCount - 1) {\n const changed = this.goTo(this.pageCount - 1);\n return { changed, spill: target - (this.pageCount - 1) };\n }\n return { changed: this.goTo(target), spill: 0 };\n }\n\n /** Forget the current page; used when a different chapter is loaded. */\n reset(): void {\n this.current = 0;\n this.pageCount = 1;\n if (this.scroller) this.scroller.scrollLeft = 0;\n }\n\n /** Page index for a horizontal scroll offset (e.g. after a user swipe). */\n pageAt(offset: number): number {\n if (this.stride <= 0) return 0;\n return Math.max(0, Math.min(this.pageCount - 1, Math.round(offset / this.stride)));\n }\n}\n\n/**\n * Fraction of the chapter read, from a page index.\n *\n * Reported as a fraction of the chapter rather than of the book: the server\n * cannot know a chapter's length in characters for most books, and inventing one\n * would put a wrong percentage under the reader's thumb. The reader combines this\n * with the chapter's position in the spine, which it does know exactly.\n */\nexport function pageFraction(page: number, pageCount: number): number {\n if (pageCount <= 1) return 0;\n return Math.max(0, Math.min(1, page / (pageCount - 1)));\n}\n\n/**\n * Capture a position that survives repagination.\n *\n * The page index does not: the same index means a different part of the text\n * after a font-size change. Anchoring to the top-level block at the page's\n * leading edge, plus how far into that block, means a reader who resizes the\n * text stays on the sentence they were reading — which is the entire reason\n * readers tolerate changing the font size at all.\n */\nexport function capturePosition(scroller: HTMLElement | null, page: number, stride: number): ChapterPosition {\n const blocks = topLevelBlocks(scroller);\n if (blocks.length === 0) return { blockIndex: 0, offsetRatio: 0 };\n\n const origin = page * stride;\n let index = 0;\n for (let i = 0; i < blocks.length; i += 1) {\n const block = blocks[i]!;\n // `offsetLeft` is relative to the offsetParent, which for a chapter's blocks\n // is the nearest positioned ancestor. That is stable within a document and\n // the only thing that matters here, since capture and restore both happen\n // against the same document.\n if (block.offsetLeft <= origin + 1) index = i;\n else break;\n }\n\n const block = blocks[index]!;\n const height = block.offsetHeight || 1;\n const ratio = Math.max(0, Math.min(1, (origin - block.offsetLeft) / height));\n return { blockIndex: index, offsetRatio: ratio };\n}\n\n/** Restore a captured position. Returns the page to land on. */\nexport function restorePosition(scroller: HTMLElement | null, position: ChapterPosition, stride: number): number {\n const blocks = topLevelBlocks(scroller);\n if (blocks.length === 0) return 0;\n const block = blocks[Math.min(position.blockIndex, blocks.length - 1)];\n if (!block) return 0;\n const left = block.offsetLeft + position.offsetRatio * (block.offsetHeight || 0);\n return stride > 0 ? Math.max(0, Math.round(left / stride)) : 0;\n}\n\n/**\n * The blocks a position can anchor to.\n *\n * Only the body's direct children are considered. Going deeper would anchor to a\n * `<span>` whose offset changes the moment the publisher's CSS nests differently,\n * and anchors have to be stable across the client's own style tweaks — those\n * changes must not move the reader's place in the book.\n */\nfunction topLevelBlocks(scroller: HTMLElement | null): HTMLElement[] {\n const body = scroller?.ownerDocument?.body;\n if (!body) return [];\n return Array.from(body.children).filter((node): node is HTMLElement => node instanceof HTMLElement);\n}\n","/**\n * Neighbour prefetching.\n *\n * The reader's perceived speed is almost entirely about whether the next thing\n * the user does is already in memory. Turning a page, opening the next chapter,\n * scrolling a comic — all three are predictable one step ahead, and a phone on a\n * tunnel feels a two-second round trip on every one of them.\n *\n * The policy is deliberately conservative:\n *\n * - **One step ahead, at most** (configurable). A comic pager that fetched three\n * pages ahead on every turn would download a whole volume in a few swipes,\n * on the reader's data plan.\n * - **A byte budget, not just a count.** A chapter can be 2KB or 8MB; counting\n * requests is not the same as bounding bytes.\n * - **Skipped when the connection says so.** `navigator.connection.saveData`\n * exists for a reason, and `2g` makes prefetching actively harmful.\n * - **Cancelled on navigation.** Prefetching the chapter after the one the user\n * just left is pure waste; the in-flight request is dropped instead of being\n * allowed to finish.\n */\n\nexport interface PrefetchOptions {\n /** How many items past the current one to fetch. */\n ahead?: number;\n /** Ceiling on total prefetched bytes. */\n maxBytes?: number;\n /** Item sizes from the manifest, when the format reports them. */\n sizeOf?: (key: string) => number | undefined;\n}\n\ninterface Entry {\n blob: Blob;\n bytes: number;\n /** Order of arrival, for eviction. */\n seq: number;\n}\n\n/**\n * A bounded, ordered cache of fetched resources.\n *\n * Keys are format-private refs. The cache stores Blobs rather than strings so the\n * same instance serves chapter documents and comic pages; callers decode.\n */\nexport class PrefetchCache {\n private entries = new Map<string, Entry>();\n private pending = new Map<string, Promise<Blob>>();\n private bytes = 0;\n private counter = 0;\n private readonly ahead: number;\n private readonly maxBytes: number;\n\n constructor(options: PrefetchOptions = {}) {\n this.ahead = options.ahead ?? 1;\n this.maxBytes = options.maxBytes ?? 32 * 1024 * 1024;\n }\n\n /** A cached resource, if it is already here. */\n peek(key: string): Blob | undefined {\n return this.entries.get(key)?.blob;\n }\n\n /** Await a resource, joining an in-flight request rather than duplicating it. */\n async get(key: string, load: (key: string) => Promise<Blob>): Promise<Blob> {\n const cached = this.entries.get(key);\n if (cached) return cached.blob;\n\n const inFlight = this.pending.get(key);\n if (inFlight) return inFlight;\n\n const promise = load(key).then((blob) => {\n this.store(key, blob);\n return blob;\n });\n this.pending.set(key, promise);\n try {\n return await promise;\n } finally {\n this.pending.delete(key);\n }\n }\n\n /**\n * Warm the cache for the items around `keys[from]`.\n *\n * Returns a function that cancels the still-running requests, which the caller\n * invokes when the reader moves somewhere else.\n */\n warm(\n keys: readonly string[],\n from: number,\n load: (key: string) => Promise<Blob>,\n options: PrefetchOptions = {},\n ): () => void {\n if (!shouldPrefetch()) return () => undefined;\n const ahead = options.ahead ?? this.ahead;\n const sizeOf = options.sizeOf;\n\n let budget = this.maxBytes - this.bytes;\n const aborted = new Set<string>();\n\n for (let step = 1; step <= ahead; step += 1) {\n const key = keys[from + step];\n if (!key || this.entries.has(key) || this.pending.has(key)) continue;\n const estimate = sizeOf?.(key);\n if (estimate !== undefined && estimate > budget) continue;\n if (estimate !== undefined) budget -= estimate;\n\n void this.get(key, load).catch(() => {\n // A failed prefetch is not an error the reader needs to see: the item is\n // still reachable by asking for it, and the foreground request will\n // report the real problem.\n });\n aborted.add(key);\n }\n\n return () => {\n for (const key of aborted) {\n this.entries.delete(key);\n this.pending.delete(key);\n }\n };\n }\n\n /** Drop everything but `keep`, oldest first. Used when the book changes. */\n trim(keep: number): void {\n if (this.entries.size <= keep) return;\n const ordered = [...this.entries.entries()].sort((a, b) => a[1].seq - b[1].seq);\n for (const [key, entry] of ordered.slice(0, ordered.length - keep)) {\n this.bytes -= entry.bytes;\n this.entries.delete(key);\n }\n }\n\n clear(): void {\n this.entries.clear();\n this.pending.clear();\n this.bytes = 0;\n }\n\n private store(key: string, blob: Blob): void {\n const bytes = blob.size;\n // An oversized single resource is not cached: keeping it would evict\n // everything else and still blow the budget.\n if (bytes > this.maxBytes) return;\n this.bytes += bytes;\n this.entries.set(key, { blob, bytes, seq: this.counter++ });\n while (this.bytes > this.maxBytes) {\n const oldest = [...this.entries.entries()].sort((a, b) => a[1].seq - b[1].seq)[0];\n if (!oldest) break;\n this.bytes -= oldest[1].bytes;\n this.entries.delete(oldest[0]);\n }\n }\n}\n\n/**\n * Whether prefetching is welcome.\n *\n * `saveData` is an explicit request from the user and is honoured as one. A 2G\n * connection is not going to finish a prefetch before the reader wants the item,\n * so the bandwidth is better left for the foreground.\n */\nexport function shouldPrefetch(): boolean {\n const connection = (navigator as Navigator & {\n connection?: { saveData?: boolean; effectiveType?: string };\n }).connection;\n if (!connection) return true;\n if (connection.saveData) return false;\n return connection.effectiveType !== 'slow-2g' && connection.effectiveType !== '2g';\n}\n","/**\n * A tiny typed emitter.\n *\n * The reader emits state (chapter changed, page turned, position updated) and\n * the UI subscribes. A framework's reactive layer would do the same job, but the\n * reader's state changes faster than its DOM should: a page turn must not\n * re-render the chapter, so the two are kept explicitly apart.\n */\nexport type Listener<T> = (value: T) => void;\n\nexport class Emitter<T> {\n private listeners = new Set<Listener<T>>();\n\n on(listener: Listener<T>): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n emit(value: T): void {\n for (const listener of [...this.listeners]) listener(value);\n }\n\n clear(): void {\n this.listeners.clear();\n }\n}\n","/**\n * Reading positions.\n *\n * A position must survive three things a reader does constantly: changing the\n * font size, rotating the phone, and reading the same book on another device.\n * That rules out a page index (changes with the font) and a pixel offset\n * (changes with everything).\n *\n * What is stored instead is a small record:\n *\n * { chapter: <archive path>, spine: <index>, block: <n>, ratio: <0..1>, percent }\n *\n * - `chapter` is the server's own identity for a chapter (its archive path). It\n * is stable across repagination and across devices, which is what makes the\n * sync endpoint's `locator` field useful. It is a plain string, not an EPUB\n * CFI: the server never has to parse it, and a CFI would only work for EPUB\n * anyway — a comic's position is a page index, a PDF's is a page number.\n * - `block`/`ratio` place the reader inside the chapter, tolerantly: a missing\n * block falls back to the start of the chapter rather than to the start of\n * the book.\n * - `percent` is for the progress indicator, computed against the spine the\n * client knows exactly (chapter index / chapter count), never against a\n * character count nobody measured.\n */\n\nexport interface Locator {\n /** Format-private chapter identity; the manifest's `href`. */\n chapter: string;\n /** Index of the chapter in the spine, when the book has one. */\n spine: number;\n /** Top-level block index inside the chapter. */\n block: number;\n /** Fraction into that block. */\n ratio: number;\n /** Whole-book progress in [0, 1]. */\n percent: number;\n}\n\n/** Parse a stored locator, tolerating anything the server or an older client wrote. */\nexport function parseLocator(value: string): Locator | null {\n if (!value) return null;\n\n // Older builds wrote a bare chapter reference. Accepting it costs three lines\n // and saves every reader who had a position stored under that format — but only\n // when the value actually looks like a reference. A truncated or corrupted\n // value must fall through to null, because treating it as a chapter href would\n // send the reader to wherever that string happens to sort in the manifest.\n if (!value.startsWith('{')) {\n return /^[\\w-]+:/.test(value) ? { chapter: value, spine: 0, block: 0, ratio: 0, percent: 0 } : null;\n }\n\n try {\n const parsed = JSON.parse(value) as Partial<Locator>;\n if (typeof parsed.chapter !== 'string') return null;\n return {\n chapter: parsed.chapter,\n spine: numberOr(parsed.spine, 0),\n block: numberOr(parsed.block, 0),\n ratio: clampRatio(parsed.ratio),\n percent: clampRatio(parsed.percent),\n };\n } catch {\n return null;\n }\n}\n\nexport function serializeLocator(locator: Locator): string {\n return JSON.stringify(locator);\n}\n\nfunction numberOr(value: unknown, fallback: number): number {\n return typeof value === 'number' && Number.isFinite(value) ? value : fallback;\n}\n\nfunction clampRatio(value: unknown): number {\n if (typeof value !== 'number' || !Number.isFinite(value)) return 0;\n return Math.max(0, Math.min(1, value));\n}\n\n/**\n * Whole-book progress from a spine position.\n *\n * Within-chapter progress is weighted by the chapter's share of the spine, so a\n * reader at the middle of chapter 50 of 100 reads 50% and not 0%. Weighting by\n * character count would be more accurate and is not available — the server\n * reports a chapter count and honestly reports no length, so this uses what\n * exists rather than inventing the rest.\n */\nexport function bookPercent(spine: number, spineTotal: number, fractionInChapter: number): number {\n if (spineTotal <= 0) return 0;\n return Math.max(0, Math.min(1, (spine + fractionInChapter) / spineTotal));\n}\n","/**\n * Asset URLs that a browser can fetch on its own.\n *\n * A chapter document is rendered by the browser, which then requests its\n * `<img src>`, `<link href>` and `@font-face` resources itself. Those requests\n * cannot carry an `Authorization` header — there is no API to attach one to an\n * element's URL.\n *\n * The two available answers are both bad in different ways:\n *\n * 1. **Fetch everything in JavaScript and hand out Blob URLs.** This works, and\n * it means the client downloads and buffers every image in a chapter before\n * the book can render, defeats the browser's own caching, and makes an\n * `iframe`'s range requests for a large plate image impossible. For a chapter\n * with twelve illustrations it is several megabytes of JavaScript-side work on\n * every turn.\n * 2. **Put the token in the URL.** The browser's own caching, range requests and\n * parallel image loading all keep working, and the exposure is bounded by the\n * server only accepting a query token on read endpoints for immutable content.\n *\n * The second is what this module does, and the server's `queryTokenAllowed` is\n * the other half of the arrangement — it is an allowlist, not a blanket rule, so\n * a leaked URL cannot trigger a scan or write metadata.\n *\n * Tokens in URLs do leak: into browser history, into a proxy's access log, into a\n * `Referer` header. Three things keep that acceptable here. Asset URLs are only\n * ever built for a document that is being displayed in the reading frame, never\n * for an outbound link. They are scoped to a single book and expire with the\n * access token. And the endpoint they reach serves immutable content addressed by\n * content hash — the worst a leaked URL gives away is a page of a book whose\n * reader already had it.\n */\n\nimport type { ApiClient } from './api.ts';\n\n/**\n * Rewrite the asset URLs inside a chapter document to carry the token.\n *\n * The server has already made every relative reference absolute, so this only has\n * to append a parameter to the URLs that point back at the asset endpoint. URLs\n * that already carry `access_token` are left alone, which makes the function\n * idempotent — it runs on every chapter, including one served from the prefetch\n * cache.\n *\n * `DOMParser` rather than a regex: the document is XHTML and its markup is the\n * publisher's, so entity handling and attribute quoting are the parser's job, not\n * a pattern's. The parse is done once per chapter and the serialization is what\n * the frame receives anyway.\n */\nexport function withAssetToken(html: string, api: ApiClient): string {\n const token = api.currentSession?.accessToken;\n if (!token) return html;\n\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'application/xhtml+xml');\n\n // A parse error means the chapter is not well-formed XML. The container reports\n // that to the reader with the HTML fallback; here it just means no rewriting,\n // because rewriting a document we could not parse risks making it worse.\n if (doc.querySelector('parsererror')) return html;\n\n let touched = false;\n const attributes = ['src', 'href', 'poster'] as const;\n for (const attribute of attributes) {\n for (const element of Array.from(doc.querySelectorAll(`[${attribute}]`))) {\n const value = element.getAttribute(attribute);\n if (!value || !isApiAsset(value, api)) continue;\n if (hasToken(value)) continue;\n element.setAttribute(attribute, appendToken(value, token));\n touched = true;\n }\n }\n // `style` attributes and `<style>` blocks can carry `url(...)` too, which is\n // how a publisher ships a background image.\n for (const element of Array.from(doc.querySelectorAll('[style]'))) {\n const value = element.getAttribute('style');\n if (!value) continue;\n const rewritten = rewriteCssUrls(value, api, token);\n if (rewritten !== value) {\n element.setAttribute('style', rewritten);\n touched = true;\n }\n }\n for (const style of Array.from(doc.querySelectorAll('style'))) {\n const value = style.textContent;\n if (!value) continue;\n const rewritten = rewriteCssUrls(value, api, token);\n if (rewritten !== value) {\n style.textContent = rewritten;\n touched = true;\n }\n }\n\n if (!touched) return html;\n return `<?xml version=\"1.0\" encoding=\"utf-8\"?>\\n${new XMLSerializer().serializeToString(doc.documentElement)}`;\n}\n\n/** Whether a URL points at this instance's asset endpoint. */\nfunction isApiAsset(value: string, api: ApiClient): boolean {\n if (value.startsWith('/api/')) return true;\n const base = api.baseUrl;\n return base.length > 0 && value.startsWith(`${base}/api/`);\n}\n\nfunction hasToken(value: string): boolean {\n return value.includes('access_token=');\n}\n\nfunction appendToken(value: string, token: string): string {\n const [path, fragment] = splitFragment(value);\n const separator = path.includes('?') ? '&' : '?';\n return `${path}${separator}access_token=${encodeURIComponent(token)}${fragment}`;\n}\n\n/** Rewrite `url(...)` inside a CSS string, leaving anything else untouched. */\nfunction rewriteCssUrls(css: string, api: ApiClient, token: string): string {\n return css.replace(/url\\(\\s*(['\"]?)([^'\")]+)\\1\\s*\\)/gi, (full, quote: string, value: string) => {\n if (!isApiAsset(value, api) || hasToken(value)) return full;\n return `url(${quote}${appendToken(value, token)}${quote})`;\n });\n}\n\nfunction splitFragment(value: string): [string, string] {\n const index = value.indexOf('#');\n if (index < 0) return [value, ''];\n return [value.slice(0, index), value.slice(index)];\n}\n","/**\n * The reflowable book reader.\n *\n * Owns the chapter lifecycle for EPUB and TXT:\n *\n * chapter N-1 (cached) → chapter N (mounted, paginated) → chapter N+1 (prefetched)\n *\n * Four decisions are load-bearing and worth stating.\n *\n * ### 1. Chapters are fetched one at a time, and windowed\n *\n * The whole book is never downloaded. The manifest carries the first window of\n * chapters; turning past its last one fetches the next window. A 1200-chapter\n * omnibus therefore costs one window of metadata and one chapter of bytes to\n * open, and the client's memory holds at most three chapters.\n *\n * ### 2. Chapter identity is the server's `href`, never a spine index\n *\n * Windowed manifests return a prefix of the spine, so an index computed against\n * a window means a different chapter in the full spine. This was a real bug in\n * the first version of the server contract and is why items are addressed by\n * archive path.\n *\n * ### 3. Progress is reported on the chapter's turn, not on every page\n *\n * A page turn happens several times a minute and each one is a write to the\n * user's account. Reporting on page turn would be a request per swipe; reporting\n * only on chapter change would lose the reader's place inside a chapter. So it is\n * debounced, and flushed on close and on visibility change — the two moments a\n * browser may not give us another chance.\n *\n * ### 4. Append-vs-replace for the text of a book with no spine\n *\n * A TXT file with no chapter headings has no addressable structure. Scrolling it\n * as a stream is the only honest option, and the server exposes it as byte\n * ranges (`chunk:<offset>`). The reader appends rather than replacing, because\n * replacing would fight the reader's own scroll position.\n */\n\nimport { ApiClient, type BookContent, type BookDto, type ContentItem } from '../net/api.ts';\nimport {\n contains,\n groupForSpine,\n toLocalIndex,\n toSpineIndex,\n toWindow,\n type Window,\n} from './window.ts';\nimport { ChapterContainer, type ReaderStyle } from './container.ts';\nimport { Paginator, capturePosition, pageFraction, restorePosition } from './paginator.ts';\nimport { PrefetchCache } from './prefetch.ts';\nimport { Emitter } from '../lib/events.ts';\nimport { bookPercent, parseLocator, serializeLocator, type Locator } from '../lib/locator.ts';\nimport { withAssetToken } from '../net/asset-url.ts';\n\nexport interface ReaderState {\n book: BookDto;\n /** All groups, so the table of contents is complete before every window is fetched. */\n groups: BookContent['groups'];\n /** Items of the loaded window. */\n items: ContentItem[];\n /** Index into `items` of the chapter on screen. */\n index: number;\n /** Total chapters in the book (not just this window). */\n total: number;\n page: number;\n pageCount: number;\n warnings: string[];\n}\n\nexport interface ReaderOptions {\n /** Device name recorded with each progress update. */\n device?: string;\n /** Milliseconds to coalesce progress writes; 0 disables debouncing. */\n progressDebounceMs?: number;\n /** Host platform, so the reader can adapt gesture expectations. */\n platform?: 'web' | 'android';\n /** Type size and page width, mirrored into each chapter document. */\n style: ReaderStyle;\n}\n\nexport class BookReader {\n readonly state: Emitter<ReaderState> = new Emitter();\n private readonly container: ChapterContainer;\n private readonly paginator: Paginator;\n private readonly cache = new PrefetchCache();\n private window: Window | null = null;\n private current: ReaderState | null = null;\n private locator: Locator | null = null;\n private progressTimer: ReturnType<typeof setTimeout> | null = null;\n private cancelPrefetch: (() => void) | null = null;\n private readonly device: string;\n\n private style: ReaderStyle;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: ReaderOptions,\n ) {\n this.device = options.device ?? 'web';\n this.style = options.style;\n this.container = new ChapterContainer(host);\n this.paginator = new Paginator(null, {\n columnGap: options.style.columnGap,\n pagePadding: options.style.pagePadding,\n });\n }\n\n /**\n * Open the book at a stored position.\n *\n * One request. The manifest carries the addressable structure as well as the\n * book, so there is no second round trip before the first page can be drawn —\n * which over a tunnel is the difference between a book opening and a book that\n * appears to hang.\n */\n async open(bookId: string, group = 0): Promise<ReaderState> {\n const manifest = await this.api.manifest(bookId, group);\n this.window = toWindow(manifest.content ?? null, {\n ...(manifest.kind !== undefined ? { kind: manifest.kind } : {}),\n ...(manifest.total !== undefined ? { total: manifest.total } : {}),\n ...(manifest.groups !== undefined ? { groups: manifest.groups } : {}),\n ...(manifest.items !== undefined ? { items: manifest.items } : {}),\n });\n\n const saved = parseLocator(\n (await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',\n );\n this.locator = saved;\n\n // A saved chapter is matched by its `href`, which is the only identifier\n // that survives a window boundary. Falling back to the window's first item\n // is right: it is where a book starts.\n const target = saved\n ? this.window.items.findIndex((item) => item.href === saved.chapter)\n : -1;\n await this.show(target >= 0 ? target : 0, saved ?? undefined);\n return this.current!;\n }\n\n /** The chapter's document, for highlight and search integration. */\n get chapterDocument(): Document | null {\n return this.container.document();\n }\n\n /** Turn pages; a spill at either end moves to the neighbouring chapter. */\n async turn(pages: number): Promise<void> {\n if (!this.current || !this.window) return;\n const { changed, spill } = this.paginator.turn(pages);\n if (spill !== 0) {\n const next = this.current.index + Math.sign(spill);\n if (next < 0 || next >= this.window.items.length) {\n if (changed) this.publish();\n return;\n }\n await this.show(next);\n // A forward spill lands on the *first* page of the next chapter and a\n // backward spill on its last, which is what a real book does.\n if (spill > 0) this.paginator.goTo(0);\n else this.paginator.goTo(this.paginator.total - 1);\n this.publish();\n return;\n }\n if (changed) this.publish();\n }\n\n /** Jump to a chapter by index within the loaded window. */\n /**\n * Jump to a whole-book position.\n *\n * Fetches the window that contains it when necessary, which is what makes the\n * table of contents of a 1200-chapter book work with only one window in memory.\n */\n async goToSpine(spine: number): Promise<void> {\n if (!this.window) return;\n await this.ensureWindow(spine);\n if (!this.window || !contains(this.window, spine)) return;\n await this.show(toLocalIndex(this.window, spine));\n }\n\n /**\n * Jump to a table-of-contents entry.\n *\n * Either a whole-book index or a chapter href works: a comic's table of\n * contents is a list of volumes, and its entries map to a group rather than to\n * a single chapter.\n */\n async goToSpineOfHref(href: string): Promise<boolean> {\n if (!this.window) return false;\n const local = this.window.items.findIndex((item) => item.href === href);\n if (local >= 0) {\n await this.show(local);\n return true;\n }\n return false;\n }\n\n /** Recompute geometry after a resize, keeping the reader's place. */\n relayout(): void {\n if (!this.current) return;\n const geometry = this.paginator.layout(this.container.width());\n const position = capturePosition(this.container.scroller(), this.paginator.index, geometry.stride);\n const after = this.paginator.layout(this.container.width());\n this.paginator.goTo(restorePosition(this.container.scroller(), position, after.stride));\n this.publish();\n }\n\n /** Update the type size and repaginate, keeping the reader's place. */\n async restyle(style: ReaderStyle): Promise<void> {\n this.style = style;\n if (!this.current) return;\n await this.reshow();\n }\n\n /**\n * Re-render the current chapter from the cache.\n *\n * Used after a type-size change: a chapter document is styled at load time, and\n * the only way to re-style it is to load it again. Reading it from the prefetch\n * cache makes this a re-layout rather than a network round trip.\n */\n private async reshow(): Promise<void> {\n const state = this.current;\n if (!state) return;\n const item = this.window?.items[state.index];\n if (!item) return;\n const stride = this.paginator.layout(this.container.width()).stride;\n const position = capturePosition(this.container.scroller(), this.paginator.index, stride);\n await this.show(state.index, {\n chapter: item.href,\n spine: toSpineIndex(this.window!, state.index),\n block: position.blockIndex,\n ratio: position.offsetRatio,\n percent: state.pageCount > 1 ? state.page / state.pageCount : 0,\n });\n }\n\n /**\n * Flush the pending progress write.\n *\n * Called on `pagehide` and when the app goes to the background. A browser that\n * is being closed does not run another timer, so a debounced write that is not\n * flushed here is a lost position.\n */\n async flush(): Promise<void> {\n if (this.progressTimer) {\n clearTimeout(this.progressTimer);\n this.progressTimer = null;\n }\n await this.writeProgress();\n }\n\n destroy(): void {\n this.cancelPrefetch?.();\n this.cache.clear();\n this.container.destroy();\n this.state.clear();\n }\n\n // ---------------------------------------------------------------- internals\n\n private async show(index: number, restore?: Locator): Promise<void> {\n const window = this.window;\n if (!window) return;\n const item = window.items[index];\n if (!item) return;\n\n const xml = await this.cache\n .get(item.href, () => this.api.asset(this.book.id, item.href))\n .then((blob) => blob.text());\n\n // The chapter's own images and stylesheets are fetched by the browser, not by\n // us, so their URLs have to be self-authenticating before the document is\n // handed to the frame.\n await this.container.mount(withAssetToken(xml, this.api), this.style);\n // The scroller belongs to the chapter's own document, so the paginator is\n // pointed at it after the frame has loaded and laid out.\n this.paginator.attach(this.container.scroller());\n const geometry = this.paginator.layout(this.container.width());\n\n // Restoring an intra-chapter position is only meaningful for the chapter it\n // was captured in. Landing in a different chapter uses its first page.\n const target =\n restore && restore.chapter === item.href\n ? restorePosition(this.container.scroller(), { blockIndex: restore.block, offsetRatio: restore.ratio }, geometry.stride)\n : 0;\n this.paginator.goTo(Math.min(target, geometry.pageCount - 1));\n\n this.current = {\n book: this.book,\n groups: window.groups,\n items: window.items,\n index,\n total: window.total,\n page: this.paginator.index,\n pageCount: this.paginator.total,\n warnings: [],\n };\n this.scheduleProgress();\n this.prefetchFrom(index);\n this.publish();\n }\n\n private publish(): void {\n if (!this.current) return;\n const state: ReaderState = {\n ...this.current,\n page: this.paginator.index,\n pageCount: this.paginator.total,\n };\n this.current = state;\n this.state.emit(state);\n }\n\n private scheduleProgress(): void {\n const delay = this.options.progressDebounceMs ?? 4000;\n if (delay === 0) {\n void this.writeProgress();\n return;\n }\n if (this.progressTimer) clearTimeout(this.progressTimer);\n this.progressTimer = setTimeout(() => {\n this.progressTimer = null;\n void this.writeProgress();\n }, delay);\n }\n\n private async writeProgress(): Promise<void> {\n const window = this.window;\n const state = this.current;\n if (!window || !state) return;\n const item = window.items[state.index];\n if (!item) return;\n\n const stride = this.paginator.layout(this.container.width()).stride;\n const position = capturePosition(this.container.scroller(), this.paginator.index, stride);\n const fraction = pageFraction(this.paginator.index, this.paginator.total);\n // The spine index is derived from the loaded window's group, never from the\n // window's own array offset: `items` holds one window, so `index` is local.\n const spine = toSpineIndex(window, state.index);\n const locator: Locator = {\n chapter: item.href,\n spine,\n block: position.blockIndex,\n ratio: position.offsetRatio,\n percent: bookPercent(spine, window.total, fraction),\n };\n this.locator = locator;\n\n await this.api\n .putProgress(this.book.id, {\n locator: serializeLocator(locator),\n percentage: locator.percent,\n chapterTitle: item.title,\n device: this.device,\n updatedAt: Date.now(),\n })\n .catch(() => {\n // A failed progress write is not worth interrupting reading for. The next\n // turn retries, and the position is still on the device.\n });\n }\n\n /** Prefetch the next chapter, and cancel the previous prefetch. */\n private prefetchFrom(index: number): void {\n const window = this.window;\n if (!window) return;\n this.cancelPrefetch?.();\n const keys = window.items.map((item) => item.href);\n this.cancelPrefetch = this.cache.warm(keys, index, (key) => this.api.asset(this.book.id, key), {\n // A phone on a tunnel re-reading a volume should not download it three\n // pages at a time, but it should not stall on every turn either.\n ahead: this.options.platform === 'android' ? 1 : 2,\n sizeOf: (key) => window.items.find((item) => item.href === key)?.size,\n });\n }\n\n /** Load the window holding a whole-book index, if it is not loaded already. */\n private async ensureWindow(spine: number): Promise<void> {\n if (!this.window || contains(this.window, spine)) return;\n const group = groupForSpine(this.window, spine);\n if (!group) return;\n const loaded = await this.api.items(this.book.id, group.seq);\n this.window = toWindow(loaded);\n }\n}\n","/**\n * The paged reader: comics, image directories, single images.\n *\n * Deliberately much simpler than the reflowable reader, and for a reason the\n * user's own message named: **a comic is not text, so it should not go through a\n * WebView at all.** A native `ImageView` decodes a JPEG with hardware support and\n * recycles the bitmap; an `<img>` inside a page on a low-end Android device is\n * several times the memory and CPU for the same screen. The Android shell\n * therefore renders this content natively and only uses this module for the H5\n * build.\n *\n * What both implementations share is the addressing: `items[].href` from the\n * manifest, fetched one page at a time. That is what makes \"download the whole\n * volume\" a client decision rather than a requirement — a reader who flips\n * through three pages of a 300MB volume has downloaded three pages.\n *\n * Three details that are easy to get wrong:\n *\n * - **Page order comes from the manifest, never from the file name.** The server\n * already applied natural ordering (`page10` after `page2`), and re-sorting on\n * the client would disagree with it for archives with a directory layout.\n * - **Object URLs are revoked.** Each page is a Blob; leaking one per page turn\n * is a few hundred megabytes over a volume.\n * - **The next page is decoded before it is shown.** Swapping `img.src` and\n * letting the browser paint on arrival shows a blank frame for a moment, which\n * reads as a stutter on every single turn.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\nimport { Emitter } from '../lib/events.ts';\nimport { PrefetchCache } from './prefetch.ts';\nimport { toWindow, type Window } from './window.ts';\nimport { parseLocator, serializeLocator, type Locator } from '../lib/locator.ts';\n\nexport interface PagedState {\n book: BookDto;\n index: number;\n total: number;\n /** Set when the current page is still being fetched. */\n loading: boolean;\n warnings: string[];\n}\n\nexport interface PagedOptions {\n device?: string;\n progressDebounceMs?: number;\n /**\n * Host platform. Android renders pages natively and never constructs this\n * reader; the option exists so the H5 build can declare itself explicitly\n * rather than relying on which module imported it.\n */\n platform?: 'web' | 'android';\n}\n\nexport class PagedReader {\n readonly state = new Emitter<PagedState>();\n private readonly cache = new PrefetchCache({ ahead: 2, maxBytes: 24 * 1024 * 1024 });\n private window: Window | null = null;\n private index = 0;\n private objectUrl: string | null = null;\n private progressTimer: ReturnType<typeof setTimeout> | null = null;\n private cancelPrefetch: (() => void) | null = null;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: PagedOptions = {},\n ) {}\n\n /**\n * Open at a stored page.\n *\n * A comic's position is a page index, which is a stable locator only because it\n * is resolved against the manifest: a volume added in the middle of a series\n * moves every later page, and the server's group offsets are what let the\n * client detect that and land on the same *volume* rather than the same offset.\n */\n async open(bookId: string, group = 0): Promise<PagedState> {\n const manifest = await this.api.manifest(bookId, group);\n this.window = toWindow(manifest.content ?? null, {\n ...(manifest.kind !== undefined ? { kind: manifest.kind } : {}),\n ...(manifest.total !== undefined ? { total: manifest.total } : {}),\n ...(manifest.groups !== undefined ? { groups: manifest.groups } : {}),\n ...(manifest.items !== undefined ? { items: manifest.items } : {}),\n });\n\n const saved = parseLocator(\n (await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',\n );\n const local = saved ? this.window.items.findIndex((item) => item.href === saved.chapter) : -1;\n await this.show(local >= 0 ? local : 0);\n return this.currentState();\n }\n\n get total(): number {\n return this.window?.items.length ?? 0;\n }\n\n /** Move by a number of pages. Stops at the ends rather than wrapping. */\n async turn(pages: number): Promise<void> {\n await this.show(this.index + pages);\n }\n\n async goTo(index: number): Promise<void> {\n await this.show(index);\n }\n\n async flush(): Promise<void> {\n if (this.progressTimer) {\n clearTimeout(this.progressTimer);\n this.progressTimer = null;\n }\n await this.writeProgress();\n }\n\n destroy(): void {\n this.cancelPrefetch?.();\n this.cache.clear();\n this.releaseUrl();\n this.state.clear();\n }\n\n private async show(index: number): Promise<void> {\n const window = this.window;\n if (!window || window.items.length === 0) return;\n const clamped = Math.max(0, Math.min(window.items.length - 1, index));\n const item = window.items[clamped];\n if (!item) return;\n\n this.index = clamped;\n this.emit(true);\n\n const blob = await this.cache.get(item.href, (key) => this.api.asset(this.book.id, key));\n // A page the reader has already flipped past must not replace the current\n // one: a slow request racing a fast thumb would otherwise show an old page.\n if (this.index !== clamped) return;\n\n const url = URL.createObjectURL(blob);\n const previous = this.objectUrl;\n this.objectUrl = url;\n\n const image = document.createElement('img');\n image.className = 'reader-page';\n image.alt = item.title;\n image.decoding = 'async';\n // Decode before mounting: without this the container is empty for as long as\n // the browser takes to decode, which is visible on every turn for a large scan.\n await image.decode().catch(() => undefined);\n this.host.replaceChildren(image);\n image.src = url;\n if (previous) URL.revokeObjectURL(previous);\n\n this.emit(false);\n this.emitProgress();\n this.prefetch(clamped);\n }\n\n private prefetch(index: number): void {\n const window = this.window;\n if (!window) return;\n this.cancelPrefetch?.();\n const keys = window.items.map((item) => item.href);\n this.cancelPrefetch = this.cache.warm(keys, index, (key) => this.api.asset(this.book.id, key), {\n sizeOf: (key) => window.items.find((item) => item.href === key)?.size,\n });\n }\n\n private emitProgress(): void {\n const delay = this.options.progressDebounceMs ?? 2000;\n if (delay === 0) {\n void this.writeProgress();\n return;\n }\n if (this.progressTimer) clearTimeout(this.progressTimer);\n this.progressTimer = setTimeout(() => {\n this.progressTimer = null;\n void this.writeProgress();\n }, delay);\n }\n\n private async writeProgress(): Promise<void> {\n const window = this.window;\n if (!window) return;\n const item = window.items[this.index];\n if (!item) return;\n // For a paged book the locator is the page's own identity, which the server\n // handed us. Rebuilding it from an index would break the moment a volume is\n // re-packed with an extra page.\n const locator: Locator = {\n chapter: item.href,\n spine: (window.group?.offset ?? 0) + this.index,\n block: 0,\n ratio: 0,\n percent: this.index / Math.max(1, window.items.length),\n };\n await this.api\n .putProgress(this.book.id, {\n locator: serializeLocator(locator),\n percentage: locator.percent,\n chapterTitle: item.title,\n device: this.options.device ?? 'web',\n updatedAt: Date.now(),\n })\n .catch(() => undefined);\n }\n\n private emit(loading: boolean): void {\n this.state.emit({\n book: this.book,\n index: this.index,\n total: this.total,\n loading,\n warnings: [],\n });\n }\n\n private currentState(): PagedState {\n return {\n book: this.book,\n index: this.index,\n total: this.total,\n loading: false,\n warnings: [],\n };\n }\n\n private releaseUrl(): void {\n if (this.objectUrl) {\n URL.revokeObjectURL(this.objectUrl);\n this.objectUrl = null;\n }\n }\n}\n","/**\n * The plain-text reader.\n *\n * TXT is the format that punishes naive implementations, because everything the\n * reader wants — a page count, a table of contents, a scroll position that means\n * something — depends on the text being measured, and the text is 20MB.\n *\n * The approach:\n *\n * - **A table of contents comes from the server.** Chapter headings are detected\n * server-side (encoding-aware) and returned as items. The client does not\n * re-scan the text for headings, which would mean downloading the whole file\n * to build a contents list.\n * - **Chapters are fetched one at a time.** A book with headings is read exactly\n * like an EPUB, which is why this module is a thin layer over the reflowable\n * reader rather than the reader.\n * - **A book without headings is streamed in chunks.** The server exposes byte\n * ranges; the client appends them as the reader reaches the bottom. Appending\n * rather than replacing is what keeps a scroll position valid.\n *\n * Deliberate omission: this reader does not re-typeset the text. No paragraph\n * inference, no smart quotes, no reflow. A TXT file with hard-wrapped lines is\n * rendered exactly as it is stored, because guessing at paragraph boundaries\n * mangles as many books as it fixes — and 「精品排版」 is a promise made for\n * EPUB, not for a file that has no typesetting to preserve.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\nimport { Emitter } from '../lib/events.ts';\n\n/** Size of a streamed chunk, matching the server's own window. */\nconst CHUNK_CHARS = 256 * 1024;\n/** Stop appending this far from the end. */\nconst APPEND_THRESHOLD_PX = 1200;\n\nexport interface TextState {\n book: BookDto;\n /** Position in the text, in characters. */\n offset: number;\n /** Chapters when the book has headings; empty when it does not. */\n chapters: number;\n /** Whether more text is available to append. */\n hasMore: boolean;\n loading: boolean;\n}\n\nexport interface TextOptions {\n device?: string;\n}\n\n/**\n * Read a plain-text book.\n *\n * Two modes, chosen by whether the server found headings. This is not a\n * simplification: the choice changes what \"a position\" means (a chapter index or\n * a character offset) and therefore what a progress update contains.\n */\nexport class TextReader {\n readonly state = new Emitter<TextState>();\n private offset = 0;\n private lastLength = 0;\n private loading = false;\n private finished = false;\n private readonly scroll: HTMLElement;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: TextOptions = {},\n ) {\n this.scroll = document.createElement('div');\n this.scroll.className = 'reader-text';\n this.host.replaceChildren(this.scroll);\n }\n\n /**\n * Stream the book, appending as the reader approaches the end.\n *\n * The offset is tracked in characters rather than bytes because the client\n * counts characters: the server's `chunk:` references are character offsets,\n * and the reply's length is reported in bytes. Mixing the two would cut a\n * Chinese novel in the middle of a character every 256KB — a visible defect\n * roughly every few pages.\n */\n async open(bookId: string): Promise<void> {\n await this.append();\n this.scroll.addEventListener('scroll', () => this.maybeAppend());\n }\n\n /** Load the next chapter, when the book has a table of contents. */\n async goToChapter(index: number, bookId: string): Promise<void> {\n const blob = await this.api.asset(bookId, `chapter:${index}`);\n this.scroll.replaceChildren(paragraphs(await blob.text()));\n this.offset = 0;\n this.scroll.scrollTop = 0;\n this.emit(false);\n await this.saveProgress(bookId, index);\n }\n\n async flush(bookId: string): Promise<void> {\n await this.saveProgress(bookId, null);\n }\n\n destroy(): void {\n this.host.replaceChildren();\n this.state.clear();\n }\n\n private maybeAppend(): void {\n if (this.finished || this.loading) return;\n const remaining = this.scroll.scrollHeight - this.scroll.scrollTop - this.scroll.clientHeight;\n if (remaining > APPEND_THRESHOLD_PX) return;\n void this.append();\n }\n\n private async append(): Promise<void> {\n if (this.loading || this.finished) return;\n this.loading = true;\n this.emit(true);\n try {\n const blob = await this.api.asset(this.book.id, `chunk:${this.offset}`);\n const text = await blob.text();\n if (text.length === 0) {\n this.finished = true;\n return;\n }\n this.offset += text.length;\n this.scroll.append(paragraphs(text));\n // A short reply means the end was reached; asking again would return empty\n // forever and keep a spinner on screen.\n if (text.length < CHUNK_CHARS) this.finished = true;\n } finally {\n this.loading = false;\n this.emit(false);\n }\n }\n\n private async saveProgress(bookId: string, chapter: number | null): Promise<void> {\n // A streamed text has no known total length, so progress is \"how far in\" as a\n // fraction of what has been loaded. Reporting it as whole-book progress would\n // need the byte count the server deliberately does not compute.\n const loaded = this.scroll.scrollHeight || 1;\n const ratio = Math.max(0, Math.min(1, this.scroll.scrollTop / loaded));\n const locator =\n chapter !== null\n ? JSON.stringify({ chapter: `chapter:${chapter}`, spine: chapter, block: 0, ratio, percent: ratio })\n : JSON.stringify({ chapter: `chunk:${Math.round(this.offset * ratio)}`, spine: 0, block: 0, ratio, percent: ratio });\n await this.api\n .putProgress(bookId, {\n locator,\n percentage: ratio,\n chapterTitle: chapter !== null ? `第 ${chapter + 1} 章` : '',\n device: this.options.device ?? 'web',\n updatedAt: Date.now(),\n })\n .catch(() => undefined);\n }\n\n private emit(loading: boolean): void {\n this.state.emit({\n book: this.book,\n offset: this.offset,\n chapters: 0,\n hasMore: !this.finished,\n loading,\n });\n }\n}\n\n/**\n * Split text into paragraphs for rendering.\n *\n * `<br>` between lines rather than one element per paragraph: a TXT file's blank\n * lines are the only paragraph signal it has, and treating a blank line as a\n * break while rendering single newlines as hard breaks is the most faithful\n * reading of a format that has no markup. Collapsing them would reflow a book\n * whose author wrapped at 80 columns, which is most of them.\n */\nfunction paragraphs(text: string): DocumentFragment {\n const fragment = document.createDocumentFragment();\n const lines = text.split(/\\r\\n|\\r|\\n/);\n let buffer: string[] = [];\n\n const flush = (): void => {\n if (buffer.length === 0) return;\n const block = document.createElement('p');\n // Text content, never innerHTML: a TXT file is user data and a `&` or a `<`\n // in a novel must not be able to produce markup.\n block.textContent = buffer.join('\\n');\n block.style.whiteSpace = 'pre-wrap';\n fragment.append(block);\n buffer = [];\n };\n\n for (const line of lines) {\n if (line.trim() === '') flush();\n else buffer.push(line);\n }\n flush();\n return fragment;\n}\n","/**\n * The PDF surface.\n *\n * PDF is explicitly a \"保底可读\" format in this product, and this module keeps\n * that promise literal: the server sends the original bytes and the client hands\n * them to a PDF surface it does not own.\n *\n * ## Why an iframe here, when the reflowable reader refused one\n *\n * The container module explains the reasoning at length; the short version is\n * that the trade flips for PDF. A reflowable chapter needs to be measured,\n * paginated and selected *by this client*, and a frame would force a second\n * paginator inside it. A PDF needs none of that: the browser's own viewer already\n * handles page layout, zoom, text selection, search and print, and it expects to\n * own a browsing context to do it. Fighting that to keep a single container type\n * would mean reimplementing a PDF viewer, which is a project of its own.\n *\n * On Android the same logic points at the platform viewer, which is why the shell\n * routes `document`-kind books away from the WebView entirely.\n *\n * ## Why not PDF.js\n *\n * It would bundle about a megabyte of JavaScript plus a worker into both the H5\n * and the Android assets to do worse than the browser's built-in viewer on the\n * device it is running on, and worse than the platform viewer on Android. The\n * server also refuses to parse the page tree, so a PDF.js-based client would\n * still have to discover the page count itself. There is no version of this that\n * is better than \"give the bytes to a viewer\".\n *\n * ## What the client does contribute\n *\n * Progress. The viewer is opaque, so the position has to come from somewhere the\n * client can observe: the URL fragment (`#page=N`), which is the one interface\n * every PDF viewer agrees on. Writing it is how \"continue reading\" works for a\n * PDF at all; reading it back is not possible without the viewer's cooperation,\n * so a PDF's progress is coarse — page-level, from the client's own tracking of\n * where it sent the viewer — and that limitation is stated rather than papered\n * over with a guess.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\nimport { Emitter } from '../lib/events.ts';\nimport { parseLocator, serializeLocator } from '../lib/locator.ts';\n\nexport interface PdfState {\n book: BookDto;\n /** Page the viewer was last sent to, when the client knows it. */\n page: number;\n /** The URL the viewer is showing, for diagnostics. */\n url: string;\n}\n\nexport interface PdfOptions {\n device?: string;\n}\n\nexport class PdfReader {\n readonly state = new Emitter<PdfState>();\n private frame: HTMLIFrameElement | null = null;\n private page = 1;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: PdfOptions = {},\n ) {}\n\n /**\n * Open the document.\n *\n * The URL carries the auth token in a query parameter rather than a header,\n * because an iframe cannot set headers. The server accepts `?access_token=` on\n * asset and content routes for exactly this reason. It is the one place a\n * token travels in a URL, and it is deliberate: the alternative is proxying the\n * whole document through `fetch` and a Blob URL, which defeats the viewer's own\n * range requests and makes a 100MB manual load entirely before the first page\n * appears.\n */\n async open(bookId: string): Promise<PdfState> {\n const saved = parseLocator(\n (await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',\n );\n if (saved?.block) this.page = Math.max(1, saved.block);\n\n const frame = document.createElement('iframe');\n frame.className = 'reader-pdf';\n // `allow-scripts` is not granted: the document is the user's own file, but\n // there is no feature the viewer needs that requires it, and withholding it\n // costs nothing.\n frame.setAttribute('sandbox', 'allow-same-origin allow-popups allow-forms');\n frame.title = this.book.title;\n this.frame = frame;\n this.host.replaceChildren(frame);\n this.setPage(bookId, this.page);\n return { book: this.book, page: this.page, url: frame.src };\n }\n\n /** Send the viewer to a page. The fragment is the only portable interface. */\n setPage(bookId: string, page: number): void {\n this.page = Math.max(1, Math.floor(page));\n if (this.frame) this.frame.src = this.url(bookId, this.page);\n this.state.emit({ book: this.book, page: this.page, url: this.frame?.src ?? '' });\n void this.saveProgress(bookId);\n }\n\n /** PDF readers are opaque; the client can only report where it sent them. */\n async flush(bookId: string): Promise<void> {\n await this.saveProgress(bookId);\n }\n\n destroy(): void {\n this.host.replaceChildren();\n this.frame = null;\n this.state.clear();\n }\n\n private url(bookId: string, page: number): string {\n const token = this.api.currentSession?.accessToken ?? '';\n const base = this.api.url(`/api/v1/books/${encodeURIComponent(bookId)}/assets?ref=document`);\n const separator = base.includes('?') ? '&' : '?';\n return `${base}${separator}access_token=${encodeURIComponent(token)}#page=${page}`;\n }\n\n private async saveProgress(bookId: string): Promise<void> {\n // Page count is unknown to the server by design, so a percentage would be a\n // guess. `block` carries the page number and `percent` stays 0 rather than\n // inventing a denominator.\n const locator = {\n chapter: 'document',\n spine: 0,\n block: this.page,\n ratio: 0,\n percent: 0,\n };\n await this.api\n .putProgress(bookId, {\n locator: serializeLocator(locator),\n percentage: 0,\n chapterTitle: `第 ${this.page} 页`,\n device: this.options.device ?? 'web',\n updatedAt: Date.now(),\n })\n .catch(() => undefined);\n }\n}\n","/**\n * The client's style intervention.\n *\n * This file is the product's core differentiator, so it is worth being explicit\n * about what it does and does not do.\n *\n * ## The rule\n *\n * **The publisher's layout wins.** The client may set defaults only for things\n * the book left unspecified, and may expose values the reader is entitled to\n * control (text size, theme, page width) through variables that a book is free\n * to ignore.\n *\n * Concretely, this stylesheet never sets: `font-family`, `line-height`,\n * `text-align`, `letter-spacing`, `margin` on content elements, `writing-mode`,\n * or anything else a typographer chose. Readium CSS does set them — its premise\n * is that a consistent, controlled reading experience beats fidelity, and that\n * premise is the opposite of this product's.\n *\n * ## How the reader's settings reach the book without overwriting it\n *\n * Through CSS custom properties with fallbacks a book can ship its own value\n * for. `font-size: var(--reader-font-size, 1em)` means: a book that set an\n * explicit size keeps it, and a book that did not inherits the reader's choice.\n * That is the whole mechanism, and it is why `!important` appears nowhere here.\n *\n * ## Root font size, not per-element font sizes\n *\n * Setting `font-size` on the container only affects text that uses relative\n * units. That is deliberate: a book that declares `font-size: 14px` on a\n * paragraph has stated an intent, and the reader's slider has no business\n * overriding it. What the slider changes is the base that everything relative\n * scales from, which is the behaviour a reader expects from a real book app and\n * the only one that can be implemented without fighting the book.\n */\n\nexport type ThemeMode = 'light' | 'sepia' | 'dark';\n\nexport interface ReaderSettings {\n /** Base font size in CSS pixels. */\n fontSize: number;\n /** Page width in CSS pixels, for the column layout. */\n pageWidth: number;\n /** Gap between columns; the paginator must be told the same number. */\n columnGap: number;\n /** Padding inside a page. */\n pagePadding: number;\n theme: ThemeMode;\n}\n\nexport const DEFAULT_SETTINGS: ReaderSettings = {\n fontSize: 18,\n pageWidth: 720,\n columnGap: 32,\n pagePadding: 24,\n theme: 'light',\n};\n\n/**\n * Theme palettes.\n *\n * Applied as custom properties rather than as rules on the book's elements, so a\n * book that hard-codes `color: #333` keeps it. That is the correct trade: a dark\n * theme that silently defeats a print-oriented book's contrast is a bug report,\n * whereas a book whose text stays dark on a \"dark\" background is one the reader\n * can see and judge.\n */\nexport const PALETTES: Record<ThemeMode, { background: string; text: string; muted: string }> = {\n light: { background: '#ffffff', text: '#111111', muted: '#6b7280' },\n sepia: { background: '#f6f0e4', text: '#3b3121', muted: '#8a7c63' },\n dark: { background: '#14161a', text: '#c9cdd4', muted: '#7c828c' },\n};\n\n/**\n * Styles shared by every chapter, applied from the *outer* document.\n *\n * They live outside the shadow root on purpose: rules that reference a shadow\n * root's own content cannot be written from outside it, but the values can still\n * be inherited in, because custom properties cross the boundary. Keeping the\n * layout rules (column count, page size, scroll behaviour) out here means the\n * per-chapter shadow root holds only what came out of the book.\n */\nexport function readerCss(hostSelector: string, settings: ReaderSettings): string {\n const palette = PALETTES[settings.theme];\n return `\n${hostSelector} {\n --reader-font-size: ${settings.fontSize}px;\n --reader-page-width: ${settings.pageWidth}px;\n --reader-column-gap: ${settings.columnGap}px;\n --reader-page-padding: ${settings.pagePadding}px;\n --reader-bg: ${palette.background};\n --reader-fg: ${palette.text};\n --reader-muted: ${palette.muted};\n --reader-chapter-progress: 0;\n\n display: flex;\n justify-content: center;\n background: var(--reader-bg);\n color: var(--reader-fg);\n /* The container is a scroll *host*, never a scroll area: pages are moved by\n scrollLeft, so a vertical scrollbar appearing here would mean the paginator\n lost track of the geometry. */\n overflow: hidden;\n font-size: var(--reader-font-size);\n}\n\n${hostSelector} .reader-viewport {\n display: block;\n width: 100%;\n max-width: var(--reader-page-width);\n /* One page wide; the pages after the first are reached by scrolling sideways.\n overflow-x: auto rather than scroll, so a chapter shorter than one page does\n not show a scrollbar it cannot use. */\n overflow-x: auto;\n overflow-y: hidden;\n /* Scroll snapping makes a swipe land on a page boundary instead of between two\n pages, which is what \"turning a page\" means. The paginator still sets\n scrollLeft explicitly; snapping is what makes *user* gestures agree with it. */\n scroll-snap-type: x mandatory;\n scrollbar-width: none;\n -webkit-overflow-scrolling: touch;\n}\n\n${hostSelector} .reader-viewport::-webkit-scrollbar {\n display: none;\n}\n`;\n}\n\n/**\n * Styles that must live inside the shadow root.\n *\n * A short, fixed list, and every rule in it is about the *container* rather than\n * the book: the column layout that creates pages, and the snap points that align\n * a page to the viewport. Nothing here selects a publisher element type.\n *\n * `direction: ltr` is set explicitly because the column model reverses when the\n * inherited direction flips, and a book with `dir=\"rtl\"` on a footnote container\n * would otherwise silently paginate right-to-left from that point on.\n */\nexport function chapterCss(): string {\n return `\n:host {\n display: block;\n}\n.reader-chapter {\n /* Columns are the pagination mechanism: the browser lays the chapter out as\n N columns of exactly one page width, and a page is a scroll offset. */\n column-width: calc(var(--reader-page-width) - var(--reader-page-padding) * 2);\n column-gap: var(--reader-column-gap);\n column-fill: auto;\n direction: ltr;\n height: 100%;\n padding: 0 var(--reader-page-padding);\n box-sizing: border-box;\n /* The reader's font size is inherited through this element, so a book's\n relative sizes scale with it and a book's absolute sizes do not. */\n font-size: var(--reader-font-size);\n color: var(--reader-fg);\n}\n.reader-chapter > * {\n scroll-snap-align: start;\n /* A block taller than a page must be splittable across columns; \"avoid\" would\n push a long paragraph to the next page and leave a gap. Only images opt out. */\n break-inside: auto;\n}\n.reader-chapter img,\n.reader-chapter svg {\n /* Publisher images are sized in their own units and routinely overflow the\n column. Clamping them is the one place where \"do not touch the book\" has to\n give way: an image that breaks the column geometry makes the whole chapter\n unreadable, not just unstyled. */\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n break-inside: avoid;\n scroll-snap-align: start;\n}\n.reader-chapter img[data-reader-unsized='true'] {\n /* Reserve a line for an image the book gave no dimensions, so the chapter does\n not reflow under the reader when it finishes loading. */\n min-height: 1em;\n}\n`;\n}\n","/**\n * Reader settings, persisted per device.\n *\n * Deliberately local rather than synced. Font size and theme are properties of\n * the screen the reader is holding — a phone and a tablet want different values —\n * whereas the reading position is a property of the reader. Syncing the first\n * would mean opening a book on the tablet in the phone's font size, which is the\n * one behaviour nobody wants.\n *\n * The palette is mirrored into the reader's own theme module so the shell's\n * chrome and the book's page never disagree about whether the reader chose dark.\n */\n\nimport type { ReaderSettings, ThemeMode } from '../render/theme.ts';\nimport { DEFAULT_SETTINGS } from '../render/theme.ts';\n\nconst KEY = 'reader.settings';\n\nexport function loadSettings(): ReaderSettings {\n try {\n const raw = localStorage.getItem(KEY);\n if (!raw) return { ...DEFAULT_SETTINGS };\n const parsed = JSON.parse(raw) as Partial<ReaderSettings>;\n return {\n fontSize: clamp(parsed.fontSize, 12, 32, DEFAULT_SETTINGS.fontSize),\n pageWidth: clamp(parsed.pageWidth, 360, 1200, DEFAULT_SETTINGS.pageWidth),\n columnGap: DEFAULT_SETTINGS.columnGap,\n pagePadding: DEFAULT_SETTINGS.pagePadding,\n theme: isTheme(parsed.theme) ? parsed.theme : DEFAULT_SETTINGS.theme,\n };\n } catch {\n return { ...DEFAULT_SETTINGS };\n }\n}\n\nexport function saveSettings(settings: ReaderSettings): void {\n try {\n localStorage.setItem(KEY, JSON.stringify(settings));\n } catch {\n // Private browsing modes throw on write. The settings still apply for this\n // session, which is better than failing the interaction.\n }\n}\n\n/** Set the theme on the root element, where the shell's own variables live. */\nexport function applyTheme(root: HTMLElement, theme: ThemeMode): void {\n root.dataset.theme = theme;\n const colors: Record<ThemeMode, string> = { light: '#ffffff', sepia: '#f6f0e4', dark: '#14161a' };\n document.querySelector('meta[name=\"theme-color\"]')?.setAttribute('content', colors[theme]);\n}\n\nfunction clamp(value: unknown, min: number, max: number, fallback: number): number {\n if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;\n return Math.max(min, Math.min(max, Math.round(value)));\n}\n\nfunction isTheme(value: unknown): value is ThemeMode {\n return value === 'light' || value === 'sepia' || value === 'dark';\n}\n\nexport type { ReaderSettings };\n","/**\n * The reading surface, shared by the H5 build and the Android shell.\n *\n * Format routing lives here and is the answer to the second half of the user's\n * question: **EPUB (and TXT) render through the WebView; everything else does\n * not render here at all on Android.**\n *\n * The reasoning, because it drove the structure of this file:\n *\n * - **Reflowable text belongs in a WebView.** It is the only engine on the\n * device that implements CSS multi-column, embedded fonts, vertical writing\n * and ruby — the typography features this product is built on. A native text\n * stack would have to reimplement all of it worse.\n * - **Images do not.** A comic page is a JPEG: a native `ImageView` decodes it\n * with hardware acceleration and recycles the bitmap, while a WebView holds a\n * full compositing layer per page and decodes on the main thread. The user's\n * own note — 「其他类型的书籍需要使用原生来渲染,提升性能」 — is correct, and\n * the reason is memory rather than raw speed: a 300MB volume read in a WebView\n * is an OOM on a mid-range phone, and the same volume read natively is fine.\n * - **PDF has a viewer already.** On Android that is the platform's, on the H5\n * build it is the browser's. Neither needs this client in between.\n *\n * So this view owns the *reflowable* path plus the H5 fallbacks, and the shell\n * decides whether it is ever reached for paged/document content. `capabilities()`\n * below is what the two builds negotiate with, so the split is declared in one\n * place instead of being implied by which renderer happens to be imported.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\nimport { BookReader } from '../render/reader.ts';\nimport { PagedReader } from '../render/paged.ts';\nimport { TextReader } from '../render/text.ts';\nimport { PdfReader } from '../render/pdf.ts';\nimport { applyTheme, loadSettings, saveSettings, type ReaderSettings } from './settings.ts';\nimport type { ReaderStyle } from '../render/container.ts';\nimport { PALETTES } from '../render/theme.ts';\n\n/**\n * Which renderer handles what, per platform.\n *\n * Declared rather than hardcoded because the Android shell overrides it: the\n * shell answers 'native' for paged and document content and never mounts those\n * renderers, so the same book opened on the phone takes the native path and the\n * same book opened in a browser takes the H5 fallback.\n */\nexport interface RendererCapabilities {\n /** Reflowable text always goes through the WebView. */\n reflowable: 'webview';\n /** Paged images: native on Android, H5 elsewhere. */\n paged: 'native' | 'webview';\n /** PDF: the platform viewer on Android, an iframe elsewhere. */\n document: 'native' | 'iframe';\n}\n\nexport function capabilities(platform: 'web' | 'android'): RendererCapabilities {\n return platform === 'android'\n ? { reflowable: 'webview', paged: 'native', document: 'native' }\n : { reflowable: 'webview', paged: 'webview', document: 'iframe' };\n}\n\nexport interface ReaderHost {\n /** Called when the user swipes or taps past the last page of a book. */\n onExit?: () => void;\n platform?: 'web' | 'android';\n device?: string;\n}\n\n/**\n * A reading session for one book.\n *\n * Owns whichever renderer the book's `kind` calls for, plus the chrome around it:\n * the toolbar, the footer progress, the table of contents. Kept as a class because\n * the four renderers share no base class on purpose — they have almost nothing in\n * common beyond `turn` and `flush`, and a common interface would have to be wide\n * enough to be meaningless.\n */\nexport class ReaderView {\n private readonly root: HTMLElement;\n private readonly stage: HTMLElement;\n private readonly toolbar: HTMLElement;\n private readonly progressFill: HTMLElement;\n private readonly pageLabel: HTMLElement;\n private readonly warning: HTMLElement;\n private reader: BookReader | PagedReader | TextReader | PdfReader | null = null;\n private state: { index: number; total: number; title: string; percent: number } = {\n index: 0,\n total: 0,\n title: '',\n percent: 0,\n };\n private settings: ReaderSettings;\n private disposers: Array<() => void> = [];\n private tocDialog: HTMLElement | null = null;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: ReaderHost = {},\n ) {\n this.settings = loadSettings();\n this.root = document.createElement('div');\n this.root.className = 'reader';\n\n this.toolbar = document.createElement('div');\n this.toolbar.className = 'reader__toolbar';\n this.stage = document.createElement('div');\n this.stage.className = 'reader__stage';\n this.warning = document.createElement('div');\n this.warning.className = 'reader__warning';\n this.warning.hidden = true;\n\n const footer = document.createElement('div');\n footer.className = 'reader__footer';\n this.progressFill = document.createElement('div');\n this.progressFill.className = 'reader__progress-fill';\n const progressTrack = document.createElement('div');\n progressTrack.className = 'reader__progress';\n progressTrack.append(this.progressFill);\n this.pageLabel = document.createElement('span');\n footer.append(progressTrack, this.pageLabel);\n\n this.root.append(this.toolbar, this.warning, this.stage, footer);\n host.replaceChildren(this.root);\n this.buildToolbar();\n this.bindGestures();\n }\n\n /** Open the book with whichever renderer its `kind` calls for. */\n async open(): Promise<void> {\n const platform = this.options.platform ?? 'web';\n // One request to learn the shape of the book, then one renderer is built.\n const manifest = await this.api.manifest(this.book.id);\n const kind = manifest.content?.kind ?? manifest.kind ?? 'reflowable';\n const caps = capabilities(platform);\n\n if (kind === 'reflowable') {\n const reader = new BookReader(this.containerFor('webview'), this.api, this.book, {\n platform,\n style: this.readerStyle(),\n ...(this.options.device !== undefined ? { device: this.options.device } : {}),\n });\n this.reader = reader;\n this.disposers.push(reader.state.on((state) => this.onReflowable(state)));\n const initial = await reader.open(this.book.id);\n this.stage.classList.remove('is-paged');\n this.applySettings();\n void initial;\n return;\n }\n\n if (kind === 'text') {\n const reader = new TextReader(this.stage, this.api, this.book, {\n ...(this.options.device !== undefined ? { device: this.options.device } : {}),\n });\n this.reader = reader;\n await reader.open(this.book.id);\n this.pageLabel.textContent = '流式';\n return;\n }\n\n // A PDF is a `document`: an opaque file the client does not interpret. It\n // must be routed BEFORE the paged branch, or a `.pdf` lands in the image\n // pager and the reader sees a broken image instead of the viewer.\n if (kind === 'document') {\n const pdf = new PdfReader(this.stage, this.api, this.book, {\n ...(this.options.device !== undefined ? { device: this.options.device } : {}),\n });\n this.reader = pdf;\n this.stage.classList.add('is-paged');\n const state = await pdf.open(this.book.id);\n this.pageLabel.textContent = `${this.book.format.toUpperCase()} · 第 ${state.page} 页`;\n return;\n }\n\n // Comics and single images: paged content. On Android the shell takes this\n // over (see `capabilities`), because a native pager decodes and recycles a\n // bitmap where a WebView holds a compositing layer per page.\n if (caps.paged === 'native') {\n this.host.dispatchEvent(\n new CustomEvent('reader:native-page', { detail: { bookId: this.book.id, kind } }),\n );\n return;\n }\n\n const paged = new PagedReader(this.stage, this.api, this.book, {\n platform: 'web',\n ...(this.options.device !== undefined ? { device: this.options.device } : {}),\n });\n this.reader = paged;\n this.disposers.push(paged.state.on((state) => this.onPaged(state)));\n await paged.open(this.book.id);\n this.stage.classList.add('is-paged');\n return;\n }\n\n /** Persist the position before the surface goes away. */\n async close(): Promise<void> {\n const reader = this.reader;\n if (reader instanceof BookReader || reader instanceof PagedReader) {\n await reader.flush().catch(() => undefined);\n } else if (reader instanceof TextReader || reader instanceof PdfReader) {\n await reader.flush(this.book.id).catch(() => undefined);\n }\n for (const dispose of this.disposers) dispose();\n this.disposers = [];\n this.reader?.destroy();\n this.reader = null;\n this.host.replaceChildren();\n }\n\n /** Turn pages from a hardware key or an external control (the shell's). */\n async turn(pages: number): Promise<void> {\n if (this.reader instanceof BookReader || this.reader instanceof PagedReader) {\n await this.reader.turn(pages);\n }\n }\n\n /**\n * Re-apply the reader's settings.\n *\n * A type-size change is not a CSS variable update: the chapter document was\n * styled when it loaded, so it has to be rendered again. That is why this\n * awaits — but the chapter comes out of the prefetch cache, so it is a\n * re-layout rather than a network round trip. A page-width change is the same,\n * because the column geometry lives in that document too.\n */\n applySettings(): void {\n applyTheme(document.documentElement, this.settings.theme);\n if (this.reader instanceof BookReader) void this.reader.restyle(this.readerStyle());\n }\n\n /** The type settings a chapter document is rendered with. */\n private readerStyle(): ReaderStyle {\n return {\n fontSize: this.settings.fontSize,\n pageWidth: this.settings.pageWidth,\n columnGap: this.settings.columnGap,\n pagePadding: this.settings.pagePadding,\n ...PALETTES[this.settings.theme],\n };\n }\n\n updateSettings(patch: Partial<ReaderSettings>): void {\n this.settings = { ...this.settings, ...patch };\n saveSettings(this.settings);\n applyTheme(document.documentElement, this.settings.theme);\n this.applySettings();\n if (this.tocDialog) this.closeDialog();\n this.openSettings();\n }\n\n // ---------------------------------------------------------------- chrome\n\n private buildToolbar(): void {\n const back = document.createElement('button');\n back.className = 'reader__button';\n back.textContent = '←';\n back.setAttribute('aria-label', '返回');\n back.addEventListener('click', () => void this.exit());\n\n const title = document.createElement('div');\n title.className = 'reader__toolbar-title';\n title.textContent = this.book.title;\n\n const toc = document.createElement('button');\n toc.className = 'reader__button';\n toc.textContent = '目录';\n toc.addEventListener('click', () => void this.openToc());\n\n const settings = document.createElement('button');\n settings.className = 'reader__button';\n settings.textContent = 'Aa';\n settings.setAttribute('aria-label', '阅读设置');\n settings.addEventListener('click', () => this.openSettings());\n\n this.toolbar.append(back, title, toc, settings);\n }\n\n /**\n * Swipe and tap gestures.\n *\n * The reflowable reader's viewport scrolls natively, so only the *ends* of a\n * chapter need intervention: a swipe that arrives there should turn the chapter,\n * not rubber-band. Paged content is different — there is no scroll surface at\n * all — so its horizontal swipes are read directly.\n */\n private bindGestures(): void {\n let startX = 0;\n let startY = 0;\n let startAt = 0;\n let tracking = false;\n\n const down = (event: PointerEvent): void => {\n startX = event.clientX;\n startY = event.clientY;\n startAt = event.timeStamp;\n tracking = true;\n };\n\n const up = (event: PointerEvent): void => {\n if (!tracking) return;\n tracking = false;\n const dx = event.clientX - startX;\n const dy = event.clientY - startY;\n const elapsed = event.timeStamp - startAt;\n // A swipe needs both distance and speed: a slow drag across half the screen\n // is usually a text selection, not a page turn.\n if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy) * 1.5) return;\n if (elapsed > 800) return;\n void this.turn(dx < 0 ? 1 : -1);\n };\n\n this.stage.addEventListener('pointerdown', down);\n this.stage.addEventListener('pointerup', up);\n this.stage.addEventListener('pointercancel', () => {\n tracking = false;\n });\n\n const key = (event: KeyboardEvent): void => {\n if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') {\n event.preventDefault();\n void this.turn(1);\n } else if (event.key === 'ArrowLeft' || event.key === 'PageUp') {\n event.preventDefault();\n void this.turn(-1);\n } else if (event.key === 'Escape') {\n void this.exit();\n }\n };\n window.addEventListener('keydown', key);\n this.disposers.push(() => window.removeEventListener('keydown', key));\n\n // A locked phone or a backgrounded tab may never run another timer, so the\n // debounced progress write has to be flushed on the transition itself.\n const flush = (): void => {\n const reader = this.reader;\n if (reader instanceof BookReader || reader instanceof PagedReader) void reader.flush();\n else if (reader instanceof TextReader) void reader.flush(this.book.id);\n else if (reader instanceof PdfReader) void reader.flush(this.book.id);\n };\n document.addEventListener('visibilitychange', flush);\n window.addEventListener('pagehide', flush);\n this.disposers.push(() => {\n document.removeEventListener('visibilitychange', flush);\n window.removeEventListener('pagehide', flush);\n });\n }\n\n private async exit(): Promise<void> {\n await this.close();\n this.options.onExit?.();\n }\n\n private containerFor(_mode: 'webview'): HTMLElement {\n const holder = document.createElement('div');\n holder.style.height = '100%';\n this.stage.replaceChildren(holder);\n return holder;\n }\n\n /**\n * The book's own table of contents.\n *\n * Fetched from `/toc`, not derived from the manifest's groups. The manifest is\n * windowed, so its groups are a transfer boundary: showing them gave the reader\n * 「第 1 章 – 第 40 章」 where a chapter list belongs.\n */\n private async openToc(): Promise<void> {\n const entries = await this.api.toc(this.book.id).catch(() => []);\n\n const list = document.createElement('ul');\n list.className = 'toc';\n if (entries.length === 0) {\n const empty = document.createElement('div');\n empty.className = 'toc__empty';\n empty.textContent = '这本书没有可跳转的章节';\n list.append(empty);\n } else {\n entries.forEach((entry) => {\n const item = document.createElement('li');\n item.className = 'toc__item';\n item.textContent = entry.title;\n item.style.paddingLeft = `${4 + entry.level * 14}px`;\n item.addEventListener('click', () => {\n this.closeDialog();\n void this.jumpTo(entry);\n });\n list.append(item);\n });\n }\n this.showDialog(`目录 · ${this.book.title}`, list);\n }\n\n /** Jump to a table-of-contents entry with whichever reader is loaded. */\n private async jumpTo(entry: { href: string; spine?: number }): Promise<void> {\n const reader = this.reader;\n if (reader instanceof BookReader) {\n // `spine` is the whole-book index the server computed, so a jump lands on\n // the right chapter even when the window holding it has not been fetched.\n if (entry.spine !== undefined) await reader.goToSpine(entry.spine);\n else await reader.goToSpineOfHref(entry.href);\n return;\n }\n if (reader instanceof PagedReader) {\n await reader.goTo(entry.spine ?? 0);\n return;\n }\n if (reader instanceof TextReader) {\n const index = Number.parseInt(entry.href.replace('chapter:', ''), 10);\n await reader.goToChapter(Number.isFinite(index) ? index : 0, this.book.id);\n return;\n }\n void reader?.setPage(this.book.id, (entry.spine ?? 0) + 1);\n }\n\n private openSettings(): void {\n const form = document.createElement('div');\n\n const fontSize = this.slider('字号', this.settings.fontSize, 12, 32, 1, (value) => {\n this.settings = { ...this.settings, fontSize: value };\n this.applySettings();\n });\n // Committed on release rather than on every input event: repaginating a\n // chapter on every pixel of slider travel makes the slider itself unusable.\n fontSize.input.addEventListener('change', () => this.commitSettings());\n\n const width = this.slider('页宽', this.settings.pageWidth, 360, 1200, 20, (value) => {\n this.settings = { ...this.settings, pageWidth: value };\n this.applySettings();\n });\n width.input.addEventListener('change', () => this.commitSettings());\n\n const theme = document.createElement('select');\n for (const [value, label] of [\n ['light', '浅色'],\n ['sepia', '羊皮纸'],\n ['dark', '深色'],\n ] as const) {\n const option = document.createElement('option');\n option.value = value;\n option.textContent = label;\n option.selected = this.settings.theme === value;\n theme.append(option);\n }\n theme.addEventListener('change', () => {\n this.settings = { ...this.settings, theme: theme.value as ReaderSettings['theme'] };\n this.commitSettings();\n });\n const themeRow = document.createElement('label');\n themeRow.className = 'dialog__row';\n themeRow.append('主题', theme);\n\n form.append(fontSize.row, width.row, themeRow);\n this.showDialog('阅读设置', form);\n }\n\n private commitSettings(): void {\n saveSettings(this.settings);\n applyTheme(document.documentElement, this.settings.theme);\n this.applySettings();\n }\n\n private slider(\n label: string,\n value: number,\n min: number,\n max: number,\n step: number,\n onChange: (value: number) => void,\n ): { row: HTMLElement; input: HTMLInputElement } {\n const row = document.createElement('label');\n row.className = 'dialog__row';\n const input = document.createElement('input');\n input.type = 'range';\n input.min = String(min);\n input.max = String(max);\n input.step = String(step);\n input.value = String(value);\n input.addEventListener('input', () => onChange(Number(input.value)));\n row.append(label, input);\n return { row, input };\n }\n\n private showDialog(title: string, body: HTMLElement): void {\n this.closeDialog();\n const dialog = document.createElement('div');\n dialog.className = 'dialog';\n const panel = document.createElement('div');\n panel.className = 'dialog__panel';\n const heading = document.createElement('h2');\n heading.className = 'dialog__title';\n heading.textContent = title;\n const actions = document.createElement('div');\n actions.className = 'dialog__actions';\n const close = document.createElement('button');\n close.className = 'dialog__button dialog__button--primary';\n close.textContent = '关闭';\n close.addEventListener('click', () => this.closeDialog());\n actions.append(close);\n panel.append(heading, body, actions);\n dialog.append(panel);\n dialog.addEventListener('click', (event) => {\n if (event.target === dialog) this.closeDialog();\n });\n this.root.append(dialog);\n this.tocDialog = dialog;\n }\n\n private closeDialog(): void {\n this.tocDialog?.remove();\n this.tocDialog = null;\n }\n\n // ---------------------------------------------------------------- state\n\n private onReflowable(state: {\n page: number;\n pageCount: number;\n index: number;\n items: Array<{ title: string }>;\n total: number;\n warnings: string[];\n }): void {\n this.state = {\n index: state.index,\n total: state.total,\n title: state.items[state.index]?.title ?? '',\n percent: (state.index + (state.pageCount > 1 ? state.page / state.pageCount : 0)) / Math.max(1, state.total),\n };\n this.renderProgress();\n if (state.warnings.length > 0) {\n this.warning.hidden = false;\n // Surfaced rather than swallowed: a chapter that failed to parse renders as\n // something, and the reader deserves to know it is not what the book said.\n this.warning.textContent = `本章有 ${state.warnings.length} 处问题,可能显示不完整`;\n }\n }\n\n private onPaged(state: { index: number; total: number; loading: boolean }): void {\n this.state = {\n index: state.index,\n total: state.total,\n title: '',\n percent: state.index / Math.max(1, state.total),\n };\n this.renderProgress();\n }\n\n private renderProgress(): void {\n this.progressFill.style.width = `${Math.round(this.state.percent * 100)}%`;\n this.pageLabel.textContent =\n this.state.index >= 0 ? `${this.state.percent * 100 < 1 ? 0 : Math.round(this.state.percent * 100)}%` : '';\n this.toolbar.querySelector('.reader__toolbar-title')!.textContent =\n this.state.title ? `${this.book.title} · ${this.state.title}` : this.book.title;\n }\n}\n","/**\n * The Android shell's interface.\n *\n * The bridge is a plain object on `window`, written by the shell before this\n * bundle runs. No JavaScript is evaluated from the native side and no message is\n * passed as a string to be re-parsed: the shell is same-process, so a plain\n * object is both simpler and harder to get wrong than a postMessage protocol.\n *\n * What the shell owns, and why it is not done in JavaScript:\n *\n * - **Gestures on paged content.** The shell renders comic pages in a native\n * pager because a WebView's per-page memory is what kills a 300MB volume on a\n * mid-range phone. A native pager handles its own swipes; the H5 layer is not\n * involved.\n * - **The offline cache.** A book downloaded for offline reading lives in the\n * shell's file cache, which is what lets it be read with the server unreachable.\n * - **The account.** Tokens live in the shell's encrypted storage, so the H5\n * layer never writes a refresh token into a WebView's localStorage where any\n * injected script could read it.\n *\n * What the H5 layer owns: reflowable rendering, the shelf, and settings. Those\n * are the parts that benefit from being the same code in both builds.\n */\n\nexport interface ReaderBridge {\n /** Server origin, e.g. `http://192.168.1.10:8080`. */\n serverUrl?: string;\n platform: 'android';\n /** Device name recorded with each progress update. */\n deviceName?: string;\n /** Called when a book is opened, so the shell can take over input. */\n onBookOpened?(bookId: string): void;\n /** The shell hands back a page-turn function for hardware keys and gestures. */\n onTurnPage?(turn: (pages: number) => void): void;\n /** Ask the shell to cache a book for offline reading. */\n cacheBook?(bookId: string): void;\n}\n\ndeclare global {\n interface Window {\n readerBridge?: ReaderBridge;\n }\n}\n\n/**\n * Read the bridge, or undefined when running as a plain web page.\n *\n * A missing bridge is the normal case for the H5 deployment, not an error.\n */\nexport function installBridge(): ReaderBridge | undefined {\n const bridge = window.readerBridge;\n if (!bridge) return undefined;\n return { ...bridge, platform: 'android' };\n}\n","/**\n * H5 client entry point.\n *\n * The same bundle runs in two hosts, and the only thing that differs is how it\n * finds its server:\n *\n * - **Served by the reader server** (the H5 deployment): same origin, so the\n * API base is empty and there is nothing to configure. The server hosts this\n * build itself, which is what makes 「纯 H5 就是一个完整客户端」 true — no app\n * store, no build step for the user, no second deployment to keep in sync.\n * - **Inside the Android shell**: the assets are loaded from `file://`, so there\n * is no origin and the API base has to come from the shell. The shell injects\n * it as `window.readerBridge` before this script runs; the settings screen\n * lets the user type an address when it did not.\n *\n * The `platform` flag is what routes comic and PDF rendering to native code on\n * Android. It comes from the bridge rather than from a user-agent sniff, because\n * a UA string is a guess and the bridge is a fact.\n */\n\nimport { ApiClient, type BookDto } from './net/api.ts';\nimport { ShelfView } from './ui/shelf-view.ts';\nimport { AuthView } from './ui/auth-view.ts';\nimport { ReaderView } from './ui/reader-view.ts';\nimport { installBridge, type ReaderBridge } from './bridge.ts';\nimport './ui/styles.css';\n\nconst app = document.getElementById('app');\nif (!app) throw new Error('missing #app');\n\nconst bridge: ReaderBridge | undefined = installBridge();\nconst baseUrl = bridge?.serverUrl ?? '';\n\nconst api = new ApiClient({\n baseUrl,\n onAuthLost: () => showAuth(),\n});\n\nconst shelf = new ShelfView(app, api, {\n onOpenBook: (book) => void openBook(book),\n onSignOut: () => {\n api.signOut();\n showAuth();\n },\n});\n\nfunction showAuth(): void {\n const view = new AuthView(app!, api, { onSignedIn: () => void showShelf() });\n void view.render();\n}\n\nasync function showShelf(): Promise<void> {\n shelf.mount();\n await shelf.load(true);\n}\n\nasync function openBook(book: BookDto): Promise<void> {\n const view = new ReaderView(app!, api, book, {\n platform: bridge?.platform ?? 'web',\n ...(bridge?.deviceName !== undefined ? { device: bridge.deviceName } : {}),\n onExit: () => void showShelf(),\n });\n await view.open();\n // The shell expects a signal that a book is open, so it can hand gestures and\n // hardware keys to the reader instead of the shelf.\n bridge?.onBookOpened?.(book.id);\n bridge?.onTurnPage?.((pages) => void view.turn(pages));\n}\n\nif (api.currentSession) void showShelf();\nelse void showAuth();\n"],"names":["ApiError","status","code","message","MAX_INFLIGHT","ApiClient","options","defaultStorage","session","username","password","displayName","result","bookId","group","query","ref","token","path","params","suffix","progress","method","response","toApiError","retry","headers","current","resolve","body","key","probe","raw","memory","ShelfView","host","api","header","title","signOut","input","reset","empty","book","error","card","cover","meta","formatLabel","format","AuthView","registrationOpen","userCount","instance","mode","panel","hint","userRow","passRow","submit","actions","run","failure","describe","event","toWindow","content","fallback","items","groups","total","index","toSpineIndex","window","localIndex","toLocalIndex","spine","contains","groupForSpine","ChapterContainer","html","settings","styled","injectReaderStyle","waitForLoad","chapter","doc","frameWidth","style","pageCss","withViewport","viewportMeta","width","usable","frame","done","Paginator","scroller","pageWidth","padded","page","next","pages","target","offset","pageFraction","pageCount","capturePosition","stride","blocks","topLevelBlocks","origin","i","block","height","ratio","restorePosition","position","left","node","PrefetchCache","load","cached","inFlight","promise","blob","keys","from","shouldPrefetch","ahead","sizeOf","budget","aborted","step","estimate","keep","ordered","a","b","entry","bytes","oldest","connection","Emitter","listener","value","parseLocator","parsed","numberOr","clampRatio","serializeLocator","locator","bookPercent","spineTotal","fractionInChapter","withAssetToken","touched","attributes","attribute","element","isApiAsset","hasToken","appendToken","rewritten","rewriteCssUrls","base","fragment","splitFragment","separator","css","full","quote","BookReader","manifest","saved","item","changed","spill","href","local","geometry","after","state","restore","xml","delay","fraction","loaded","PagedReader","clamped","url","previous","image","loading","CHUNK_CHARS","APPEND_THRESHOLD_PX","TextReader","paragraphs","text","lines","buffer","flush","line","PdfReader","DEFAULT_SETTINGS","PALETTES","KEY","loadSettings","clamp","isTheme","saveSettings","applyTheme","root","theme","colors","min","max","capabilities","platform","ReaderView","footer","progressTrack","kind","caps","reader","pdf","paged","dispose","patch","back","toc","startX","startY","startAt","tracking","down","up","dx","dy","elapsed","_mode","holder","entries","list","form","fontSize","label","option","themeRow","onChange","row","dialog","heading","close","installBridge","bridge","app","baseUrl","showAuth","shelf","openBook","showShelf","view"],"mappings":"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"} \ No newline at end of file diff --git a/web/dist/assets/index-BGTmnE8-.css b/web/dist/assets/index-Ch5PggpO.css similarity index 59% rename from web/dist/assets/index-BGTmnE8-.css rename to web/dist/assets/index-Ch5PggpO.css index 2bf855b..a12f607 100644 --- a/web/dist/assets/index-BGTmnE8-.css +++ b/web/dist/assets/index-Ch5PggpO.css @@ -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}.toc__empty,.shelf__empty{color:var(--ui-muted);font-size:14px;text-align:center;padding:40px 0} +: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:pointer}.toc__item--current{color:var(--ui-accent);font-weight:600}.toc__empty,.shelf__empty{color:var(--ui-muted);font-size:14px;text-align:center;padding:40px 0} diff --git a/web/dist/assets/index-D6yU5C0y.js b/web/dist/assets/index-D6yU5C0y.js deleted file mode 100644 index 0b9447b..0000000 --- a/web/dist/assets/index-D6yU5C0y.js +++ /dev/null @@ -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.onSignOut?.()),i.append(o,r);const h=document.createElement("input");h.className="shelf__search",h.type="search",h.placeholder="搜索书名、作者、系列",h.addEventListener("input",()=>{this.search=h.value.trim(),this.searchTimer&&clearTimeout(this.searchTimer),this.searchTimer=setTimeout(()=>{this.page=1,this.load(!0)},250)}),this.grid=document.createElement("div"),this.grid.className="shelf__grid",this.root.append(i,h,this.grid),t.replaceChildren(this.root),this.root.addEventListener("scroll",()=>{if(this.loading||this.grid.childElementCount>=this.total)return;this.root.scrollHeight-this.root.scrollTop-this.root.clientHeight<600&&this.load(!1)})}root;grid;search="";page=1;total=0;loading=!1;searchTimer=null;async load(t){if(!this.loading){this.loading=!0,t&&this.grid.replaceChildren();try{const e=await this.api.shelf({page:this.page,pageSize:60,...this.search?{search:this.search}:{}});if(this.total=e.total,e.items.length===0&&t){const s=document.createElement("div");s.className="shelf__empty",s.textContent=this.search?"没有匹配的书":"书库是空的,检查挂载目录",this.grid.append(s)}for(const s of e.items)this.grid.append(this.card(s));this.page+=1}catch(e){const s=document.createElement("div");s.className="shelf__empty",s.textContent=e instanceof Error?e.message:"加载失败",this.grid.replaceChildren(s)}finally{this.loading=!1}}}card(t){const e=document.createElement("button");e.className="book-card",e.type="button",e.addEventListener("click",()=>this.options.onOpenBook?.(t));const s=document.createElement("img");s.className="book-card__cover",s.alt="",s.loading="lazy",s.decoding="async",t.coverUrl&&(s.src=this.api.url(t.coverUrl)),s.addEventListener("error",()=>{s.removeAttribute("src")});const i=document.createElement("div");i.className="book-card__title",i.textContent=t.title;const o=document.createElement("div");return o.className="book-card__meta",o.textContent=[t.author,X(t.format)].filter(Boolean).join(" · "),e.append(s,i,o),e}}function X(n){return{epub:"EPUB",pdf:"PDF",cbz:"漫画","comic-dir":"漫画",txt:"TXT",image:"图片"}[n]??n.toUpperCase()}class K{constructor(t,e,s={}){this.host=t,this.api=e,this.options=s,this.root=document.createElement("div"),this.root.className="dialog",t.replaceChildren(this.root)}root;async render(){let t=!0,e=0;try{const g=await this.api.instance();t=g.registrationOpen,e=g.userCount}catch{}const s=t&&e===0?"register":"login",i=document.createElement("div");i.className="dialog__panel";const o=document.createElement("h2");o.className="dialog__title",o.textContent=s==="register"?"创建第一个账号":"登录";const r=document.createElement("p");r.className="dialog__row",r.textContent=s==="register"?"第一个注册的账号会自动成为管理员。":"请用管理员创建的账号登录。";const h=document.createElement("input");h.autocomplete="username",h.placeholder="用户名";const a=document.createElement("input");a.type="password",a.autocomplete=s==="register"?"new-password":"current-password",a.placeholder="口令";const c=document.createElement("label");c.className="dialog__row",c.append("用户名",h);const l=document.createElement("label");l.className="dialog__row",l.append("口令",a);const d=document.createElement("div");d.className="reader__warning",d.hidden=!0;const u=document.createElement("button");u.className="dialog__button dialog__button--primary",u.textContent=s==="register"?"创建并登录":"登录";const k=document.createElement("div");k.className="dialog__actions",k.append(u),i.append(o,r,c,l,d,k),this.root.replaceChildren(i);const M=async()=>{d.hidden=!0,u.disabled=!0;try{s==="register"?await this.api.register(h.value.trim(),a.value):await this.api.login(h.value.trim(),a.value),this.options.onSignedIn?.()}catch(g){d.hidden=!1,d.textContent=Y(g)}finally{u.disabled=!1}};u.addEventListener("click",()=>void M()),a.addEventListener("keydown",g=>{g.key==="Enter"&&M()}),h.focus()}}function Y(n){switch(n.code){case"BAD_CREDENTIALS":return"用户名或口令错误";case"REGISTRATION_DISABLED":return"这个实例已关闭公开注册,请让管理员创建账号";case"PASSWORD_TOO_SHORT":return"口令至少 8 位";case"USERNAME_TAKEN":return"用户名已被占用";case"ACCOUNT_DISABLED":return"账号已被停用";case"HTTP_ERROR":case void 0:return"无法连接服务端,检查地址与网络";default:return n instanceof Error?n.message:"操作失败"}}function _(n,t){const e=n?.items??t?.items??[],s=n?.groups??t?.groups??[],i=n?.total??t?.total??e.length,o=n?.group??0;return{items:e,group:s[o]??null,groups:s,total:i,kind:n?.kind??t?.kind??"document"}}function V(n,t){return n.group?n.group.offset+Math.max(0,Math.min(n.group.count-1,t)):t}function Q(n,t){return n.group?t-n.group.offset:t}function O(n,t){return n.group?t>=n.group.offset&&t<n.group.offset+n.group.count:t>=0&&t<n.items.length}function Z(n,t){return n.groups.find(e=>t>=e.offset&&t<e.offset+e.count)??null}class tt{constructor(t){this.host=t,this.viewport=document.createElement("div"),this.viewport.className="reader-viewport",this.host.append(this.viewport),this.root=this.viewport.attachShadow({mode:"open"})}viewport;root;current=null;mount(t,e={}){const s=[],i=et(t,s),o=it(i,s);return this.root.replaceChildren(...st(i),o),this.current=o,e.columnGap?{content:o,viewport:this.viewport,warnings:s}:{content:o,viewport:this.viewport,warnings:s}}get content(){return this.current}clear(){this.root.replaceChildren(),this.current=null}}function et(n,t){const e=new DOMParser,s=e.parseFromString(n,"application/xhtml+xml"),i=s.querySelector("parsererror");return i?(t.push(i.textContent?.trim().slice(0,200)||"chapter is not well-formed XML"),e.parseFromString(n,"text/html")):s}function st(n){const t=[];for(const e of Array.from(n.querySelectorAll("style"))){const s=document.createElement("style");s.textContent=e.textContent,t.push(s)}for(const e of Array.from(n.querySelectorAll('link[rel~="stylesheet" i]'))){const s=e.getAttribute("href");if(!s)continue;const i=document.createElement("link");i.setAttribute("rel","stylesheet"),i.setAttribute("href",s),t.push(i)}return t}function it(n,t){const e=document.createElement("div");e.className="reader-chapter";const s=n.body??n.documentElement;if(!s)return t.push("chapter has no body"),e;for(const i of Array.from(s.childNodes))e.append(document.importNode(i,!0));return e}class nt{constructor(t,e={}){this.viewport=t,this.columnGap=e.columnGap??32,this.pagePadding=e.pagePadding??24}columnGap;pagePadding;stride=0;pageCount=1;current=0;layout(t=this.viewport.clientWidth){const e=Math.max(1,t-this.pagePadding*2);this.stride=Math.max(1,e+this.columnGap);const s=this.viewport.scrollWidth;return this.pageCount=Math.max(1,Math.round((s+this.columnGap)/this.stride)),{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.viewport.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.viewport.scrollLeft=0}pageAt(t){return this.stride<=0?0:Math.max(0,Math.min(this.pageCount-1,Math.round(t/this.stride)))}}function ot(n,t){return t<=1?0:Math.max(0,Math.min(1,n/(t-1)))}function R(n,t,e){const s=z(n);if(s.length===0)return{blockIndex:0,offsetRatio:0};const i=t*e;let o=0;for(let c=0;c<s.length&&s[c].offsetLeft<=i+1;c+=1)o=c;const r=s[o],h=r.offsetHeight||1,a=Math.max(0,Math.min(1,(i-r.offsetLeft)/h));return{blockIndex:o,offsetRatio:a}}function A(n,t,e){const s=z(n),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 z(n){const t=n.querySelector(".reader-chapter")??n;return Array.from(t.children).filter(e=>e instanceof HTMLElement)}class B{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(!rt())return()=>{};const o=i.ahead??this.ahead,r=i.sizeOf;let h=this.maxBytes-this.bytes;const a=new Set;for(let c=1;c<=o;c+=1){const l=t[e+c];if(!l||this.entries.has(l)||this.pending.has(l))continue;const d=r?.(l);d!==void 0&&d>h||(d!==void 0&&(h-=d),this.get(l,s).catch(()=>{}),a.add(l))}return()=>{for(const c of a)this.entries.delete(c),this.pending.delete(c)}}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 rt(){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 C(n){if(!n)return null;if(!n.startsWith("{"))return{chapter:n,spine:0,block:0,ratio:0,percent:0};try{const t=JSON.parse(n);return typeof t.chapter!="string"?null:{chapter:t.chapter,spine:U(t.spine,0),block:U(t.block,0),ratio:$(t.ratio),percent:$(t.percent)}}catch{return null}}function P(n){return JSON.stringify(n)}function U(n,t){return typeof n=="number"&&Number.isFinite(n)?n:t}function $(n){return typeof n!="number"||!Number.isFinite(n)?0:Math.max(0,Math.min(1,n))}function at(n,t,e){return t<=0?0:Math.max(0,Math.min(1,(n+e)/t))}class f{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i,this.device=i.device??"web",this.container=new tt(t),this.paginator=new nt(this.container.viewport)}state=new v;container;paginator;cache=new B;window=null;current=null;locator=null;progressTimer=null;cancelPrefetch=null;device;async open(t,e=0){const s=await this.api.manifest(t,e);this.window=_(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=C((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 chapterElement(){return this.container.content}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(Q(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().stride,e=R(this.container.viewport,this.paginator.index,t),s=this.paginator.layout().stride;this.paginator.goTo(A(this.container.viewport,e,s)),this.publish()}async flush(){this.progressTimer&&(clearTimeout(this.progressTimer),this.progressTimer=null),await this.writeProgress()}destroy(){this.cancelPrefetch?.(),this.cache.clear(),this.container.clear(),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(c=>c.text()),r=this.container.mount(o),h=this.paginator.layout(),a=e&&e.chapter===i.href?A(this.container.viewport,{blockIndex:e.block,offsetRatio:e.ratio},h.stride):0;this.paginator.goTo(Math.min(a,h.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:r.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().stride,o=R(this.container.viewport,this.paginator.index,i),r=ot(this.paginator.index,this.paginator.total),h=V(t,e.index),a={chapter:s.href,spine:h,block:o.blockIndex,ratio:o.offsetRatio,percent:at(h,t.total,r)};this.locator=a,await this.api.putProgress(this.book.id,{locator:P(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=Z(this.window,t);if(!e)return;const s=await this.api.items(this.book.id,e.seq);this.window=_(s)}}class w{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i}state=new v;cache=new B({ahead:2,maxBytes:24*1024*1024});window=null;index=0;objectUrl=null;progressTimer=null;cancelPrefetch=null;async open(t,e=0){const s=await this.api.manifest(t,e);this.window=_(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=C((await this.api.progress(t).catch(()=>({progress:null}))).progress?.locator??""),o=i?this.window.items.findIndex(r=>r.href===i.chapter):-1;return await this.show(o>=0?o:0),this.currentState()}get total(){return this.window?.items.length??0}async turn(t){await this.show(this.index+t)}async goTo(t){await this.show(t)}async flush(){this.progressTimer&&(clearTimeout(this.progressTimer),this.progressTimer=null),await this.writeProgress()}destroy(){this.cancelPrefetch?.(),this.cache.clear(),this.releaseUrl(),this.state.clear()}async show(t){const e=this.window;if(!e||e.items.length===0)return;const s=Math.max(0,Math.min(e.items.length-1,t)),i=e.items[s];if(!i)return;this.index=s,this.emit(!0);const o=await this.cache.get(i.href,c=>this.api.asset(this.book.id,c));if(this.index!==s)return;const r=URL.createObjectURL(o),h=this.objectUrl;this.objectUrl=r;const a=document.createElement("img");a.className="reader-page",a.alt=i.title,a.decoding="async",await a.decode().catch(()=>{}),this.host.replaceChildren(a),a.src=r,h&&URL.revokeObjectURL(h),this.emit(!1),this.emitProgress(),this.prefetch(s)}prefetch(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),{sizeOf:i=>e.items.find(o=>o.href===i)?.size})}emitProgress(){const t=this.options.progressDebounceMs??2e3;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;if(!t)return;const e=t.items[this.index];if(!e)return;const s={chapter:e.href,spine:(t.group?.offset??0)+this.index,block:0,ratio:0,percent:this.index/Math.max(1,t.items.length)};await this.api.putProgress(this.book.id,{locator:P(s),percentage:s.percent,chapterTitle:e.title,device:this.options.device??"web",updatedAt:Date.now()}).catch(()=>{})}emit(t){this.state.emit({book:this.book,index:this.index,total:this.total,loading:t,warnings:[]})}currentState(){return{book:this.book,index:this.index,total:this.total,loading:!1,warnings:[]}}releaseUrl(){this.objectUrl&&(URL.revokeObjectURL(this.objectUrl),this.objectUrl=null)}}const ct=256*1024,ht=1200;class y{constructor(t,e,s,i={}){this.host=t,this.api=e,this.book=s,this.options=i,this.scroll=document.createElement("div"),this.scroll.className="reader-text",this.host.replaceChildren(this.scroll)}state=new v;offset=0;lastLength=0;loading=!1;finished=!1;scroll;async open(t){await this.append(),this.scroll.addEventListener("scroll",()=>this.maybeAppend())}async goToChapter(t,e){const s=await this.api.asset(e,`chapter:${t}`);this.scroll.replaceChildren(D(await s.text())),this.offset=0,this.scroll.scrollTop=0,this.emit(!1),await this.saveProgress(e,t)}async flush(t){await this.saveProgress(t,null)}destroy(){this.host.replaceChildren(),this.state.clear()}maybeAppend(){this.finished||this.loading||this.scroll.scrollHeight-this.scroll.scrollTop-this.scroll.clientHeight>ht||this.append()}async append(){if(!(this.loading||this.finished)){this.loading=!0,this.emit(!0);try{const e=await(await this.api.asset(this.book.id,`chunk:${this.offset}`)).text();if(e.length===0){this.finished=!0;return}this.offset+=e.length,this.scroll.append(D(e)),e.length<ct&&(this.finished=!0)}finally{this.loading=!1,this.emit(!1)}}}async saveProgress(t,e){const s=this.scroll.scrollHeight||1,i=Math.max(0,Math.min(1,this.scroll.scrollTop/s)),o=JSON.stringify(e!==null?{chapter:`chapter:${e}`,spine:e,block:0,ratio:i,percent:i}:{chapter:`chunk:${Math.round(this.offset*i)}`,spine:0,block:0,ratio:i,percent:i});await this.api.putProgress(t,{locator:o,percentage:i,chapterTitle:e!==null?`第 ${e+1} 章`:"",device:this.options.device??"web",updatedAt:Date.now()}).catch(()=>{})}emit(t){this.state.emit({book:this.book,offset:this.offset,chapters:0,hasMore:!this.finished,loading:t})}}function D(n){const t=document.createDocumentFragment(),e=n.split(/\r\n|\r|\n/);let s=[];const i=()=>{if(s.length===0)return;const o=document.createElement("p");o.textContent=s.join(` -`),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.fontSize}px`),this.root.style.setProperty("--reader-page-width",`${this.settings.pageWidth}px`),this.reader instanceof f&&this.reader.relayout()}updateSettings(t){this.settings={...this.settings,...t},q(this.settings),T(document.documentElement,this.settings.theme),this.applySettings(),this.tocDialog&&this.closeDialog(),this.openSettings()}buildToolbar(){const t=document.createElement("button");t.className="reader__button",t.textContent="←",t.setAttribute("aria-label","返回"),t.addEventListener("click",()=>void this.exit());const e=document.createElement("div");e.className="reader__toolbar-title",e.textContent=this.book.title;const s=document.createElement("button");s.className="reader__button",s.textContent="目录",s.addEventListener("click",()=>void this.openToc());const i=document.createElement("button");i.className="reader__button",i.textContent="Aa",i.setAttribute("aria-label","阅读设置"),i.addEventListener("click",()=>this.openSettings()),this.toolbar.append(t,e,s,i)}bindGestures(){let t=0,e=0,s=0,i=!1;const o=c=>{t=c.clientX,e=c.clientY,s=c.timeStamp,i=!0},r=c=>{if(!i)return;i=!1;const l=c.clientX-t,d=c.clientY-e,u=c.timeStamp-s;Math.abs(l)<40||Math.abs(l)<Math.abs(d)*1.5||u>800||this.turn(l<0?1:-1)};this.stage.addEventListener("pointerdown",o),this.stage.addEventListener("pointerup",r),this.stage.addEventListener("pointercancel",()=>{i=!1});const h=c=>{c.key==="ArrowRight"||c.key==="PageDown"||c.key===" "?(c.preventDefault(),this.turn(1)):c.key==="ArrowLeft"||c.key==="PageUp"?(c.preventDefault(),this.turn(-1)):c.key==="Escape"&&this.exit()};window.addEventListener("keydown",h),this.disposers.push(()=>window.removeEventListener("keydown",h));const a=()=>{const c=this.reader;c instanceof f||c instanceof w?c.flush():c instanceof y?c.flush(this.book.id):c instanceof I&&c.flush(this.book.id)};document.addEventListener("visibilitychange",a),window.addEventListener("pagehide",a),this.disposers.push(()=>{document.removeEventListener("visibilitychange",a),window.removeEventListener("pagehide",a)})}async exit(){await this.close(),this.options.onExit?.()}containerFor(t){const e=document.createElement("div");return e.style.height="100%",this.stage.replaceChildren(e),e}async openToc(){const t=await this.api.manifest(this.book.id).then(s=>s.groups??[]).catch(()=>[]),e=document.createElement("ul");if(e.className="toc",t.length===0){const s=document.createElement("div");s.className="toc__empty",s.textContent="这本书没有可跳转的章节",e.append(s)}else t.forEach((s,i)=>{const o=document.createElement("li");o.className="toc__item",o.textContent=`${s.title}(${s.count})`,o.addEventListener("click",()=>{this.closeDialog();const r=this.reader;r instanceof f?r.goToSpine(s.offset):r instanceof w?r.goTo(0):r instanceof y?r.goToChapter(i,this.book.id):r?.setPage(this.book.id,s.offset+1)}),e.append(o)});this.showDialog("目录",e)}openSettings(){const t=document.createElement("div"),e=this.slider("字号",this.settings.fontSize,12,32,1,r=>{this.settings={...this.settings,fontSize:r},this.applySettings()});e.input.addEventListener("change",()=>this.commitSettings());const s=this.slider("页宽",this.settings.pageWidth,360,1200,20,r=>{this.settings={...this.settings,pageWidth:r},this.applySettings()});s.input.addEventListener("change",()=>this.commitSettings());const i=document.createElement("select");for(const[r,h]of[["light","浅色"],["sepia","羊皮纸"],["dark","深色"]]){const a=document.createElement("option");a.value=r,a.textContent=h,a.selected=this.settings.theme===r,i.append(a)}i.addEventListener("change",()=>{this.settings={...this.settings,theme:i.value},this.commitSettings()});const o=document.createElement("label");o.className="dialog__row",o.append("主题",i),t.append(e.row,s.row,o),this.showDialog("阅读设置",t)}commitSettings(){q(this.settings),T(document.documentElement,this.settings.theme),this.applySettings()}slider(t,e,s,i,o,r){const h=document.createElement("label");h.className="dialog__row";const a=document.createElement("input");return a.type="range",a.min=String(s),a.max=String(i),a.step=String(o),a.value=String(e),a.addEventListener("input",()=>r(Number(a.value))),h.append(t,a),{row:h,input:a}}showDialog(t,e){this.closeDialog();const s=document.createElement("div");s.className="dialog";const i=document.createElement("div");i.className="dialog__panel";const o=document.createElement("h2");o.className="dialog__title",o.textContent=t;const r=document.createElement("div");r.className="dialog__actions";const h=document.createElement("button");h.className="dialog__button dialog__button--primary",h.textContent="关闭",h.addEventListener("click",()=>this.closeDialog()),r.append(h),i.append(o,e,r),s.append(i),s.addEventListener("click",a=>{a.target===s&&this.closeDialog()}),this.root.append(s),this.tocDialog=s}closeDialog(){this.tocDialog?.remove(),this.tocDialog=null}onReflowable(t){this.state={index:t.index,total:t.total,title:t.items[t.index]?.title??"",percent:(t.index+(t.pageCount>1?t.page/t.pageCount:0))/Math.max(1,t.total)},this.renderProgress(),t.warnings.length>0&&(this.warning.hidden=!1,this.warning.textContent=`本章有 ${t.warnings.length} 处问题,可能显示不完整`)}onPaged(t){this.state={index:t.index,total:t.total,title:"",percent:t.index/Math.max(1,t.total)},this.renderProgress()}renderProgress(){this.progressFill.style.width=`${Math.round(this.state.percent*100)}%`,this.pageLabel.textContent=this.state.index>=0?`${this.state.percent*100<1?0:Math.round(this.state.percent*100)}%`:"",this.toolbar.querySelector(".reader__toolbar-title").textContent=this.state.title?`${this.book.title} · ${this.state.title}`:this.book.title}}function gt(){const n=window.readerBridge;if(n)return{...n,platform:"android"}}const E=document.getElementById("app");if(!E)throw new Error("missing #app");const m=gt(),ft=m?.serverUrl??"",b=new W({baseUrl:ft,onAuthLost:()=>N()}),mt=new J(E,b,{onOpenBook:n=>void wt(n),onSignOut:()=>{b.signOut(),N()}});function N(){new K(E,b,{onSignedIn:()=>void L()}).render()}async function L(){await mt.load(!0)}async function wt(n){const t=new pt(E,b,n,{platform:m?.platform??"web",...m?.deviceName!==void 0?{device:m.deviceName}:{},onExit:()=>void L()});await t.open(),m?.onBookOpened?.(n.id),m?.onTurnPage?.(e=>void t.turn(e))}b.currentSession?L():N(); -//# sourceMappingURL=index-D6yU5C0y.js.map diff --git a/web/dist/assets/index-D6yU5C0y.js.map b/web/dist/assets/index-D6yU5C0y.js.map deleted file mode 100644 index 95db736..0000000 --- a/web/dist/assets/index-D6yU5C0y.js.map +++ /dev/null @@ -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 });\n this.setSession(session);\n return session;\n }\n\n async register(username: string, password: string, displayName?: string): Promise<Session> {\n const result = await this.request<{ session: Session }>('POST', '/api/v1/auth/register', {\n body: { username, password, ...(displayName ? { displayName } : {}) },\n anonymous: true,\n });\n this.setSession(result.session);\n return result.session;\n }\n\n /** Instance info, used to decide whether to offer registration. */\n async instance(): Promise<{ name: string; apiVersion: number; registrationOpen: boolean; userCount: number }> {\n return this.request('GET', '/api/v1/instance', { anonymous: true });\n }\n\n /**\n * Everything needed to open a book, in one round trip.\n *\n * `group` narrows the items to one window (a comic volume, a chapter range) so\n * opening a 40-volume series transfers one volume's worth of entries.\n */\n async manifest(bookId: string, group?: number): Promise<ManifestResponse> {\n const query = group === undefined ? '' : `?group=${group}`;\n return this.request('GET', `/api/v1/books/${encodeURIComponent(bookId)}/manifest${query}`);\n }\n\n /** One window of items, for a book already open. */\n async items(bookId: string, group?: number): Promise<BookContent> {\n const query = group === undefined ? '' : `?group=${group}`;\n return this.request('GET', `/api/v1/books/${encodeURIComponent(bookId)}/items${query}`);\n }\n\n /**\n * A single addressable resource, as bytes.\n *\n * Returns a Blob rather than text: the same method serves a chapter document, a\n * comic page and a PDF, and the caller decides how to interpret it. `ref` is\n * opaque and must be passed through unchanged.\n */\n async asset(bookId: string, ref: string): Promise<Blob> {\n return this.requestBlob('GET', `/api/v1/books/${encodeURIComponent(bookId)}/assets?ref=${encodeURIComponent(ref)}`);\n }\n\n /** A chapter document as text, which is what the container mounts. */\n async chapterText(bookId: string, ref: string): Promise<string> {\n const blob = await this.asset(bookId, ref);\n return blob.text();\n }\n\n /** The raw book file; a client uses this to cache a book for offline reading. */\n contentUrl(bookId: string): string {\n return this.url(`/api/v1/books/${encodeURIComponent(bookId)}/content`);\n }\n\n /** A URL a plain `<img>` can load. Cover images are per-book and immutable. */\n coverUrl(bookId: string): string {\n return this.url(`/api/v1/books/${encodeURIComponent(bookId)}/cover`);\n }\n\n async shelf(params: { search?: string; page?: number; pageSize?: number } = {}): Promise<{\n items: BookDto[];\n total: number;\n page: number;\n pageSize: number;\n }> {\n const query = new URLSearchParams();\n if (params.search) query.set('search', params.search);\n if (params.page) query.set('page', String(params.page));\n if (params.pageSize) query.set('pageSize', String(params.pageSize));\n const suffix = query.toString() ? `?${query}` : '';\n return this.request('GET', `/api/v1/books${suffix}`);\n }\n\n async progress(bookId: string): Promise<{ progress: ProgressDto | null }> {\n return this.request('GET', `/api/v1/sync/progress/${encodeURIComponent(bookId)}`);\n }\n\n async putProgress(bookId: string, progress: Omit<ProgressDto, 'bookId'>): Promise<void> {\n await this.request('PUT', `/api/v1/sync/progress/${encodeURIComponent(bookId)}`, { body: progress });\n }\n\n /** Absolute URL for an API path, including the origin when one is configured. */\n url(path: string): string {\n return `${this.baseUrl}${path}`;\n }\n\n // ---------------------------------------------------------------- plumbing\n\n private async request<T>(\n method: string,\n path: string,\n options: { body?: unknown; anonymous?: boolean } = {},\n ): Promise<T> {\n const response = await this.fetch(method, path, options);\n if (response.status === 204) return undefined as T;\n return (await response.json()) as T;\n }\n\n private async requestBlob(method: string, path: string): Promise<Blob> {\n const response = await this.fetch(method, path, {});\n return response.blob();\n }\n\n /**\n * One request, with auth, one retry after a token refresh, and a queue slot.\n *\n * The refresh is deduplicated: a comic prefetching four pages at once would\n * otherwise revoke its own refresh token three times, because the server\n * rotates them on use.\n */\n private async fetch(\n method: string,\n path: string,\n options: { body?: unknown; anonymous?: boolean },\n ): Promise<Response> {\n await this.acquire();\n try {\n const response = await this.send(method, path, options);\n if (response.status !== 401 || options.anonymous) {\n if (!response.ok) throw await toApiError(response);\n return response;\n }\n\n const refreshed = await this.refresh();\n if (!refreshed) {\n this.signOut();\n throw await toApiError(response);\n }\n const retry = await this.send(method, path, options);\n if (!retry.ok) throw await toApiError(retry);\n return retry;\n } finally {\n this.release();\n }\n }\n\n private send(method: string, path: string, options: { body?: unknown; anonymous?: boolean }): Promise<Response> {\n const headers: Record<string, string> = {};\n if (options.body !== undefined) headers['content-type'] = 'application/json';\n if (!options.anonymous && this.session) headers.authorization = `Bearer ${this.session.accessToken}`;\n return fetch(this.url(path), {\n method,\n headers,\n ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),\n });\n }\n\n private async refresh(): Promise<boolean> {\n this.refreshInFlight ??= (async () => {\n const current = this.session;\n if (!current?.refreshToken) return null;\n try {\n const response = await fetch(this.url('/api/v1/auth/refresh'), {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ refreshToken: current.refreshToken }),\n });\n if (!response.ok) return null;\n const session = (await response.json()) as Session;\n return session;\n } catch {\n return null;\n } finally {\n // Cleared in a microtask so concurrent callers awaited on this same\n // promise still observe its result.\n queueMicrotask(() => {\n this.refreshInFlight = null;\n });\n }\n })();\n\n const session = await this.refreshInFlight;\n if (!session) return false;\n this.setSession(session);\n return true;\n }\n\n private acquire(): Promise<void> {\n if (this.inflight < MAX_INFLIGHT) {\n this.inflight += 1;\n return Promise.resolve();\n }\n return new Promise((resolve) => {\n this.queue.push(() => {\n this.inflight += 1;\n resolve();\n });\n });\n }\n\n private release(): void {\n this.inflight -= 1;\n this.queue.shift()?.();\n }\n}\n\nasync function toApiError(response: Response): Promise<ApiError> {\n let code = 'HTTP_ERROR';\n let message = `request failed with ${response.status}`;\n try {\n const body = (await response.json()) as ApiErrorBody;\n if (body?.error?.code) {\n code = body.error.code;\n message = body.error.message;\n }\n } catch {\n // A non-JSON error body (a proxy's HTML error page) is not worth a second\n // failure; the status code is the useful part.\n }\n return new ApiError(response.status, code, message);\n}\n\n/** localStorage when it exists; an in-memory stub in a worker or a test. */\nfunction defaultStorage(): SessionStorage {\n const key = 'reader.session';\n try {\n const probe = globalThis.localStorage;\n if (!probe) throw new Error('no localStorage');\n return {\n read: () => {\n const raw = probe.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as Session;\n } catch {\n return null;\n }\n },\n write: (session) => {\n if (session) probe.setItem(key, JSON.stringify(session));\n else probe.removeItem(key);\n },\n };\n } catch {\n let memory: Session | null = null;\n return {\n read: () => memory,\n write: (session) => {\n memory = session;\n },\n };\n }\n}\n","/**\n * The bookshelf.\n *\n * A grid of covers, which is the only browse surface a personal library needs.\n * Two choices worth naming:\n *\n * - **Covers are loaded eagerly and lazily at the same time.** The first screen\n * of covers is requested immediately so the shelf is not a wall of grey; the\n * rest are `loading=\"lazy\"`, so scrolling a 2000-book library does not open\n * 2000 requests on the way down.\n * - **Search is debounced and server-side.** Filtering 2000 books in the client\n * would mean holding all of them in memory, and the server already indexes\n * title, author, series and ISBN — a client-side filter would silently miss\n * fields the user expects to match.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\n\nexport interface ShelfHost {\n onOpenBook?: (book: BookDto) => void;\n onSignOut?: () => void;\n}\n\nexport class ShelfView {\n private readonly root: HTMLElement;\n private readonly grid: HTMLElement;\n private search = '';\n private page = 1;\n private total = 0;\n private loading = false;\n private searchTimer: ReturnType<typeof setTimeout> | null = null;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly options: ShelfHost = {},\n ) {\n this.root = document.createElement('div');\n this.root.className = 'shelf';\n\n const header = document.createElement('div');\n header.className = 'shelf__header';\n const title = document.createElement('h1');\n title.className = 'shelf__title';\n title.textContent = '书库';\n const signOut = document.createElement('button');\n signOut.className = 'reader__button';\n signOut.textContent = '退出';\n signOut.addEventListener('click', () => this.options.onSignOut?.());\n header.append(title, signOut);\n\n const input = document.createElement('input');\n input.className = 'shelf__search';\n input.type = 'search';\n input.placeholder = '搜索书名、作者、系列';\n input.addEventListener('input', () => {\n this.search = input.value.trim();\n // Debounced: a request per keystroke against a self-hosted server on a NAS\n // is a request per keystroke against a spinning disk.\n if (this.searchTimer) clearTimeout(this.searchTimer);\n this.searchTimer = setTimeout(() => {\n this.page = 1;\n void this.load(true);\n }, 250);\n });\n\n this.grid = document.createElement('div');\n this.grid.className = 'shelf__grid';\n\n this.root.append(header, input, this.grid);\n host.replaceChildren(this.root);\n\n // Infinite scroll: a library is browsed by scrolling, and paging controls on\n // a phone are a tax on every visit.\n this.root.addEventListener('scroll', () => {\n if (this.loading || this.grid.childElementCount >= this.total) return;\n const remaining = this.root.scrollHeight - this.root.scrollTop - this.root.clientHeight;\n if (remaining < 600) void this.load(false);\n });\n }\n\n async load(reset: boolean): Promise<void> {\n if (this.loading) return;\n this.loading = true;\n if (reset) this.grid.replaceChildren();\n try {\n const result = await this.api.shelf({ page: this.page, pageSize: 60, ...(this.search ? { search: this.search } : {}) });\n this.total = result.total;\n if (result.items.length === 0 && reset) {\n const empty = document.createElement('div');\n empty.className = 'shelf__empty';\n empty.textContent = this.search ? '没有匹配的书' : '书库是空的,检查挂载目录';\n this.grid.append(empty);\n }\n for (const book of result.items) this.grid.append(this.card(book));\n this.page += 1;\n } catch (error) {\n const message = document.createElement('div');\n message.className = 'shelf__empty';\n message.textContent = error instanceof Error ? error.message : '加载失败';\n this.grid.replaceChildren(message);\n } finally {\n this.loading = false;\n }\n }\n\n private card(book: BookDto): HTMLElement {\n const card = document.createElement('button');\n card.className = 'book-card';\n card.type = 'button';\n // The whole card is the hit target: on a phone a tap on a 108px cover is\n // already at the edge of the 44px recommendation, and a separate title link\n // would be worse.\n card.addEventListener('click', () => this.options.onOpenBook?.(book));\n\n const cover = document.createElement('img');\n cover.className = 'book-card__cover';\n cover.alt = '';\n // `loading=lazy` plus an explicit aspect ratio means the browser reserves the\n // space before the image arrives, so the grid does not reflow under a thumb\n // that is already scrolling.\n cover.loading = 'lazy';\n cover.decoding = 'async';\n if (book.coverUrl) cover.src = this.api.url(book.coverUrl);\n cover.addEventListener('error', () => {\n cover.removeAttribute('src');\n });\n\n const title = document.createElement('div');\n title.className = 'book-card__title';\n title.textContent = book.title;\n\n const meta = document.createElement('div');\n meta.className = 'book-card__meta';\n meta.textContent = [book.author, formatLabel(book.format)].filter(Boolean).join(' · ');\n\n card.append(cover, title, meta);\n return card;\n }\n}\n\nfunction formatLabel(format: string): string {\n const labels: Record<string, string> = {\n epub: 'EPUB',\n pdf: 'PDF',\n cbz: '漫画',\n 'comic-dir': '漫画',\n txt: 'TXT',\n image: '图片',\n };\n return labels[format] ?? format.toUpperCase();\n}\n","/**\n * Sign-in.\n *\n * The first account on a fresh instance becomes the admin, so this screen has to\n * know whether the instance is empty — showing a login form to the person setting\n * the server up for the first time is the most common way a self-hosted product\n * loses a user in the first two minutes.\n *\n * The instance probe is unauthenticated by design, so it can run before anyone\n * has a token.\n */\n\nimport { ApiClient } from '../net/api.ts';\n\nexport interface AuthHost {\n onSignedIn?: () => void;\n}\n\nexport class AuthView {\n private readonly root: HTMLElement;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly options: AuthHost = {},\n ) {\n this.root = document.createElement('div');\n this.root.className = 'dialog';\n host.replaceChildren(this.root);\n }\n\n async render(): Promise<void> {\n let registrationOpen = true;\n let userCount = 0;\n try {\n const instance = await this.api.instance();\n registrationOpen = instance.registrationOpen;\n userCount = instance.userCount;\n } catch {\n // An unreachable server is reported by the submit handler with a real\n // message; guessing here would only duplicate it worse.\n }\n // The first account is always allowed, whatever the instance says.\n const mode: 'register' | 'login' = registrationOpen && userCount === 0 ? 'register' : 'login';\n\n const panel = document.createElement('div');\n panel.className = 'dialog__panel';\n const title = document.createElement('h2');\n title.className = 'dialog__title';\n title.textContent = mode === 'register' ? '创建第一个账号' : '登录';\n\n const hint = document.createElement('p');\n hint.className = 'dialog__row';\n hint.textContent =\n mode === 'register' ? '第一个注册的账号会自动成为管理员。' : '请用管理员创建的账号登录。';\n\n const username = document.createElement('input');\n username.autocomplete = 'username';\n username.placeholder = '用户名';\n const password = document.createElement('input');\n password.type = 'password';\n password.autocomplete = mode === 'register' ? 'new-password' : 'current-password';\n password.placeholder = '口令';\n\n const userRow = document.createElement('label');\n userRow.className = 'dialog__row';\n userRow.append('用户名', username);\n const passRow = document.createElement('label');\n passRow.className = 'dialog__row';\n passRow.append('口令', password);\n\n const error = document.createElement('div');\n error.className = 'reader__warning';\n error.hidden = true;\n\n const submit = document.createElement('button');\n submit.className = 'dialog__button dialog__button--primary';\n submit.textContent = mode === 'register' ? '创建并登录' : '登录';\n\n const actions = document.createElement('div');\n actions.className = 'dialog__actions';\n actions.append(submit);\n panel.append(title, hint, userRow, passRow, error, actions);\n this.root.replaceChildren(panel);\n\n const run = async (): Promise<void> => {\n error.hidden = true;\n submit.disabled = true;\n try {\n if (mode === 'register') await this.api.register(username.value.trim(), password.value);\n else await this.api.login(username.value.trim(), password.value);\n this.options.onSignedIn?.();\n } catch (failure) {\n error.hidden = false;\n error.textContent = describe(failure);\n } finally {\n submit.disabled = false;\n }\n };\n\n submit.addEventListener('click', () => void run());\n password.addEventListener('keydown', (event) => {\n if (event.key === 'Enter') void run();\n });\n username.focus();\n }\n}\n\n/**\n * Turn a server error into something a person can act on.\n *\n * The codes are part of the API's compatibility promise, so mapping them here is\n * safe — and the server's own messages are English, while this UI is not.\n */\nfunction describe(failure: unknown): string {\n const code = (failure as { code?: string }).code;\n switch (code) {\n case 'BAD_CREDENTIALS':\n return '用户名或口令错误';\n case 'REGISTRATION_DISABLED':\n return '这个实例已关闭公开注册,请让管理员创建账号';\n case 'PASSWORD_TOO_SHORT':\n return '口令至少 8 位';\n case 'USERNAME_TAKEN':\n return '用户名已被占用';\n case 'ACCOUNT_DISABLED':\n return '账号已被停用';\n case 'HTTP_ERROR':\n case undefined:\n return '无法连接服务端,检查地址与网络';\n default:\n return failure instanceof Error ? failure.message : '操作失败';\n }\n}\n","/**\n * The loaded window of a book.\n *\n * When the server returns a manifest narrowed to `?group=N`, `items` holds only\n * that group's entries. Every translation between \"whole-book spine index\" (what\n * progress and the table of contents use) and \"index into the array in memory\"\n * (what the DOM code uses) has to go through one place, or the two drift and the\n * reader opens the wrong chapter.\n *\n * This was the bug that made windowed loading look unsafe at first: two\n * different call sites each computed the offset slightly differently and got\n * different answers for the same chapter. There is now one function.\n */\n\nimport type { BookContent, ContentGroup, ContentItem } from '../net/api.ts';\n\nexport interface Window {\n /** The window's items, in reading order. */\n items: ContentItem[];\n /** The window's first group, when the format has groups. */\n group: ContentGroup | null;\n /** All groups of the book, so a table of contents stays complete. */\n groups: ContentGroup[];\n /** Chapters/pages in the whole book, not just this window. */\n total: number;\n kind: BookContent['kind'];\n}\n\n/** Build a window from a manifest response that may or may not be narrowed. */\nexport function toWindow(content: BookContent | null, fallback?: Partial<BookContent>): Window {\n const items = content?.items ?? fallback?.items ?? [];\n const groups = content?.groups ?? fallback?.groups ?? [];\n const total = content?.total ?? fallback?.total ?? items.length;\n\n // A narrowed response is the one that carries a `group` index; an unnarrowed\n // one starts at the book's first item by definition.\n const index = content?.group ?? 0;\n return {\n items,\n group: groups[index] ?? null,\n groups,\n total,\n kind: content?.kind ?? fallback?.kind ?? 'document',\n };\n}\n\n/** Whole-book index of a local item index. */\nexport function toSpineIndex(window: Window, localIndex: number): number {\n if (!window.group) return localIndex;\n return window.group.offset + Math.max(0, Math.min(window.group.count - 1, localIndex));\n}\n\n/** Local item index of a whole-book index, when that index is in this window. */\nexport function toLocalIndex(window: Window, spine: number): number {\n if (!window.group) return spine;\n return spine - window.group.offset;\n}\n\n/** Whether a whole-book index is inside the loaded window. */\nexport function contains(window: Window, spine: number): boolean {\n if (!window.group) return spine >= 0 && spine < window.items.length;\n return spine >= window.group.offset && spine < window.group.offset + window.group.count;\n}\n\n/** Whose group holds a whole-book index. */\nexport function groupForSpine(window: Window, spine: number): ContentGroup | null {\n return window.groups.find((group) => spine >= group.offset && spine < group.offset + group.count) ?? null;\n}\n\n/** Chapter title for a whole-book index, from the groups when items are absent. */\nexport function titleForSpine(window: Window, spine: number): string {\n const group = groupForSpine(window, spine);\n if (!group) return '';\n const local = group.seq === window.group?.seq && window.group ? spine - group.offset : -1;\n const item = local >= 0 ? window.items[local] : undefined;\n return item?.title ?? group.title;\n}\n","/**\n * Where a chapter document lives inside the reader.\n *\n * ============================================================================\n * The iframe vs Shadow DOM decision\n * ============================================================================\n *\n * The user asked whether a pure-H5 build needs an iframe or a Shadow DOM for\n * strict epub resources. The answer is **Shadow DOM, and not for the reason it\n * is usually recommended**. Working through why is worth doing, because the\n * usual reason (\"style isolation\") is the one that does not hold here.\n *\n * ## Shadow DOM is NOT isolation from the publisher's stylesheet\n *\n * A shadow root blocks the *document's* rules from matching into it, but the\n * publisher's stylesheet is not \"the document's rules\" — it is a chapter asset\n * that has to end up inside the shadow root to do anything at all. So:\n *\n * - A shadow root gives zero protection from a publisher's `p { margin: 2em }`,\n * `body { writing-mode: vertical-rl }` or `* { float: left }`. Those load\n * inside the root, next to the markup they style, exactly as they would in an\n * iframe. Anyone who reaches for Shadow DOM to \"not get affected by the\n * book's CSS\" has misread the primitive: the book must style itself, that is\n * the product.\n * - It also gives zero protection from `@font-face` and `@page`, which are\n * scoped to the document either way in practice.\n *\n * The style isolation Shadow DOM *does* provide is the direction that actually\n * matters here: the client's own UI styles cannot leak in and restyle a\n * footnote or a blockquote. And, critically, `all: unset` / `!important`\n * overrides in the client's CSS cannot silently flatten a publisher's layout —\n * which is the failure mode this product exists to avoid.\n *\n * ## What a shadow root buys beyond that\n *\n * - **Per-chapter styles do not accumulate.** Each chapter's shadow root is\n * discarded with it. An iframe has the same property, but at a much higher\n * cost (see below). Without either, a book whose chapters each link their own\n * CSS would corrupt later chapters with earlier chapters' remnants.\n * - **No document per chapter.** An iframe is a nested browsing context: its own\n * document, its own layout, resize and scripting lifecycles. Replacing one is\n * costly enough to be visible on a 200-chapter book, and each iframe needs its\n * own scroll container, focus handling and (on Android WebView) its own\n * compositing layer.\n * - **Same JS context.** `scrollLeft`, `offsetLeft`, `getBoundingClientRect`\n * are directly queryable. Cross-frame layout reads need `postMessage` plus a\n * second implementation of the paginator inside the frame, which is the real\n * reason cross-frame readers end up duplicating logic.\n * - **Text selection and highlight are plain DOM work.** Selecting across a\n * frame boundary is impossible; a highlight library that must choose between\n * frames will inevitably pick one and mishandle the other.\n *\n * ## So what handles the *actual* problems the question was pointing at\n *\n * Strict epubs break WebView rendering in three specific ways, and none of them\n * are solved by choosing a container. They are solved by what the container is\n * given:\n *\n * 1. **Absolute resource paths.** The server already rewrites relative\n * `src`/`href` in the chapter and keeps absolute URLs, `data:` URIs and\n * fragment anchors untouched. That is what makes the document standalone.\n * 2. **XML, not HTML.** Strict epubs are XHTML with proper namespaces, and\n * `innerHTML` in an HTML parser is remarkably forgiving in ways that matter:\n * it drops unknown namespaced elements, and it mishandles self-closing tags\n * on void elements like `<br/>`. Fetching the chapter as a string and\n * handing it to `DOMParser.parseFromString(text, 'application/xhtml+xml')`\n * keeps the document as XML. A parse error is real XML, reported rather than\n * silently half-rendered.\n * 3. **No document-level `html`/`body` styling.** The `<html>` and `<body>`\n * rules in a chapter are written for a full window. Inside a reader the\n * container supplies its own scroller, so `body { height: 100vh }` or\n * `html { background: #000 }` would fight it. The chapter's roots are\n * unwrapped: the top-level blocks are moved into the shadow root and the\n * `<html>`/`<body>` elements themselves are dropped. What is NOT touched is\n * anything with a selector of its own — no `div` is unwrapped, no publisher\n * rule is rewritten or removed.\n *\n * ## When an iframe would be the right call\n *\n * Not never. An iframe wins if a book needs its own `window` — a fixed layout\n * epub with scripted interactivity, or a PDF.js viewer, which expects a frame it\n * can own. The comic and PDF renderers below do use one for the PDF case, for\n * exactly that reason. For reflowable text, a frame would buy nothing and cost a\n * second layout pipeline.\n */\n\n/** Result of mounting a chapter. */\nexport interface MountedChapter {\n /** The element the publisher's content ended up in. */\n content: HTMLElement;\n /** The scroll container the paginator drives. */\n viewport: HTMLElement;\n /** Non-fatal problems worth surfacing (an XML error, a missing resource). */\n warnings: string[];\n}\n\nexport interface ContainerOptions {\n /** Gap between columns in CSS pixels; must match the stylesheet. */\n columnGap?: number;\n}\n\n/**\n * The scrolling viewport that chapters are mounted into.\n *\n * Kept as a class so the tricky parts — replacing a chapter without losing the\n * shadow root's styles, and reporting an XML error instead of rendering nothing\n * — live in one place.\n */\nexport class ChapterContainer {\n readonly viewport: HTMLDivElement;\n private root: ShadowRoot;\n private current: HTMLElement | null = null;\n\n constructor(private readonly host: HTMLElement) {\n this.viewport = document.createElement('div');\n this.viewport.className = 'reader-viewport';\n this.host.append(this.viewport);\n // `open` rather than `closed`: the client's own highlight and search code\n // must be able to walk the tree, and closed roots are a speed bump, not a\n // security boundary (the API to reach them is public).\n this.root = this.viewport.attachShadow({ mode: 'open' });\n }\n\n /**\n * Replace the mounted chapter.\n *\n * `xml` must be the chapter document as text. The container does not fetch it:\n * loading and mounting are separated so the caller can cache, prefetch and\n * order requests without knowing anything about the DOM.\n */\n mount(xml: string, options: ContainerOptions = {}): MountedChapter {\n const warnings: string[] = [];\n const doc = parseChapter(xml, warnings);\n const content = buildContent(doc, warnings);\n\n // Styles first, content second: the publisher's `@import` order decides the\n // cascade, and moving them after the markup would not change that but does\n // make the document read in the reverse of its natural order.\n this.root.replaceChildren(...collectStyles(doc), content);\n this.current = content;\n\n if (!options.columnGap) return { content, viewport: this.viewport, warnings };\n return { content, viewport: this.viewport, warnings };\n }\n\n /**\n * The mounted content element, for the paginator and for highlight work.\n *\n * Exposed deliberately: highlights are stored as a text quote plus a location,\n * and both have to be resolved against this element. Hiding it would push the\n * querySelector logic into every consumer.\n */\n get content(): HTMLElement | null {\n return this.current;\n }\n\n /** Drop the mounted chapter and release its styles. */\n clear(): void {\n this.root.replaceChildren();\n this.current = null;\n }\n}\n\n/**\n * Parse a chapter as XML.\n *\n * `application/xhtml+xml` is not cosmetic: the HTML parser silently repairs\n * broken markup by *discarding* it, so a strict epub with a namespace typo would\n * render as a chapter missing a paragraph and nobody would know why. The XML\n * parser reports it. The caller still shows whatever parsed, because a book with\n * one bad chapter should be readable.\n */\nexport function parseChapter(xml: string, warnings: string[]): Document {\n const parser = new DOMParser();\n const doc = parser.parseFromString(xml, 'application/xhtml+xml');\n const failure = doc.querySelector('parsererror');\n if (failure) {\n warnings.push(failure.textContent?.trim().slice(0, 200) || 'chapter is not well-formed XML');\n // Fall back to the HTML parser rather than showing a blank page. The warning\n // is what tells the reader (and us, in a bug report) that it happened.\n return parser.parseFromString(xml, 'text/html');\n }\n return doc;\n}\n\n/**\n * Everything the publisher linked, in document order.\n *\n * `<link rel=stylesheet>` is included because epubs routinely use it instead of\n * `<style>`. The URLs are already absolute by the time the chapter reaches the\n * client, so they resolve inside the shadow root without a `<base>` element —\n * and `<base>` would be the wrong tool anyway, since a shadow root does not use\n * the document's base URL.\n */\nfunction collectStyles(doc: Document): HTMLElement[] {\n const nodes: HTMLElement[] = [];\n for (const style of Array.from(doc.querySelectorAll('style'))) {\n const copy = document.createElement('style');\n copy.textContent = style.textContent;\n nodes.push(copy);\n }\n for (const link of Array.from(doc.querySelectorAll('link[rel~=\"stylesheet\" i]'))) {\n const href = link.getAttribute('href');\n if (!href) continue;\n const copy = document.createElement('link');\n copy.setAttribute('rel', 'stylesheet');\n copy.setAttribute('href', href);\n nodes.push(copy);\n }\n return nodes;\n}\n\n/**\n * Move the chapter's blocks into a single element.\n *\n * The `<html>` and `<body>` wrappers are dropped, as explained in the module\n * comment. Everything below them is moved verbatim — no class is rewritten, no\n * element is unwrapped, no attribute is added. The publisher's structure is the\n * book.\n */\nfunction buildContent(doc: Document, warnings: string[]): HTMLElement {\n const host = document.createElement('div');\n host.className = 'reader-chapter';\n const source = doc.body ?? doc.documentElement;\n if (!source) {\n warnings.push('chapter has no body');\n return host;\n }\n // `importNode` with `deep: true` copies nodes into this document, which is what\n // makes the shadow root able to hold them. Moving them directly would work for\n // nodes already in this document, but a chapter parsed by DOMParser is in its\n // own document and must be adopted.\n for (const child of Array.from(source.childNodes)) {\n host.append(document.importNode(child, true));\n }\n return host;\n}\n","/**\n * Column-based pagination for reflowable content.\n *\n * ## Why not `scroll` mode\n *\n * Continuous scrolling is what every web reader does, and it is the wrong model\n * for a book. A reader's position is measured in pages: the progress indicator,\n * \"12 pages left in this chapter\", turning back to re-read a paragraph — all of\n * it assumes a fixed page. Scroll position is a pixel offset that changes meaning\n * when the font size, viewport or column width changes, which is exactly the set\n * of things a reader adjusts.\n *\n * ## Why CSS multi-column instead of measuring by hand\n *\n * The alternative is to wrap every word in a span and do a binary search for the\n * page break. That rewrites the publisher's markup, which this product refuses to\n * do, and it is slow enough to be felt on a chapter with images. Multi-column\n * layout is the browser's own implementation of the same thing: the content is\n * laid out once, columns are created, and a page is a horizontal scroll offset.\n *\n * The cost is that pagination is done by the layout engine, so page boundaries\n * land wherever the engine puts them. That is acceptable — and it is what every\n * commercial reader does too — as long as a *position* survives a repagination,\n * which the anchor tolerance below handles.\n */\n\n/** A position inside a chapter, anchored to a DOM node so it survives repagination. */\nexport interface ChapterPosition {\n /** Index of the top-level block the reader was in. */\n blockIndex: number;\n /** How far into that block, as a fraction of its height. */\n offsetRatio: number;\n}\n\nexport interface PaginatorOptions {\n /** Gap between columns, in CSS pixels. Must match the stylesheet. */\n columnGap?: number;\n /** Extra page padding, in CSS pixels, on each side. */\n pagePadding?: number;\n}\n\n/**\n * Drive a single chapter's viewport.\n *\n * Deliberately owns no DOM: it is handed the scrolling element and reports\n * positions. Keeping it free of element creation means the same class can be\n * unit tested without a browser, which matters because pagination bugs (a\n * position that drifts one page forward on every resize) are only reproducible\n * against a real layout engine and are otherwise very hard to pin down.\n */\nexport class Paginator {\n private readonly columnGap: number;\n private readonly pagePadding: number;\n /** Column stride: the exact distance between two page origins. */\n private stride = 0;\n private pageCount = 1;\n private current = 0;\n\n constructor(private readonly viewport: HTMLElement, options: PaginatorOptions = {}) {\n this.columnGap = options.columnGap ?? 32;\n this.pagePadding = options.pagePadding ?? 24;\n }\n\n /** Recompute the column geometry. Call after a resize or a font change. */\n layout(pageWidth = this.viewport.clientWidth): { pageCount: number; stride: number } {\n const usable = Math.max(1, pageWidth - this.pagePadding * 2);\n this.stride = Math.max(1, usable + this.columnGap);\n // `scrollWidth` is the browser's answer to \"how much content is there\", and\n // dividing it by the stride is the only way to learn the page count without\n // duplicating the layout engine's work.\n const scrollWidth = this.viewport.scrollWidth;\n this.pageCount = Math.max(1, Math.round((scrollWidth + this.columnGap) / this.stride));\n return { pageCount: this.pageCount, stride: this.stride };\n }\n\n get total(): number {\n return this.pageCount;\n }\n\n get index(): number {\n return this.current;\n }\n\n /** Move to a page, clamped to the chapter. Returns true if the page changed. */\n goTo(page: number): boolean {\n const next = Math.max(0, Math.min(this.pageCount - 1, Math.floor(page)));\n if (next === this.current) return false;\n this.current = next;\n this.viewport.scrollLeft = next * this.stride;\n return true;\n }\n\n /** Move by a relative number of pages. Returns the leftover for chapter turns. */\n turn(pages: number): { changed: boolean; spill: number } {\n const target = this.current + pages;\n if (target < 0) return { changed: this.goTo(0), spill: target };\n if (target > this.pageCount - 1) {\n const changed = this.goTo(this.pageCount - 1);\n return { changed, spill: target - (this.pageCount - 1) };\n }\n return { changed: this.goTo(target), spill: 0 };\n }\n\n /** Forget the current page; used when a different chapter is loaded. */\n reset(): void {\n this.current = 0;\n this.pageCount = 1;\n this.viewport.scrollLeft = 0;\n }\n\n /** Page index for a horizontal scroll offset (e.g. after a user swipe). */\n pageAt(offset: number): number {\n if (this.stride <= 0) return 0;\n return Math.max(0, Math.min(this.pageCount - 1, Math.round(offset / this.stride)));\n }\n}\n\n/**\n * Fraction of the chapter read, from a page index.\n *\n * Reported as a fraction of the chapter rather than of the book: the server\n * cannot know a chapter's length in characters for most books, and inventing one\n * would put a wrong percentage under the reader's thumb. The reader combines this\n * with the chapter's position in the spine, which it does know exactly.\n */\nexport function pageFraction(page: number, pageCount: number): number {\n if (pageCount <= 1) return 0;\n return Math.max(0, Math.min(1, page / (pageCount - 1)));\n}\n\n/**\n * Capture a position that survives repagination.\n *\n * The page index does not: the same page index means a different part of the\n * text after a font size change. Anchoring to the top-level block at the page's\n * leading edge (plus how far into it) means a reader who resizes the text stays\n * on the sentence they were reading, which is the entire reason readers tolerate\n * changing the font size at all.\n */\nexport function capturePosition(viewport: HTMLElement, page: number, stride: number): ChapterPosition {\n const blocks = topLevelBlocks(viewport);\n if (blocks.length === 0) return { blockIndex: 0, offsetRatio: 0 };\n\n const origin = page * stride;\n let index = 0;\n for (let i = 0; i < blocks.length; i += 1) {\n const block = blocks[i]!;\n const left = block.offsetLeft;\n if (left <= origin + 1) index = i;\n else break;\n }\n\n const block = blocks[index]!;\n const height = block.offsetHeight || 1;\n const ratio = Math.max(0, Math.min(1, (origin - block.offsetLeft) / height));\n return { blockIndex: index, offsetRatio: ratio };\n}\n\n/** Restore a captured position. Returns the page to land on, or 0 if unmatchable. */\nexport function restorePosition(viewport: HTMLElement, position: ChapterPosition, stride: number): number {\n const blocks = topLevelBlocks(viewport);\n const block = blocks[Math.min(position.blockIndex, blocks.length - 1)];\n if (!block) return 0;\n const left = block.offsetLeft + position.offsetRatio * (block.offsetHeight || 0);\n return stride > 0 ? Math.max(0, Math.round(left / stride)) : 0;\n}\n\n/**\n * The blocks a position can anchor to.\n *\n * Only the viewport's direct children are considered. Going deeper would anchor\n * to a `<span>` whose offset would change the moment the publisher's CSS nests\n * differently, and the anchors have to be stable across the client's own style\n * tweaks — those changes must not move the reader's place in the book.\n */\nfunction topLevelBlocks(viewport: HTMLElement): HTMLElement[] {\n const root = (viewport.querySelector('.reader-chapter') ?? viewport) as HTMLElement;\n return Array.from(root.children).filter((node): node is HTMLElement => node instanceof HTMLElement);\n}\n","/**\n * Neighbour prefetching.\n *\n * The reader's perceived speed is almost entirely about whether the next thing\n * the user does is already in memory. Turning a page, opening the next chapter,\n * scrolling a comic — all three are predictable one step ahead, and a phone on a\n * tunnel feels a two-second round trip on every one of them.\n *\n * The policy is deliberately conservative:\n *\n * - **One step ahead, at most** (configurable). A comic pager that fetched three\n * pages ahead on every turn would download a whole volume in a few swipes,\n * on the reader's data plan.\n * - **A byte budget, not just a count.** A chapter can be 2KB or 8MB; counting\n * requests is not the same as bounding bytes.\n * - **Skipped when the connection says so.** `navigator.connection.saveData`\n * exists for a reason, and `2g` makes prefetching actively harmful.\n * - **Cancelled on navigation.** Prefetching the chapter after the one the user\n * just left is pure waste; the in-flight request is dropped instead of being\n * allowed to finish.\n */\n\nexport interface PrefetchOptions {\n /** How many items past the current one to fetch. */\n ahead?: number;\n /** Ceiling on total prefetched bytes. */\n maxBytes?: number;\n /** Item sizes from the manifest, when the format reports them. */\n sizeOf?: (key: string) => number | undefined;\n}\n\ninterface Entry {\n blob: Blob;\n bytes: number;\n /** Order of arrival, for eviction. */\n seq: number;\n}\n\n/**\n * A bounded, ordered cache of fetched resources.\n *\n * Keys are format-private refs. The cache stores Blobs rather than strings so the\n * same instance serves chapter documents and comic pages; callers decode.\n */\nexport class PrefetchCache {\n private entries = new Map<string, Entry>();\n private pending = new Map<string, Promise<Blob>>();\n private bytes = 0;\n private counter = 0;\n private readonly ahead: number;\n private readonly maxBytes: number;\n\n constructor(options: PrefetchOptions = {}) {\n this.ahead = options.ahead ?? 1;\n this.maxBytes = options.maxBytes ?? 32 * 1024 * 1024;\n }\n\n /** A cached resource, if it is already here. */\n peek(key: string): Blob | undefined {\n return this.entries.get(key)?.blob;\n }\n\n /** Await a resource, joining an in-flight request rather than duplicating it. */\n async get(key: string, load: (key: string) => Promise<Blob>): Promise<Blob> {\n const cached = this.entries.get(key);\n if (cached) return cached.blob;\n\n const inFlight = this.pending.get(key);\n if (inFlight) return inFlight;\n\n const promise = load(key).then((blob) => {\n this.store(key, blob);\n return blob;\n });\n this.pending.set(key, promise);\n try {\n return await promise;\n } finally {\n this.pending.delete(key);\n }\n }\n\n /**\n * Warm the cache for the items around `keys[from]`.\n *\n * Returns a function that cancels the still-running requests, which the caller\n * invokes when the reader moves somewhere else.\n */\n warm(\n keys: readonly string[],\n from: number,\n load: (key: string) => Promise<Blob>,\n options: PrefetchOptions = {},\n ): () => void {\n if (!shouldPrefetch()) return () => undefined;\n const ahead = options.ahead ?? this.ahead;\n const sizeOf = options.sizeOf;\n\n let budget = this.maxBytes - this.bytes;\n const aborted = new Set<string>();\n\n for (let step = 1; step <= ahead; step += 1) {\n const key = keys[from + step];\n if (!key || this.entries.has(key) || this.pending.has(key)) continue;\n const estimate = sizeOf?.(key);\n if (estimate !== undefined && estimate > budget) continue;\n if (estimate !== undefined) budget -= estimate;\n\n void this.get(key, load).catch(() => {\n // A failed prefetch is not an error the reader needs to see: the item is\n // still reachable by asking for it, and the foreground request will\n // report the real problem.\n });\n aborted.add(key);\n }\n\n return () => {\n for (const key of aborted) {\n this.entries.delete(key);\n this.pending.delete(key);\n }\n };\n }\n\n /** Drop everything but `keep`, oldest first. Used when the book changes. */\n trim(keep: number): void {\n if (this.entries.size <= keep) return;\n const ordered = [...this.entries.entries()].sort((a, b) => a[1].seq - b[1].seq);\n for (const [key, entry] of ordered.slice(0, ordered.length - keep)) {\n this.bytes -= entry.bytes;\n this.entries.delete(key);\n }\n }\n\n clear(): void {\n this.entries.clear();\n this.pending.clear();\n this.bytes = 0;\n }\n\n private store(key: string, blob: Blob): void {\n const bytes = blob.size;\n // An oversized single resource is not cached: keeping it would evict\n // everything else and still blow the budget.\n if (bytes > this.maxBytes) return;\n this.bytes += bytes;\n this.entries.set(key, { blob, bytes, seq: this.counter++ });\n while (this.bytes > this.maxBytes) {\n const oldest = [...this.entries.entries()].sort((a, b) => a[1].seq - b[1].seq)[0];\n if (!oldest) break;\n this.bytes -= oldest[1].bytes;\n this.entries.delete(oldest[0]);\n }\n }\n}\n\n/**\n * Whether prefetching is welcome.\n *\n * `saveData` is an explicit request from the user and is honoured as one. A 2G\n * connection is not going to finish a prefetch before the reader wants the item,\n * so the bandwidth is better left for the foreground.\n */\nexport function shouldPrefetch(): boolean {\n const connection = (navigator as Navigator & {\n connection?: { saveData?: boolean; effectiveType?: string };\n }).connection;\n if (!connection) return true;\n if (connection.saveData) return false;\n return connection.effectiveType !== 'slow-2g' && connection.effectiveType !== '2g';\n}\n","/**\n * A tiny typed emitter.\n *\n * The reader emits state (chapter changed, page turned, position updated) and\n * the UI subscribes. A framework's reactive layer would do the same job, but the\n * reader's state changes faster than its DOM should: a page turn must not\n * re-render the chapter, so the two are kept explicitly apart.\n */\nexport type Listener<T> = (value: T) => void;\n\nexport class Emitter<T> {\n private listeners = new Set<Listener<T>>();\n\n on(listener: Listener<T>): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n emit(value: T): void {\n for (const listener of [...this.listeners]) listener(value);\n }\n\n clear(): void {\n this.listeners.clear();\n }\n}\n","/**\n * Reading positions.\n *\n * A position must survive three things a reader does constantly: changing the\n * font size, rotating the phone, and reading the same book on another device.\n * That rules out a page index (changes with the font) and a pixel offset\n * (changes with everything).\n *\n * What is stored instead is a small record:\n *\n * { chapter: <archive path>, spine: <index>, block: <n>, ratio: <0..1>, percent }\n *\n * - `chapter` is the server's own identity for a chapter (its archive path). It\n * is stable across repagination and across devices, which is what makes the\n * sync endpoint's `locator` field useful. It is a plain string, not an EPUB\n * CFI: the server never has to parse it, and a CFI would only work for EPUB\n * anyway — a comic's position is a page index, a PDF's is a page number.\n * - `block`/`ratio` place the reader inside the chapter, tolerantly: a missing\n * block falls back to the start of the chapter rather than to the start of\n * the book.\n * - `percent` is for the progress indicator, computed against the spine the\n * client knows exactly (chapter index / chapter count), never against a\n * character count nobody measured.\n */\n\nexport interface Locator {\n /** Format-private chapter identity; the manifest's `href`. */\n chapter: string;\n /** Index of the chapter in the spine, when the book has one. */\n spine: number;\n /** Top-level block index inside the chapter. */\n block: number;\n /** Fraction into that block. */\n ratio: number;\n /** Whole-book progress in [0, 1]. */\n percent: number;\n}\n\n/** Parse a stored locator, tolerating anything the server or an older client wrote. */\nexport function parseLocator(value: string): Locator | null {\n if (!value) return null;\n\n // Older builds wrote a bare chapter reference. Accepting it costs three lines\n // and saves every reader who had a position stored under that format.\n if (!value.startsWith('{')) {\n return { chapter: value, spine: 0, block: 0, ratio: 0, percent: 0 };\n }\n\n try {\n const parsed = JSON.parse(value) as Partial<Locator>;\n if (typeof parsed.chapter !== 'string') return null;\n return {\n chapter: parsed.chapter,\n spine: numberOr(parsed.spine, 0),\n block: numberOr(parsed.block, 0),\n ratio: clampRatio(parsed.ratio),\n percent: clampRatio(parsed.percent),\n };\n } catch {\n return null;\n }\n}\n\nexport function serializeLocator(locator: Locator): string {\n return JSON.stringify(locator);\n}\n\nfunction numberOr(value: unknown, fallback: number): number {\n return typeof value === 'number' && Number.isFinite(value) ? value : fallback;\n}\n\nfunction clampRatio(value: unknown): number {\n if (typeof value !== 'number' || !Number.isFinite(value)) return 0;\n return Math.max(0, Math.min(1, value));\n}\n\n/**\n * Whole-book progress from a spine position.\n *\n * Within-chapter progress is weighted by the chapter's share of the spine, so a\n * reader at the middle of chapter 50 of 100 reads 50% and not 0%. Weighting by\n * character count would be more accurate and is not available — the server\n * reports a chapter count and honestly reports no length, so this uses what\n * exists rather than inventing the rest.\n */\nexport function bookPercent(spine: number, spineTotal: number, fractionInChapter: number): number {\n if (spineTotal <= 0) return 0;\n return Math.max(0, Math.min(1, (spine + fractionInChapter) / spineTotal));\n}\n","/**\n * The reflowable book reader.\n *\n * Owns the chapter lifecycle for EPUB and TXT:\n *\n * chapter N-1 (cached) → chapter N (mounted, paginated) → chapter N+1 (prefetched)\n *\n * Four decisions are load-bearing and worth stating.\n *\n * ### 1. Chapters are fetched one at a time, and windowed\n *\n * The whole book is never downloaded. The manifest carries the first window of\n * chapters; turning past its last one fetches the next window. A 1200-chapter\n * omnibus therefore costs one window of metadata and one chapter of bytes to\n * open, and the client's memory holds at most three chapters.\n *\n * ### 2. Chapter identity is the server's `href`, never a spine index\n *\n * Windowed manifests return a prefix of the spine, so an index computed against\n * a window means a different chapter in the full spine. This was a real bug in\n * the first version of the server contract and is why items are addressed by\n * archive path.\n *\n * ### 3. Progress is reported on the chapter's turn, not on every page\n *\n * A page turn happens several times a minute and each one is a write to the\n * user's account. Reporting on page turn would be a request per swipe; reporting\n * only on chapter change would lose the reader's place inside a chapter. So it is\n * debounced, and flushed on close and on visibility change — the two moments a\n * browser may not give us another chance.\n *\n * ### 4. Append-vs-replace for the text of a book with no spine\n *\n * A TXT file with no chapter headings has no addressable structure. Scrolling it\n * as a stream is the only honest option, and the server exposes it as byte\n * ranges (`chunk:<offset>`). The reader appends rather than replacing, because\n * replacing would fight the reader's own scroll position.\n */\n\nimport { ApiClient, type BookContent, type BookDto, type ContentItem } from '../net/api.ts';\nimport {\n contains,\n groupForSpine,\n toLocalIndex,\n toSpineIndex,\n toWindow,\n type Window,\n} from './window.ts';\nimport { ChapterContainer } from './container.ts';\nimport { Paginator, capturePosition, pageFraction, restorePosition } from './paginator.ts';\nimport { PrefetchCache } from './prefetch.ts';\nimport { Emitter } from '../lib/events.ts';\nimport { bookPercent, parseLocator, serializeLocator, type Locator } from '../lib/locator.ts';\n\nexport interface ReaderState {\n book: BookDto;\n /** All groups, so the table of contents is complete before every window is fetched. */\n groups: BookContent['groups'];\n /** Items of the loaded window. */\n items: ContentItem[];\n /** Index into `items` of the chapter on screen. */\n index: number;\n /** Total chapters in the book (not just this window). */\n total: number;\n page: number;\n pageCount: number;\n warnings: string[];\n}\n\nexport interface ReaderOptions {\n /** Device name recorded with each progress update. */\n device?: string;\n /** Milliseconds to coalesce progress writes; 0 disables debouncing. */\n progressDebounceMs?: number;\n /** Host platform, so the reader can adapt gesture expectations. */\n platform?: 'web' | 'android';\n}\n\nexport class BookReader {\n readonly state: Emitter<ReaderState> = new Emitter();\n private readonly container: ChapterContainer;\n private readonly paginator: Paginator;\n private readonly cache = new PrefetchCache();\n private window: Window | null = null;\n private current: ReaderState | null = null;\n private locator: Locator | null = null;\n private progressTimer: ReturnType<typeof setTimeout> | null = null;\n private cancelPrefetch: (() => void) | null = null;\n private readonly device: string;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: ReaderOptions = {},\n ) {\n this.device = options.device ?? 'web';\n this.container = new ChapterContainer(host);\n this.paginator = new Paginator(this.container.viewport);\n }\n\n /**\n * Open the book at a stored position.\n *\n * One request. The manifest carries the addressable structure as well as the\n * book, so there is no second round trip before the first page can be drawn —\n * which over a tunnel is the difference between a book opening and a book that\n * appears to hang.\n */\n async open(bookId: string, group = 0): Promise<ReaderState> {\n const manifest = await this.api.manifest(bookId, group);\n this.window = toWindow(manifest.content ?? null, {\n ...(manifest.kind !== undefined ? { kind: manifest.kind } : {}),\n ...(manifest.total !== undefined ? { total: manifest.total } : {}),\n ...(manifest.groups !== undefined ? { groups: manifest.groups } : {}),\n ...(manifest.items !== undefined ? { items: manifest.items } : {}),\n });\n\n const saved = parseLocator(\n (await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',\n );\n this.locator = saved;\n\n // A saved chapter is matched by its `href`, which is the only identifier\n // that survives a window boundary. Falling back to the window's first item\n // is right: it is where a book starts.\n const target = saved\n ? this.window.items.findIndex((item) => item.href === saved.chapter)\n : -1;\n await this.show(target >= 0 ? target : 0, saved ?? undefined);\n return this.current!;\n }\n\n /** The container the reader mounted into, exposed for highlight integration. */\n get chapterElement(): HTMLElement | null {\n return this.container.content;\n }\n\n /** Turn pages; a spill at either end moves to the neighbouring chapter. */\n async turn(pages: number): Promise<void> {\n if (!this.current || !this.window) return;\n const { changed, spill } = this.paginator.turn(pages);\n if (spill !== 0) {\n const next = this.current.index + Math.sign(spill);\n if (next < 0 || next >= this.window.items.length) {\n if (changed) this.publish();\n return;\n }\n await this.show(next);\n // A forward spill lands on the *first* page of the next chapter and a\n // backward spill on its last, which is what a real book does.\n if (spill > 0) this.paginator.goTo(0);\n else this.paginator.goTo(this.paginator.total - 1);\n this.publish();\n return;\n }\n if (changed) this.publish();\n }\n\n /** Jump to a chapter by index within the loaded window. */\n /**\n * Jump to a whole-book position.\n *\n * Fetches the window that contains it when necessary, which is what makes the\n * table of contents of a 1200-chapter book work with only one window in memory.\n */\n async goToSpine(spine: number): Promise<void> {\n if (!this.window) return;\n await this.ensureWindow(spine);\n if (!this.window || !contains(this.window, spine)) return;\n await this.show(toLocalIndex(this.window, spine));\n }\n\n /**\n * Jump to a table-of-contents entry.\n *\n * Either a whole-book index or a chapter href works: a comic's table of\n * contents is a list of volumes, and its entries map to a group rather than to\n * a single chapter.\n */\n async goToSpineOfHref(href: string): Promise<boolean> {\n if (!this.window) return false;\n const local = this.window.items.findIndex((item) => item.href === href);\n if (local >= 0) {\n await this.show(local);\n return true;\n }\n return false;\n }\n\n /** Recompute geometry after a resize, keeping the reader's place. */\n relayout(): void {\n if (!this.current) return;\n const stride = this.paginator.layout().stride;\n const position = capturePosition(this.container.viewport, this.paginator.index, stride);\n const stride2 = this.paginator.layout().stride;\n this.paginator.goTo(restorePosition(this.container.viewport, position, stride2));\n this.publish();\n }\n\n /**\n * Flush the pending progress write.\n *\n * Called on `pagehide` and when the app goes to the background. A browser that\n * is being closed does not run another timer, so a debounced write that is not\n * flushed here is a lost position.\n */\n async flush(): Promise<void> {\n if (this.progressTimer) {\n clearTimeout(this.progressTimer);\n this.progressTimer = null;\n }\n await this.writeProgress();\n }\n\n destroy(): void {\n this.cancelPrefetch?.();\n this.cache.clear();\n this.container.clear();\n this.state.clear();\n }\n\n // ---------------------------------------------------------------- internals\n\n private async show(index: number, restore?: Locator): Promise<void> {\n const window = this.window;\n if (!window) return;\n const item = window.items[index];\n if (!item) return;\n\n const xml = await this.cache\n .get(item.href, () => this.api.asset(this.book.id, item.href))\n .then((blob) => blob.text());\n\n const mounted = this.container.mount(xml);\n const geometry = this.paginator.layout();\n\n // Restoring an intra-chapter position is only meaningful for the chapter it\n // was captured in. Landing in a different chapter uses its first page.\n const target =\n restore && restore.chapter === item.href\n ? restorePosition(this.container.viewport, { blockIndex: restore.block, offsetRatio: restore.ratio }, geometry.stride)\n : 0;\n this.paginator.goTo(Math.min(target, geometry.pageCount - 1));\n\n this.current = {\n book: this.book,\n groups: window.groups,\n items: window.items,\n index,\n total: window.total,\n page: this.paginator.index,\n pageCount: this.paginator.total,\n warnings: mounted.warnings,\n };\n this.scheduleProgress();\n this.prefetchFrom(index);\n this.publish();\n }\n\n private publish(): void {\n if (!this.current) return;\n const state: ReaderState = {\n ...this.current,\n page: this.paginator.index,\n pageCount: this.paginator.total,\n };\n this.current = state;\n this.state.emit(state);\n }\n\n private scheduleProgress(): void {\n const delay = this.options.progressDebounceMs ?? 4000;\n if (delay === 0) {\n void this.writeProgress();\n return;\n }\n if (this.progressTimer) clearTimeout(this.progressTimer);\n this.progressTimer = setTimeout(() => {\n this.progressTimer = null;\n void this.writeProgress();\n }, delay);\n }\n\n private async writeProgress(): Promise<void> {\n const window = this.window;\n const state = this.current;\n if (!window || !state) return;\n const item = window.items[state.index];\n if (!item) return;\n\n const stride = this.paginator.layout().stride;\n const position = capturePosition(this.container.viewport, this.paginator.index, stride);\n const fraction = pageFraction(this.paginator.index, this.paginator.total);\n // The spine index is derived from the loaded window's group, never from the\n // window's own array offset: `items` holds one window, so `index` is local.\n const spine = toSpineIndex(window, state.index);\n const locator: Locator = {\n chapter: item.href,\n spine,\n block: position.blockIndex,\n ratio: position.offsetRatio,\n percent: bookPercent(spine, window.total, fraction),\n };\n this.locator = locator;\n\n await this.api\n .putProgress(this.book.id, {\n locator: serializeLocator(locator),\n percentage: locator.percent,\n chapterTitle: item.title,\n device: this.device,\n updatedAt: Date.now(),\n })\n .catch(() => {\n // A failed progress write is not worth interrupting reading for. The next\n // turn retries, and the position is still on the device.\n });\n }\n\n /** Prefetch the next chapter, and cancel the previous prefetch. */\n private prefetchFrom(index: number): void {\n const window = this.window;\n if (!window) return;\n this.cancelPrefetch?.();\n const keys = window.items.map((item) => item.href);\n this.cancelPrefetch = this.cache.warm(keys, index, (key) => this.api.asset(this.book.id, key), {\n // A phone on a tunnel re-reading a volume should not download it three\n // pages at a time, but it should not stall on every turn either.\n ahead: this.options.platform === 'android' ? 1 : 2,\n sizeOf: (key) => window.items.find((item) => item.href === key)?.size,\n });\n }\n\n /** Load the window holding a whole-book index, if it is not loaded already. */\n private async ensureWindow(spine: number): Promise<void> {\n if (!this.window || contains(this.window, spine)) return;\n const group = groupForSpine(this.window, spine);\n if (!group) return;\n const loaded = await this.api.items(this.book.id, group.seq);\n this.window = toWindow(loaded);\n }\n}\n","/**\n * The paged reader: comics, image directories, single images.\n *\n * Deliberately much simpler than the reflowable reader, and for a reason the\n * user's own message named: **a comic is not text, so it should not go through a\n * WebView at all.** A native `ImageView` decodes a JPEG with hardware support and\n * recycles the bitmap; an `<img>` inside a page on a low-end Android device is\n * several times the memory and CPU for the same screen. The Android shell\n * therefore renders this content natively and only uses this module for the H5\n * build.\n *\n * What both implementations share is the addressing: `items[].href` from the\n * manifest, fetched one page at a time. That is what makes \"download the whole\n * volume\" a client decision rather than a requirement — a reader who flips\n * through three pages of a 300MB volume has downloaded three pages.\n *\n * Three details that are easy to get wrong:\n *\n * - **Page order comes from the manifest, never from the file name.** The server\n * already applied natural ordering (`page10` after `page2`), and re-sorting on\n * the client would disagree with it for archives with a directory layout.\n * - **Object URLs are revoked.** Each page is a Blob; leaking one per page turn\n * is a few hundred megabytes over a volume.\n * - **The next page is decoded before it is shown.** Swapping `img.src` and\n * letting the browser paint on arrival shows a blank frame for a moment, which\n * reads as a stutter on every single turn.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\nimport { Emitter } from '../lib/events.ts';\nimport { PrefetchCache } from './prefetch.ts';\nimport { toWindow, type Window } from './window.ts';\nimport { parseLocator, serializeLocator, type Locator } from '../lib/locator.ts';\n\nexport interface PagedState {\n book: BookDto;\n index: number;\n total: number;\n /** Set when the current page is still being fetched. */\n loading: boolean;\n warnings: string[];\n}\n\nexport interface PagedOptions {\n device?: string;\n progressDebounceMs?: number;\n /**\n * Host platform. Android renders pages natively and never constructs this\n * reader; the option exists so the H5 build can declare itself explicitly\n * rather than relying on which module imported it.\n */\n platform?: 'web' | 'android';\n}\n\nexport class PagedReader {\n readonly state = new Emitter<PagedState>();\n private readonly cache = new PrefetchCache({ ahead: 2, maxBytes: 24 * 1024 * 1024 });\n private window: Window | null = null;\n private index = 0;\n private objectUrl: string | null = null;\n private progressTimer: ReturnType<typeof setTimeout> | null = null;\n private cancelPrefetch: (() => void) | null = null;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: PagedOptions = {},\n ) {}\n\n /**\n * Open at a stored page.\n *\n * A comic's position is a page index, which is a stable locator only because it\n * is resolved against the manifest: a volume added in the middle of a series\n * moves every later page, and the server's group offsets are what let the\n * client detect that and land on the same *volume* rather than the same offset.\n */\n async open(bookId: string, group = 0): Promise<PagedState> {\n const manifest = await this.api.manifest(bookId, group);\n this.window = toWindow(manifest.content ?? null, {\n ...(manifest.kind !== undefined ? { kind: manifest.kind } : {}),\n ...(manifest.total !== undefined ? { total: manifest.total } : {}),\n ...(manifest.groups !== undefined ? { groups: manifest.groups } : {}),\n ...(manifest.items !== undefined ? { items: manifest.items } : {}),\n });\n\n const saved = parseLocator(\n (await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',\n );\n const local = saved ? this.window.items.findIndex((item) => item.href === saved.chapter) : -1;\n await this.show(local >= 0 ? local : 0);\n return this.currentState();\n }\n\n get total(): number {\n return this.window?.items.length ?? 0;\n }\n\n /** Move by a number of pages. Stops at the ends rather than wrapping. */\n async turn(pages: number): Promise<void> {\n await this.show(this.index + pages);\n }\n\n async goTo(index: number): Promise<void> {\n await this.show(index);\n }\n\n async flush(): Promise<void> {\n if (this.progressTimer) {\n clearTimeout(this.progressTimer);\n this.progressTimer = null;\n }\n await this.writeProgress();\n }\n\n destroy(): void {\n this.cancelPrefetch?.();\n this.cache.clear();\n this.releaseUrl();\n this.state.clear();\n }\n\n private async show(index: number): Promise<void> {\n const window = this.window;\n if (!window || window.items.length === 0) return;\n const clamped = Math.max(0, Math.min(window.items.length - 1, index));\n const item = window.items[clamped];\n if (!item) return;\n\n this.index = clamped;\n this.emit(true);\n\n const blob = await this.cache.get(item.href, (key) => this.api.asset(this.book.id, key));\n // A page the reader has already flipped past must not replace the current\n // one: a slow request racing a fast thumb would otherwise show an old page.\n if (this.index !== clamped) return;\n\n const url = URL.createObjectURL(blob);\n const previous = this.objectUrl;\n this.objectUrl = url;\n\n const image = document.createElement('img');\n image.className = 'reader-page';\n image.alt = item.title;\n image.decoding = 'async';\n // Decode before mounting: without this the container is empty for as long as\n // the browser takes to decode, which is visible on every turn for a large scan.\n await image.decode().catch(() => undefined);\n this.host.replaceChildren(image);\n image.src = url;\n if (previous) URL.revokeObjectURL(previous);\n\n this.emit(false);\n this.emitProgress();\n this.prefetch(clamped);\n }\n\n private prefetch(index: number): void {\n const window = this.window;\n if (!window) return;\n this.cancelPrefetch?.();\n const keys = window.items.map((item) => item.href);\n this.cancelPrefetch = this.cache.warm(keys, index, (key) => this.api.asset(this.book.id, key), {\n sizeOf: (key) => window.items.find((item) => item.href === key)?.size,\n });\n }\n\n private emitProgress(): void {\n const delay = this.options.progressDebounceMs ?? 2000;\n if (delay === 0) {\n void this.writeProgress();\n return;\n }\n if (this.progressTimer) clearTimeout(this.progressTimer);\n this.progressTimer = setTimeout(() => {\n this.progressTimer = null;\n void this.writeProgress();\n }, delay);\n }\n\n private async writeProgress(): Promise<void> {\n const window = this.window;\n if (!window) return;\n const item = window.items[this.index];\n if (!item) return;\n // For a paged book the locator is the page's own identity, which the server\n // handed us. Rebuilding it from an index would break the moment a volume is\n // re-packed with an extra page.\n const locator: Locator = {\n chapter: item.href,\n spine: (window.group?.offset ?? 0) + this.index,\n block: 0,\n ratio: 0,\n percent: this.index / Math.max(1, window.items.length),\n };\n await this.api\n .putProgress(this.book.id, {\n locator: serializeLocator(locator),\n percentage: locator.percent,\n chapterTitle: item.title,\n device: this.options.device ?? 'web',\n updatedAt: Date.now(),\n })\n .catch(() => undefined);\n }\n\n private emit(loading: boolean): void {\n this.state.emit({\n book: this.book,\n index: this.index,\n total: this.total,\n loading,\n warnings: [],\n });\n }\n\n private currentState(): PagedState {\n return {\n book: this.book,\n index: this.index,\n total: this.total,\n loading: false,\n warnings: [],\n };\n }\n\n private releaseUrl(): void {\n if (this.objectUrl) {\n URL.revokeObjectURL(this.objectUrl);\n this.objectUrl = null;\n }\n }\n}\n","/**\n * The plain-text reader.\n *\n * TXT is the format that punishes naive implementations, because everything the\n * reader wants — a page count, a table of contents, a scroll position that means\n * something — depends on the text being measured, and the text is 20MB.\n *\n * The approach:\n *\n * - **A table of contents comes from the server.** Chapter headings are detected\n * server-side (encoding-aware) and returned as items. The client does not\n * re-scan the text for headings, which would mean downloading the whole file\n * to build a contents list.\n * - **Chapters are fetched one at a time.** A book with headings is read exactly\n * like an EPUB, which is why this module is a thin layer over the reflowable\n * reader rather than the reader.\n * - **A book without headings is streamed in chunks.** The server exposes byte\n * ranges; the client appends them as the reader reaches the bottom. Appending\n * rather than replacing is what keeps a scroll position valid.\n *\n * Deliberate omission: this reader does not re-typeset the text. No paragraph\n * inference, no smart quotes, no reflow. A TXT file with hard-wrapped lines is\n * rendered exactly as it is stored, because guessing at paragraph boundaries\n * mangles as many books as it fixes — and 「精品排版」 is a promise made for\n * EPUB, not for a file that has no typesetting to preserve.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\nimport { Emitter } from '../lib/events.ts';\n\n/** Size of a streamed chunk, matching the server's own window. */\nconst CHUNK_CHARS = 256 * 1024;\n/** Stop appending this far from the end. */\nconst APPEND_THRESHOLD_PX = 1200;\n\nexport interface TextState {\n book: BookDto;\n /** Position in the text, in characters. */\n offset: number;\n /** Chapters when the book has headings; empty when it does not. */\n chapters: number;\n /** Whether more text is available to append. */\n hasMore: boolean;\n loading: boolean;\n}\n\nexport interface TextOptions {\n device?: string;\n}\n\n/**\n * Read a plain-text book.\n *\n * Two modes, chosen by whether the server found headings. This is not a\n * simplification: the choice changes what \"a position\" means (a chapter index or\n * a character offset) and therefore what a progress update contains.\n */\nexport class TextReader {\n readonly state = new Emitter<TextState>();\n private offset = 0;\n private lastLength = 0;\n private loading = false;\n private finished = false;\n private readonly scroll: HTMLElement;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: TextOptions = {},\n ) {\n this.scroll = document.createElement('div');\n this.scroll.className = 'reader-text';\n this.host.replaceChildren(this.scroll);\n }\n\n /**\n * Stream the book, appending as the reader approaches the end.\n *\n * The offset is tracked in characters rather than bytes because the client\n * counts characters: the server's `chunk:` references are character offsets,\n * and the reply's length is reported in bytes. Mixing the two would cut a\n * Chinese novel in the middle of a character every 256KB — a visible defect\n * roughly every few pages.\n */\n async open(bookId: string): Promise<void> {\n await this.append();\n this.scroll.addEventListener('scroll', () => this.maybeAppend());\n }\n\n /** Load the next chapter, when the book has a table of contents. */\n async goToChapter(index: number, bookId: string): Promise<void> {\n const blob = await this.api.asset(bookId, `chapter:${index}`);\n this.scroll.replaceChildren(paragraphs(await blob.text()));\n this.offset = 0;\n this.scroll.scrollTop = 0;\n this.emit(false);\n await this.saveProgress(bookId, index);\n }\n\n async flush(bookId: string): Promise<void> {\n await this.saveProgress(bookId, null);\n }\n\n destroy(): void {\n this.host.replaceChildren();\n this.state.clear();\n }\n\n private maybeAppend(): void {\n if (this.finished || this.loading) return;\n const remaining = this.scroll.scrollHeight - this.scroll.scrollTop - this.scroll.clientHeight;\n if (remaining > APPEND_THRESHOLD_PX) return;\n void this.append();\n }\n\n private async append(): Promise<void> {\n if (this.loading || this.finished) return;\n this.loading = true;\n this.emit(true);\n try {\n const blob = await this.api.asset(this.book.id, `chunk:${this.offset}`);\n const text = await blob.text();\n if (text.length === 0) {\n this.finished = true;\n return;\n }\n this.offset += text.length;\n this.scroll.append(paragraphs(text));\n // A short reply means the end was reached; asking again would return empty\n // forever and keep a spinner on screen.\n if (text.length < CHUNK_CHARS) this.finished = true;\n } finally {\n this.loading = false;\n this.emit(false);\n }\n }\n\n private async saveProgress(bookId: string, chapter: number | null): Promise<void> {\n // A streamed text has no known total length, so progress is \"how far in\" as a\n // fraction of what has been loaded. Reporting it as whole-book progress would\n // need the byte count the server deliberately does not compute.\n const loaded = this.scroll.scrollHeight || 1;\n const ratio = Math.max(0, Math.min(1, this.scroll.scrollTop / loaded));\n const locator =\n chapter !== null\n ? JSON.stringify({ chapter: `chapter:${chapter}`, spine: chapter, block: 0, ratio, percent: ratio })\n : JSON.stringify({ chapter: `chunk:${Math.round(this.offset * ratio)}`, spine: 0, block: 0, ratio, percent: ratio });\n await this.api\n .putProgress(bookId, {\n locator,\n percentage: ratio,\n chapterTitle: chapter !== null ? `第 ${chapter + 1} 章` : '',\n device: this.options.device ?? 'web',\n updatedAt: Date.now(),\n })\n .catch(() => undefined);\n }\n\n private emit(loading: boolean): void {\n this.state.emit({\n book: this.book,\n offset: this.offset,\n chapters: 0,\n hasMore: !this.finished,\n loading,\n });\n }\n}\n\n/**\n * Split text into paragraphs for rendering.\n *\n * `<br>` between lines rather than one element per paragraph: a TXT file's blank\n * lines are the only paragraph signal it has, and treating a blank line as a\n * break while rendering single newlines as hard breaks is the most faithful\n * reading of a format that has no markup. Collapsing them would reflow a book\n * whose author wrapped at 80 columns, which is most of them.\n */\nfunction paragraphs(text: string): DocumentFragment {\n const fragment = document.createDocumentFragment();\n const lines = text.split(/\\r\\n|\\r|\\n/);\n let buffer: string[] = [];\n\n const flush = (): void => {\n if (buffer.length === 0) return;\n const block = document.createElement('p');\n // Text content, never innerHTML: a TXT file is user data and a `&` or a `<`\n // in a novel must not be able to produce markup.\n block.textContent = buffer.join('\\n');\n block.style.whiteSpace = 'pre-wrap';\n fragment.append(block);\n buffer = [];\n };\n\n for (const line of lines) {\n if (line.trim() === '') flush();\n else buffer.push(line);\n }\n flush();\n return fragment;\n}\n","/**\n * The PDF surface.\n *\n * PDF is explicitly a \"保底可读\" format in this product, and this module keeps\n * that promise literal: the server sends the original bytes and the client hands\n * them to a PDF surface it does not own.\n *\n * ## Why an iframe here, when the reflowable reader refused one\n *\n * The container module explains the reasoning at length; the short version is\n * that the trade flips for PDF. A reflowable chapter needs to be measured,\n * paginated and selected *by this client*, and a frame would force a second\n * paginator inside it. A PDF needs none of that: the browser's own viewer already\n * handles page layout, zoom, text selection, search and print, and it expects to\n * own a browsing context to do it. Fighting that to keep a single container type\n * would mean reimplementing a PDF viewer, which is a project of its own.\n *\n * On Android the same logic points at the platform viewer, which is why the shell\n * routes `document`-kind books away from the WebView entirely.\n *\n * ## Why not PDF.js\n *\n * It would bundle about a megabyte of JavaScript plus a worker into both the H5\n * and the Android assets to do worse than the browser's built-in viewer on the\n * device it is running on, and worse than the platform viewer on Android. The\n * server also refuses to parse the page tree, so a PDF.js-based client would\n * still have to discover the page count itself. There is no version of this that\n * is better than \"give the bytes to a viewer\".\n *\n * ## What the client does contribute\n *\n * Progress. The viewer is opaque, so the position has to come from somewhere the\n * client can observe: the URL fragment (`#page=N`), which is the one interface\n * every PDF viewer agrees on. Writing it is how \"continue reading\" works for a\n * PDF at all; reading it back is not possible without the viewer's cooperation,\n * so a PDF's progress is coarse — page-level, from the client's own tracking of\n * where it sent the viewer — and that limitation is stated rather than papered\n * over with a guess.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\nimport { Emitter } from '../lib/events.ts';\nimport { parseLocator, serializeLocator } from '../lib/locator.ts';\n\nexport interface PdfState {\n book: BookDto;\n /** Page the viewer was last sent to, when the client knows it. */\n page: number;\n /** The URL the viewer is showing, for diagnostics. */\n url: string;\n}\n\nexport interface PdfOptions {\n device?: string;\n}\n\nexport class PdfReader {\n readonly state = new Emitter<PdfState>();\n private frame: HTMLIFrameElement | null = null;\n private page = 1;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: PdfOptions = {},\n ) {}\n\n /**\n * Open the document.\n *\n * The URL carries the auth token in a query parameter rather than a header,\n * because an iframe cannot set headers. The server accepts `?access_token=` on\n * asset and content routes for exactly this reason. It is the one place a\n * token travels in a URL, and it is deliberate: the alternative is proxying the\n * whole document through `fetch` and a Blob URL, which defeats the viewer's own\n * range requests and makes a 100MB manual load entirely before the first page\n * appears.\n */\n async open(bookId: string): Promise<PdfState> {\n const saved = parseLocator(\n (await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',\n );\n if (saved?.block) this.page = Math.max(1, saved.block);\n\n const frame = document.createElement('iframe');\n frame.className = 'reader-pdf';\n // `allow-scripts` is not granted: the document is the user's own file, but\n // there is no feature the viewer needs that requires it, and withholding it\n // costs nothing.\n frame.setAttribute('sandbox', 'allow-same-origin allow-popups allow-forms');\n frame.title = this.book.title;\n this.frame = frame;\n this.host.replaceChildren(frame);\n this.setPage(bookId, this.page);\n return { book: this.book, page: this.page, url: frame.src };\n }\n\n /** Send the viewer to a page. The fragment is the only portable interface. */\n setPage(bookId: string, page: number): void {\n this.page = Math.max(1, Math.floor(page));\n if (this.frame) this.frame.src = this.url(bookId, this.page);\n this.state.emit({ book: this.book, page: this.page, url: this.frame?.src ?? '' });\n void this.saveProgress(bookId);\n }\n\n /** PDF readers are opaque; the client can only report where it sent them. */\n async flush(bookId: string): Promise<void> {\n await this.saveProgress(bookId);\n }\n\n destroy(): void {\n this.host.replaceChildren();\n this.frame = null;\n this.state.clear();\n }\n\n private url(bookId: string, page: number): string {\n const token = this.api.currentSession?.accessToken ?? '';\n const base = this.api.url(`/api/v1/books/${encodeURIComponent(bookId)}/assets?ref=document`);\n const separator = base.includes('?') ? '&' : '?';\n return `${base}${separator}access_token=${encodeURIComponent(token)}#page=${page}`;\n }\n\n private async saveProgress(bookId: string): Promise<void> {\n // Page count is unknown to the server by design, so a percentage would be a\n // guess. `block` carries the page number and `percent` stays 0 rather than\n // inventing a denominator.\n const locator = {\n chapter: 'document',\n spine: 0,\n block: this.page,\n ratio: 0,\n percent: 0,\n };\n await this.api\n .putProgress(bookId, {\n locator: serializeLocator(locator),\n percentage: 0,\n chapterTitle: `第 ${this.page} 页`,\n device: this.options.device ?? 'web',\n updatedAt: Date.now(),\n })\n .catch(() => undefined);\n }\n}\n","/**\n * The client's style intervention.\n *\n * This file is the product's core differentiator, so it is worth being explicit\n * about what it does and does not do.\n *\n * ## The rule\n *\n * **The publisher's layout wins.** The client may set defaults only for things\n * the book left unspecified, and may expose values the reader is entitled to\n * control (text size, theme, page width) through variables that a book is free\n * to ignore.\n *\n * Concretely, this stylesheet never sets: `font-family`, `line-height`,\n * `text-align`, `letter-spacing`, `margin` on content elements, `writing-mode`,\n * or anything else a typographer chose. Readium CSS does set them — its premise\n * is that a consistent, controlled reading experience beats fidelity, and that\n * premise is the opposite of this product's.\n *\n * ## How the reader's settings reach the book without overwriting it\n *\n * Through CSS custom properties with fallbacks a book can ship its own value\n * for. `font-size: var(--reader-font-size, 1em)` means: a book that set an\n * explicit size keeps it, and a book that did not inherits the reader's choice.\n * That is the whole mechanism, and it is why `!important` appears nowhere here.\n *\n * ## Root font size, not per-element font sizes\n *\n * Setting `font-size` on the container only affects text that uses relative\n * units. That is deliberate: a book that declares `font-size: 14px` on a\n * paragraph has stated an intent, and the reader's slider has no business\n * overriding it. What the slider changes is the base that everything relative\n * scales from, which is the behaviour a reader expects from a real book app and\n * the only one that can be implemented without fighting the book.\n */\n\nexport type ThemeMode = 'light' | 'sepia' | 'dark';\n\nexport interface ReaderSettings {\n /** Base font size in CSS pixels. */\n fontSize: number;\n /** Page width in CSS pixels, for the column layout. */\n pageWidth: number;\n /** Gap between columns; the paginator must be told the same number. */\n columnGap: number;\n /** Padding inside a page. */\n pagePadding: number;\n theme: ThemeMode;\n}\n\nexport const DEFAULT_SETTINGS: ReaderSettings = {\n fontSize: 18,\n pageWidth: 720,\n columnGap: 32,\n pagePadding: 24,\n theme: 'light',\n};\n\n/**\n * Theme palettes.\n *\n * Applied as custom properties rather than as rules on the book's elements, so a\n * book that hard-codes `color: #333` keeps it. That is the correct trade: a dark\n * theme that silently defeats a print-oriented book's contrast is a bug report,\n * whereas a book whose text stays dark on a \"dark\" background is one the reader\n * can see and judge.\n */\nconst PALETTES: Record<ThemeMode, { background: string; text: string; muted: string }> = {\n light: { background: '#ffffff', text: '#111111', muted: '#6b7280' },\n sepia: { background: '#f6f0e4', text: '#3b3121', muted: '#8a7c63' },\n dark: { background: '#14161a', text: '#c9cdd4', muted: '#7c828c' },\n};\n\n/**\n * Styles shared by every chapter, applied from the *outer* document.\n *\n * They live outside the shadow root on purpose: rules that reference a shadow\n * root's own content cannot be written from outside it, but the values can still\n * be inherited in, because custom properties cross the boundary. Keeping the\n * layout rules (column count, page size, scroll behaviour) out here means the\n * per-chapter shadow root holds only what came out of the book.\n */\nexport function readerCss(hostSelector: string, settings: ReaderSettings): string {\n const palette = PALETTES[settings.theme];\n return `\n${hostSelector} {\n --reader-font-size: ${settings.fontSize}px;\n --reader-page-width: ${settings.pageWidth}px;\n --reader-column-gap: ${settings.columnGap}px;\n --reader-page-padding: ${settings.pagePadding}px;\n --reader-bg: ${palette.background};\n --reader-fg: ${palette.text};\n --reader-muted: ${palette.muted};\n --reader-chapter-progress: 0;\n\n display: flex;\n justify-content: center;\n background: var(--reader-bg);\n color: var(--reader-fg);\n /* The container is a scroll *host*, never a scroll area: pages are moved by\n scrollLeft, so a vertical scrollbar appearing here would mean the paginator\n lost track of the geometry. */\n overflow: hidden;\n font-size: var(--reader-font-size);\n}\n\n${hostSelector} .reader-viewport {\n display: block;\n width: 100%;\n max-width: var(--reader-page-width);\n /* One page wide; the pages after the first are reached by scrolling sideways.\n overflow-x: auto rather than scroll, so a chapter shorter than one page does\n not show a scrollbar it cannot use. */\n overflow-x: auto;\n overflow-y: hidden;\n /* Scroll snapping makes a swipe land on a page boundary instead of between two\n pages, which is what \"turning a page\" means. The paginator still sets\n scrollLeft explicitly; snapping is what makes *user* gestures agree with it. */\n scroll-snap-type: x mandatory;\n scrollbar-width: none;\n -webkit-overflow-scrolling: touch;\n}\n\n${hostSelector} .reader-viewport::-webkit-scrollbar {\n display: none;\n}\n`;\n}\n\n/**\n * Styles that must live inside the shadow root.\n *\n * A short, fixed list, and every rule in it is about the *container* rather than\n * the book: the column layout that creates pages, and the snap points that align\n * a page to the viewport. Nothing here selects a publisher element type.\n *\n * `direction: ltr` is set explicitly because the column model reverses when the\n * inherited direction flips, and a book with `dir=\"rtl\"` on a footnote container\n * would otherwise silently paginate right-to-left from that point on.\n */\nexport function chapterCss(): string {\n return `\n:host {\n display: block;\n}\n.reader-chapter {\n /* Columns are the pagination mechanism: the browser lays the chapter out as\n N columns of exactly one page width, and a page is a scroll offset. */\n column-width: calc(var(--reader-page-width) - var(--reader-page-padding) * 2);\n column-gap: var(--reader-column-gap);\n column-fill: auto;\n direction: ltr;\n height: 100%;\n padding: 0 var(--reader-page-padding);\n box-sizing: border-box;\n /* The reader's font size is inherited through this element, so a book's\n relative sizes scale with it and a book's absolute sizes do not. */\n font-size: var(--reader-font-size);\n color: var(--reader-fg);\n}\n.reader-chapter > * {\n scroll-snap-align: start;\n /* A block taller than a page must be splittable across columns; \"avoid\" would\n push a long paragraph to the next page and leave a gap. Only images opt out. */\n break-inside: auto;\n}\n.reader-chapter img,\n.reader-chapter svg {\n /* Publisher images are sized in their own units and routinely overflow the\n column. Clamping them is the one place where \"do not touch the book\" has to\n give way: an image that breaks the column geometry makes the whole chapter\n unreadable, not just unstyled. */\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n break-inside: avoid;\n scroll-snap-align: start;\n}\n.reader-chapter img[data-reader-unsized='true'] {\n /* Reserve a line for an image the book gave no dimensions, so the chapter does\n not reflow under the reader when it finishes loading. */\n min-height: 1em;\n}\n`;\n}\n","/**\n * Reader settings, persisted per device.\n *\n * Deliberately local rather than synced. Font size and theme are properties of\n * the screen the reader is holding — a phone and a tablet want different values —\n * whereas the reading position is a property of the reader. Syncing the first\n * would mean opening a book on the tablet in the phone's font size, which is the\n * one behaviour nobody wants.\n *\n * The palette is mirrored into the reader's own theme module so the shell's\n * chrome and the book's page never disagree about whether the reader chose dark.\n */\n\nimport type { ReaderSettings, ThemeMode } from '../render/theme.ts';\nimport { DEFAULT_SETTINGS } from '../render/theme.ts';\n\nconst KEY = 'reader.settings';\n\nexport function loadSettings(): ReaderSettings {\n try {\n const raw = localStorage.getItem(KEY);\n if (!raw) return { ...DEFAULT_SETTINGS };\n const parsed = JSON.parse(raw) as Partial<ReaderSettings>;\n return {\n fontSize: clamp(parsed.fontSize, 12, 32, DEFAULT_SETTINGS.fontSize),\n pageWidth: clamp(parsed.pageWidth, 360, 1200, DEFAULT_SETTINGS.pageWidth),\n columnGap: DEFAULT_SETTINGS.columnGap,\n pagePadding: DEFAULT_SETTINGS.pagePadding,\n theme: isTheme(parsed.theme) ? parsed.theme : DEFAULT_SETTINGS.theme,\n };\n } catch {\n return { ...DEFAULT_SETTINGS };\n }\n}\n\nexport function saveSettings(settings: ReaderSettings): void {\n try {\n localStorage.setItem(KEY, JSON.stringify(settings));\n } catch {\n // Private browsing modes throw on write. The settings still apply for this\n // session, which is better than failing the interaction.\n }\n}\n\n/** Set the theme on the root element, where the shell's own variables live. */\nexport function applyTheme(root: HTMLElement, theme: ThemeMode): void {\n root.dataset.theme = theme;\n const colors: Record<ThemeMode, string> = { light: '#ffffff', sepia: '#f6f0e4', dark: '#14161a' };\n document.querySelector('meta[name=\"theme-color\"]')?.setAttribute('content', colors[theme]);\n}\n\nfunction clamp(value: unknown, min: number, max: number, fallback: number): number {\n if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;\n return Math.max(min, Math.min(max, Math.round(value)));\n}\n\nfunction isTheme(value: unknown): value is ThemeMode {\n return value === 'light' || value === 'sepia' || value === 'dark';\n}\n\nexport type { ReaderSettings };\n","/**\n * The reading surface, shared by the H5 build and the Android shell.\n *\n * Format routing lives here and is the answer to the second half of the user's\n * question: **EPUB (and TXT) render through the WebView; everything else does\n * not render here at all on Android.**\n *\n * The reasoning, because it drove the structure of this file:\n *\n * - **Reflowable text belongs in a WebView.** It is the only engine on the\n * device that implements CSS multi-column, embedded fonts, vertical writing\n * and ruby — the typography features this product is built on. A native text\n * stack would have to reimplement all of it worse.\n * - **Images do not.** A comic page is a JPEG: a native `ImageView` decodes it\n * with hardware acceleration and recycles the bitmap, while a WebView holds a\n * full compositing layer per page and decodes on the main thread. The user's\n * own note — 「其他类型的书籍需要使用原生来渲染,提升性能」 — is correct, and\n * the reason is memory rather than raw speed: a 300MB volume read in a WebView\n * is an OOM on a mid-range phone, and the same volume read natively is fine.\n * - **PDF has a viewer already.** On Android that is the platform's, on the H5\n * build it is the browser's. Neither needs this client in between.\n *\n * So this view owns the *reflowable* path plus the H5 fallbacks, and the shell\n * decides whether it is ever reached for paged/document content. `capabilities()`\n * below is what the two builds negotiate with, so the split is declared in one\n * place instead of being implied by which renderer happens to be imported.\n */\n\nimport { ApiClient, type BookDto } from '../net/api.ts';\nimport { BookReader } from '../render/reader.ts';\nimport { PagedReader } from '../render/paged.ts';\nimport { TextReader } from '../render/text.ts';\nimport { PdfReader } from '../render/pdf.ts';\nimport { applyTheme, loadSettings, saveSettings, type ReaderSettings } from './settings.ts';\n\n/**\n * Which renderer handles what, per platform.\n *\n * Declared rather than hardcoded because the Android shell overrides it: the\n * shell answers 'native' for paged and document content and never mounts those\n * renderers, so the same book opened on the phone takes the native path and the\n * same book opened in a browser takes the H5 fallback.\n */\nexport interface RendererCapabilities {\n /** Reflowable text always goes through the WebView. */\n reflowable: 'webview';\n /** Paged images: native on Android, H5 elsewhere. */\n paged: 'native' | 'webview';\n /** PDF: the platform viewer on Android, an iframe elsewhere. */\n document: 'native' | 'iframe';\n}\n\nexport function capabilities(platform: 'web' | 'android'): RendererCapabilities {\n return platform === 'android'\n ? { reflowable: 'webview', paged: 'native', document: 'native' }\n : { reflowable: 'webview', paged: 'webview', document: 'iframe' };\n}\n\nexport interface ReaderHost {\n /** Called when the user swipes or taps past the last page of a book. */\n onExit?: () => void;\n platform?: 'web' | 'android';\n device?: string;\n}\n\n/**\n * A reading session for one book.\n *\n * Owns whichever renderer the book's `kind` calls for, plus the chrome around it:\n * the toolbar, the footer progress, the table of contents. Kept as a class because\n * the four renderers share no base class on purpose — they have almost nothing in\n * common beyond `turn` and `flush`, and a common interface would have to be wide\n * enough to be meaningless.\n */\nexport class ReaderView {\n private readonly root: HTMLElement;\n private readonly stage: HTMLElement;\n private readonly toolbar: HTMLElement;\n private readonly progressFill: HTMLElement;\n private readonly pageLabel: HTMLElement;\n private readonly warning: HTMLElement;\n private reader: BookReader | PagedReader | TextReader | PdfReader | null = null;\n private state: { index: number; total: number; title: string; percent: number } = {\n index: 0,\n total: 0,\n title: '',\n percent: 0,\n };\n private settings: ReaderSettings;\n private disposers: Array<() => void> = [];\n private tocDialog: HTMLElement | null = null;\n\n constructor(\n private readonly host: HTMLElement,\n private readonly api: ApiClient,\n private readonly book: BookDto,\n private readonly options: ReaderHost = {},\n ) {\n this.settings = loadSettings();\n this.root = document.createElement('div');\n this.root.className = 'reader';\n\n this.toolbar = document.createElement('div');\n this.toolbar.className = 'reader__toolbar';\n this.stage = document.createElement('div');\n this.stage.className = 'reader__stage';\n this.warning = document.createElement('div');\n this.warning.className = 'reader__warning';\n this.warning.hidden = true;\n\n const footer = document.createElement('div');\n footer.className = 'reader__footer';\n this.progressFill = document.createElement('div');\n this.progressFill.className = 'reader__progress-fill';\n const progressTrack = document.createElement('div');\n progressTrack.className = 'reader__progress';\n progressTrack.append(this.progressFill);\n this.pageLabel = document.createElement('span');\n footer.append(progressTrack, this.pageLabel);\n\n this.root.append(this.toolbar, this.warning, this.stage, footer);\n host.replaceChildren(this.root);\n this.buildToolbar();\n this.bindGestures();\n }\n\n /** Open the book with whichever renderer its `kind` calls for. */\n async open(): Promise<void> {\n const platform = this.options.platform ?? 'web';\n // One request to learn the shape of the book, then one renderer is built.\n const manifest = await this.api.manifest(this.book.id);\n const kind = manifest.content?.kind ?? manifest.kind ?? 'reflowable';\n const caps = capabilities(platform);\n\n if (kind === 'reflowable') {\n const reader = new BookReader(this.containerFor('webview'), this.api, this.book, {\n platform,\n ...(this.options.device !== undefined ? { device: this.options.device } : {}),\n });\n this.reader = reader;\n this.disposers.push(reader.state.on((state) => this.onReflowable(state)));\n const initial = await reader.open(this.book.id);\n this.stage.classList.remove('is-paged');\n this.applySettings();\n void initial;\n return;\n }\n\n if (kind === 'text') {\n const reader = new TextReader(this.stage, this.api, this.book, {\n ...(this.options.device !== undefined ? { device: this.options.device } : {}),\n });\n this.reader = reader;\n await reader.open(this.book.id);\n this.pageLabel.textContent = '流式';\n return;\n }\n\n if (caps.paged === 'native') {\n // The shell renders it; this view only reports the hand-off so the H5\n // bundle does not silently mount a renderer the shell will replace.\n this.host.dispatchEvent(new CustomEvent('reader:native-page', { detail: { bookId: this.book.id } }));\n return;\n }\n\n const paged = new PagedReader(this.stage, this.api, this.book, {\n platform: 'web',\n ...(this.options.device !== undefined ? { device: this.options.device } : {}),\n });\n this.reader = paged;\n this.disposers.push(paged.state.on((state) => this.onPaged(state)));\n await paged.open(this.book.id);\n this.stage.classList.add('is-paged');\n return;\n }\n\n /** Persist the position before the surface goes away. */\n async close(): Promise<void> {\n const reader = this.reader;\n if (reader instanceof BookReader || reader instanceof PagedReader) {\n await reader.flush().catch(() => undefined);\n } else if (reader instanceof TextReader || reader instanceof PdfReader) {\n await reader.flush(this.book.id).catch(() => undefined);\n }\n for (const dispose of this.disposers) dispose();\n this.disposers = [];\n this.reader?.destroy();\n this.reader = null;\n this.host.replaceChildren();\n }\n\n /** Turn pages from a hardware key or an external control (the shell's). */\n async turn(pages: number): Promise<void> {\n if (this.reader instanceof BookReader || this.reader instanceof PagedReader) {\n await this.reader.turn(pages);\n }\n }\n\n /** Re-apply the reader's settings and repaginate. */\n applySettings(): void {\n applyTheme(document.documentElement, this.settings.theme);\n this.root.style.setProperty('--reader-font-size', `${this.settings.fontSize}px`);\n this.root.style.setProperty('--reader-page-width', `${this.settings.pageWidth}px`);\n if (this.reader instanceof BookReader) this.reader.relayout();\n }\n\n updateSettings(patch: Partial<ReaderSettings>): void {\n this.settings = { ...this.settings, ...patch };\n saveSettings(this.settings);\n applyTheme(document.documentElement, this.settings.theme);\n this.applySettings();\n if (this.tocDialog) this.closeDialog();\n this.openSettings();\n }\n\n // ---------------------------------------------------------------- chrome\n\n private buildToolbar(): void {\n const back = document.createElement('button');\n back.className = 'reader__button';\n back.textContent = '←';\n back.setAttribute('aria-label', '返回');\n back.addEventListener('click', () => void this.exit());\n\n const title = document.createElement('div');\n title.className = 'reader__toolbar-title';\n title.textContent = this.book.title;\n\n const toc = document.createElement('button');\n toc.className = 'reader__button';\n toc.textContent = '目录';\n toc.addEventListener('click', () => void this.openToc());\n\n const settings = document.createElement('button');\n settings.className = 'reader__button';\n settings.textContent = 'Aa';\n settings.setAttribute('aria-label', '阅读设置');\n settings.addEventListener('click', () => this.openSettings());\n\n this.toolbar.append(back, title, toc, settings);\n }\n\n /**\n * Swipe and tap gestures.\n *\n * The reflowable reader's viewport scrolls natively, so only the *ends* of a\n * chapter need intervention: a swipe that arrives there should turn the chapter,\n * not rubber-band. Paged content is different — there is no scroll surface at\n * all — so its horizontal swipes are read directly.\n */\n private bindGestures(): void {\n let startX = 0;\n let startY = 0;\n let startAt = 0;\n let tracking = false;\n\n const down = (event: PointerEvent): void => {\n startX = event.clientX;\n startY = event.clientY;\n startAt = event.timeStamp;\n tracking = true;\n };\n\n const up = (event: PointerEvent): void => {\n if (!tracking) return;\n tracking = false;\n const dx = event.clientX - startX;\n const dy = event.clientY - startY;\n const elapsed = event.timeStamp - startAt;\n // A swipe needs both distance and speed: a slow drag across half the screen\n // is usually a text selection, not a page turn.\n if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy) * 1.5) return;\n if (elapsed > 800) return;\n void this.turn(dx < 0 ? 1 : -1);\n };\n\n this.stage.addEventListener('pointerdown', down);\n this.stage.addEventListener('pointerup', up);\n this.stage.addEventListener('pointercancel', () => {\n tracking = false;\n });\n\n const key = (event: KeyboardEvent): void => {\n if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') {\n event.preventDefault();\n void this.turn(1);\n } else if (event.key === 'ArrowLeft' || event.key === 'PageUp') {\n event.preventDefault();\n void this.turn(-1);\n } else if (event.key === 'Escape') {\n void this.exit();\n }\n };\n window.addEventListener('keydown', key);\n this.disposers.push(() => window.removeEventListener('keydown', key));\n\n // A locked phone or a backgrounded tab may never run another timer, so the\n // debounced progress write has to be flushed on the transition itself.\n const flush = (): void => {\n const reader = this.reader;\n if (reader instanceof BookReader || reader instanceof PagedReader) void reader.flush();\n else if (reader instanceof TextReader) void reader.flush(this.book.id);\n else if (reader instanceof PdfReader) void reader.flush(this.book.id);\n };\n document.addEventListener('visibilitychange', flush);\n window.addEventListener('pagehide', flush);\n this.disposers.push(() => {\n document.removeEventListener('visibilitychange', flush);\n window.removeEventListener('pagehide', flush);\n });\n }\n\n private async exit(): Promise<void> {\n await this.close();\n this.options.onExit?.();\n }\n\n private containerFor(_mode: 'webview'): HTMLElement {\n const holder = document.createElement('div');\n holder.style.height = '100%';\n this.stage.replaceChildren(holder);\n return holder;\n }\n\n private async openToc(): Promise<void> {\n const groups = await this.api\n .manifest(this.book.id)\n .then((manifest) => manifest.groups ?? [])\n .catch(() => []);\n\n const list = document.createElement('ul');\n list.className = 'toc';\n if (groups.length === 0) {\n const empty = document.createElement('div');\n empty.className = 'toc__empty';\n empty.textContent = '这本书没有可跳转的章节';\n list.append(empty);\n } else {\n groups.forEach((group, index) => {\n const item = document.createElement('li');\n item.className = 'toc__item';\n item.textContent = `${group.title}(${group.count})`;\n // The group's `offset` is a whole-book index, which is exactly what\n // `goToSpine` accepts — no summing of previous counts on the client, so no\n // drift when a window was never fetched.\n item.addEventListener('click', () => {\n this.closeDialog();\n const reader = this.reader;\n if (reader instanceof BookReader) void reader.goToSpine(group.offset);\n else if (reader instanceof PagedReader) void reader.goTo(0);\n else if (reader instanceof TextReader) void reader.goToChapter(index, this.book.id);\n else void reader?.setPage(this.book.id, group.offset + 1);\n });\n list.append(item);\n });\n }\n this.showDialog('目录', list);\n }\n\n private openSettings(): void {\n const form = document.createElement('div');\n\n const fontSize = this.slider('字号', this.settings.fontSize, 12, 32, 1, (value) => {\n this.settings = { ...this.settings, fontSize: value };\n this.applySettings();\n });\n // Committed on release rather than on every input event: repaginating a\n // chapter on every pixel of slider travel makes the slider itself unusable.\n fontSize.input.addEventListener('change', () => this.commitSettings());\n\n const width = this.slider('页宽', this.settings.pageWidth, 360, 1200, 20, (value) => {\n this.settings = { ...this.settings, pageWidth: value };\n this.applySettings();\n });\n width.input.addEventListener('change', () => this.commitSettings());\n\n const theme = document.createElement('select');\n for (const [value, label] of [\n ['light', '浅色'],\n ['sepia', '羊皮纸'],\n ['dark', '深色'],\n ] as const) {\n const option = document.createElement('option');\n option.value = value;\n option.textContent = label;\n option.selected = this.settings.theme === value;\n theme.append(option);\n }\n theme.addEventListener('change', () => {\n this.settings = { ...this.settings, theme: theme.value as ReaderSettings['theme'] };\n this.commitSettings();\n });\n const themeRow = document.createElement('label');\n themeRow.className = 'dialog__row';\n themeRow.append('主题', theme);\n\n form.append(fontSize.row, width.row, themeRow);\n this.showDialog('阅读设置', form);\n }\n\n private commitSettings(): void {\n saveSettings(this.settings);\n applyTheme(document.documentElement, this.settings.theme);\n this.applySettings();\n }\n\n private slider(\n label: string,\n value: number,\n min: number,\n max: number,\n step: number,\n onChange: (value: number) => void,\n ): { row: HTMLElement; input: HTMLInputElement } {\n const row = document.createElement('label');\n row.className = 'dialog__row';\n const input = document.createElement('input');\n input.type = 'range';\n input.min = String(min);\n input.max = String(max);\n input.step = String(step);\n input.value = String(value);\n input.addEventListener('input', () => onChange(Number(input.value)));\n row.append(label, input);\n return { row, input };\n }\n\n private showDialog(title: string, body: HTMLElement): void {\n this.closeDialog();\n const dialog = document.createElement('div');\n dialog.className = 'dialog';\n const panel = document.createElement('div');\n panel.className = 'dialog__panel';\n const heading = document.createElement('h2');\n heading.className = 'dialog__title';\n heading.textContent = title;\n const actions = document.createElement('div');\n actions.className = 'dialog__actions';\n const close = document.createElement('button');\n close.className = 'dialog__button dialog__button--primary';\n close.textContent = '关闭';\n close.addEventListener('click', () => this.closeDialog());\n actions.append(close);\n panel.append(heading, body, actions);\n dialog.append(panel);\n dialog.addEventListener('click', (event) => {\n if (event.target === dialog) this.closeDialog();\n });\n this.root.append(dialog);\n this.tocDialog = dialog;\n }\n\n private closeDialog(): void {\n this.tocDialog?.remove();\n this.tocDialog = null;\n }\n\n // ---------------------------------------------------------------- state\n\n private onReflowable(state: {\n page: number;\n pageCount: number;\n index: number;\n items: Array<{ title: string }>;\n total: number;\n warnings: string[];\n }): void {\n this.state = {\n index: state.index,\n total: state.total,\n title: state.items[state.index]?.title ?? '',\n percent: (state.index + (state.pageCount > 1 ? state.page / state.pageCount : 0)) / Math.max(1, state.total),\n };\n this.renderProgress();\n if (state.warnings.length > 0) {\n this.warning.hidden = false;\n // Surfaced rather than swallowed: a chapter that failed to parse renders as\n // something, and the reader deserves to know it is not what the book said.\n this.warning.textContent = `本章有 ${state.warnings.length} 处问题,可能显示不完整`;\n }\n }\n\n private onPaged(state: { index: number; total: number; loading: boolean }): void {\n this.state = {\n index: state.index,\n total: state.total,\n title: '',\n percent: state.index / Math.max(1, state.total),\n };\n this.renderProgress();\n }\n\n private renderProgress(): void {\n this.progressFill.style.width = `${Math.round(this.state.percent * 100)}%`;\n this.pageLabel.textContent =\n this.state.index >= 0 ? `${this.state.percent * 100 < 1 ? 0 : Math.round(this.state.percent * 100)}%` : '';\n this.toolbar.querySelector('.reader__toolbar-title')!.textContent =\n this.state.title ? `${this.book.title} · ${this.state.title}` : this.book.title;\n }\n}\n","/**\n * The Android shell's interface.\n *\n * The bridge is a plain object on `window`, written by the shell before this\n * bundle runs. No JavaScript is evaluated from the native side and no message is\n * passed as a string to be re-parsed: the shell is same-process, so a plain\n * object is both simpler and harder to get wrong than a postMessage protocol.\n *\n * What the shell owns, and why it is not done in JavaScript:\n *\n * - **Gestures on paged content.** The shell renders comic pages in a native\n * pager because a WebView's per-page memory is what kills a 300MB volume on a\n * mid-range phone. A native pager handles its own swipes; the H5 layer is not\n * involved.\n * - **The offline cache.** A book downloaded for offline reading lives in the\n * shell's file cache, which is what lets it be read with the server unreachable.\n * - **The account.** Tokens live in the shell's encrypted storage, so the H5\n * layer never writes a refresh token into a WebView's localStorage where any\n * injected script could read it.\n *\n * What the H5 layer owns: reflowable rendering, the shelf, and settings. Those\n * are the parts that benefit from being the same code in both builds.\n */\n\nexport interface ReaderBridge {\n /** Server origin, e.g. `http://192.168.1.10:8080`. */\n serverUrl?: string;\n platform: 'android';\n /** Device name recorded with each progress update. */\n deviceName?: string;\n /** Called when a book is opened, so the shell can take over input. */\n onBookOpened?(bookId: string): void;\n /** The shell hands back a page-turn function for hardware keys and gestures. */\n onTurnPage?(turn: (pages: number) => void): void;\n /** Ask the shell to cache a book for offline reading. */\n cacheBook?(bookId: string): void;\n}\n\ndeclare global {\n interface Window {\n readerBridge?: ReaderBridge;\n }\n}\n\n/**\n * Read the bridge, or undefined when running as a plain web page.\n *\n * A missing bridge is the normal case for the H5 deployment, not an error.\n */\nexport function installBridge(): ReaderBridge | undefined {\n const bridge = window.readerBridge;\n if (!bridge) return undefined;\n return { ...bridge, platform: 'android' };\n}\n","/**\n * H5 client entry point.\n *\n * The same bundle runs in two hosts, and the only thing that differs is how it\n * finds its server:\n *\n * - **Served by the reader server** (the H5 deployment): same origin, so the\n * API base is empty and there is nothing to configure. The server hosts this\n * build itself, which is what makes 「纯 H5 就是一个完整客户端」 true — no app\n * store, no build step for the user, no second deployment to keep in sync.\n * - **Inside the Android shell**: the assets are loaded from `file://`, so there\n * is no origin and the API base has to come from the shell. The shell injects\n * it as `window.readerBridge` before this script runs; the settings screen\n * lets the user type an address when it did not.\n *\n * The `platform` flag is what routes comic and PDF rendering to native code on\n * Android. It comes from the bridge rather than from a user-agent sniff, because\n * a UA string is a guess and the bridge is a fact.\n */\n\nimport { ApiClient, type BookDto } from './net/api.ts';\nimport { ShelfView } from './ui/shelf-view.ts';\nimport { AuthView } from './ui/auth-view.ts';\nimport { ReaderView } from './ui/reader-view.ts';\nimport { installBridge, type ReaderBridge } from './bridge.ts';\nimport './ui/styles.css';\n\nconst app = document.getElementById('app');\nif (!app) throw new Error('missing #app');\n\nconst bridge: ReaderBridge | undefined = installBridge();\nconst baseUrl = bridge?.serverUrl ?? '';\n\nconst api = new ApiClient({\n baseUrl,\n onAuthLost: () => showAuth(),\n});\n\nconst shelf = new ShelfView(app, api, {\n onOpenBook: (book) => void openBook(book),\n onSignOut: () => {\n api.signOut();\n showAuth();\n },\n});\n\nfunction showAuth(): void {\n const view = new AuthView(app!, api, { onSignedIn: () => void showShelf() });\n void view.render();\n}\n\nasync function showShelf(): Promise<void> {\n await shelf.load(true);\n}\n\nasync function openBook(book: BookDto): Promise<void> {\n const view = new ReaderView(app!, api, book, {\n platform: bridge?.platform ?? 'web',\n ...(bridge?.deviceName !== undefined ? { device: bridge.deviceName } : {}),\n onExit: () => void showShelf(),\n });\n await view.open();\n // The shell expects a signal that a book is open, so it can hand gestures and\n // hardware keys to the reader instead of the shelf.\n bridge?.onBookOpened?.(book.id);\n bridge?.onTurnPage?.((pages) => void view.turn(pages));\n}\n\nif (api.currentSession) void showShelf();\nelse void showAuth();\n"],"names":["ApiError","status","code","message","MAX_INFLIGHT","ApiClient","options","defaultStorage","session","username","password","displayName","result","bookId","group","query","ref","params","suffix","progress","path","method","response","toApiError","retry","headers","current","resolve","body","key","probe","raw","memory","ShelfView","host","api","header","title","signOut","input","reset","empty","book","error","card","cover","meta","formatLabel","format","AuthView","registrationOpen","userCount","instance","mode","panel","hint","userRow","passRow","submit","actions","run","failure","describe","event","toWindow","content","fallback","items","groups","total","index","toSpineIndex","window","localIndex","toLocalIndex","spine","contains","groupForSpine","ChapterContainer","xml","warnings","doc","parseChapter","buildContent","collectStyles","parser","nodes","style","copy","link","href","source","child","Paginator","viewport","pageWidth","usable","scrollWidth","page","next","pages","target","offset","pageFraction","pageCount","capturePosition","stride","blocks","topLevelBlocks","origin","i","block","height","ratio","restorePosition","position","left","root","node","PrefetchCache","load","cached","inFlight","promise","blob","keys","from","shouldPrefetch","ahead","sizeOf","budget","aborted","step","estimate","keep","ordered","a","b","entry","bytes","oldest","connection","Emitter","listener","value","parseLocator","parsed","numberOr","clampRatio","serializeLocator","locator","bookPercent","spineTotal","fractionInChapter","BookReader","manifest","saved","item","changed","spill","local","stride2","restore","mounted","geometry","state","delay","fraction","loaded","PagedReader","clamped","url","previous","image","loading","CHUNK_CHARS","APPEND_THRESHOLD_PX","TextReader","paragraphs","text","chapter","fragment","lines","buffer","flush","line","PdfReader","frame","token","base","separator","DEFAULT_SETTINGS","KEY","loadSettings","clamp","isTheme","saveSettings","settings","applyTheme","theme","colors","min","max","capabilities","platform","ReaderView","footer","progressTrack","kind","caps","reader","paged","dispose","patch","back","toc","startX","startY","startAt","tracking","down","up","dx","dy","elapsed","_mode","holder","list","form","fontSize","width","label","option","themeRow","onChange","row","dialog","heading","close","installBridge","bridge","app","baseUrl","showAuth","shelf","openBook","showShelf","view"],"mappings":"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"} \ No newline at end of file diff --git a/web/dist/index.html b/web/dist/index.html index 770cdc8..a3d6f7e 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -15,8 +15,8 @@ <meta name="color-scheme" content="light dark" /> <meta name="theme-color" content="#ffffff" /> <title>书库 - - + +
diff --git a/web/package-lock.json b/web/package-lock.json index 752c122..d0cff78 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 5df71e6..069bffa 100644 --- a/web/package.json +++ b/web/package.json @@ -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" diff --git a/web/src/main.ts b/web/src/main.ts index 5018e5f..4197e66 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -50,6 +50,7 @@ function showAuth(): void { } async function showShelf(): Promise { + shelf.mount(); await shelf.load(true); } diff --git a/web/src/net/api.ts b/web/src/net/api.ts index e3277fc..211660e 100644 --- a/web/src/net/api.ts +++ b/web/src/net/api.ts @@ -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 | 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 { + 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 { 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 `` can load. Cover images are per-book and immutable. */ + /** + * A URL a plain `` can load. + * + * The token travels in the query because an `` 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<{ diff --git a/web/src/net/asset-url.ts b/web/src/net/asset-url.ts new file mode 100644 index 0000000..6acc223 --- /dev/null +++ b/web/src/net/asset-url.ts @@ -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 + * ``, `` 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 ``; + const withViewport = /]*>/i.test(html) + ? html.replace(/]*)>/i, `${viewportMeta()}`) + : html; + if (/<\/head>/i.test(withViewport)) return withViewport.replace(/<\/head>/i, `${style}`); + return `${style}${withViewport}`; +} + +/** A phone-width viewport, so a chapter authored for a desktop window reflows. */ +function viewportMeta(): string { + return ''; } /** - * Everything the publisher linked, in document order. + * Page geometry for a chapter. * - * `` is included because epubs routinely use it instead of - * `'; + const out = withAssetToken(html, client('tok')); + assert.match(out, /access_token=tok/); + }); +});