mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 14:31:11 +08:00
fix(web): 修复阅读页章节跳转失灵,补齐上一章/下一章
This commit is contained in:
12 files changed
+1091
-397
No files matched your search
@@ -107,9 +107,16 @@ volumes:
|
||||
继承变量上,所以一本按章节变行距的书不会被压平,一本给段落写死 `font-size: 14px`
|
||||
的书也不会被字号滑块踩掉。这与「所有书看起来一致」的 Readium 路线正好相反。
|
||||
|
||||
### 翻页
|
||||
### 翻页与章节导航
|
||||
|
||||
- **点击区域**:中间呼出/隐藏工具栏,左右翻页;可切换「左进右退」适配左右手。
|
||||
- **上一章 / 下一章**:页脚中间的 `‹` `›` 直接换章,不用先滚到章末。
|
||||
到达书的第一章 / 最后一章时按钮置灰。跨窗口换章(一千二百章的书按 40 章一窗)
|
||||
会先取那一窗再落章,页脚在取窗期间显示「正在切换…」。
|
||||
- **页脚报位置**:`第 100/120 章 · 本章 1/3 页 · 40%`。章内页码与进度条同源、
|
||||
同一次量测,所以两者不会互相矛盾。
|
||||
- **目录即跳转**:点任意一章直接跳过去,不在当前窗口的会先拉取那一窗。
|
||||
跳转失败的章会明说「这一章暂时无法跳转」,不会静默无反应。
|
||||
- **翻页动画**:滑动 / 淡入 / 无。系统开启「减少动态效果」时自动不播。
|
||||
- **硬件翻页键**:`←` / `→`、`PageUp` / `PageDown`,空格播放/暂停朗读。
|
||||
Android 外壳把音量键映射成方向键,所以音量键翻页、蓝牙翻页器都走同一条路。
|
||||
|
||||
@@ -361,6 +361,46 @@ iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、A
|
||||
shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。
|
||||
关键是**不往 shadow root 里注入任何 reset**。
|
||||
|
||||
但隔离是双向的:**文档里的选择器进不去 shadow root**。这不是风格问题,
|
||||
它决定了两条规则各自该写在哪儿,搞错了不会报错、只会静默失效:
|
||||
|
||||
- `.book-flow`(正文列:版心宽度、页边距、字号缩放、分页列)、`.fixed-page`、
|
||||
朗读高亮、翻页动画——这些元素在 shadow root **里**,所以规则必须注入到
|
||||
shadow root **里**(见 `ui/shadow.ts` 的 `FLOW_STYLESHEET`)。
|
||||
写在 `styles/reader.css` 里等于删除:正文列会以浏览器默认样式渲染,
|
||||
没有版心、没有边距、翻页模式没有列。而外围 chrome 在文档里、样式完全正常,
|
||||
所以看上去不像样式表丢了,只像「阅读器没有页边距这个功能」。
|
||||
- `.book-host`、亮度过场(`::after` 伪元素)、`book-content` 的 `display`
|
||||
——这些是 shadow **host** 本身,属于文档树,所以规则写在 `styles/reader.css`。
|
||||
- host 上的状态属性(`data-paginated`、`data-animating`)只能用 `:host(...)`
|
||||
选择:从 shadow root 内部无法用类选择器匹配 host。
|
||||
写成 `.book-host[data-paginated] .book-flow` 永远不会匹配,
|
||||
于是翻页模式静静地不产生任何列,翻页也就无处可翻。
|
||||
|
||||
两条规则里的**值**不重复:全部是自定义属性,而属性本身定义在 `:root` 上,
|
||||
shadow root 会继承。
|
||||
|
||||
### 窗口化的三个接缝
|
||||
|
||||
「一份产物两个宿主」和「一千二百章的书不能先下载整本」是同一件事的两面,
|
||||
而窗口是这件事的传输边界。跨过它有三个必须写死的地方:
|
||||
|
||||
- **换窗口和落到章节是一次操作。** 只换窗口会留下*上一章*在屏幕上:
|
||||
宿主内容没动、`currentSectionIndex` 还指着旧窗口的下标、页脚还在报读者刚离开的章节。
|
||||
这正是「点击章节没有反应」的症状。
|
||||
- **窗口接口只有一个名字。** 客户端建的 `StagedDoc` 叫 `setWindow`,
|
||||
`StagedBook` 接口叫 `loadWindow`,屏幕曾经用鸭子类型去猜——猜错了,
|
||||
于是每次跨窗口跳转都是静默的空操作。现在由 `adoptWindow()` 一处识别。
|
||||
- **窗口由 manifest 的 `groups` 算出来,不是由「40 章」算出来。**
|
||||
漫画按*卷*开窗,而卷不是 40 页。`spine / 40` 会请求一个不存在的组,
|
||||
跳转就落到空处。
|
||||
|
||||
另外,位置必须按**整书坐标**判断,不能按窗口内的下标:
|
||||
在窗口最后一章时下标是 39,拿它和窗口长度 40 比会得出「已经是最后一章」,
|
||||
而后面还有八十章。窗口长度是传输细节,不该决定书在哪里结束。
|
||||
|
||||
### 阅读位置格式
|
||||
|
||||
### 书不能联网
|
||||
|
||||
加载时把章节里所有本地引用改写成内部 `reader-res:` 协议,渲染层从内存资源表解出;
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface StagedBook {
|
||||
* window does not contain it.
|
||||
*/
|
||||
loadWindow(content: unknown, spine: number): number;
|
||||
/** Whole-book index of the loaded window's first section. */
|
||||
windowOffset?(): number;
|
||||
}
|
||||
|
||||
export interface LoadContext {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
import type { BookContent, ContentItem } from '../net/api.ts';
|
||||
import { RemoteZip, type RangeSource } from './remote-zip.ts';
|
||||
import type { BookDoc, Section, TocEntry } from './types.ts';
|
||||
import type { BookDoc, Section, StagedBook, TocEntry } from './types.ts';
|
||||
|
||||
/**
|
||||
* How a staged section's bytes are obtained.
|
||||
@@ -67,6 +67,13 @@ export interface StagedDoc extends BookDoc {
|
||||
*
|
||||
* Returns the local index of `spine` within the new window, or -1 when the new
|
||||
* window does not contain it.
|
||||
*
|
||||
* Named `setWindow` here and `loadWindow` on `StagedBook` — and one of those
|
||||
* two names is the reason this comment exists. The screen used to duck-type a
|
||||
* `loadWindow` off the document, found nothing (the method was called
|
||||
* `setWindow`), and so never swapped a window: tapping chapter 900 of a
|
||||
* 1200-chapter book did nothing at all. The duck type is gone; the contract is
|
||||
* this interface, and `adoptWindow` below is the single implementation of it.
|
||||
*/
|
||||
setWindow(content: BookContent, spine: number): number;
|
||||
/** Whole-book index of the loaded window's first section. */
|
||||
@@ -125,6 +132,14 @@ export function createStagedDoc(options: StagedDocOptions): StagedDoc {
|
||||
return section;
|
||||
},
|
||||
setWindow(content: BookContent, spine: number): number {
|
||||
// Checked *before* anything is mutated. A window that does not hold the
|
||||
// chapter must leave the loaded one exactly as it was: swapping first and
|
||||
// then reporting -1 replaces the reader's chapter with a different one and
|
||||
// then tells the caller the jump failed, which is both wrong on screen and
|
||||
// impossible for the caller to undo — the old sections are already gone.
|
||||
const local = content.items.findIndex((item) => item.seq === spine);
|
||||
if (local === -1) return -1;
|
||||
|
||||
// The offset comes from the group the response actually carries. A
|
||||
// response that omits `group` (a server that chose not to window, or a
|
||||
// hand-built one in a test) still has to answer correctly, so the offset
|
||||
@@ -144,7 +159,6 @@ export function createStagedDoc(options: StagedDocOptions): StagedDoc {
|
||||
path: item.href,
|
||||
});
|
||||
}
|
||||
const local = items.findIndex((item) => item.seq === spine);
|
||||
return local;
|
||||
},
|
||||
windowOffset(): number {
|
||||
@@ -160,6 +174,62 @@ export function createStagedDoc(options: StagedDocOptions): StagedDoc {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps a document's loaded window, whichever way the document exposes it.
|
||||
*
|
||||
* Two method names exist in the codebase for one operation — `setWindow` on a
|
||||
* client-built `StagedDoc`, `loadWindow` on the `StagedBook` interface — and the
|
||||
* reader screen used to pick between them by checking which one *looked* present.
|
||||
* It checked for `loadWindow`, the documents answer to `setWindow`, and the check
|
||||
* therefore always failed: every jump out of the loaded window was a silent
|
||||
* no-op. This function is the one place that knows both names, so no caller has
|
||||
* to guess and a future third name has one place to be added.
|
||||
*
|
||||
* Returns the local index of `spine` in the newly loaded window, or -1 when there
|
||||
* is no window to load or the window does not hold that spine.
|
||||
*/
|
||||
export function adoptWindow(doc: BookDoc, content: BookContent, spine: number): number {
|
||||
const staged = doc as StagedBook & Partial<StagedDoc>;
|
||||
if (typeof staged.setWindow === 'function') return staged.setWindow(content, spine);
|
||||
if (typeof staged.loadWindow === 'function') return staged.loadWindow(content, spine);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which window (group) holds a whole-book index.
|
||||
*
|
||||
* Derived from the manifest's own group sizes rather than from the constant the
|
||||
* client keeps, because a format is free to window at its own granularity — a
|
||||
* comic windows by volume, and a volume is not forty pages. The constant is only
|
||||
* a fallback for a manifest that ships no groups at all.
|
||||
*
|
||||
* The result is the group's *index*, which is what the endpoint takes, not the
|
||||
* group's `seq`.
|
||||
*/
|
||||
export function windowIndexOf(content: BookContent, spine: number): number {
|
||||
const groups = content.groups ?? [];
|
||||
if (groups.length > 0) {
|
||||
let offset = groups[0]?.offset ?? 0;
|
||||
for (const [index, group] of groups.entries()) {
|
||||
// The first group may declare an offset of its own instead of zero; the
|
||||
// running offset is anchored on it so both conventions answer correctly.
|
||||
if (index > 0) offset += groups[index - 1]?.count ?? 0;
|
||||
if (spine < offset + group.count) return index;
|
||||
}
|
||||
return groups.length - 1;
|
||||
}
|
||||
return Math.max(0, Math.floor(spine / CHAPTER_WINDOW));
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapters per window, for a manifest that reports no groups of its own.
|
||||
*
|
||||
* Duplicated from the server's constant on purpose (see `CHAPTER_WINDOW` in the
|
||||
* reader screen): overlapping by one window costs one request, never a wrong
|
||||
* chapter, because the response is always the authority on what it contains.
|
||||
*/
|
||||
const CHAPTER_WINDOW = 40;
|
||||
|
||||
function formatFor(kind: BookContent['kind']): BookDoc['format'] {
|
||||
switch (kind) {
|
||||
case 'paged':
|
||||
|
||||
+99
-317
@@ -136,6 +136,41 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* The stage's slot in the component tree.
|
||||
*
|
||||
* A wrapper rather than the stage itself, because the stage is owned by the
|
||||
* screen and handed in through a ref callback (see `StageHost`): it is a node
|
||||
* with its own lifetime, and putting it in a tree that re-renders is how a
|
||||
* chapter's shadow root ends up detached from the element the paginator
|
||||
* measured. The wrapper does not inherit `.stage`'s rules, so it has to carry
|
||||
* the one that matters — *growing*. Without this the slot is a zero-height block
|
||||
* in a flex column, and the reader sees the chrome, the header and the footer
|
||||
* with no text between them.
|
||||
*/
|
||||
.stage-host {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/*
|
||||
* The stage fills its slot.
|
||||
*
|
||||
* `.stage` is `position: relative` with a percentage-free height, which is all it
|
||||
* needs once its parent is a flex container — but the parent is `.stage-host`
|
||||
* above, added when the chrome became a component tree, and a *block* parent
|
||||
* gives an auto-height child its content height instead of the slot's. The stage
|
||||
* then measures zero and the book is laid out in a band at the top of the
|
||||
* screen. `flex: 1` here is what restores "the stage is the space between the
|
||||
* chrome", which is the one thing this layout is supposed to guarantee.
|
||||
*/
|
||||
.stage-host > .stage {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The chapter document is rendered into a shadow root so a book's own
|
||||
* stylesheet cannot reach the app chrome, and the app chrome cannot reach into
|
||||
@@ -165,46 +200,30 @@ body {
|
||||
}
|
||||
|
||||
/*
|
||||
* Reflowable content column.
|
||||
* The reading column's own rules are in `ui/shadow.ts`, not here — and that
|
||||
* separation is load-bearing.
|
||||
*
|
||||
* `max-inline-size` on the *container* rather than on the book's own elements:
|
||||
* it constrains the line length without editing a single author rule, which is
|
||||
* the only way to bound measure without also overriding the author's margins on
|
||||
* `p` and `div`.
|
||||
* `.book-flow`, `.fixed-page`, `.reader-speech-highlight` and the page-turn
|
||||
* animations all belong to elements inside the book's *shadow root*, and a
|
||||
* selector in a document stylesheet does not reach into a shadow root. Declaring
|
||||
* them here is therefore indistinguishable from deleting them: the column
|
||||
* rendered at the browser default — no measure, no margins, no font scale, and
|
||||
* no columns in paged mode — while the surrounding chrome, which *is* in the
|
||||
* document, looked perfectly styled. That asymmetry is what made it read as a
|
||||
* missing feature rather than a missing stylesheet.
|
||||
*
|
||||
* What stays below is everything that is genuinely in the document: the
|
||||
* brightness overlay (a pseudo-element on the *host*, which is light DOM), the
|
||||
* host element's own box, and the chrome.
|
||||
*/
|
||||
.book-flow {
|
||||
position: relative;
|
||||
max-inline-size: var(--reader-measure);
|
||||
margin-inline: auto;
|
||||
padding-block: var(--reader-page-padding-block);
|
||||
/*
|
||||
* The margin control is a *variable* rather than a fixed padding, and the
|
||||
* reader's value rides on the left/right pair only: the block padding is set by
|
||||
* the shell because vertical whitespace on a phone is a different decision
|
||||
* (safe areas) from the measure.
|
||||
*/
|
||||
padding-inline: var(--reader-page-margin);
|
||||
/* The size control scales the root of the injected document only. */
|
||||
font-size: calc(1em * var(--reader-font-scale));
|
||||
line-height: var(--reader-line-height);
|
||||
/*
|
||||
* Both of these are set as inheriting values on the container, never as rules
|
||||
* on the book's own elements. `inherit` is the default in both cases, so "off"
|
||||
* means the author's typography is untouched — which is the whole contract of
|
||||
* this stylesheet. Where the book ships an absolute font stack, this is prefixed
|
||||
* rather than replaced: the book's stack stays first.
|
||||
*/
|
||||
font-family: var(--reader-font-family);
|
||||
text-align: var(--reader-text-align);
|
||||
}
|
||||
|
||||
/*
|
||||
* Brightness.
|
||||
*
|
||||
* A dimming bed-time overlay rather than a change to the palette: the reader's
|
||||
* theme is the author's colours, and darkening them keeps the relationship
|
||||
* between paper and ink. An overlay *on top* would darken the text as much as the
|
||||
* paper, which is what makes a "brightness" slider feel broken. Applied with
|
||||
* between paper and ink. An overlay *on top* would darken the text as much as
|
||||
* the paper, which is what makes a "brightness" slider feel broken. Applied with
|
||||
* `mix-blend-mode: multiply` on a pseudo-element so it costs no extra DOM and
|
||||
* cannot be reached by the book.
|
||||
*/
|
||||
@@ -219,295 +238,15 @@ body {
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.book-host[data-paginated='true'] .book-flow {
|
||||
/*
|
||||
* Paged mode: the column is exactly one viewport tall and overflows
|
||||
* horizontally by column. This is the one place a structural override is
|
||||
* unavoidable — CSS multi-column is the only way to paginate reflowable text
|
||||
* without re-measuring every line in JS, and it needs the ancestor chain to
|
||||
* honour a fixed height.
|
||||
*/
|
||||
height: 100%;
|
||||
max-inline-size: none;
|
||||
padding: 0;
|
||||
column-gap: 0;
|
||||
columns: 1;
|
||||
column-width: 100vw;
|
||||
column-fill: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-host[data-paginated='true'] .book-flow > * {
|
||||
/* A block that spans a column break would be split awkwardly; keeping
|
||||
headings with their following paragraph is the cheap 90% fix. */
|
||||
break-inside: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* The host is the scroll container in scroll mode and a plain frame in paged
|
||||
* mode; `book-content` is an unknown element to the browser, so its `display`
|
||||
* has to be stated.
|
||||
*/
|
||||
book-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Fixed-layout pages (comic, PDF fallback)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.fixed-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/*
|
||||
* `contain` keeps a 4000px-tall scan from forcing the whole layout to
|
||||
* reflow on every page turn.
|
||||
*/
|
||||
contain: layout paint size;
|
||||
background: #0d0d0f;
|
||||
}
|
||||
|
||||
.fixed-page img {
|
||||
/*
|
||||
* `max-*` rather than fixed dimensions: a comic page must letterbox inside
|
||||
* the viewport, and `object-fit: contain` is the only way to do that without
|
||||
* knowing the image's intrinsic size before it loads.
|
||||
*/
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
/* Prevents the 1px dark seam some Android GPUs render along a scaled edge. */
|
||||
image-rendering: auto;
|
||||
outline: none;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fixed-page[data-fit='width'] img {
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* PDF
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.pdf-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #0d0d0f;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Injected book content: the minimum set of overrides
|
||||
* ------------------------------------------------------------------ *
|
||||
*
|
||||
* Everything below is scoped to `book-content`, so it reaches the book's own
|
||||
* markup and nothing else. Each rule states why it is necessary. A rule without
|
||||
* a justification does not belong in this section.
|
||||
*/
|
||||
|
||||
book-content img,
|
||||
book-content svg,
|
||||
book-content video {
|
||||
/*
|
||||
* A book laid out for a print page has images sized in absolute units. On a
|
||||
* 360px screen those overflow and force a horizontal scrollbar that makes the
|
||||
* whole chapter feel broken. Bounding them is an override, but the author's
|
||||
* value is honoured up to the viewport, so nothing is enlarged.
|
||||
*/
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
book-content table {
|
||||
/*
|
||||
* Tables are the one construct that genuinely cannot reflow into a narrow
|
||||
* column. Allowing a horizontal scroll inside the table is strictly better
|
||||
* than letting it push the chapter wider than the screen.
|
||||
*/
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
book-content pre {
|
||||
/*
|
||||
* `pre` does not wrap by default, so a long code line or an ASCII-art page
|
||||
* makes the entire chapter scroll horizontally. Wrapping keeps the chapter
|
||||
* usable; the author's font and spacing are untouched.
|
||||
*/
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
book-content a[href] {
|
||||
/*
|
||||
* Footnotes and cross-references must be tappable, and the platform default
|
||||
* is a colour the author already chose. Only the touch target is enforced.
|
||||
*/
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
book-content :is(h1, h2, h3, h4, h5, h6) {
|
||||
/*
|
||||
* Long Chinese headings have no spaces and will overflow rather than wrap.
|
||||
* `overflow-wrap` only affects the overflowing case, so a heading that fits is
|
||||
* rendered exactly as the author specified.
|
||||
*/
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
book-content ruby rt {
|
||||
/*
|
||||
* 振假名 (ruby annotations) are named in the product design as a
|
||||
* differentiator (§9). Sizing them correctly relative to the base text is what
|
||||
* makes them readable on a phone; the default 50% is too small at 16px.
|
||||
*/
|
||||
font-size: 0.55em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
book-content :is(ruby, rt) {
|
||||
/* Ruby is a first-class text feature here, so it must not be hidden or
|
||||
clipped by an ancestor's overflow or line-height. */
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
book-content [epub|type~='pagebreak'],
|
||||
book-content hr {
|
||||
/*
|
||||
* Some publishers put a decorative rule at every page break, which turns into
|
||||
* a stray line in the middle of a paragraph once pagination is redone.
|
||||
* Collapsing it to a spacing hint keeps the author's intent without the
|
||||
* artefact. `hr` is intentionally NOT hidden: a rule the author drew to
|
||||
* separate scenes is content.
|
||||
*/
|
||||
border: 0;
|
||||
}
|
||||
|
||||
book-content figure,
|
||||
book-content blockquote {
|
||||
/*
|
||||
* Print-oriented books indent these with fixed margins that waste a third of
|
||||
* a phone screen. The author's indentation is preserved, only the outer
|
||||
* margin is capped to the container.
|
||||
*/
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Selection
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
book-content ::selection {
|
||||
background: color-mix(in srgb, var(--reader-accent) 35%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Page-turn transition
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.book-host[data-animating='slide-next'] .book-flow {
|
||||
animation: slide-next 180ms ease-out;
|
||||
}
|
||||
|
||||
.book-host[data-animating='slide-previous'] .book-flow {
|
||||
animation: slide-previous 180ms ease-out;
|
||||
}
|
||||
|
||||
.book-host[data-animating='fade-next'] .book-flow {
|
||||
animation: fade-next 160ms ease-out;
|
||||
}
|
||||
|
||||
.book-host[data-animating='fade-previous'] .book-flow {
|
||||
animation: fade-previous 160ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-next {
|
||||
from {
|
||||
transform: translateX(6%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-previous {
|
||||
from {
|
||||
transform: translateX(-6%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-next {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-previous {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.book-host[data-animating] .book-flow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Read-aloud helpers
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* The sentence being spoken.
|
||||
*
|
||||
* Drawn as a translucent block *behind* the text, positioned from a `Range`'s
|
||||
* client rect. The alternative — wrapping the sentence in a `<span>` — would edit
|
||||
* the publisher's markup and, worse, change the line box metrics for that phrase,
|
||||
* which repaginates the chapter the moment playback starts.
|
||||
*/
|
||||
.reader-speech-highlight {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--reader-accent) 34%, transparent);
|
||||
transition: top 120ms linear, left 120ms linear, width 120ms linear, height 120ms linear;
|
||||
}
|
||||
|
||||
/* The overlay needs a stacking context that puts it under the text but above the
|
||||
page background; `z-index: -1` alone would fall behind an opaque body. */
|
||||
.book-flow {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reader-speech-highlight {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Chrome: header, shelf, panels
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -1487,10 +1226,53 @@ book-content ::selection {
|
||||
|
||||
.footer-row .chapter {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
/* The chapter label is the second way into the contents panel, because the ☰
|
||||
button beside it is a 2.5rem square and the label is the thing a reader
|
||||
actually aims at when they want to pick a chapter. */
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.25rem;
|
||||
border-radius: var(--reader-radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footer-row .chapter:active {
|
||||
background: var(--reader-line);
|
||||
}
|
||||
|
||||
/*
|
||||
* The chapter buttons share the row with the chapter label, so they are a size
|
||||
* down from the topbar's icon buttons: four 2.5rem controls plus a label is more
|
||||
* than a 360px phone has, and the two that turn pages deserve the room.
|
||||
*/
|
||||
.footer-row .icon-button {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.footer-row .icon-button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.footer-meta span {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.footer-meta span:first-child {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.footer-meta .progress-label {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* ---- read-aloud bar ---- */
|
||||
|
||||
@@ -20,6 +20,21 @@ import type { SpeechEngineKind } from '../render/speech.ts';
|
||||
import { type ComponentChildren, type JSX } from './vendor/preact.ts';
|
||||
import { IconButton, SectionTitle } from './toolkit.tsx';
|
||||
|
||||
/**
|
||||
* A table-of-contents row.
|
||||
*
|
||||
* `spine` is the whole-book position, and its absence is meaningful: the server
|
||||
* omits it for formats whose navigation is not a linear chapter list. A row
|
||||
* without one cannot be jumped to across windows, so the panel marks it instead
|
||||
* of offering a tap that would do nothing.
|
||||
*/
|
||||
export interface ChromeTocEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
depth: number;
|
||||
spine?: number;
|
||||
}
|
||||
|
||||
export interface SpeechBarState {
|
||||
active: boolean;
|
||||
state: string;
|
||||
@@ -43,8 +58,16 @@ export interface ChromeState {
|
||||
chapterLabel: string;
|
||||
tocOpen: boolean;
|
||||
settingsOpen: boolean;
|
||||
toc: Array<{ id: string; label: string; depth: number }>;
|
||||
toc: ChromeTocEntry[];
|
||||
currentSectionId: string;
|
||||
/** 1-based position of the current page inside its chapter, and the count. */
|
||||
pageInChapter: number;
|
||||
chapterPages: number;
|
||||
/** Which whole-book chapter is open, 1-based, and how many there are. */
|
||||
chapterIndex: number;
|
||||
chapterCount: number;
|
||||
/** True while a window is being fetched, so the chapter buttons can say so. */
|
||||
navigating: boolean;
|
||||
tts: SpeechBarState;
|
||||
layout: string;
|
||||
format: string;
|
||||
@@ -90,6 +113,7 @@ export interface ChromeHandlers {
|
||||
onSpeakFromHere(): void;
|
||||
onSwitchEngine(kind: AppSettings['ttsEngine']): void;
|
||||
onTocEntry(ref: string): void;
|
||||
onChapter(delta: 1 | -1): void;
|
||||
onTurnPage(direction: 'next' | 'previous'): void;
|
||||
onSpeechToggle(): void;
|
||||
onSpeechPrevious(): void;
|
||||
@@ -142,15 +166,37 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
<IconButton label="目录" onClick={handlers.toggleToc}>
|
||||
☰
|
||||
</IconButton>
|
||||
<span className="chapter">{state.chapterLabel}</span>
|
||||
<IconButton
|
||||
label="上一章"
|
||||
disabled={state.navigating || state.chapterIndex <= 1}
|
||||
onClick={() => handlers.onChapter(-1)}
|
||||
>
|
||||
⟨
|
||||
</IconButton>
|
||||
<button type="button" className="chapter" onClick={handlers.toggleToc}>
|
||||
{state.navigating ? '正在切换…' : state.chapterLabel || '目录'}
|
||||
</button>
|
||||
<IconButton
|
||||
label="下一章"
|
||||
disabled={state.navigating || state.chapterIndex >= state.chapterCount}
|
||||
onClick={() => handlers.onChapter(1)}
|
||||
>
|
||||
⟩
|
||||
</IconButton>
|
||||
<IconButton label="阅读设置" onClick={handlers.toggleSettings}>
|
||||
⚙
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="footer-row">
|
||||
<span />
|
||||
<span>{percentOf(state.progress)}</span>
|
||||
<span />
|
||||
<div className="footer-row footer-meta">
|
||||
<span>
|
||||
{state.chapterCount > 0 ? `${state.chapterIndex}/${state.chapterCount} 章` : ''}
|
||||
</span>
|
||||
<span>
|
||||
{state.chapterPages > 0
|
||||
? `本章 ${state.pageInChapter}/${state.chapterPages} 页`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="progress-label">{percentOf(state.progress)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -167,6 +213,10 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
<button
|
||||
type="button"
|
||||
aria-current={entry.id === state.currentSectionId}
|
||||
// A row that is already on screen is a scroll, not a jump:
|
||||
// saying so is the difference between a list that reacts and
|
||||
// a list that appears not to.
|
||||
title={entry.spine === undefined ? '这一章不在当前窗口中' : undefined}
|
||||
style={entry.depth > 0 ? `padding-inline-start:${0.4 + entry.depth * 0.9}rem` : undefined}
|
||||
onClick={() => handlers.onTocEntry(entry.id)}
|
||||
>
|
||||
|
||||
+263
-35
@@ -1,6 +1,6 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi } from '../api/client.ts';
|
||||
import type { Book, Manifest, Note } from '../api/types.ts';
|
||||
import type { Book, BookContent, Manifest, Note } from '../api/types.ts';
|
||||
import { loadBook, isImagePath, extensionOf } from '../formats/index.ts';
|
||||
import type { OfflineStore } from '../store/offline.ts';
|
||||
import type { SyncEngine, SyncStatus } from '../core/sync.ts';
|
||||
@@ -8,7 +8,7 @@ import type { Platform } from '../core/platform.ts';
|
||||
import { ReaderView, type Position, type ViewSettings } from './reader-view.ts';
|
||||
import { attachGestures } from './gestures.ts';
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import { createStagedDoc, isStagedKind } from '../formats/windowed.ts';
|
||||
import { createStagedDoc, isStagedKind, windowIndexOf } from '../formats/windowed.ts';
|
||||
import type { BookDoc } from '../formats/types.ts';
|
||||
import type { TocEntry } from '../api/types.ts';
|
||||
import type { NativePageHost } from './native-page.ts';
|
||||
@@ -23,18 +23,7 @@ import {
|
||||
} from '../render/speech.ts';
|
||||
import type { NativeSpeechBridge } from '../android-bridge.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { ReaderChrome, type ChromeState } from './reader-chrome.tsx';
|
||||
|
||||
/**
|
||||
* Chapters per window, matching the server's own constant.
|
||||
*
|
||||
* Duplicated rather than fetched because the client needs it to *compute* which
|
||||
* window holds a chapter, and a round trip to learn a constant would defeat the
|
||||
* purpose. It matches `CHAPTER_WINDOW` in the server's epub handler; a mismatch
|
||||
* costs one extra request, never a wrong chapter, because the response is always
|
||||
* the authority on what it contains.
|
||||
*/
|
||||
const CHAPTER_WINDOW = 40;
|
||||
import { ReaderChrome, type ChromeState, type ChromeTocEntry } from './reader-chrome.tsx';
|
||||
|
||||
export interface ReaderScreenOptions {
|
||||
api: ReaderApi;
|
||||
@@ -117,6 +106,11 @@ export class ReaderScreen {
|
||||
settingsOpen: false,
|
||||
toc: [],
|
||||
currentSectionId: '',
|
||||
pageInChapter: 0,
|
||||
chapterPages: 0,
|
||||
chapterIndex: 1,
|
||||
chapterCount: 0,
|
||||
navigating: false,
|
||||
// Settings rows, flattened for the panel. See `settingsView`.
|
||||
...emptyChromeSettings(),
|
||||
tts: { active: false, state: 'idle', label: '', chip: '从头朗读', index: 0, total: 0, sentenceIndex: -1, sentenceTotal: 0 },
|
||||
@@ -155,9 +149,29 @@ export class ReaderScreen {
|
||||
|
||||
private view: ReaderView | null = null;
|
||||
private doc: BookDoc | null = null;
|
||||
/**
|
||||
* The windowed structure the server handed out, when the book is read in
|
||||
* windows.
|
||||
*
|
||||
* Kept beside `doc` rather than folded into it because the two answer different
|
||||
* questions: `doc` is what the *view* renders (the loaded window's sections),
|
||||
* and this is the whole book's shape — the group sizes a jump needs to compute
|
||||
* which window holds a chapter. It is the manifest's own `content`, passed to
|
||||
* `createStagedDoc` and kept here so a later jump does not have to re-fetch it.
|
||||
*/
|
||||
private content: BookContent | null = null;
|
||||
private book: Book | null = null;
|
||||
private manifest: Manifest | null = null;
|
||||
private chromeVisible = true;
|
||||
/**
|
||||
* True while a window is being fetched for a chapter jump.
|
||||
*
|
||||
* A guard *and* published state, because the footer's chapter buttons have to
|
||||
* say what is happening: a tap that fetches a window over a tunnel takes a
|
||||
* visible moment, and a button that looks dead for two seconds is the
|
||||
* difference between "slow" and "broken" to the reader pressing it.
|
||||
*/
|
||||
private navigating = false;
|
||||
private detachGestures: (() => void) | null = null;
|
||||
private progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private statusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -194,6 +208,7 @@ export class ReaderScreen {
|
||||
onSpeakFromHere: () => void this.speakFromReaderPosition(),
|
||||
onSwitchEngine: (kind) => this.switchSpeechEngine(kind),
|
||||
onTocEntry: (ref) => void this.goToChapterRef(ref),
|
||||
onChapter: (delta) => void this.goToChapter(delta),
|
||||
onTurnPage: (direction) => void this.turnPage(direction),
|
||||
onSpeechToggle: () => this.toggleSpeech(),
|
||||
onSpeechPrevious: () => void this.tts?.previous(),
|
||||
@@ -285,6 +300,7 @@ export class ReaderScreen {
|
||||
}
|
||||
if (token !== this.loadingToken) return null;
|
||||
|
||||
this.content = content;
|
||||
const doc = createStagedDoc({
|
||||
kind: content.kind,
|
||||
toc: toc.map((entry) => ({ id: entry.href, label: entry.title, depth: entry.level })),
|
||||
@@ -425,7 +441,15 @@ export class ReaderScreen {
|
||||
|
||||
/** Publishes what the chrome needs to know about the book that just loaded. */
|
||||
private afterDocLoaded(): void {
|
||||
this.patch({ layout: this.doc?.layout ?? 'reflowable', format: this.doc?.format ?? '' });
|
||||
this.patch({
|
||||
layout: this.doc?.layout ?? 'reflowable',
|
||||
format: this.doc?.format ?? '',
|
||||
// The window's own length is a page count for the first window and a
|
||||
// chapter count for the rest; `chapterCount` is corrected from the whole
|
||||
// book's total below, which is what the server reports and the window does
|
||||
// not.
|
||||
chapterCount: this.chapterCount,
|
||||
});
|
||||
this.buildView();
|
||||
void this.renderToc();
|
||||
}
|
||||
@@ -448,6 +472,7 @@ export class ReaderScreen {
|
||||
onPositionChange: (position) => this.onPosition(position),
|
||||
onChapterChange: (index, section) => {
|
||||
this.patch({ chapterLabel: section.label, currentSectionId: section.id });
|
||||
this.syncTocPosition();
|
||||
// The previous chapter's sentence nodes are gone the moment this fires,
|
||||
// so the engine is holding a queue that can never be spoken. It is told
|
||||
// to refill from the new chapter rather than left to speak into the void:
|
||||
@@ -462,8 +487,14 @@ export class ReaderScreen {
|
||||
this.view.applySettings(this.viewSettings());
|
||||
|
||||
if (doc.layout === 'reflowable') {
|
||||
// On the *scroll container*, which is the host element and not the stage
|
||||
// around it. A `scroll` event does not bubble, so a listener on the stage
|
||||
// sees nothing at all: the reading position never advanced past the first
|
||||
// screen, the footer's page counter stayed at 1, and — because a position
|
||||
// change is also what schedules the progress write — a reader could scroll
|
||||
// through a whole chapter and have the app believe they never moved.
|
||||
this.listeners.push(
|
||||
addDebouncedListener(this.stage, 'scroll', () => this.onPosition(this.view?.position() ?? null), 250),
|
||||
addDebouncedListener(this.view.elementHost, 'scroll', () => this.onPosition(this.view?.position() ?? null), 250),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -578,8 +609,14 @@ export class ReaderScreen {
|
||||
|
||||
private onPosition(position: Position | null): void {
|
||||
if (!position || !this.book) return;
|
||||
this.patch({ progress: position.percentage });
|
||||
this.patch({
|
||||
progress: position.percentage,
|
||||
pageInChapter: position.pageInChapter,
|
||||
chapterPages: position.chapterPages,
|
||||
chapterIndex: position.chapterPosition + 1,
|
||||
});
|
||||
if (position.chapterTitle) this.patch({ chapterLabel: position.chapterTitle });
|
||||
if (position.sectionId !== this.chrome.currentSectionId) this.patch({ currentSectionId: position.sectionId });
|
||||
this.pendingPosition = position;
|
||||
this.recordSpeechAnchor();
|
||||
|
||||
@@ -686,11 +723,21 @@ export class ReaderScreen {
|
||||
private async renderToc(): Promise<void> {
|
||||
const book = this.book;
|
||||
if (!book) return;
|
||||
// Fetched once per book. The panel is opened and closed constantly and the
|
||||
// TOC cannot change under a reader, so refetching it is a request per tap on
|
||||
// the ☰ button — which, on a NAS over a tunnel, is a visible pause before the
|
||||
// panel appears.
|
||||
if (this.chrome.toc.length > 0) return;
|
||||
|
||||
let entries: Array<{ id: string; label: string; depth: number }> = [];
|
||||
let entries: ChromeTocEntry[] = [];
|
||||
try {
|
||||
const toc = await this.options.api.toc(book.id);
|
||||
entries = toc.map((entry) => ({ id: entry.href, label: entry.title, depth: entry.level }));
|
||||
entries = toc.map((entry) => ({
|
||||
id: entry.href,
|
||||
label: entry.title,
|
||||
depth: entry.level,
|
||||
...(entry.spine !== undefined ? { spine: entry.spine } : {}),
|
||||
}));
|
||||
} catch {
|
||||
entries = this.view?.chapterLabels() ?? [];
|
||||
}
|
||||
@@ -699,32 +746,213 @@ export class ReaderScreen {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to a contents entry, loading the window that holds it when necessary.
|
||||
* Marks the contents entry the reader is in, and points the window's own
|
||||
* labels at their whole-book position.
|
||||
*
|
||||
* A position inside the loaded window is a local index change; one outside it
|
||||
* means fetching that window first. Without this, tapping chapter 900 of a
|
||||
* 1200-chapter book does nothing at all, which is how a windowed reader turns
|
||||
* from fast into broken.
|
||||
* The TOC and the loaded window carry different identifiers — one is the
|
||||
* server's chapter reference, the other the window's — so "which entry is
|
||||
* current" cannot be answered by comparing them, and `chapterLabels()` (built
|
||||
* from the window) has no `spine` at all. Both are resolved here, against the
|
||||
* loaded window, once per chapter change rather than once per entry in a
|
||||
* 1200-row list.
|
||||
*/
|
||||
private async goToChapterRef(ref: string): Promise<void> {
|
||||
if (this.view?.openLocator(`${ref}:0`)) {
|
||||
await this.view.openLocator(`${ref}:0`);
|
||||
private syncTocPosition(): void {
|
||||
const view = this.view;
|
||||
const doc = this.doc;
|
||||
if (!view || !doc) return;
|
||||
const local = view.currentSectionIndex();
|
||||
const spine = view.windowIndexOfRef(view.sectionIdAt(local) ?? '') ?? local;
|
||||
const item = this.content?.items[local];
|
||||
// A window the client built itself (no manifest `content`) has no items to
|
||||
// read a reference off, and the section id is then the reference.
|
||||
const currentRef = item?.href ?? doc.sections[local]?.id ?? '';
|
||||
|
||||
const toc = this.chrome.toc.map((entry) =>
|
||||
entry.spine === undefined && entry.id === currentRef ? { ...entry, spine } : entry,
|
||||
);
|
||||
if (toc.some((entry, index) => entry.spine !== this.chrome.toc[index]?.spine)) {
|
||||
this.patch({ toc });
|
||||
return;
|
||||
}
|
||||
if (currentRef !== this.chrome.currentSectionId) this.patch({ currentSectionId: currentRef });
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to a contents entry, loading the window that holds it when necessary.
|
||||
*
|
||||
* A chapter inside the loaded window is a local index change; one outside it
|
||||
* means fetching that window first. Without the second half, tapping chapter
|
||||
* 900 of a 1200-chapter book does nothing at all, which is how a windowed
|
||||
* reader turns from fast into broken.
|
||||
*
|
||||
* Three things here were wrong and are the reason the 目录 felt dead:
|
||||
*
|
||||
* - `openLocator` was awaited *after* being called for its boolean, so every
|
||||
* tap rendered the chapter twice. On a large chapter that is two full
|
||||
* document injections per tap, and the second one lands after the panel has
|
||||
* closed, so the reader sees a flash and no movement.
|
||||
* - The window fallback asked the *view* to swap windows through a method the
|
||||
* document never had (see `adoptWindow`), so a jump outside the window was a
|
||||
* silent no-op. It now goes through `view.loadWindow`, which owns the swap
|
||||
* and the landing together.
|
||||
* - The old code needed the TOC's `spine`, and a TOC entry without one (a
|
||||
* comic whose chapter is a page, a format that only has items) returned
|
||||
* without a word. It is derived from the loaded window's own `seq` instead.
|
||||
*/
|
||||
private async goToChapterRef(ref: string): Promise<void> {
|
||||
const view = this.view;
|
||||
if (!view || this.navigating) return;
|
||||
const book = this.book;
|
||||
if (!book) return;
|
||||
|
||||
const local = view.indexOfSection(ref);
|
||||
|
||||
// The overwhelmingly common case: the chapter is one the window already
|
||||
// holds. No network, no window, no second render.
|
||||
if (local >= 0) {
|
||||
await view.open(local, 0);
|
||||
return;
|
||||
}
|
||||
if (!this.content) return;
|
||||
|
||||
this.navigating = true;
|
||||
this.patch({ navigating: true });
|
||||
this.setStatus('loading', '正在跳到这一章…');
|
||||
try {
|
||||
// The TOC's `spine` is the whole-book index the windowing is keyed on.
|
||||
const toc = await this.options.api.toc(book.id);
|
||||
const spine = toc.find((entry) => entry.href === ref)?.spine;
|
||||
if (spine === undefined) return;
|
||||
const group = Math.floor(spine / CHAPTER_WINDOW);
|
||||
const spine = view.windowIndexOfRef(ref) ?? this.tocSpineFor(ref);
|
||||
if (spine === null) {
|
||||
// A chapter the server's windowing cannot address at all — an image
|
||||
// whose reference names a page rather than a position. Saying so is the
|
||||
// whole fix: the old code was silent here, and silence is what a reader
|
||||
// reports as "点击章节没有反应".
|
||||
this.flashStatus('这一章暂时无法跳转', 2600);
|
||||
return;
|
||||
}
|
||||
const group = windowIndexOf(this.content ?? this.windowShape(), spine);
|
||||
const content = await this.options.api.items(book.id, group);
|
||||
if (this.view?.loadWindow(content, spine)) {
|
||||
await this.view.openLocator(`${ref}:0`);
|
||||
const landed = await view.loadWindow(content, spine);
|
||||
if (!landed) this.flashStatus('这一章暂时无法跳转', 2600);
|
||||
else this.hideStatus();
|
||||
} catch {
|
||||
this.flashStatus('无法跳到这一章', 2600);
|
||||
this.setStatus('error', '无法跳到这一章');
|
||||
} finally {
|
||||
this.navigating = false;
|
||||
this.patch({ navigating: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal group list, for a book whose manifest carried no windowed content.
|
||||
*
|
||||
* Only reached by the fallback path of a server old enough to answer a manifest
|
||||
* without `content`; the client then reads the file whole and never jumps
|
||||
* between windows, so this exists to keep the arithmetic total rather than to
|
||||
* be used. Sizing it from the loaded document is what makes the arithmetic
|
||||
* right in the one case it can be reached: sections are chapters there.
|
||||
*/
|
||||
private windowShape(): BookContent {
|
||||
const count = this.doc?.sections.length ?? 0;
|
||||
return {
|
||||
kind: 'reflowable',
|
||||
total: count,
|
||||
groups: count > 0 ? [{ id: 'chapters', seq: 0, title: '章节', count, offset: 0 }] : [],
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Whole-book index of a contents entry, from the fetched table of contents. */
|
||||
private tocSpineFor(ref: string): number | null {
|
||||
const entry = this.chrome.toc.find((candidate) => candidate.id === ref);
|
||||
return entry?.spine ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps one chapter at a time, the way the footer's ‹ / › buttons do.
|
||||
*
|
||||
* A chapter, not a page: the two controls answer different questions ("next
|
||||
* screen" is the tap zone and the swipe, "next chapter" is this), and the one
|
||||
* this exists for is the one a reader wants at the end of a chapter — the
|
||||
* point where a page turn lands them on the next chapter's first page anyway,
|
||||
* but only after a variable number of taps.
|
||||
*/
|
||||
private async goToChapter(delta: 1 | -1): Promise<void> {
|
||||
const view = this.view;
|
||||
if (!view || this.navigating) return;
|
||||
// The *whole-book* position, not the window-local index: `sectionCount` is
|
||||
// the loaded window's length (forty chapters, or one comic volume), so a
|
||||
// reader at the last chapter of a window would be told "已经是最后一章" while
|
||||
// eighty chapters remained. The window's length is a transport detail and
|
||||
// has no business deciding where the book ends.
|
||||
const target = view.currentChapterPosition + delta;
|
||||
if (target < 0) {
|
||||
this.flashStatus('已经是第一章', 1800);
|
||||
return;
|
||||
}
|
||||
if (this.chapterCount > 0 && target >= this.chapterCount) {
|
||||
this.flashStatus('已经是最后一章', 1800);
|
||||
return;
|
||||
}
|
||||
// A chapter inside the loaded window is the only case this can resolve on
|
||||
// its own; one past the window's end has to fetch, and that is exactly what
|
||||
// `goToSection` does — window included, because a chapter at the boundary is
|
||||
// in the next window on one side and this one on the other.
|
||||
await this.goToSection(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many chapters the book has, from the manifest's own total.
|
||||
*
|
||||
* Not `view.sectionCount`: that is the window. A manifest with no `total` (an
|
||||
* old server) reports the window's length, which makes the › button stop at the
|
||||
* window's end rather than at the book's — the old behaviour, and the honest one
|
||||
* when the client genuinely does not know.
|
||||
*/
|
||||
private get chapterCount(): number {
|
||||
return this.manifest?.total ?? this.doc?.sections.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a section by whole-book position, fetching its window when it is not
|
||||
* the one loaded.
|
||||
*
|
||||
* The position is resolved through the *window's own* `seq`, never through the
|
||||
* client's idea of the window size: a comic windows by volume, and forty is a
|
||||
* chapter count, not a volume.
|
||||
*/
|
||||
private async goToSection(spine: number): Promise<void> {
|
||||
const view = this.view;
|
||||
const book = this.book;
|
||||
if (!view || !book) return;
|
||||
// The local index of a whole-book position, when the loaded window holds it.
|
||||
// Resolved by arithmetic against the window's offset rather than by looking a
|
||||
// reference up: a section id is the server's, and a whole-book position is
|
||||
// not one — the two meet only through the window's own offset.
|
||||
const local = spine - view.windowOffset;
|
||||
if (local >= 0 && local < view.sectionCount) {
|
||||
await view.open(local, 0);
|
||||
return;
|
||||
}
|
||||
this.navigating = true;
|
||||
this.patch({ navigating: true });
|
||||
this.setStatus('loading', '正在切换章节…');
|
||||
try {
|
||||
const content = await this.options.api.items(book.id, windowIndexOf(this.content ?? this.windowShape(), spine));
|
||||
const landed = await view.loadWindow(content, spine);
|
||||
if (landed) {
|
||||
// The loaded window is a new set of sections; the *shape* does not change
|
||||
// (the server's group list is complete in every response), but its own
|
||||
// group marker does, and a later jump computes its target from it.
|
||||
this.content = content;
|
||||
this.hideStatus();
|
||||
} else {
|
||||
this.flashStatus('这一章暂时无法跳转', 2600);
|
||||
}
|
||||
} catch {
|
||||
this.flashStatus('无法跳到这一章');
|
||||
this.flashStatus('无法切换章节', 2600);
|
||||
} finally {
|
||||
this.navigating = false;
|
||||
this.patch({ navigating: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+183
-32
@@ -5,6 +5,7 @@ import { ResourceResolver, hydrateResources } from './resources.ts';
|
||||
import { createBookHost, extractBody, extractInlineStyles, sanitiseInjectedContent, type BookShadowHost } from './shadow.ts';
|
||||
import { bookPercentage, formatLocator, parseLocator } from './locator.ts';
|
||||
import { collectSpokenChunks, type SpokenChunk } from '../render/tts-text.ts';
|
||||
import { adoptWindow } from '../formats/windowed.ts';
|
||||
|
||||
export interface ReaderViewOptions {
|
||||
container: HTMLElement;
|
||||
@@ -30,6 +31,20 @@ export interface Position {
|
||||
percentage: number;
|
||||
locator: string;
|
||||
chapterTitle: string;
|
||||
/**
|
||||
* Which page of the current chapter this is, 1-based.
|
||||
*
|
||||
* Reported as part of the position rather than measured by the caller because
|
||||
* "where am I inside this chapter" is the same measurement `within` already is
|
||||
* — one `scrollLeft` read in paged mode, one `scrollHeight` read in scroll
|
||||
* mode — and a footer that showed a page number by asking separately would be
|
||||
* a second measurement of the same thing, taken at a different moment, and
|
||||
* therefore able to disagree with the progress bar beside it.
|
||||
*/
|
||||
pageInChapter: number;
|
||||
chapterPages: number;
|
||||
/** Whole-book index of the section, window offset included. */
|
||||
chapterPosition: number;
|
||||
}
|
||||
|
||||
export type TextAlign = 'inherit' | 'start' | 'justify';
|
||||
@@ -145,8 +160,69 @@ export class ReaderView {
|
||||
return this.sectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loaded window's own labels, as contents entries.
|
||||
*
|
||||
* The fallback for a book whose table of contents cannot be fetched: worse than
|
||||
* the server's list, because it names one window, and better than an empty
|
||||
* panel for a book that plainly has chapters.
|
||||
*/
|
||||
chapterLabels(): Array<{ id: string; label: string; depth: number }> {
|
||||
return this.doc.toc;
|
||||
return this.doc.sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
depth: section.depth,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The identifier of the section at a local index, or null when out of range. */
|
||||
sectionIdAt(index: number): string | null {
|
||||
return this.doc.sections[index]?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local index of a section by id, or -1 when the loaded window has none.
|
||||
*
|
||||
* The table of contents speaks in the server's references and the view speaks
|
||||
* in local indices, so every jump needs this translation. It is a linear scan
|
||||
* of at most one window — forty entries for an EPUB, one volume for a comic —
|
||||
* and it replaces the *wrong* way of doing the same thing, which was to build a
|
||||
* whole locator and ask `openLocator` to parse it back.
|
||||
*/
|
||||
indexOfSection(id: string): number {
|
||||
if (!id) return -1;
|
||||
return this.doc.sections.findIndex((section) => section.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole-book position of a section in the loaded window.
|
||||
*
|
||||
* Derived from the section's own index in this window plus the window's offset,
|
||||
* never from the client's idea of the window size: a comic windows by volume,
|
||||
* and a chapter count is not a volume count. Null when the window does not hold
|
||||
* the section.
|
||||
*/
|
||||
windowIndexOfRef(id: string): number | null {
|
||||
const local = this.indexOfSection(id);
|
||||
if (local === -1) return null;
|
||||
return this.windowOffset + local;
|
||||
}
|
||||
|
||||
/** Whole-book index of the loaded window's first section. */
|
||||
get windowOffset(): number {
|
||||
return this.staged?.windowOffset?.() ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-book index of the chapter on screen.
|
||||
*
|
||||
* The unit the chapter buttons step in. `currentSectionIndex` is window-local
|
||||
* and must not be used for that: at the last chapter of a window it reads 39,
|
||||
* which the screen would compare against the window's length of 40 and call
|
||||
* "the last chapter" of a 120-chapter book.
|
||||
*/
|
||||
get currentChapterPosition(): number {
|
||||
return this.windowOffset + this.sectionIndex;
|
||||
}
|
||||
|
||||
applySettings(next?: Partial<ViewSettings>): void {
|
||||
@@ -197,16 +273,49 @@ export class ReaderView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the loaded window of a staged book.
|
||||
* Replace the loaded window of a staged book, and land on `spine`.
|
||||
*
|
||||
* Optional: a book read whole (the fallback when a server has no addressable
|
||||
* structure) simply never calls it. Returns false when the book is not staged
|
||||
* or the new window does not hold `spine`, so a caller can report honestly
|
||||
* rather than silently landing on the wrong chapter.
|
||||
* A book read whole (the fallback when a server has no addressable structure)
|
||||
* never calls this, and answers false. So does a window that does not contain
|
||||
* `spine`, so a caller can report honestly rather than silently landing on the
|
||||
* wrong chapter.
|
||||
*
|
||||
* The swap and the landing are one operation rather than two calls from the
|
||||
* screen, because doing them apart is what allowed the bug this method's
|
||||
* comment in `windowed.ts` describes: the screen asked the document to swap
|
||||
* through a method that did not exist, got nothing, and then asked the view to
|
||||
* open a locator in the old window — which failed, silently, for a reader who
|
||||
* had just tapped a chapter in the 目录. Returns whether the reader is now
|
||||
* inside `spine`.
|
||||
*/
|
||||
loadWindow(content: BookContent, spine: number): boolean {
|
||||
async loadWindow(content: BookContent, spine: number): Promise<boolean> {
|
||||
if (!this.staged) return false;
|
||||
return this.staged.loadWindow(content, spine) >= 0;
|
||||
const local = adoptWindow(this.doc, content, spine);
|
||||
if (local < 0) return false;
|
||||
// Landing on the chapter is part of swapping the window, not a second call
|
||||
// the caller has to remember. Swapping alone leaves the *old* chapter on
|
||||
// screen — the host's content is untouched, `currentSectionIndex` still
|
||||
// points into the previous window's sections, and the footer keeps naming
|
||||
// the chapter the reader just left. That is precisely the "点击章节没有反应"
|
||||
// report, one layer down.
|
||||
await this.open(local, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that actually scrolls the chapter.
|
||||
*
|
||||
* Two different elements depending on the mode, and getting it wrong is silent:
|
||||
* a `scroll` event does not bubble, so a listener on the wrong one never fires;
|
||||
* `scrollTop` on the wrong one is always zero, so every measurement reads as
|
||||
* "the top of the chapter". In scroll mode the *host* scrolls and the column
|
||||
* inside it is simply taller than the viewport (`overflow-y: auto` on
|
||||
* `book-host`, which is where the reading position has to be read from). In
|
||||
* paged mode the host is `overflow: hidden` and the *column* is the horizontal
|
||||
* scroller that holds the columns.
|
||||
*/
|
||||
get scroller(): HTMLElement {
|
||||
return this.settings.mode === 'paged' ? this.host.flow : this.host;
|
||||
}
|
||||
|
||||
/** Opens a section by index, optionally at a fractional offset within it. */
|
||||
@@ -247,6 +356,7 @@ export class ReaderView {
|
||||
position(): Position {
|
||||
const section = this.doc.sections[this.sectionIndex];
|
||||
const within = this.measureWithin();
|
||||
const pages = this.chapterPaging(within);
|
||||
return {
|
||||
sectionIndex: this.sectionIndex,
|
||||
sectionId: section?.id ?? '',
|
||||
@@ -254,9 +364,45 @@ export class ReaderView {
|
||||
percentage: bookPercentage(this.doc, this.sectionIndex, within),
|
||||
locator: formatLocator(section?.id ?? '', within),
|
||||
chapterTitle: section?.label ?? '',
|
||||
pageInChapter: pages.current,
|
||||
chapterPages: pages.total,
|
||||
chapterPosition: this.windowOffset + this.sectionIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The page the reader is on, and how many the chapter has.
|
||||
*
|
||||
* Both are derived from the same measurement the progress fraction uses, so
|
||||
* the two can never disagree. A fixed-layout section is atomic — one page —
|
||||
* and a chapter that fits the screen entirely is one page, which is why the
|
||||
* counts are clamped to at least one rather than reported as zero.
|
||||
*/
|
||||
private chapterPaging(within: number): { current: number; total: number } {
|
||||
if (this.doc.layout === 'fixed') return { current: 1, total: 1 };
|
||||
// The *scroll container*, not the content column inside it — and the two are
|
||||
// different elements. In scroll mode the host scrolls and the column is
|
||||
// taller than it; in paged mode the column scrolls sideways inside a host
|
||||
// that does not scroll at all. Measuring the wrong one reports every chapter
|
||||
// as a single page, which is worse than reporting nothing: a footer that
|
||||
// says "1/1" while the reader is scrolling through six screens is a footer
|
||||
// that is lying.
|
||||
const scroller = this.scroller;
|
||||
if (this.settings.mode === 'paged') {
|
||||
const page = Math.max(1, scroller.clientWidth);
|
||||
const total = Math.max(1, Math.round(scroller.scrollWidth / page));
|
||||
const current = Math.min(total, Math.max(1, Math.round(scroller.scrollLeft / page) + 1));
|
||||
return { current, total };
|
||||
}
|
||||
const page = Math.max(1, scroller.clientHeight);
|
||||
const total = Math.max(1, Math.ceil(scroller.scrollHeight / page));
|
||||
// The last screen is "the last page" even when it is a half-screen: a scroll
|
||||
// position of 0.83 of the extent is on page 5 of 6, and rounding the fraction
|
||||
// up instead would give a different number for the same scroll position.
|
||||
const current = Math.min(total, Math.max(1, Math.floor(within * (total - 1)) + 1));
|
||||
return { current, total };
|
||||
}
|
||||
|
||||
/** Advances one unit: a page when paged, a screenful when scrolling. */
|
||||
async next(): Promise<boolean> {
|
||||
if (this.doc.layout === 'fixed') return this.stepFixed(1);
|
||||
@@ -265,10 +411,11 @@ export class ReaderView {
|
||||
if (advanced) return true;
|
||||
return this.stepSection(1);
|
||||
}
|
||||
const flow = this.host.flow;
|
||||
const limit = flow.scrollHeight - flow.clientHeight;
|
||||
if (flow.scrollTop < limit - 8) {
|
||||
flow.scrollTop = Math.min(limit, flow.scrollTop + flow.clientHeight * 0.9);
|
||||
const scroller = this.scroller;
|
||||
const limit = scroller.scrollHeight - scroller.clientHeight;
|
||||
if (scroller.scrollTop < limit - 8) {
|
||||
scroller.scrollTop = Math.min(limit, scroller.scrollTop + scroller.clientHeight * 0.9);
|
||||
this.emitPosition();
|
||||
return true;
|
||||
}
|
||||
return this.stepSection(1);
|
||||
@@ -281,9 +428,10 @@ export class ReaderView {
|
||||
if (moved) return true;
|
||||
return this.stepSection(-1, true);
|
||||
}
|
||||
const flow = this.host.flow;
|
||||
if (flow.scrollTop > 8) {
|
||||
flow.scrollTop = Math.max(0, flow.scrollTop - flow.clientHeight * 0.9);
|
||||
const scroller = this.scroller;
|
||||
if (scroller.scrollTop > 8) {
|
||||
scroller.scrollTop = Math.max(0, scroller.scrollTop - scroller.clientHeight * 0.9);
|
||||
this.emitPosition();
|
||||
return true;
|
||||
}
|
||||
return this.stepSection(-1, true);
|
||||
@@ -402,27 +550,27 @@ export class ReaderView {
|
||||
// Fixed layout has no sub-section position: a comic page is atomic, so the
|
||||
// position is entirely expressed by the section index.
|
||||
if (this.doc.layout === 'fixed') return 0;
|
||||
const flow = this.host.flow;
|
||||
const scroller = this.scroller;
|
||||
if (this.settings.mode === 'paged') {
|
||||
const width = flow.scrollWidth;
|
||||
const width = scroller.scrollWidth;
|
||||
if (width <= 0) return this.sectionOffset;
|
||||
return Math.min(1, Math.max(0, flow.scrollLeft / Math.max(1, width - flow.clientWidth)));
|
||||
return Math.min(1, Math.max(0, scroller.scrollLeft / Math.max(1, width - scroller.clientWidth)));
|
||||
}
|
||||
const limit = flow.scrollHeight - flow.clientHeight;
|
||||
const limit = scroller.scrollHeight - scroller.clientHeight;
|
||||
if (limit <= 0) return 0;
|
||||
return Math.min(1, Math.max(0, flow.scrollTop / limit));
|
||||
return Math.min(1, Math.max(0, scroller.scrollTop / limit));
|
||||
}
|
||||
|
||||
private restoreOffset(): void {
|
||||
const flow = this.host.flow;
|
||||
const scroller = this.scroller;
|
||||
if (this.doc.layout === 'fixed') return;
|
||||
if (this.settings.mode === 'paged') {
|
||||
const max = Math.max(0, flow.scrollWidth - flow.clientWidth);
|
||||
flow.scrollLeft = max * this.sectionOffset;
|
||||
const max = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
|
||||
scroller.scrollLeft = max * this.sectionOffset;
|
||||
return;
|
||||
}
|
||||
const limit = Math.max(0, flow.scrollHeight - flow.clientHeight);
|
||||
flow.scrollTop = limit * this.sectionOffset;
|
||||
const limit = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||
scroller.scrollTop = limit * this.sectionOffset;
|
||||
}
|
||||
|
||||
private emitPosition(): void {
|
||||
@@ -445,8 +593,11 @@ export class ReaderView {
|
||||
private attachResizeObserver(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
this.resizeObserver = new ResizeObserver(() => this.restoreOffset());
|
||||
this.resizeObserver.observe(this.host.flow);
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.restoreOffset();
|
||||
this.emitPosition();
|
||||
});
|
||||
this.resizeObserver.observe(this.host);
|
||||
}
|
||||
|
||||
private async stepFixed(delta: number): Promise<boolean> {
|
||||
@@ -465,12 +616,12 @@ export class ReaderView {
|
||||
|
||||
/** Moves by one CSS column, which is one screen in paged mode. */
|
||||
private stepColumn(delta: number): boolean {
|
||||
const flow = this.host.flow;
|
||||
const step = Math.max(1, flow.clientWidth);
|
||||
const max = Math.max(0, flow.scrollWidth - flow.clientWidth);
|
||||
const target = flow.scrollLeft + step * delta;
|
||||
const scroller = this.scroller;
|
||||
const step = Math.max(1, scroller.clientWidth);
|
||||
const max = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
|
||||
const target = scroller.scrollLeft + step * delta;
|
||||
if (target < -1 || target > max + 1) return false;
|
||||
flow.scrollLeft = Math.min(max, Math.max(0, target));
|
||||
scroller.scrollLeft = Math.min(max, Math.max(0, target));
|
||||
this.emitPosition();
|
||||
return true;
|
||||
}
|
||||
|
||||
+134
-2
@@ -19,21 +19,153 @@
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* The reading column's own stylesheet, for the shadow root.
|
||||
*
|
||||
* A selector in the document's stylesheet cannot reach into a shadow root, and
|
||||
* that is the whole reason this constant exists. `.book-flow` is the content
|
||||
* column — the element that carries the measure, the margins, the font scale and
|
||||
* the pagination columns — and it lives in *here*, so every one of those rules has
|
||||
* to be declared *here*. They used to be declared in `styles/reader.css`, matched
|
||||
* nothing, and the column therefore rendered at the browser's default: full
|
||||
* width, no padding, a 42rem measure it never applied, and — in paged mode — no
|
||||
* columns at all. It did not look like a missing stylesheet, because the parts
|
||||
* that *did* work (the book's own CSS, which is injected here) still applied; it
|
||||
* looked like the reader simply had no margin setting.
|
||||
*
|
||||
* The same reason dictates `:host(...)` rather than `.book-host[...]` in the rules
|
||||
* below. `data-paginated` and `data-animating` are set on the *host element*, and a
|
||||
* shadow root's own stylesheet cannot match its host with a class selector — the
|
||||
* host is outside the shadow tree and the class is on it, not in it. `:host()` is
|
||||
* the only selector that crosses that line, and without it paged mode silently did
|
||||
* nothing: the columns were never created, so every "page" was the whole chapter
|
||||
* and a page turn had nowhere to go.
|
||||
*
|
||||
* What must not be duplicated between the two copies is the *values* — every one
|
||||
* of them is a custom property, and the properties themselves are defined on
|
||||
* `:root`, which a shadow root inherits.
|
||||
*/
|
||||
const FLOW_STYLESHEET = `
|
||||
.book-flow {
|
||||
position: relative;
|
||||
max-inline-size: var(--reader-measure, 42rem);
|
||||
margin-inline: auto;
|
||||
padding-block: var(--reader-page-padding-block, 1.25rem);
|
||||
padding-inline: var(--reader-page-margin, 1.5rem);
|
||||
font-size: calc(1em * var(--reader-font-scale, 1));
|
||||
line-height: var(--reader-line-height, inherit);
|
||||
font-family: var(--reader-font-family, inherit);
|
||||
text-align: var(--reader-text-align, inherit);
|
||||
isolation: isolate;
|
||||
}
|
||||
:host([data-paginated='true']) .book-flow {
|
||||
height: 100%;
|
||||
max-inline-size: none;
|
||||
padding: 0;
|
||||
column-gap: 0;
|
||||
columns: 1;
|
||||
column-width: 100vw;
|
||||
column-fill: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
:host([data-paginated='true']) .book-flow > * {
|
||||
break-inside: auto;
|
||||
}
|
||||
:host([data-animating='slide-next']) .book-flow {
|
||||
animation: slide-next 180ms ease-out;
|
||||
}
|
||||
:host([data-animating='slide-previous']) .book-flow {
|
||||
animation: slide-previous 180ms ease-out;
|
||||
}
|
||||
:host([data-animating='fade-next']) .book-flow {
|
||||
animation: fade-next 160ms ease-out;
|
||||
}
|
||||
:host([data-animating='fade-previous']) .book-flow {
|
||||
animation: fade-previous 160ms ease-out;
|
||||
}
|
||||
@keyframes slide-next {
|
||||
from { transform: translateX(8px); opacity: 0.4; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
@keyframes slide-previous {
|
||||
from { transform: translateX(-8px); opacity: 0.4; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
@keyframes fade-next {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fade-previous {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.reader-speech-highlight {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--reader-accent, #7a5c3e) 34%, transparent);
|
||||
transition: top 120ms linear, left 120ms linear, width 120ms linear, height 120ms linear;
|
||||
}
|
||||
.fixed-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.fixed-page[data-fit='contain'] img,
|
||||
.fixed-page[data-fit='contain'] .pdf-frame {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.fixed-page[data-fit='width'] img,
|
||||
.fixed-page[data-fit='width'] .pdf-frame {
|
||||
width: 100%;
|
||||
}
|
||||
.fixed-page img {
|
||||
display: block;
|
||||
}
|
||||
.fixed-page .pdf-frame {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:host([data-animating]) .book-flow { animation: none; }
|
||||
.reader-speech-highlight { transition: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
export class BookShadowHost extends HTMLElement {
|
||||
readonly shadow: ShadowRoot;
|
||||
private readonly styleEl: HTMLStyleElement;
|
||||
/** The reader's own layout rules, injected once and never replaced. */
|
||||
private readonly layoutEl: HTMLStyleElement;
|
||||
private readonly contentEl: HTMLDivElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: 'open' });
|
||||
this.layoutEl = document.createElement('style');
|
||||
this.styleEl = document.createElement('style');
|
||||
this.contentEl = document.createElement('div');
|
||||
this.contentEl.className = 'book-flow';
|
||||
this.shadow.append(this.styleEl, this.contentEl);
|
||||
// The layout sheet goes first so the book's own styles, which follow in
|
||||
// `styleEl`, can still override it — the same cascade order the document
|
||||
// stylesheet gives them.
|
||||
this.layoutEl.textContent = FLOW_STYLESHEET;
|
||||
this.shadow.append(this.layoutEl, this.styleEl, this.contentEl);
|
||||
}
|
||||
|
||||
/** Replaces the reading surface and the stylesheets that apply to it. */
|
||||
/**
|
||||
* Replaces the reading surface and the *book's* stylesheets.
|
||||
*
|
||||
* Only the book's own styles live in `styleEl`, which is replaced on every
|
||||
* chapter: the reader's layout rules are in `layoutEl` and are never touched,
|
||||
* because a chapter change is not a reason for the column to lose its measure
|
||||
* for one frame.
|
||||
*/
|
||||
setContent(html: string, styles: string[]): void {
|
||||
this.styleEl.textContent = styles.join('\n');
|
||||
this.contentEl.innerHTML = html;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReaderView } from '../src/ui/reader-view.ts';
|
||||
import { createStagedDoc } from '../src/formats/windowed.ts';
|
||||
import type { BookContent, ContentItem } from '../src/net/api.ts';
|
||||
import type { BookDoc } from '../src/formats/types.ts';
|
||||
|
||||
/**
|
||||
@@ -76,6 +78,44 @@ function make(container: HTMLDivElement, doc: BookDoc): ReaderView {
|
||||
return new ReaderView({ container, doc });
|
||||
}
|
||||
|
||||
/** One window of a staged book, as the manifest endpoint delivers it. */
|
||||
function windowOf(from: number, total: number, count = 40): BookContent {
|
||||
const items: ContentItem[] = Array.from({ length: count }, (_value, index) => ({
|
||||
id: `c${from + index}`,
|
||||
seq: from + index,
|
||||
title: `第 ${from + index + 1} 章`,
|
||||
kind: 'chapter',
|
||||
mediaType: 'application/xhtml+xml',
|
||||
href: `xhtml:ch${from + index}.xhtml`,
|
||||
}));
|
||||
const groups = Array.from({ length: Math.ceil(total / count) }, (_value, index) => ({
|
||||
id: `spine:${index * count}`,
|
||||
seq: index,
|
||||
title: `${index * count + 1}..`,
|
||||
count: Math.min(count, total - index * count),
|
||||
offset: index * count,
|
||||
}));
|
||||
return { kind: 'reflowable', total, groups, items, group: Math.floor(from / count) };
|
||||
}
|
||||
|
||||
/** A `ReaderView` over a staged book whose first window starts at `from`. */
|
||||
function stagedBook(count: number, total: number, from: number): BookDoc {
|
||||
const doc = createStagedDoc({
|
||||
kind: 'reflowable',
|
||||
toc: Array.from({ length: total }, (_value, index) => ({
|
||||
id: `xhtml:ch${index}.xhtml`,
|
||||
label: `第 ${index + 1} 章`,
|
||||
depth: 0,
|
||||
})),
|
||||
content: windowOf(from, total, count),
|
||||
loader: { async read(item) { return { html: `<p>${item.title}</p>` }; } },
|
||||
});
|
||||
// The same hand-off the reader screen performs: a staged document answers to
|
||||
// `setWindow`, and the view finds it through this property.
|
||||
(doc as BookDoc & { staged?: unknown }).staged = doc;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe('ReaderView navigation', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -165,6 +205,76 @@ describe('ReaderView navigation', () => {
|
||||
expect(view.currentSectionIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('lands on the chapter when a window is swapped in', async () => {
|
||||
// The bug this pins was reported as "点击章节没有反应": the screen asked the
|
||||
// document to swap windows, and the swap — when it happened at all — replaced
|
||||
// the section list without opening the new chapter. The reader was left
|
||||
// staring at the chapter they had just tried to leave. Swapping and landing
|
||||
// are one operation now.
|
||||
const view = make(container, stagedBook(40, 120, 0));
|
||||
await view.open(0, 0);
|
||||
expect(view.currentSectionIndex()).toBe(0);
|
||||
expect(view.currentChapterPosition).toBe(0);
|
||||
|
||||
const landed = await view.loadWindow(windowOf(80, 120), 90);
|
||||
expect(landed).toBe(true);
|
||||
expect(view.currentSectionIndex()).toBe(10);
|
||||
expect(view.currentChapterPosition).toBe(90);
|
||||
expect(view.position().chapterTitle).toBe('第 91 章');
|
||||
});
|
||||
|
||||
it('refuses a window that does not hold the chapter', async () => {
|
||||
const view = make(container, stagedBook(40, 120, 0));
|
||||
await view.open(7, 0);
|
||||
const landed = await view.loadWindow(windowOf(80, 120), 3);
|
||||
expect(landed).toBe(false);
|
||||
// The old chapter is still on screen: a failed jump that also moved the
|
||||
// reader would be worse than one that did nothing.
|
||||
expect(view.currentSectionIndex()).toBe(7);
|
||||
expect(view.currentChapterPosition).toBe(7);
|
||||
});
|
||||
|
||||
it('refuses a window for a book that is not staged', async () => {
|
||||
const view = make(container, reflowableDoc(5));
|
||||
await view.open(0, 0);
|
||||
expect(await view.loadWindow(windowOf(0, 120), 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a whole-book chapter position that ignores the window boundary', () => {
|
||||
// `currentSectionIndex` is window-local and must not be used to decide where
|
||||
// the book ends: at the last chapter of a window it reads 39, which a naive
|
||||
// check against the window's length would call "the last chapter" of a
|
||||
// 120-chapter book.
|
||||
const view = make(container, stagedBook(40, 120, 80));
|
||||
expect(view.sectionCount).toBe(40);
|
||||
expect(view.windowOffset).toBe(80);
|
||||
expect(view.currentChapterPosition).toBe(80);
|
||||
});
|
||||
|
||||
it('resolves a section id to its local index and whole-book position', async () => {
|
||||
const view = make(container, stagedBook(40, 120, 40));
|
||||
await view.open(0, 0);
|
||||
expect(view.indexOfSection('xhtml:ch57.xhtml')).toBe(17);
|
||||
expect(view.indexOfSection('xhtml:ch0.xhtml')).toBe(-1);
|
||||
expect(view.windowIndexOfRef('xhtml:ch57.xhtml')).toBe(57);
|
||||
expect(view.windowIndexOfRef('xhtml:ch0.xhtml')).toBeNull();
|
||||
expect(view.sectionIdAt(17)).toBe('xhtml:ch57.xhtml');
|
||||
expect(view.sectionIdAt(99)).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the pages of a chapter from the scroll container', () => {
|
||||
// jsdom has no layout, so every extent is zero and the honest answer is "one
|
||||
// page" rather than a division by zero. What is asserted is that the two
|
||||
// page-turn modes do not read the *wrong element*: the host scrolls in scroll
|
||||
// mode and the column scrolls in paged mode, and measuring the other one
|
||||
// reports every chapter as a single page.
|
||||
const view = make(container, reflowableDoc(2));
|
||||
const position = view.position();
|
||||
expect(position.pageInChapter).toBe(1);
|
||||
expect(position.chapterPages).toBe(1);
|
||||
expect(position.chapterPosition).toBe(0);
|
||||
});
|
||||
|
||||
it('disposes without leaving the host in the container', () => {
|
||||
const view = make(container, reflowableDoc(2));
|
||||
expect(container.querySelector('book-content')).not.toBeNull();
|
||||
|
||||
+61
-1
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractBody, extractInlineStyles, sanitiseInjectedContent } from '../src/ui/shadow.ts';
|
||||
import { createBookHost, extractBody, extractInlineStyles, sanitiseInjectedContent } from '../src/ui/shadow.ts';
|
||||
|
||||
/**
|
||||
* Sanitisation and style extraction.
|
||||
@@ -101,3 +101,63 @@ describe('extractInlineStyles', () => {
|
||||
expect(extractInlineStyles('<p>x</p>')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The host's own styling.
|
||||
*
|
||||
* These exist because the reading column was, for a while, completely unstyled
|
||||
* and nothing said so. `.book-flow` is inside a shadow root, so the rules in
|
||||
* `styles/reader.css` matched nothing at all — a document stylesheet cannot reach
|
||||
* into a shadow root, and the failure is invisible from the outside: the book's
|
||||
* own CSS still applied, so the page looked *styled*, just without a measure, a
|
||||
* margin, a font scale or any columns. What is asserted here is the mechanism,
|
||||
* not the appearance: the layout sheet is in the shadow root, it survives a
|
||||
* chapter change, and it reaches the host's own state attribute through `:host()`
|
||||
* rather than through a class selector that cannot match.
|
||||
*/
|
||||
describe('BookShadowHost', () => {
|
||||
it('injects the reading column rules into the shadow root', () => {
|
||||
const host = createBookHost();
|
||||
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
|
||||
// The measure, the margins, the font scale and the columns: the four things
|
||||
// the document stylesheet was supposedly providing.
|
||||
expect(sheet).toContain('.book-flow');
|
||||
expect(sheet).toContain('max-inline-size: var(--reader-measure');
|
||||
expect(sheet).toContain('padding-inline: var(--reader-page-margin');
|
||||
expect(sheet).toContain('font-size: calc(1em * var(--reader-font-scale');
|
||||
expect(sheet).toContain('column-width: 100vw');
|
||||
});
|
||||
|
||||
it('reaches the host state through :host(), not through a class selector', () => {
|
||||
// `data-paginated` and `data-animating` are set on the host element, which is
|
||||
// *outside* the shadow tree. `.book-host[data-paginated] .book-flow` cannot
|
||||
// match it from inside — which is why paged mode silently produced no columns
|
||||
// and a page turn had nowhere to go.
|
||||
const host = createBookHost();
|
||||
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
|
||||
expect(sheet).toContain(":host([data-paginated='true']) .book-flow");
|
||||
expect(sheet).toContain(":host([data-animating='slide-next']) .book-flow");
|
||||
expect(sheet).not.toContain('.book-host[');
|
||||
});
|
||||
|
||||
it('keeps the layout sheet when a chapter replaces the book styles', () => {
|
||||
// The book's styles change with every chapter; the reader's layout rules must
|
||||
// not, or the column loses its measure for a frame on every chapter change.
|
||||
const host = createBookHost();
|
||||
const before = host.shadow.querySelectorAll('style')[0]?.textContent;
|
||||
host.setContent('<p>正文</p>', ['p { color: red }']);
|
||||
const sheets = [...host.shadow.querySelectorAll('style')].map((node) => node.textContent ?? '');
|
||||
expect(sheets[0]).toBe(before);
|
||||
expect(sheets[1]).toContain('p { color: red }');
|
||||
// The layout sheet is first, so the book's own rules still win the cascade.
|
||||
expect(sheets[0]).toContain('.book-flow');
|
||||
});
|
||||
|
||||
it('keeps the layout sheet through clear()', () => {
|
||||
const host = createBookHost();
|
||||
host.clear();
|
||||
const sheets = [...host.shadow.querySelectorAll('style')].map((node) => node.textContent ?? '');
|
||||
expect(sheets[0]).toContain('.book-flow');
|
||||
expect(sheets[1]).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createStagedDoc } from '../src/formats/windowed.ts';
|
||||
import { adoptWindow, createStagedDoc, windowIndexOf } from '../src/formats/windowed.ts';
|
||||
import { HttpRangeSource, RemoteZip, type RangeSource } from '../src/formats/remote-zip.ts';
|
||||
import type { BookContent, ContentItem } from '../src/net/api.ts';
|
||||
|
||||
@@ -100,6 +100,68 @@ describe('staged documents', () => {
|
||||
expect(doc.toc[1199]?.label).toBe('第 1200 章');
|
||||
});
|
||||
|
||||
it('swaps a window through either method name it might answer to', async () => {
|
||||
// The regression this pins is the reason the 目录 looked dead on a long book:
|
||||
// the screen duck-typed a `loadWindow` method off the document, the document
|
||||
// answered to `setWindow`, and the check therefore never succeeded — so a
|
||||
// jump to a chapter outside the loaded window was a silent no-op. One helper
|
||||
// now knows both names, and it is tested with a document that uses *only* the
|
||||
// other one.
|
||||
const plain = createStagedDoc({
|
||||
kind: 'reflowable',
|
||||
toc: [],
|
||||
content: windowOf(0, 40, 1200),
|
||||
loader: { async read() { return { html: '' }; } },
|
||||
});
|
||||
expect(adoptWindow(plain, windowOf(880, 40, 1200), 900)).toBe(20);
|
||||
expect(plain.windowOffset()).toBe(880);
|
||||
|
||||
const legacy = {
|
||||
sections: [],
|
||||
toc: [],
|
||||
loadWindow: (_content: unknown, spine: number) => (spine === 900 ? 20 : -1),
|
||||
} as unknown as import('../src/formats/types.ts').BookDoc;
|
||||
expect(adoptWindow(legacy, windowOf(880, 40, 1200), 900)).toBe(20);
|
||||
expect(adoptWindow(legacy, windowOf(880, 40, 1200), 1)).toBe(-1);
|
||||
});
|
||||
|
||||
it('answers -1 for a document that has no window at all', () => {
|
||||
// A book read whole (the fallback for a server with no addressable structure)
|
||||
// must say so rather than throw, because the caller's next move is to report
|
||||
// "this chapter cannot be reached" rather than to crash the reader.
|
||||
const whole = { sections: [], toc: [] } as unknown as import('../src/formats/types.ts').BookDoc;
|
||||
expect(adoptWindow(whole, windowOf(0, 40, 1200), 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it('computes the window from the groups, not from a chapter constant', () => {
|
||||
// A comic windows by *volume*, and a volume is not forty pages. Deriving the
|
||||
// window from `spine / 40` asks for a group that does not exist and the jump
|
||||
// lands nowhere; the manifest's own group sizes are the only authority.
|
||||
const volumes: BookContent = {
|
||||
kind: 'paged',
|
||||
total: 900,
|
||||
groups: [
|
||||
{ id: 'v0', seq: 0, title: '卷一', count: 500, offset: 0 },
|
||||
{ id: 'v1', seq: 1, title: '卷二', count: 400, offset: 500 },
|
||||
],
|
||||
items: [],
|
||||
};
|
||||
expect(windowIndexOf(volumes, 0)).toBe(0);
|
||||
expect(windowIndexOf(volumes, 499)).toBe(0);
|
||||
expect(windowIndexOf(volumes, 500)).toBe(1);
|
||||
expect(windowIndexOf(volumes, 899)).toBe(1);
|
||||
// Past the end clamps to the last group rather than to a nonexistent one.
|
||||
expect(windowIndexOf(volumes, 9000)).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to the chapter window when a manifest ships no groups', () => {
|
||||
const bare: BookContent = { kind: 'text', total: 120, groups: [], items: [] };
|
||||
expect(windowIndexOf(bare, 0)).toBe(0);
|
||||
expect(windowIndexOf(bare, 39)).toBe(0);
|
||||
expect(windowIndexOf(bare, 40)).toBe(1);
|
||||
expect(windowIndexOf(bare, 119)).toBe(2);
|
||||
});
|
||||
|
||||
it('finds a whole-book position inside a newly loaded window', async () => {
|
||||
const doc = createStagedDoc({
|
||||
kind: 'reflowable',
|
||||
|
||||
Reference in new issue
Block a user