fix(web): 修复阅读页章节跳转失灵,补齐上一章/下一章

This commit is contained in:
hectorqin committed 2026-09-17 23:52:11 +08:00
1 parent 4fd5f7e972
commit ecb2ef4806
12 files changed
+1091 -397

No files matched your search

+8 -1
View File
@@ -107,9 +107,16 @@ volumes:
继承变量上,所以一本按章节变行距的书不会被压平,一本给段落写死 `font-size: 14px`
的书也不会被字号滑块踩掉。这与「所有书看起来一致」的 Readium 路线正好相反。
### 翻页
### 翻页与章节导航
- **点击区域**:中间呼出/隐藏工具栏,左右翻页;可切换「左进右退」适配左右手。
- **上一章 / 下一章**:页脚中间的 `‹` `›` 直接换章,不用先滚到章末。
到达书的第一章 / 最后一章时按钮置灰。跨窗口换章(一千二百章的书按 40 章一窗)
会先取那一窗再落章,页脚在取窗期间显示「正在切换…」。
- **页脚报位置**:`第 100/120 章 · 本章 1/3 页 · 40%`。章内页码与进度条同源、
同一次量测,所以两者不会互相矛盾。
- **目录即跳转**:点任意一章直接跳过去,不在当前窗口的会先拉取那一窗。
跳转失败的章会明说「这一章暂时无法跳转」,不会静默无反应。
- **翻页动画**:滑动 / 淡入 / 无。系统开启「减少动态效果」时自动不播。
- **硬件翻页键**:`←` / `→`、`PageUp` / `PageDown`,空格播放/暂停朗读。
Android 外壳把音量键映射成方向键,所以音量键翻页、蓝牙翻页器都走同一条路。
+40
View File
@@ -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:` 协议,渲染层从内存资源表解出;
+2
View File
@@ -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 {
+72 -2
View File
@@ -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
View File
@@ -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 ---- */
+56 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+110
View File
@@ -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
View File
@@ -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('');
});
});
+63 -1
View File
@@ -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',