From b8fda56c34f8bf3b433f80624363d4a0bb72df15 Mon Sep 17 00:00:00 2001 From: hectorqin <8m0v8c12sZDrY2infddElF+hectorqin@noreply.cnb.cool> Date: Sat, 19 Sep 2026 08:17:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(web,server):=20=E5=BA=95=E9=83=A8=E6=BB=91?= =?UTF-8?q?=E6=9D=86=E9=87=8F=E7=AB=A0=E8=8A=82=E5=86=85=E9=A1=B5=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E5=BF=AB=E6=8D=B7=E5=88=97=E9=9A=8F=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A0=8F=E6=94=B6=E6=94=BE=EF=BC=8Cchapter-full=20=E6=94=B9?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ui-review/README.md | 13 +- docs/ui-review/report.json | 35 +++- docs/ui.md | 40 +++-- server/src/indexer/formats/text-html.ts | 15 +- server/src/indexer/formats/text.ts | 56 +++++-- server/test/formats.test.ts | 31 +++- web/src/formats/txt.ts | 2 +- web/src/styles/reader.css | 49 +++++- web/src/ui/reader-chrome.tsx | 103 +++++++----- web/src/ui/reader-screen.tsx | 51 ++---- web/src/ui/reader-view.ts | 46 ++++-- web/src/ui/rendition.ts | 40 +++++ web/test/reader-paging.test.ts | 83 ++++++++++ web/test/reader-view.test.ts | 17 +- web/test/rendition.test.ts | 46 ++++++ web/tools/ui-review/run.mjs | 210 ++++++++++++++++++++++-- 16 files changed, 670 insertions(+), 167 deletions(-) create mode 100644 web/src/ui/rendition.ts create mode 100644 web/test/rendition.test.ts diff --git a/docs/ui-review/README.md b/docs/ui-review/README.md index 5945cf6..bd3f651 100644 --- a/docs/ui-review/README.md +++ b/docs/ui-review/README.md @@ -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 个控件全部有名字 | diff --git a/docs/ui-review/report.json b/docs/ui-review/report.json index f578ee9..6562597 100644 --- a/docs/ui-review/report.json +++ b/docs/ui-review/report.json @@ -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, diff --git a/docs/ui.md b/docs/ui.md index 0eaf6db..10f6bd0 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -307,13 +307,13 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 - **服务端**(`server/src/indexer/formats/text.ts`):只做一件事——把**整章 交出来**。`chapter:` 是为流式读取开的窗口,按 `size` 截断是它的功能; - `chapter-html:` 是给读者整章读的,**不截断**,内容类型是 + `chapter-full:`(旧名 `chapter-html:`,仍兼容)是给读者整章读的,**不截断**,内容类型是 `text/plain`。它**不再断段、不再提升标题、不再输出 `

`**:断段是阅读 决定,服务端做就等于让 windowed 那一份有段落、流式那一份没有,也让读者的 缩进设置变成一次往返。 - **客户端排版**(`web/src/formats/segments.ts`):`splitTextParagraphs` 是 「一段是什么」的**唯一定义**,本地加载的 TXT、windowed 的 - `chapter-html:`、流式的 `chunk:` 三条路都走它,所以同一本书不会因为走哪条 + `chapter-full:`、流式的 `chunk:` 三条路都走它,所以同一本书不会因为走哪条 传输看起来不一样。它同时负责**去掉段前空格**(`  正文` 里那个全角空格是 「段前空格」的来源,留着就是和读者的缩进**叠加**),以及**章节标题行提升成 `

`**(`splitChapterHeading`,否则正文会变成 `第二章 落雨雨来了。`)。 @@ -338,7 +338,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 `--reader-txt-para-gap`),所以调整不需要重新注入章节、也不需要请求。 引用保持 `chapter:` 不变,`format: 'html'` 字段告诉客户端取哪一份 -(`renditionRef` 据此把它换成不截断的 `chapter-html:`)。`href` 同时是 +(`renditionRef` 据此把它换成不截断的 `chapter-full:`)。`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—— diff --git a/server/src/indexer/formats/text-html.ts b/server/src/indexer/formats/text-html.ts index 52873e3..9574849 100644 --- a/server/src/indexer/formats/text-html.ts +++ b/server/src/indexer/formats/text-html.ts @@ -1,5 +1,5 @@ /** - * The `chapter-html:` rendition: one chapter, whole, and nothing else. + * The `chapter-full:` rendition: one chapter, whole, and nothing else. * * This file used to be a typesetting pass — it inferred paragraph boundaries, * promoted a heading and emitted `

` 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. */ diff --git a/server/src/indexer/formats/text.ts b/server/src/indexer/formats/text.ts index fa0d212..3a50c2e 100644 --- a/server/src/indexer/formats/text.ts +++ b/server/src/indexer/formats/text.ts @@ -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 `

` 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:` one whole chapter, as the characters it is stored as + * - `chapter-full:` one whole chapter, as the characters it is stored as * - `chapter:` the same chapter, capped at `size` (a streaming window) * - `chunk:` 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 `

` 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 `

` 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:` 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 { 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`); diff --git a/server/test/formats.test.ts b/server/test/formats.test.ts index 55a2141..b0ba2b7 100644 --- a/server/test/formats.test.ts +++ b/server/test/formats.test.ts @@ -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 `

` 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); diff --git a/web/src/formats/txt.ts b/web/src/formats/txt.ts index e9a6ed5..fd2c93b 100644 --- a/web/src/formats/txt.ts +++ b/web/src/formats/txt.ts @@ -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; diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 060b924..a8f2452 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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 { diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index 637daf3..c6c7270 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -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 ) : 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 ? ( -

- - handlers.onSetting({ theme: nextTheme(state.theme) })} - /> - handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })} /> - -
- ) : 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. */} +
+ + handlers.onSetting({ theme: nextTheme(state.theme) })} + /> + handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })} /> + +
{/* 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*. */}