mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
合并来自 auto/reader-scrub-page-rail-861d 的合并请求 #41
fix(web,server): 底部滑杆量章节内页数,快捷列随工具栏收放,chapter-full 改名 PR-URL: #41
This commit is contained in:
16 files changed
+670
-167
No files matched your search
@@ -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 个控件全部有名字 |
|
||||
|
||||
@@ -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
@@ -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——
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user