合并来自 auto/reader-scrub-page-rail-861d 的合并请求 #41

fix(web,server): 底部滑杆量章节内页数,快捷列随工具栏收放,chapter-full 改名

PR-URL: #41
This commit is contained in:
hectorqin authored and cnb committed 2026-09-19 08:31:22 +08:00
commit f2eb73be2a
16 files changed
+670 -167

No files matched your search

+9 -4
View File
@@ -6,31 +6,36 @@
| 截图 | 场景 | 看什么 |
| --- | --- | --- |
| `01-shelf.png` | 书架 | 第一屏:先看到书,再看到控件 |
| `02-reader.png` | 阅读页 | 顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸 |
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码 |
| `02-reader.png` | 阅读页 | 顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章 |
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数 |
| `04-panel-toc.png` | 目录 · 半屏 | 下半屏,上半屏正文仍可见 |
| `05-panel-settings.png` | 阅读设置 · 半屏 | 一行式行:标签在左、控件在右 |
| `06-reader-paged.png` | 阅读页 · 翻页模式 | 分栏后的一页,页数应与可翻次数一致 |
| `07-reader-sepia.png` | 阅读页 · 米黄 | 主题切换后的同一页 |
| `08-reader-dark.png` | 阅读页 · 夜间 | 暗色下的正文与工具栏 |
| `09-panel-txt.png` | TXT · 正文排版 | 纯文本专属的缩进/段间距/编码三行,且面板仍是半屏 |
| `10-reader-paged-scrub.png` | 阅读页 · 滑杆拖到本章第 3 页 | 分栏模式下拖动底部滑杆:读数与画面都停在本章内,不换章 |
## 自动测量
| 检查 | 结果 | 说明 |
| --- | --- | --- |
| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 4 个 pointer-events=auto |
| 收起工具栏: 顶栏与底栏一起走 | ✅ | state=hidden 顶栏 1px / 底栏 0px |
| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=hidden footer=hidden |
| 收起工具栏: 正文占满整屏 | ✅ | 正文 1..844 / 视口 844 |
| 收起工具栏: 右侧仍有快捷按钮 | ✅ | rail=true |
| 收起工具栏: 快捷按钮跟着两条栏一起收 | ✅ | visible=false pointer-events=none |
| 收起工具栏: 保留章节与页码指示 | ✅ | chapter="第一章 惊蛰" progress="第 1/4 页 0%" |
| 收起工具栏: 阅读指示不吃手势 | ✅ | pointer-events=none |
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=54 |
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 266px |
| 目录: 面板有遮罩和把手 | ✅ | scrim=true grip=true |
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 701px |
| 底栏: 进度是可拖动的滑杆 | ✅ | type=range 宽 304px label="阅读进度" |
| 底栏: 进度是可拖动的滑杆 | ✅ | type=range 宽 304px label="章节内页数" |
| 底栏: 有页码与阅读进度读数 | ✅ | page="第 1/4 页" nav="阅读进度:0%" |
| 底栏: 滑杆量的是章节内页数,不是全书百分比 | ✅ | min=1 max=4 value=1 读数="第 1/4 页"(章节共 4 页) |
| 底栏: 拖动滑杆切到对应页 | ✅ | 拖到 4 后读数="第 4/4 页" |
| 翻页模式: 滑杆拖到第 3 页,仍在本章 | ✅ | min=1 max=4 value=3 拖前="第 4/4 页" 拖后="第 3/4 页" |
| 状态提示不推动正文 | ✅ | 正文顶部 y: 54.390625 / 54.390625 / 54.390625 |
| 触控目标不小于 28px | ✅ | 9 个控件全部合格 |
| 每个控件都有可读的名字 | ✅ | 9 个控件全部有名字 |
+30 -5
View File
@@ -12,12 +12,12 @@
{
"name": "02-reader",
"label": "阅读页",
"what": "顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸"
"what": "顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章"
},
{
"name": "03-reader-no-chrome",
"label": "阅读页 · 收起工具栏",
"what": "顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码"
"what": "顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数"
},
{
"name": "04-panel-toc",
@@ -48,9 +48,19 @@
"name": "09-panel-txt",
"label": "TXT · 正文排版",
"what": "纯文本专属的缩进/段间距/编码三行,且面板仍是半屏"
},
{
"name": "10-reader-paged-scrub",
"label": "阅读页 · 滑杆拖到本章第 3 页",
"what": "分栏模式下拖动底部滑杆:读数与画面都停在本章内,不换章"
}
],
"results": [
{
"name": "展开工具栏: 快捷按钮与两条栏一起出现",
"ok": true,
"detail": "visible=true 按钮 4 个 pointer-events=auto"
},
{
"name": "收起工具栏: 顶栏与底栏一起走",
"ok": true,
@@ -67,9 +77,9 @@
"detail": "正文 1..844 / 视口 844"
},
{
"name": "收起工具栏: 右侧仍有快捷按钮",
"name": "收起工具栏: 快捷按钮跟着两条栏一起收",
"ok": true,
"detail": "rail=true"
"detail": "visible=false pointer-events=none"
},
{
"name": "收起工具栏: 保留章节与页码指示",
@@ -104,13 +114,28 @@
{
"name": "底栏: 进度是可拖动的滑杆",
"ok": true,
"detail": "type=range 宽 304px label=\"阅读进度\""
"detail": "type=range 宽 304px label=\"章节内页数\""
},
{
"name": "底栏: 有页码与阅读进度读数",
"ok": true,
"detail": "page=\"第 1/4 页\" nav=\"阅读进度:0%\""
},
{
"name": "底栏: 滑杆量的是章节内页数,不是全书百分比",
"ok": true,
"detail": "min=1 max=4 value=1 读数=\"第 1/4 页\"(章节共 4 页)"
},
{
"name": "底栏: 拖动滑杆切到对应页",
"ok": true,
"detail": "拖到 4 后读数=\"第 4/4 页\""
},
{
"name": "翻页模式: 滑杆拖到第 3 页,仍在本章",
"ok": true,
"detail": "min=1 max=4 value=3 拖前=\"第 4/4 页\" 拖后=\"第 3/4 页\""
},
{
"name": "状态提示不推动正文",
"ok": true,
+26 -14
View File
@@ -307,13 +307,13 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- **服务端**(`server/src/indexer/formats/text.ts`):只做一件事——把**整章
交出来**。`chapter:` 是为流式读取开的窗口,按 `size` 截断是它的功能;
`chapter-html:<n>` 是给读者整章读的,**不截断**,内容类型是
`chapter-full:<n>`(旧名 `chapter-html:<n>`,仍兼容)是给读者整章读的,**不截断**,内容类型是
`text/plain`。它**不再断段、不再提升标题、不再输出 `<p>`**:断段是阅读
决定,服务端做就等于让 windowed 那一份有段落、流式那一份没有,也让读者的
缩进设置变成一次往返。
- **客户端排版**(`web/src/formats/segments.ts`):`splitTextParagraphs` 是
「一段是什么」的**唯一定义**,本地加载的 TXT、windowed 的
`chapter-html:<n>`、流式的 `chunk:` 三条路都走它,所以同一本书不会因为走哪条
`chapter-full:<n>`、流式的 `chunk:` 三条路都走它,所以同一本书不会因为走哪条
传输看起来不一样。它同时负责**去掉段前空格**(`  正文` 里那个全角空格是
「段前空格」的来源,留着就是和读者的缩进**叠加**),以及**章节标题行提升成
`<h3>`**(`splitChapterHeading`,否则正文会变成 `第二章 落雨雨来了。`)。
@@ -338,7 +338,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
`--reader-txt-para-gap`),所以调整不需要重新注入章节、也不需要请求。
引用保持 `chapter:<n>` 不变,`format: 'html'` 字段告诉客户端取哪一份
(`renditionRef` 据此把它换成不截断的 `chapter-html:<n>`)。`href` 同时是
(`renditionRef` 据此把它换成不截断的 `chapter-full:<n>`)。`href` 同时是
**阅读位置的标识**,改它会把每个现有 TXT 读者打回第一章。
---
@@ -359,10 +359,12 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- **`visibility` 在动画结束后才生效**(`transition: visibility 0s linear
var(--motion-duration)`):滑出的过程中标题还在,动画走完它就既看不见也点不
到了。少写这一行,读者能点到一个看不见的返回按钮。
- **收起时右侧出现快捷按钮列**(`.reader-rail`)。没有它,收起工具栏就是一个
「必须离开才能操作」的状态:改个字号要「点回来 → 调 → 再点走」三次交互,
而正文在每一次下面重排。有了它,读者最常用的四个动作永远一步可达,而且按钮
住在正文的版心旁边(宽屏时用 `--reader-measure` 算出的天头地脚里),不压字。
- **右侧的快捷按钮列和两条栏一起出现、一起收起**(`.reader-rail`),由同一个
`data-chrome` 属性开关,和顶栏底栏共用一套动效。读者最常用的四个动作(目录 /
主题 / 字号 / 设置)一步可达,而且按钮住在正文的版心旁边(宽屏时用
`--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:那四个动作
和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在正文右
边缘的按钮,就不是读者要的「只剩正文」。
- **收起时正文一角仍留两个读数**(`.reading-indicator`):当前章节名在左上、
页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏
就是要看正文,但**仍然要知道自己在哪一页**,让他为此再点一下正是收起状态要
@@ -377,11 +379,17 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
眼睛去的地方是正文角上,不是工具栏。按钮只保留这个屏幕**真能做到**的动作
(返回 / 目录 / 设置):参照稿的「首页/书架/书源」里有阅读页内部走不到的目的地,
一个点了没处的按钮比少一个按钮更糟。
- **底栏**是两行(`.progress-row` + `.chapter-nav`):一行可拖动的进度滑杆加页码
读数,一行「上一章 / 阅读进度 / 下一章」。滑杆取代了原来那条 3px 的进度条——
条只能**报告**位置,读者想到一章末尾得连点四十次「下一页」;`input[type=range]`
一个手势就到,而且拇指、轨道、拖动惯性和无障碍(方向键、`aria-valuenow`)都是
原生的,不用自己实现一遍。
- **底栏**是两行(`.progress-row` + `.chapter-nav`):一行可拖动的**章节内页数**
滑杆加页码读数,一行「上一章 / 阅读进度 / 下一章」。滑杆取代了原来那条 3px 的
进度条——条只能**报告**位置,读者想到一章末尾得连点四十次「下一页」;
`input[type=range]` 一个手势就到,而且拇指、轨道、拖动惯性和无障碍(方向键、
`aria-valuenow`)都是原生的,不用自己实现一遍。
- **滑杆量的是章节内的页**(`min=1`、`max=chapterPages`),不是全书百分比。
滑杆旁边写的是「第 3/9 页」,两者必须来自同一个测量,否则一次拖动会落在
读数从没提过的位置上——这正是最初「滑杆拖到中间却跳到第 40 章」的成因。
全书进度是下面那行「阅读进度:xx%」的事,那行**只报告**,不承载手势。
所以拖动滑杆**不会跨章**:它只移动当前章节内的位置(见
`ReaderView.seekPageInChapter`)。
手势上,**中间三分之一在两个方向上都是开关**;左右三分之一只在 chrome 已经
收起时才充当「叫回来」的出口(§3.6 的优先级第 2 条)。收起状态下任何一条区都
@@ -450,14 +458,18 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
| 页数按内容高度算 | ✅ 画出了 `本章 x/y 页` | 翻到最后一页时数字不认账 |
| 状态行是 flex 里的一行 | ✅ 文案正确渲染 | 每次同步都把他正在读的那句推走 |
| 图标按钮自己拼的 | ✅ 点击有反应 | 图标和文字差半个字高 |
| 底栏滑杆量的是全书百分比 | ✅ 拖了会动、页码也变了 | 想翻到本章第 7 页,被带到了第 40 章 |
| 快捷按钮列只在沉浸态出现 | ✅ 收起工具栏后按钮还在 | 收起后正文右边仍压着一列浮层 |
所以 `npm run ui:review` 做两件事,两件都要:
1. **截图**(`docs/ui-review/*.png`)。这一步没有断言可以替代。「只是丑」不是
测试失败,但它是缺陷。截图交给评审的人看。
2. **量尺寸**(同一脚本,断言)。可以量的事情不该让人用眼睛判断,而且评审的人
不在流水线里。当前量的是:面板不遮住整页且留出 ≥120px 正文、面板有遮罩和
把手、状态提示不推动正文、每个控件 ≥28px 且有名字、没有横向溢出。
不在流水线里。当前量的是:顶栏/底栏/快捷列三条 band 一起收放、收起时正文占满
且只留两个读数、面板不遮住整页且留出 ≥120px 正文、面板有遮罩和把手、滑杆量
的是章节内页数并且拖到第 N 页就停在第 N 页、状态提示不推动正文、每个控件
≥28px 且有名字、没有横向溢出、翻页不再成对发 sync。
它跑在**真实的生产产物**上:`npm run build` 之后由 `tools/ui-review/server.mjs`
起一个替身 API,再让真实 Chromium 以 390×844、2x 逐屏加载。不是 storybook——
+8 -7
View File
@@ -1,5 +1,5 @@
/**
* The `chapter-html:<n>` rendition: one chapter, whole, and nothing else.
* The `chapter-full:<n>` rendition: one chapter, whole, and nothing else.
*
* This file used to be a typesetting pass — it inferred paragraph boundaries,
* promoted a heading and emitted `<p>` elements. It is not one any more, and the
@@ -8,7 +8,7 @@
* *reading* decisions. A server that baked them into a response made every one of
* them a round trip, and it meant the same file could be typeset two different
* ways depending on which transport happened to deliver it (the windowed
* `chapter-html:` path got markup; the streamed `chapter:` path got characters and
* `chapter-full:` path got markup; the streamed `chapter:` path got characters and
* no paragraphs at all).
*
* So the server's job has shrunk to the one thing only it can do: hand over the
@@ -16,11 +16,12 @@
* `web/src/formats/segments.ts`, which is the single definition of what a paragraph
* is for a TXT and is shared by every path a text can reach the reader through.
*
* The reference is kept (`chapter-html:` rather than deleting it and rewriting the
* client's `renditionRef`), because it is opaque and format-specific by design: a
* client that still asks for `chapter-html:` must keep working, and it does — it
* now receives the same bytes as `chapter:`. Removing it would have been a
* breaking change to a published reference for no gain.
* The reference itself is what this file is named after, and it has two spellings
* while they migrate: `chapter-full:` is the name, and `chapter-html:` is the old
* one, still answered. The name matters because a reference is opaque and
* format-specific by design — a client that understands neither falls back to
* `chapter:` and keeps working, and a client that was shipped asking for
* `chapter-html:` keeps getting the same bytes while it is replaced.
*/
/** Escapes the five characters that would otherwise let a file rewrite the page. */
+41 -15
View File
@@ -47,12 +47,27 @@ const CHAPTER_PATTERNS: readonly RegExp[] = [
];
/**
* The asset reference prefix for a chapter rendered as markup.
* The asset reference prefix for one whole chapter.
*
* A second prefix rather than a new `kind` on the manifest: the reference is
* opaque and format-specific by design, so a client that does not understand
* `chapter-html:` keeps working through `chapter:` unchanged.
* `chapter-full:` keeps working through `chapter:` unchanged.
*
* It was called `chapter-html:` when the server rendered `<p>` markup on this
* reference. It no longer renders anything — paragraph splitting moved to the
* client (`web/src/formats/segments.ts`) and this response is plain characters —
* so the name was the last thing left saying "html", and a name that lies about
* its own content is how the *next* change gets it wrong.
*
* The old name is still accepted as an alias. The reference is stored in a
* client's offline cache and in a saved reading position, and breaking it would
* cost an existing TXT reader their chapter rather than their place: an app that
* drops a reference its own client asked for last week is one that fails offline
* on the second launch. Answering both names costs one comparison.
*/
const CHAPTER_FULL_REF = 'chapter-full:';
/** The previous name for {@link CHAPTER_FULL_REF}, answered for compatibility. */
const CHAPTER_HTML_REF = 'chapter-html:';
/** A heading has to be short; a 200-character line is prose, not a title. */
@@ -207,22 +222,28 @@ export const textHandler = registerFileHandler({
/**
* Body text. Three addressing modes, because clients need all three:
* - `chapter-html:<n>` one whole chapter, as the characters it is stored as
* - `chapter-full:<n>` one whole chapter, as the characters it is stored as
* - `chapter:<n>` the same chapter, capped at `size` (a streaming window)
* - `chunk:<byteOffset>` for streaming a novel with no headings
*
* Not one of them carries the reader's typography, and not one of them carries
* paragraph boundaries either. `chapter-html:` used to render `<p>` markup on
* the server; it no longer does, because paragraph splitting is a *reading*
* decision (see `web/src/formats/segments.ts`) and doing it here meant the
* windowed path got paragraphs while the streamed path could not, and meant the
* reader's indent was a round trip. What the server owes the reader is the
* chapter entire; the client typesets it.
* paragraph boundaries either. `chapter-full:` used to render `<p>` markup on
* the server (under its old name, `chapter-html:`); it no longer does, because
* paragraph splitting is a *reading* decision (see
* `web/src/formats/segments.ts`) and doing it here meant the windowed path got
* paragraphs while the streamed path could not, and meant the reader's indent
* was a round trip. What the server owes the reader is the chapter entire; the
* client typesets it.
*
* `chapter-html:` is therefore now an alias for the whole chapter as plain
* characters, with the one difference that it is *not* capped: `chapter:` bounds
* its reply because it is a window for streaming, and bounding a chapter the
* reader is about to read would hand them a chapter that stops mid-sentence.
* `chapter-full:` is therefore the whole chapter as plain characters, with the
* one difference from `chapter:` that it is *not* capped: `chapter:` bounds its
* reply because it is a window for streaming, and bounding a chapter the reader
* is about to read would hand them a chapter that stops mid-sentence.
*
* `chapter-html:<n>` is the old name and is still answered identically. The
* reference has been asked for by shipped clients and is stored in their
* offline caches, so dropping it would strand a reader rather than improve
* anything — the name is not part of the API's meaning, only of its history.
*/
async asset(ctx: HandlerContext, req): Promise<AssetPayload> {
const size = Math.min(MAX_CHUNK_BYTES, DEFAULT_CHUNK_BYTES);
@@ -231,8 +252,13 @@ export const textHandler = registerFileHandler({
// The whole chapter, uncapped. See the method comment: `chapter:` bounds its
// reply because it is a streaming window, and that bound must not be applied
// to a chapter the reader is about to read in one piece.
if (req.ref.startsWith(CHAPTER_HTML_REF)) {
const index = Number.parseInt(req.ref.slice(CHAPTER_HTML_REF.length), 10);
const wholeChapterRef = req.ref.startsWith(CHAPTER_FULL_REF)
? CHAPTER_FULL_REF
: req.ref.startsWith(CHAPTER_HTML_REF)
? CHAPTER_HTML_REF
: '';
if (wholeChapterRef) {
const index = Number.parseInt(req.ref.slice(wholeChapterRef.length), 10);
const { chapters, lines } = splitChapters(text);
const chapter = chapters[index];
if (!chapter) throw new Error(`chapter ${index} is out of range`);
+26 -5
View File
@@ -645,7 +645,7 @@ describe('reading endpoints per format', () => {
assert.ok(!res.body.includes('风来了'), 'chapter content must not bleed into the next chapter');
});
test('the chapter-html reference hands over the whole chapter, unmarked up', async () => {
test('the chapter-full reference hands over the whole chapter, unmarked up', async () => {
// This reference used to answer with server-rendered `<p>` markup. It no
// longer does: paragraph boundaries are a *reading* decision, and rendering
// them here meant the reader's indent was a round trip and a windowed chapter
@@ -656,7 +656,7 @@ describe('reading endpoints per format', () => {
const book = await findBook('小说');
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${book.id}/assets?ref=${encodeURIComponent('chapter-html:1')}`,
url: `/api/v1/books/${book.id}/assets?ref=${encodeURIComponent('chapter-full:1')}`,
headers: auth(),
});
assert.equal(res.statusCode, 200, res.body);
@@ -667,9 +667,30 @@ describe('reading endpoints per format', () => {
assert.ok(!res.body.includes('风来了'), 'chapter content must not bleed into the next chapter');
});
test('chapter-html is not capped the way the streaming chapter reference is', async () => {
test('the old chapter-html name still answers, identically', async () => {
// The reference is stored in a shipped client's offline cache and asked for by
// name, so dropping the old spelling would strand a reader on a chapter their
// own client still asks for. Both names have to answer the same bytes, and the
// assertion is on equality rather than on "both 200": an alias that answers a
// *different* body is worse than a missing one, because it is invisible.
const book = await findBook('小说');
const refs = ['chapter-full:1', 'chapter-html:1'];
const bodies = [];
for (const ref of refs) {
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${book.id}/assets?ref=${encodeURIComponent(ref)}`,
headers: auth(),
});
assert.equal(res.statusCode, 200, `${ref}: ${res.body}`);
bodies.push(res.body);
}
assert.equal(bodies[0], bodies[1], 'the old name must answer the same chapter');
});
test('chapter-full is not capped the way the streaming chapter reference is', async () => {
// `chapter:` is a *window* for streaming, so bounding its reply is the
// feature. `chapter-html:` is a chapter the reader is about to read in one
// feature. `chapter-full:` is a chapter the reader is about to read in one
// piece, so bounding it would hand them a chapter that stops mid-sentence.
const long = Array.from({ length: 4000 }, (_, i) => `第 ${i} 行的内容,足够长以越过流式窗口。`).join('\n');
const target = join(booksDir, '超长章节.txt');
@@ -678,7 +699,7 @@ describe('reading endpoints per format', () => {
const book = await findBook('超长章节');
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${book.id}/assets?ref=${encodeURIComponent('chapter-html:0')}`,
url: `/api/v1/books/${book.id}/assets?ref=${encodeURIComponent('chapter-full:0')}`,
headers: auth(),
});
assert.equal(res.statusCode, 200, res.body);
+1 -1
View File
@@ -202,7 +202,7 @@ export function chunkByLength(text: string, maxLength: number): Chunk[] {
* Kept as a named export because the loader has always had one and the name is
* what the tests look for; the work itself moved to `segments.ts`, which is the
* single definition of what a paragraph is for a TXT — shared with the server's
* `chapter-html:` rendition's consumer and with the text reader, so a book cannot
* `chapter-full:` rendition's consumer and with the text reader, so a book cannot
* be typeset one way locally and another way when it arrives windowed.
*/
export const textToHtml = textToChapterHtml;
+41 -8
View File
@@ -1926,12 +1926,17 @@ book-content {
text-overflow: ellipsis;
}
/* ---- immersion rail ----
/* ---- quick-action rail ----
*
* The column of controls that appears when the chrome is hidden.
* The column of controls that shows and hides **with** the two bars, not instead of
* them. It used to be drawn only while the chrome was hidden, which made it the one
* piece of chrome that survived immersion: a reader who tapped to get "just the
* page" still had a floating column over the right edge of the text. The rail holds
* the same destinations the top bar names (目录 / 主题 / 字号 / 设置), so it belongs to
* the same state — the reader has asked for the controls — and it goes with them.
*
* Positioned in the *page margin* rather than over the text, which is the reason it
* can stay on screen while a reader is reading: the reading column is capped by
* Positioned in the *page margin* rather than over the text, which is why it can be
* on screen while a reader is reading: the reading column is capped by
* `--reader-measure`, so on anything wider than a phone there is a genuine gutter to
* put it in. On a phone there is not, and the rail floats over the right edge of the
* text — deliberately, because the alternative is not having the controls at all,
@@ -1942,6 +1947,12 @@ book-content {
* down, which is where the thumb of a hand holding a phone at the bottom lands and
* where a hand holding a tablet at the side reaches. It is not centred, because the
* centre of the screen is where the reading is.
*
* The collapse is the *same* mechanism as the bars': a height/opacity/visibility
* transition gated on `data-chrome`, so the three bands cannot disagree about
* whether the chrome is on screen. `animation` for entry was replaced by a
* transition for the same reason: an animation cannot be reversed mid-flight, so a
* reader who tapped twice quickly left the rail visible over a hidden toolbar.
*/
.reader-rail {
@@ -1961,12 +1972,34 @@ book-content {
flex-direction: column;
gap: var(--space-1);
padding: 0.2rem;
animation: rail-in var(--motion-duration) var(--motion-ease);
opacity: 1;
transform: none;
transition:
opacity var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-ease),
visibility 0s linear;
}
@keyframes rail-in {
from { opacity: 0; transform: translateX(0.5rem); }
to { opacity: 1; transform: none; }
/*
* Hidden: it slides out to the right and fades, the direction its own entry motion
* came from, so a reader who taps twice sees one movement rather than two.
*
* Gated on the screen's own `data-chrome`, exactly like `.topbar` and `.footer`.
* One attribute, three bands: they cannot disagree about whether the chrome is on
* screen, which is the failure mode ("only the toolbar collapsed") that a second
* source of truth produces. `visibility: hidden` lands at the *end* of the fade so
* a control that is on its way off the screen cannot be tapped or reached by a
* screen reader, and `pointer-events: none` covers the interval.
*/
.reader-screen[data-chrome='hidden'] .reader-rail {
visibility: hidden;
opacity: 0;
transform: translateX(0.5rem);
pointer-events: none;
transition:
opacity var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-ease),
visibility 0s linear var(--motion-duration);
}
.rail-button {
+66 -37
View File
@@ -119,8 +119,15 @@ export interface ChromeHandlers {
onSwitchEngine(kind: AppSettings['ttsEngine']): void;
onTocEntry(ref: string): void;
onChapter(delta: 1 | -1): void;
/** Jump to a fraction of the whole book, from the footer scrubber. */
onScrub(fraction: number): void;
/**
* Jump to a page *inside the current chapter*, from the footer scrubber.
*
* A page number rather than a fraction, because a page is what the reader is
* choosing: the readout beside the thumb says "第 3/9 页", and a slider that
* moved by whole-book fraction would land them somewhere the readout never
* names.
*/
onScrubPage(page: number): void;
onTurnPage(direction: 'next' | 'previous'): void;
onSpeechToggle(): void;
onSpeechPrevious(): void;
@@ -202,53 +209,75 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
</div>
) : null}
{/* The immersion rail.
{/* The quick-action rail.
—
A column of controls on the right edge, drawn *only* while the chrome is
hidden. It is what makes hiding the chrome a usable state rather than a
state the reader has to leave in order to do anything: without it, the
only way to change the font size or open the contents is to tap back to
the toolbar, do the thing, and tap again — three interactions for one
adjustment, and the text moves under the reader on every one of them.
With it, the controls the reader reaches for most are always one tap away
and never cover the column, because the rail lives in the page margin
beside the text rather than over it.
The rail is *not* drawn while the chrome is visible. A rail plus a
toolbar is two competing sets of the same controls, and a reader who can
see the toolbar has already found what they were looking for. */}
{!state.chromeVisible ? (
<div className="reader-rail" role="toolbar" aria-label="阅读快捷操作" aria-orientation="vertical">
<RailButton icon="menu" label="目录" onClick={handlers.toggleToc} />
<RailButton
icon={state.theme === 'dark' ? 'sun' : 'moon'}
label={state.theme === 'dark' ? '日间' : '夜间'}
onClick={() => handlers.onSetting({ theme: nextTheme(state.theme) })}
/>
<RailButton icon="text-type" label="字号" onClick={() => handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })} />
<RailButton icon="sliders" label="阅读设置" onClick={handlers.toggleSettings} />
</div>
) : null}
A column of controls on the right edge, shown **and hidden with the two
bars**. That pairing is the request, and it is also the coherent reading
of what the rail is for: the rail is a second way to reach the same
destinations the top bar names (目录 / 主题 / 字号 / 设置), so it belongs to
the same "the reader has asked for the controls" state as the bars. Drawn
while the chrome is hidden, it was the one piece of chrome that survived
immersion — a reader who tapped to hide the navigation still had seven
controls floating over the right edge of the text, which is not the
"only the page" they asked for.
It is still *not* the toolbar's duplicate: the rail holds the four
controls a reader nudges while reading (contents, theme, size, settings)
and is a floating column in the page margin rather than a band, so it
covers no line of text on a wide screen.
Drawn unconditionally and collapsed by `data-chrome` in CSS, exactly like
the two bars: `visibility: hidden` at the end of the fade is what takes it
out of reach of both a finger and a screen reader, and one attribute for
three bands is what makes "the chrome is hidden" a single fact. */}
<div
className="reader-rail"
role="toolbar"
aria-label="阅读快捷操作"
aria-orientation="vertical"
>
<RailButton icon="menu" label="目录" onClick={handlers.toggleToc} />
<RailButton
icon={state.theme === 'dark' ? 'sun' : 'moon'}
label={state.theme === 'dark' ? '日间' : '夜间'}
onClick={() => handlers.onSetting({ theme: nextTheme(state.theme) })}
/>
<RailButton icon="text-type" label="字号" onClick={() => handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })} />
<RailButton icon="sliders" label="阅读设置" onClick={handlers.toggleSettings} />
</div>
{/* The bottom bar.
—
Two rows, and the split is the reference's: a scrubber the reader can
drag to any page, then a navigation row with the chapter buttons at the
ends and the reading progress in the middle. The scrubber replaces the
old 3px bar plus the "本章 x/y 页" readout — a bar that only reports could
not be used to *go* anywhere, and the reader who wanted the end of the
chapter had to tap the next button forty times. */}
drag to any page **of this chapter**, then a navigation row with the
chapter buttons at the ends and the reading progress in the middle. The
scrubber replaces the old 3px bar plus the "本章 x/y 页" readout — a bar
that only reports could not be used to *go* anywhere, and the reader who
wanted the end of the chapter had to tap the next button forty times.
It is scoped to the chapter, not the book: the readout beside it says
"第 3/9 页", and the reference's own screenshot shows the thumb near the
middle at page 25 of 32 — a book-wide bar would be two thirds of the way
across and blank in a chapter that is one of a thousand. The whole-book
number is still on screen, as the "阅读进度" readout below, which is the
row that *reports*; the slider is the row that *moves*. */}
<div className="footer" hidden={!state.chromeVisible}>
<div className="progress-row">
<input
className="progress-scrubber"
type="range"
min={0}
max={100}
min={1}
max={Math.max(1, state.chapterPages)}
step={1}
value={Math.round(Math.min(1, Math.max(0, state.progress)) * 100)}
aria-label="阅读进度"
// The *page*, not the whole-book fraction. The readout beside the thumb
// names a page in this chapter, so the control has to move by the same
// unit; a book-percentage slider next to "第 3/9 页" is two answers to
// two different questions sharing one thumb.
value={Math.min(Math.max(1, state.pageInChapter), Math.max(1, state.chapterPages))}
aria-label="章节内页数"
aria-valuetext={
state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页` : '章节内页数'
}
disabled={state.chapterPages <= 1}
onInput={(event) =>
handlers.onScrub(Number((event.currentTarget as HTMLInputElement).value) / 100)
handlers.onScrubPage(Number((event.currentTarget as HTMLInputElement).value))
}
/>
<span className="progress-page">
+18 -33
View File
@@ -1,4 +1,5 @@
import { ApiError } from '../api/errors.ts';
import { renditionRef } from './rendition.ts';
import type { ReaderApi } from '../api/client.ts';
import type { Book, BookContent, Manifest, Note } from '../api/types.ts';
import { loadBook, isImagePath, extensionOf } from '../formats/index.ts';
@@ -214,7 +215,7 @@ export class ReaderScreen {
onSwitchEngine: (kind) => this.switchSpeechEngine(kind),
onTocEntry: (ref) => void this.goToChapterRef(ref),
onChapter: (delta) => void this.goToChapter(delta),
onScrub: (fraction) => void this.scrubTo(fraction),
onScrubPage: (page) => void this.scrubToPage(page),
onTurnPage: (direction) => void this.turnPage(direction),
onSpeechToggle: () => this.toggleSpeech(),
onSpeechPrevious: () => void this.tts?.previous(),
@@ -981,22 +982,30 @@ export class ReaderScreen {
}
/**
* Jumps to a fraction of the whole book, from the footer's scrubber.
* Jumps to a page in the current chapter, from the footer's scrubber.
*
* The slider is `input[type=range]`, which fires on every pixel of a drag, so
* this is deliberately the *cheap* path: `seekPercentage` asks the view to open
* the section at that fraction, and the view's own position reporting feeds the
* numbers back through the same `onPosition` every other movement uses. There is
* no separate "scrubbing" state to get out of sync with — the reader drags, the
* book follows, and letting go leaves them where they let go.
* this is deliberately the *cheap* path: `seekPageInChapter` moves the reader
* inside the chapter already open, and the view's own position reporting feeds
* the numbers back through the same `onPosition` every other movement uses.
* There is no separate "scrubbing" state to get out of sync with — the reader
* drags, the page follows, and letting go leaves them where they let go.
*
* A page, not a whole-book fraction: the readout beside the thumb and the
* positions the page turns land on are both pages *of this chapter*, and only a
* slider in the same unit stays in agreement with them. Reaching this control
* also no longer crosses a chapter boundary — a drag cannot silently navigate —
* because a slider whose thumb sits at 3% while the reader watches the book
* jump from chapter 40 to chapter 400 is a slider that answers a question
* nobody asked.
*
* Position writes are untouched on purpose: `onPosition` already debounces the
* network write by 1.5s, so a drag does not produce a request per frame.
*/
private async scrubTo(fraction: number): Promise<void> {
private async scrubToPage(page: number): Promise<void> {
const view = this.view;
if (!view) return;
await view.seekPercentage(fraction);
await view.seekPageInChapter(page - 1);
// The drag can end between two scroll events, and a book with no reported
// position after the seek would leave the bar showing the pre-drag value.
this.pendingPosition = view.position();
@@ -1662,30 +1671,6 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return copy;
}
/**
* The reference to fetch for an item's rendition.
*
* The mapping lives here rather than being baked into the server's `href`, because
* `href` is also the section id and therefore the identity a saved reading
* position is matched on: changing it would move every existing TXT reader back to
* chapter one. Prefixing on the client keeps old positions resolving and new
* chapters rendering.
*
* `chapter-html:` is `chapter:` without the streaming cap — one whole chapter for a
* reader who is about to read it in one piece. It used to mean "server-rendered
* markup" as well; that half is gone (the client typesets, see
* `web/src/formats/segments.ts`) and the reference name is kept because it is the
* identity a cached section and a saved position are keyed on, and because a
* client that asks for it must keep working. An item that does not declare `html`
* is fetched exactly as the server named it, which is the behaviour every other
* format already relies on.
*/
function renditionRef(item: { href: string; format?: string }): string {
if (item.format !== 'html') return item.href;
if (item.href.startsWith('chapter:')) return `chapter-html:${item.href.slice('chapter:'.length)}`;
return item.href;
}
/**
* True when the reader has selected text inside the reading surface.
*
+35 -11
View File
@@ -486,15 +486,39 @@ export class ReaderView {
return this.stepSection(-1, true);
}
/** Jumps to a fraction of the whole book, used by the progress slider. */
async seekPercentage(percentage: number): Promise<void> {
const clamped = Math.min(1, Math.max(0, percentage));
const index = Math.min(
this.doc.sections.length - 1,
Math.floor(clamped * this.doc.sections.length),
);
const remainder = clamped * this.doc.sections.length - index;
await this.open(index, remainder);
/**
* Jumps to a 0-based page *inside the current chapter*.
*
* This is the footer scrubber's move, and the distinction is the whole point of
* it: a reader dragging the slider is asking "show me page 7 of this chapter",
* not "show me 40% of the book". Those two questions are answered by two
* different measurements, and a control that answers the wrong one moves the
* reader to a different chapter than the one whose page number is written
* beside its thumb — which is exactly the report this method closes. There is
* deliberately no whole-book seek left on this view: the only caller it had was
* the slider, and a second seek with the *wrong* semantics is a trap for the
* next change rather than an API.
*
* Both modes go through the same page arithmetic the page turns use
* (`stepColumn`/`stepScreen`), so the position a drag lands on is a position
* the next press will move *from*, and the counter beside the slider names it.
* A target outside the chapter is clamped rather than followed: the slider is
* bounded by `chapterPages`, and a jump to page 900 of a 9-page chapter would
* have to invent a destination.
*/
async seekPageInChapter(page: number): Promise<void> {
if (this.doc.layout === 'fixed') return;
if (this.settings.mode === 'paged') {
const columns = this.columnCount();
const target = Math.min(columns - 1, Math.max(0, Math.round(page)));
this.scroller.scrollLeft = target * this.columnStride(columns);
this.emitPosition();
return;
}
const total = this.screenCount();
const target = Math.min(total - 1, Math.max(0, Math.round(page)));
this.scroller.scrollTop = this.screenOffset(target);
this.emitPosition();
}
dispose(): void {
@@ -545,7 +569,7 @@ export class ReaderView {
*
* The input is the *characters* of the chapter, however they arrived:
*
* - a `chapter-html:<n>` body fetched by the windowed path, which is that
* - a `chapter-full:<n>` body fetched by the windowed path, which is that
* book's text and nothing else (the server stopped typesetting — see
* `server/src/indexer/formats/text-html.ts`); or
* - a body from a server that still renders markup, which is handled by
@@ -630,7 +654,7 @@ export class ReaderView {
// Whether the server's rendition arrived with paragraphs or as one slab, this
// is where the reader's own split runs — so the indent, the paragraph spacing
// and the removal of a scraper's leading spaces apply to *both* of the two ways
// a TXT reaches this view (`chapter-html:<n>` windowed, `chapter:<n>` streamed)
// a TXT reaches this view (`chapter-full:<n>` windowed, `chapter:<n>` streamed)
// without a second request, and the settings panel's rows do something on
// whichever one the reader happens to be looking at.
const raw = this.retypePlainText(section);
+40
View File
@@ -0,0 +1,40 @@
/**
* The reference a section is fetched under.
*
* Kept out of the screen on purpose: it is a *mapping between two namespaces* — the
* server's `href`, which is also the section id and therefore the identity a saved
* position and an offline cache entry are keyed on, and the `ref` this client asks
* the asset endpoint for. Getting it wrong is not a rendering bug; it is a chapter
* that comes back as the wrong bytes, or an offline reader whose cache never hits.
*
* The reason it exists at all: for a TXT the server names *two* renditions under one
* `href` — a capped streaming window (`chapter:<n>`) and the whole chapter
* (`chapter-full:<n>`) — and the client has to choose. Baking the choice into the
* server's `href` would change the id, and every existing reader's saved position
* with it; deciding it here keeps old positions resolving and new chapters whole.
*
* `chapter-full:` used to be `chapter-html:`, back when this reference also meant
* "server-rendered markup". That half is gone (the client typesets, see
* `formats/segments.ts`) and only the name still claimed otherwise. The old spelling
* is still answered by the server, so a client and a server on opposite sides of the
* rename both work; this client asks under the new one.
*
* Because the reference is also the offline cache key, the new name gets a new cache
* entry. That is intended: a body cached under the old name was fetched when the old
* name could have meant markup, and reusing it would be trusting a response's
* *label* over its content. One re-fetch per chapter, once.
*/
/**
* The reference to fetch for an item's rendition.
*
* `chapter-full:` is `chapter:` without the streaming cap — one whole chapter for a
* reader who is about to read it in one piece. An item that does not declare `html`
* is fetched exactly as the server named it, which is the behaviour every other
* format already relies on.
*/
export function renditionRef(item: { href: string; format?: string }): string {
if (item.format !== 'html') return item.href;
if (item.href.startsWith('chapter:')) return `chapter-full:${item.href.slice('chapter:'.length)}`;
return item.href;
}
+83
View File
@@ -295,3 +295,86 @@ describe('page turns in paged mode', () => {
}
});
});
/**
* The footer scrubber, in the unit the footer reads out.
*
* The report is specific: the slider under the readout moved the *book*, not the
* page, so dragging it to one place left the reader in another chapter than the
* "第 3/9 页" beside its thumb. These assertions are written against the two things
* that have to agree — where a drag lands, and where the next page turn goes from.
*/
describe('seeking a page inside the chapter', () => {
let container: HTMLDivElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.append(container);
});
it('lands on the page the readout names, in scroll mode', async () => {
const view = scrollingView(container, 4);
await view.open(1, 0);
const total = view.position().chapterPages;
expect(total).toBe(4);
for (let page = 1; page <= total; page += 1) {
await view.seekPageInChapter(page - 1);
expect(view.position().pageInChapter).toBe(page);
// Still in the chapter the reader was in: a scrub is not navigation.
expect(view.currentSectionIndex()).toBe(1);
}
});
it('lands on the page the readout names, in paged mode', async () => {
const view = pagedView(container, 4);
await view.open(1, 0);
expect(view.position().chapterPages).toBe(4);
for (let page = 1; page <= 4; page += 1) {
await view.seekPageInChapter(page - 1);
expect(view.position().pageInChapter).toBe(page);
expect(view.currentSectionIndex()).toBe(1);
}
});
it('leaves the next turn stepping from where the drag landed', async () => {
// The half that a "does the drag move the page" check misses: the scrub and
// the page turn have to share one arithmetic, or the reader drags to page 3
// and the next press jumps somewhere that is not page 4.
const view = scrollingView(container, 5);
await view.open(1, 0);
await view.seekPageInChapter(2);
expect(view.position().pageInChapter).toBe(3);
await view.next();
expect(view.position().pageInChapter).toBe(4);
await view.previous();
expect(view.position().pageInChapter).toBe(3);
});
it('clamps a page outside the chapter instead of following it', async () => {
// The slider is bounded by `chapterPages`, but a stale render or a chapter
// that re-paginated under a held thumb can still hand over a page number the
// chapter no longer has. Landing on the ends is the honest answer; inventing
// a destination is not.
const view = scrollingView(container, 3);
await view.open(1, 0);
await view.seekPageInChapter(99);
expect(view.position().pageInChapter).toBe(3);
expect(view.currentSectionIndex()).toBe(1);
await view.seekPageInChapter(-5);
expect(view.position().pageInChapter).toBe(1);
expect(view.currentSectionIndex()).toBe(1);
});
it('does not cross a chapter boundary, even at the last page', async () => {
// The distinction from the old whole-book seek: dragging to the last page of a
// chapter must stop there. A slider that navigated would take a reader who
// wanted the end of this chapter to the start of the next one, having asked
// for neither.
const view = scrollingView(container, 3);
await view.open(1, 0);
await view.seekPageInChapter(2);
expect(view.position().pageInChapter).toBe(3);
expect(view.currentSectionIndex()).toBe(1);
});
});
+2 -15
View File
@@ -191,20 +191,6 @@ describe('ReaderView navigation', () => {
expect(seen).toEqual(['第 1 章', '第 3 章']);
});
it('seeks to a fraction of the book', async () => {
const view = make(container, reflowableDoc(10));
await view.seekPercentage(0.55);
expect(view.currentSectionIndex()).toBe(5);
});
it('clamps a seek beyond the ends', async () => {
const view = make(container, reflowableDoc(4));
await view.seekPercentage(5);
expect(view.currentSectionIndex()).toBe(3);
await view.seekPercentage(-1);
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
@@ -517,7 +503,8 @@ describe('plain-text chapters are typeset by the reader', () => {
/**
* A chapter exactly as the server now sends it: the characters, and nothing else.
*
* `chapter-html:<n>` used to answer with server-rendered `<p>` markup. It answers
* `chapter-full:<n>` (formerly `chapter-html:<n>`) used to answer with
* server-rendered `<p>` markup. It answers
* with the text now, because paragraph boundaries are a *reading* decision — see
* `server/src/indexer/formats/text.ts`. Every assertion below is written against
* this shape, because this is the shape the reader actually receives.
+46
View File
@@ -0,0 +1,46 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { renditionRef } from '../src/ui/rendition.ts';
/**
* Which reference the reader asks the server for.
*
* It is a *rename* that these pin, and a rename is the kind of change that either
* breaks a shipped client silently or is a no-op. Two halves have to hold:
*
* - a TXT chapter keeps being asked for as one *whole* chapter (not the streaming
* `chapter:` window), under the new name; and
* - every other format is passed through untouched, because the reference is the
* server's own and is also the identity an offline cache is keyed on — a client
* that renamed an EPUB's href would ask for a chapter that does not exist.
*
* The old name (`chapter-html:`) is what shipped clients ask for; the server still
* answers it. Nothing here asserts on the old spelling beyond the fact that this
* client no longer produces it, which is what the rename is.
*/
describe('the reference a section is fetched under', () => {
it('asks for the whole chapter, under the name that says so', () => {
// The name is the point: this reference stopped being HTML when the server
// stopped typesetting, and a reference whose name lies about its content is how
// the next change reasons wrongly about it.
expect(renditionRef({ href: 'chapter:12', format: 'html' })).toBe('chapter-full:12');
});
it('never asks for the old spelling', () => {
// A regression that "works" — the server answers both — and leaves every
// offline cache keyed under a name the client no longer reads. The assertion is
// on the string because that is what the cache key and the request are.
expect(renditionRef({ href: 'chapter:0', format: 'html' })).not.toContain('chapter-html');
});
it('passes every other format through unchanged', () => {
// The reference is opaque and server-owned for everything but this one case.
// Renaming an EPUB href here is the bug this branch exists to prevent: the href
// is a file path inside the archive, and `xhtml:OEBPS/ch1.xhtml` is not a
// `chapter:<n>` anyone can prefix.
expect(renditionRef({ href: 'xhtml:OEBPS/ch1.xhtml', format: 'html' })).toBe('xhtml:OEBPS/ch1.xhtml');
expect(renditionRef({ href: 'chapter:3' })).toBe('chapter:3');
expect(renditionRef({ href: 'page:0', format: 'image' })).toBe('page:0');
expect(renditionRef({ href: 'chunk:4096' })).toBe('chunk:4096');
});
});
+198 -12
View File
@@ -39,8 +39,8 @@ const SCALE = 2;
const SCENES = [
{ name: '01-shelf', label: '书架', what: '第一屏:先看到书,再看到控件' },
{ name: '02-reader', label: '阅读页', what: '顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸', openBook: true },
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码', openBook: true, tapCenter: true },
{ name: '02-reader', label: '阅读页', what: '顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章', openBook: true },
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数', openBook: true, tapCenter: true },
{ name: '04-panel-toc', label: '目录 · 半屏', what: '下半屏,上半屏正文仍可见', openBook: true, openPanel: '目录' },
{ name: '05-panel-settings', label: '阅读设置 · 半屏', what: '一行式行:标签在左、控件在右', openBook: true, openPanel: '设置' },
{ name: '06-reader-paged', label: '阅读页 · 翻页模式', what: '分栏后的一页,页数应与可翻次数一致', openBook: true, choose: ['设置', '翻页'], closePanel: true },
@@ -53,6 +53,15 @@ const SCENES = [
openBook: true,
openPanel: '设置',
},
{
name: '10-reader-paged-scrub',
label: '阅读页 · 滑杆拖到本章第 3 页',
what: '分栏模式下拖动底部滑杆:读数与画面都停在本章内,不换章',
openBook: true,
choose: ['设置', '翻页'],
closePanel: true,
scrubPage: 3,
},
];
/** Measurements that must hold, on the scenes where they apply. */
@@ -64,20 +73,45 @@ async function audit(cdp, scenes, results) {
if (!ok) failures.push(`${name}: ${detail}`);
};
// The chrome is one state, not two.
// The chrome is one state, not two *or three*.
//
// The request behind this is specific — the top bar and the bottom bar hide
// together — and the failure it guards against is the one a reader reports as
// "只收起了一半": a header that leaves the text full height while a footer keeps
// covering the last two lines. So the collapse is asserted on *both* bands and on
// the space they occupied, in the state where the reader asked for immersion, and
// the rail is asserted to be there — hiding the chrome without it is a state the
// reader has to leave in order to do anything.
// covering the last two lines. So the collapse is asserted on both bands and on
// the space they occupied, in the state where the reader asked for immersion.
//
// The rail is the third band, and the follow-up report was that it *didn't* go
// with them: shown only while the chrome was hidden, it was the one piece of
// chrome that survived immersion, so a reader who tapped to get "just the page"
// still had a floating column over the text. It is asserted in both directions
// here — present with the bars, gone with the bars — because a control that only
// ever appears is exactly the state that was reported.
//
// Asked for the way the reader asks for it (a tap on the middle third) rather than
// by setting the attribute: the tap is also a thing that has to keep working.
await cdp.sleep(300);
const visibleChrome = await cdp.evaluate(`(() => {
const rail = document.querySelector('.reader-rail');
if (!rail) return { missing: true };
const r = rail.getBoundingClientRect();
return {
present: true,
visible: getComputedStyle(rail).visibility !== 'hidden' && r.width > 0 && r.height > 0,
buttons: rail.querySelectorAll('button').length,
pointerEvents: getComputedStyle(rail).pointerEvents,
};
})()`);
check(
'展开工具栏: 快捷按钮与两条栏一起出现',
!visibleChrome.missing && visibleChrome.visible && visibleChrome.buttons > 0 && visibleChrome.pointerEvents !== 'none',
visibleChrome.missing
? '没有找到快捷按钮列'
: `visible=${visibleChrome.visible} 按钮 ${visibleChrome.buttons} 个 pointer-events=${visibleChrome.pointerEvents}`,
);
await cdp.tapMiddle();
await cdp.sleep(400);
await cdp.sleep(700);
const hidden = await cdp.evaluate(`(() => {
const screen = document.querySelector('.reader-screen');
const topbar = document.querySelector('.topbar');
@@ -96,7 +130,16 @@ async function audit(cdp, scenes, results) {
stageTop: Math.round(s.top),
stageBottom: Math.round(s.bottom),
viewportHeight: window.innerHeight,
rail: !!document.querySelector('.reader-rail'),
rail: (() => {
const el = document.querySelector('.reader-rail');
if (!el) return null;
const r = el.getBoundingClientRect();
return {
present: true,
visible: getComputedStyle(el).visibility !== 'hidden' && r.width > 0 && r.height > 0,
pointerEvents: getComputedStyle(el).pointerEvents,
};
})(),
indicator: (() => {
const el = document.querySelector('.reading-indicator');
if (!el) return null;
@@ -127,10 +170,18 @@ async function audit(cdp, scenes, results) {
!hidden.missing && hidden.stageTop <= 1 && hidden.viewportHeight - hidden.stageBottom <= 1,
hidden.missing ? 'n/a' : `正文 ${hidden.stageTop}..${hidden.stageBottom} / 视口 ${hidden.viewportHeight}`,
);
// The rail goes with the bars, in this direction too. `visibility: hidden` is
// asserted rather than merely "height is zero", because a control a finger can
// still land on is a control that is still there — and the state being guarded
// against is precisely "the rail outlived the toolbar".
check(
'收起工具栏: 右侧仍有快捷按钮',
!hidden.missing && hidden.rail,
hidden.missing ? 'n/a' : `rail=${hidden.rail}`,
'收起工具栏: 快捷按钮跟着两条栏一起收',
!hidden.missing && hidden.rail !== null && !hidden.rail.visible && hidden.rail.pointerEvents === 'none',
hidden.missing
? 'n/a'
: hidden.rail
? `visible=${hidden.rail.visible} pointer-events=${hidden.rail.pointerEvents}`
: '没有找到快捷按钮列',
);
// Immersion keeps the *where am I* readouts. Hiding the chrome should leave a
// reader who knows which chapter they are in and how far through — not a blank
@@ -220,6 +271,9 @@ async function audit(cdp, scenes, results) {
width: Math.round(r.width),
height: Math.round(r.height),
label: el.getAttribute('aria-label') ?? '',
min: Number(el.min),
max: Number(el.max),
value: Number(el.value),
page: document.querySelector('.progress-page')?.textContent.trim() ?? '',
nav: document.querySelector('.chapter-nav .nav-progress')?.textContent.trim() ?? '',
};
@@ -236,6 +290,122 @@ async function audit(cdp, scenes, results) {
!scrubber.missing && scrubber.page.length > 0 && scrubber.nav.length > 0,
scrubber.missing ? 'n/a' : `page="${scrubber.page}" nav="${scrubber.nav}"`,
);
// The slider is in *chapter pages*, not in whole-book percent.
//
// This is the report: the readout beside the thumb said "第 8/9 页" while the
// thumb sat near the left end, because the two were counting different things —
// the readout counted pages in a nine-page chapter, and the slider counted the
// reader's position in a book of hundreds. The check is that the slider's own
// range *is* the chapter's page range, read from the same readout the reader
// sees, so the two cannot drift apart behind a passing screenshot.
const chapterPages = (() => {
const match = /\/\s*(\d+)\s*页/.exec(scrubber.page ?? '');
return match ? Number(match[1]) : 0;
})();
check(
'底栏: 滑杆量的是章节内页数,不是全书百分比',
!scrubber.missing && chapterPages > 0 && scrubber.max === chapterPages && scrubber.min === 1,
scrubber.missing
? '没有找到滑杆'
: `min=${scrubber.min} max=${scrubber.max} value=${scrubber.value} 读数="${scrubber.page}"(章节共 ${chapterPages} 页)`,
);
// And it *moves*. Setting the value through a real `input` event is what a drag
// produces, and the assertion is on the page the reader ends up on: a slider
// wired to the wrong measurement still reports the right number while failing
// this, which is the bug being closed.
const scrubMove = await cdp.evaluate(`(async () => {
const el = document.querySelector('.progress-scrubber');
if (!el || Number(el.max) < 2) return { skipped: true };
// A page in *this* chapter, not a page of the book. A slider wired to the whole
// book reaches the same visible readout while quietly navigating: the page number
// it lands on is a page of whatever chapter the drag moved the reader into. That
// is why the range assertion above is on min/max matching the chapter's own page
// count — the two have to come from the same source, which is what makes this
// drag land where the readout says.
const target = Number(el.max);
el.value = String(target);
el.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 500));
const page = document.querySelector('.progress-page')?.textContent.trim() ?? '';
return { skipped: false, want: target, page, value: Number(el.value) };
})()`);
if (scrubMove.skipped) {
check('底栏: 拖动滑杆切到对应页', true, `本章只有 ${scrubber.max} 页,这一轮跳过`);
} else {
check(
'底栏: 拖动滑杆切到对应页',
scrubMove.page.includes(`第 ${scrubMove.want}/`) && scrubMove.page.includes(`/${scrubMove.want} 页`),
`拖到 ${scrubMove.want} 后读数="${scrubMove.page}"`,
);
}
// The same drag, in **paged** mode, where the original defect was easiest to see
// and hardest to reason about: a paged surface does not scroll, so a slider wired
// to the book moves the reader by *opening a different chapter*, and the page
// number beside it then describes that other chapter.
//
// Driven here rather than left to the scene that screenshots it, because the audit
// has to hold the state while it measures: a scene runs once for the picture, and
// the mode it left the reader in is not the mode the checks below run in.
const pagedScrub = await cdp.evaluate(`(async () => {
// Switch to paged through the real control, then drag. Both are things a reader
// does; setting the setting directly would prove the slider works in a mode no
// reader could have reached.
document.querySelector('button[aria-label="设置"]').click();
await new Promise((r) => setTimeout(r, 350));
const paged = [...document.querySelectorAll('.segmented button')].find((b) => b.textContent.trim() === '翻页');
if (!paged) return { skipped: true };
paged.click();
await new Promise((r) => setTimeout(r, 350));
document.querySelector('button[aria-label="关闭"]').click();
await new Promise((r) => setTimeout(r, 400));
// The chapter the reader is in, taken from the immersion indicator's own source
// is not available here (the chrome is shown), so the *page count* stands in:
// a drag that navigated would change the chapter, and a chapter change is what
// the readout's denominator comes from.
const el = document.querySelector('.progress-scrubber');
if (!el) return { skipped: true };
const beforePage = document.querySelector('.progress-page')?.textContent.trim() ?? '';
const beforeMax = Number(el.max);
const target = Math.min(3, beforeMax);
el.value = String(target);
el.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 500));
const afterPage = document.querySelector('.progress-page')?.textContent.trim() ?? '';
return {
skipped: false,
target,
min: Number(el.min),
max: Number(el.max),
value: Number(el.value),
before: beforePage,
page: afterPage,
};
})()`);
if (pagedScrub.skipped) {
check('翻页模式: 滑杆拖到本章某页', true, '没有找到滑杆或分栏选项,这一轮跳过');
} else {
check(
`翻页模式: 滑杆拖到第 ${pagedScrub.target} 页,仍在本章`,
pagedScrub.min === 1 &&
pagedScrub.value === pagedScrub.target &&
pagedScrub.max >= pagedScrub.target &&
pagedScrub.page.includes(`第 ${pagedScrub.target}/${pagedScrub.max} 页`),
`min=${pagedScrub.min} max=${pagedScrub.max} value=${pagedScrub.value} 拖前="${pagedScrub.before}" 拖后="${pagedScrub.page}"`,
);
// Back to scroll mode. The traffic window below is about the requests a
// *reading* session makes, and a paged surface does not scroll — it would
// generate no position at all, and the check would pass on an empty window.
await cdp.evaluate(`(async () => {
document.querySelector('button[aria-label="设置"]').click();
await new Promise((r) => setTimeout(r, 350));
const scroll = [...document.querySelectorAll('.segmented button')].find((b) => b.textContent.trim() === '滚动');
if (scroll) scroll.click();
await new Promise((r) => setTimeout(r, 350));
document.querySelector('button[aria-label="关闭"]').click();
})()`);
await cdp.sleep(500);
}
// The status line must not change the page's geometry. Measured by moving it
// from empty to a message and back, and comparing the reading column's top.
@@ -513,6 +683,22 @@ async function main() {
await cdp.tapMiddle();
await cdp.sleep(300);
}
// A drag on the footer scrubber, driven through a real `input` event because
// that is what a drag produces. Captured as a screenshot for the same reason
// the other scenes are: "the slider moved the book instead of the page" is
// invisible to a check that only reads the page number — both numbers are on
// screen — and obvious in a picture of a chapter that is not the one the
// reader was in.
if (scene.scrubPage) {
await cdp.evaluate(`(() => {
const el = document.querySelector('.progress-scrubber');
if (!el) return null;
el.value = String(${scene.scrubPage});
el.dispatchEvent(new Event('input', { bubbles: true }));
return el.value;
})()`);
await cdp.sleep(600);
}
const shot = join(outDir, `${scene.name}.png`);
await cdp.screenshot(shot);