Merge remote-tracking branch 'origin/main' into auto/library-browse-files-split-2e69

把 #48 的朗读条与快捷列改动合进本分支,两处冲突都在
`web/src/ui/reader-chrome.tsx`,都取 main 那一侧:

- 快捷列:main 删掉了 目录 / 行距 / 页边距 / 设置 四个入口(#48 的报告),
  本分支新增的 `menu` / `line-height` / `indent` / `sliders` 随之作废。
  留下 主题 / 字号 / 听书 三项,字号改用本分支新词汇表的 `text-size`,
  听书改用 `volume`。
- 朗读条:main 拆成两行(控制行 + 进度行)并加了 `tts-stop`。本分支的单行
  版本丢弃。图标名按本分支的词汇表回落:`backward-step`/`forward-step`
  → `step-backward`/`step-forward`,`xmark` → `close`。

为让两边都能编译,`icon-names.ts` 增加一层别名表 `ICON_ALIASES`
(`font`/`volume-high`/`bars`/`gear`/`xmark`/`magnifying-glass`/`books`
→ 已画的码点),另导出 `ICON_CODEPOINT_TABLE`。别名是**拼写**不是字形:
`icons.test.ts` 的借用名单断言只管 `paths.mjs`,新增两条断言钉住别名既
落在被否名单里、又必须指向字体真画了的码点;码点唯一性改按去重后的集合计。
This commit is contained in:
hectorqin committed 2026-09-20 09:34:58 +08:00
commit 86c2d8631e
20 files changed
+1350 -273

No files matched your search

+17
View File
@@ -723,6 +723,23 @@ Android WebView 是真实会发生的)才推进,不是为了给朗读计时
第一版就是这么做的,结果是目录面板显示「第 1 章 – 第 40 章」,服务端的分页边界
漏进了 UI。
### 书自己的目录页也是一个章节,它的链接要当章节切换
一本 EPUB 的 `nav`/`toc` 文档是**普通的 spine 条目**,读者翻到它、点「第三章」,
要的是换章。但服务端把章节里的相对引用都改写成了 assets 端点的绝对 URL
(否则相对 href 从归档外面根本解析不到),于是那个 `<a href>` 默认是一个
**资源请求**:浏览器去下载另一章的文档,读者看到的不是第三章,而是一张坏掉的页面。
客户端因此要在**点击**这一层把它读回来:`chapterRefFromLink()` 认出
`__reader-book-resource__=1&ref=OEBPS/ch3.xhtml` 里的 `ref`,拼成 manifest 的
`xhtml:<path>`——和目录面板给的是同一种引用,所以它走的是**同一条**跳转路径
(`goToChapterRef`),跨窗口取窗也一样。
两条附带的规则同样重要:识别不出来的链接**不放行**(一本书里的站外链接不能让
读者离开阅读器,WebView 壳里更是回不来),而**纯片段链接**(`#note3`)交给浏览器
自己跳——脚注必须能用。边界写在 `ReaderView.bindChapterLinks` 里,因为它是唯一
同时看得见 shadow 树里真实节点和网络地址的地方。
### 章节的读取代价
一次章节读取 = 一次中央目录读(打开时)+ 一次条目的局部头 + 该条目的数据。
+22 -19
View File
@@ -26,40 +26,43 @@
| 检查 | 结果 | 说明 |
| --- | --- | --- |
| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 7 个 pointer-events=auto |
| 快捷列: 整组控件都在页内 | ✅ | 列 y=186..534 / 屏高 844 |
| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 3 个 pointer-events=auto |
| 快捷列: 整组控件都在页内 | ✅ | 列 y=186..335 / 屏高 844 |
| 快捷列: 装不下的控件有滚动条,说明还有更多 | ✅ | 溢出 否 overflow-y=true 滚动条 0px |
| 快捷列: 一列里含整组快捷操作,而不是只露出前几个 | ✅ | 按钮 7 个:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置 |
| 快捷列: 听书在列内 | ✅ | 列内控件:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置 |
| 快捷列(420px 短屏): 列在页内,超出的控件能滚到 | ✅ | 列底 322 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true |
| 快捷列: 只留页面内就能完成的调整 | ✅ | 按钮 3 个:日间 / 字号 / 听书 |
| 快捷列: 不含打开面板的五个入口 | ✅ | 列内控件:日间 / 字号 / 听书 |
| 快捷列: 听书在列内 | ✅ | 列内控件:日间 / 字号 / 听书 |
| 快捷列(420px 短屏): 列在页内,超出的控件能滚到 | ✅ | 列底 242 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true |
| 收起工具栏: 顶栏与底栏一起走 | ✅ | state=hidden 顶栏 hidden / 底栏 hidden |
| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=none footer=none |
| 收起工具栏: 正文一行的位置不变(工具栏是浮层) | ✅ | 正文首行 y: 显示时 78 / 收起后 78 |
| 收起工具栏: 正文一行的位置不变(工具栏是浮层) | ✅ | 正文首行 y: 显示时 24 / 收起后 24 |
| 正文铺满: 页面不为工具栏预留高度 | ✅ | padding 0/0px, 页面 844px / 屏高 844 |
| 工具栏是浮动层,不占正文的高度 | ✅ | topbar=absolute footer=absolute |
| 收起工具栏: 快捷按钮跟着两条栏一起收 | ✅ | visible=false pointer-events=none |
| 收起工具栏: 保留章节与页码指示 | ✅ | chapter="第一章 惊蛰" progress="第 1/4 页 0%" |
| 收起工具栏: 保留章节与页码指示 | ✅ | chapter="第一章 惊蛰" progress="第 1/3 页 0%" |
| 收起工具栏: 阅读指示不吃手势 | ✅ | pointer-events=none |
| 收起工具栏: 章节名画在正文之上 | ✅ | 层级 30 vs 正文 1,y=10..28 文本="第一章 惊蛰" |
| 收起工具栏: 章节名自带底色,不被正文透出 | ✅ | background=rgb(22, 23, 26) |
| 收起工具栏: 页码读数画在正文之上 | ✅ | 层级 30 vs 正文 1,y=819..834 文本="第 1/4 页 0%" |
| 收起工具栏: 页码读数画在正文之上 | ✅ | 层级 30 vs 正文 1,y=819..834 文本="第 1/3 页 0%" |
| 收起工具栏: 页码读数自带底色,不被正文透出 | ✅ | background=rgb(22, 23, 26) |
| 收起工具栏: 章节名与页码左边对齐 | ✅ | 章节名 left=20 页码 left=20 |
| 点右侧翻页: 既翻页又把工具栏叫回来 | ✅ | 收起前 hidden(第 1/4 页) → 点击后 visible(第 2/4 页) |
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=54 |
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 266px |
| 点右侧只翻页: 页码前进, 工具栏保持收起 | ✅ | 收起前 hidden(第 1/3 页) → 点击后 hidden(第 2/3 页) |
| 点中间切换工具栏 | ✅ | 点击中间后 visible |
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=0 |
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 321px |
| 目录: 面板有遮罩和把手 | ✅ | scrim=true grip=true |
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 700px |
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 844px |
| 顶栏: 三个按钮左右两边对齐 | ✅ | 左 12px / 右 12px,两道间隙 136px / 136px |
| 底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐 | ✅ | 页码右 12px / 下一章右 12px;上一章左 12px / 滑杆左 12px |
| 顶栏与底栏共用同一条内边距 | ✅ | 顶栏 12px / 底栏 12px |
| 底栏: 进度是可拖动的滑杆 | ✅ | type=range 宽 308px label="章节内页数" |
| 底栏: 有页码与阅读进度读数 | ✅ | page="第 2/4 页" nav="阅读进度:27%" |
| 底栏: 滑杆量的是章节内页数,不是全书百分比 | ✅ | min=1 max=4 value=2 读数="第 2/4 页"(章节共 4 页) |
| 底栏: 拖动滑杆切到对应页 | ✅ | 拖到 4 后读数="第 4/4 页" |
| 翻页模式: 滑杆拖到第 3 页,仍在本章 | ✅ | min=1 max=4 value=3 拖前="第 4/4 页" 拖后="第 3/4 页" |
| 状态提示不推动正文 | ✅ | 正文顶部 y: 54.390625 / 54.390625 / 54.390625 |
| 触控目标不小于 28px | ✅ | 12 个控件全部合格 |
| 每个控件都有可读的名字 | ✅ | 12 个控件全部有名字 |
| 底栏: 有页码与阅读进度读数 | ✅ | page="第 2/3 页" nav="阅读进度:41%" |
| 底栏: 滑杆量的是章节内页数,不是全书百分比 | ✅ | min=1 max=3 value=2 读数="第 2/3 页"(章节共 3 页) |
| 底栏: 拖动滑杆切到对应页 | ✅ | 拖到 3 后读数="第 3/3 页" |
| 翻页模式: 滑杆拖到第 3 页,仍在本章 | ✅ | min=1 max=3 value=3 拖前="第 3/3 页" 拖后="第 3/3 页" |
| 状态提示不推动正文 | ✅ | 正文顶部 y: 0 / 0 / 0 |
| 触控目标不小于 28px | ✅ | 8 个控件全部合格 |
| 每个控件都有可读的名字 | ✅ | 8 个控件全部有名字 |
| 没有横向溢出 | ✅ | document 溢出 0px |
| 翻页不再每次都发两次 sync | ✅ | 8 次翻页 + 6 秒静默:1 次 POST / 1 次 GET(共 2 次 sync 请求) |
| EPUB: 章节里的图片真的画出来了 | ✅ | naturalWidth=240 显示 240x160 src="/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI" |
+36 -21
View File
@@ -89,12 +89,12 @@
{
"name": "展开工具栏: 快捷按钮与两条栏一起出现",
"ok": true,
"detail": "visible=true 按钮 7 个 pointer-events=auto"
"detail": "visible=true 按钮 3 个 pointer-events=auto"
},
{
"name": "快捷列: 整组控件都在页内",
"ok": true,
"detail": "列 y=186..534 / 屏高 844"
"detail": "列 y=186..335 / 屏高 844"
},
{
"name": "快捷列: 装不下的控件有滚动条,说明还有更多",
@@ -102,19 +102,24 @@
"detail": "溢出 否 overflow-y=true 滚动条 0px"
},
{
"name": "快捷列: 一列里含整组快捷操作,而不是只露出前几个",
"name": "快捷列: 只留页面内就能完成的调整",
"ok": true,
"detail": "按钮 7 个:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置"
"detail": "按钮 3 个:日间 / 字号 / 听书"
},
{
"name": "快捷列: 不含打开面板的五个入口",
"ok": true,
"detail": "列内控件:日间 / 字号 / 听书"
},
{
"name": "快捷列: 听书在列内",
"ok": true,
"detail": "列内控件:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置"
"detail": "列内控件:日间 / 字号 / 听书"
},
{
"name": "快捷列(420px 短屏): 列在页内,超出的控件能滚到",
"ok": true,
"detail": "列底 322 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true"
"detail": "列底 242 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true"
},
{
"name": "收起工具栏: 顶栏与底栏一起走",
@@ -129,7 +134,12 @@
{
"name": "收起工具栏: 正文一行的位置不变(工具栏是浮层)",
"ok": true,
"detail": "正文首行 y: 显示时 78 / 收起后 78"
"detail": "正文首行 y: 显示时 24 / 收起后 24"
},
{
"name": "正文铺满: 页面不为工具栏预留高度",
"ok": true,
"detail": "padding 0/0px, 页面 844px / 屏高 844"
},
{
"name": "工具栏是浮动层,不占正文的高度",
@@ -144,7 +154,7 @@
{
"name": "收起工具栏: 保留章节与页码指示",
"ok": true,
"detail": "chapter=\"第一章 惊蛰\" progress=\"第 1/4 页 0%\""
"detail": "chapter=\"第一章 惊蛰\" progress=\"第 1/3 页 0%\""
},
{
"name": "收起工具栏: 阅读指示不吃手势",
@@ -164,7 +174,7 @@
{
"name": "收起工具栏: 页码读数画在正文之上",
"ok": true,
"detail": "层级 30 vs 正文 1,y=819..834 文本=\"第 1/4 页 0%\""
"detail": "层级 30 vs 正文 1,y=819..834 文本=\"第 1/3 页 0%\""
},
{
"name": "收起工具栏: 页码读数自带底色,不被正文透出",
@@ -177,19 +187,24 @@
"detail": "章节名 left=20 页码 left=20"
},
{
"name": "点右侧翻页: 既翻页又把工具栏叫回来",
"name": "点右侧只翻页: 页码前进, 工具栏保持收起",
"ok": true,
"detail": "收起前 hidden(第 1/4 页) → 点击后 visible(第 2/4 页)"
"detail": "收起前 hidden(第 1/3 页) → 点击后 hidden(第 2/3 页)"
},
{
"name": "点中间切换工具栏",
"ok": true,
"detail": "点击中间后 visible"
},
{
"name": "目录: 面板不遮住整页",
"ok": true,
"detail": "面板从 y=321 开始,正文区 y=54"
"detail": "面板从 y=321 开始,正文区 y=0"
},
{
"name": "目录: 面板留出可预览的正文",
"ok": true,
"detail": "正文可见高度 266px"
"detail": "正文可见高度 321px"
},
{
"name": "目录: 面板有遮罩和把手",
@@ -199,7 +214,7 @@
{
"name": "目录: 面板高度不超过阅读区",
"ok": true,
"detail": "面板 523px / 阅读区 700px"
"detail": "面板 523px / 阅读区 844px"
},
{
"name": "顶栏: 三个按钮左右两边对齐",
@@ -224,37 +239,37 @@
{
"name": "底栏: 有页码与阅读进度读数",
"ok": true,
"detail": "page=\"第 2/4 页\" nav=\"阅读进度:27%\""
"detail": "page=\"第 2/3 页\" nav=\"阅读进度:41%\""
},
{
"name": "底栏: 滑杆量的是章节内页数,不是全书百分比",
"ok": true,
"detail": "min=1 max=4 value=2 读数=\"第 2/4 页\"(章节共 4 页)"
"detail": "min=1 max=3 value=2 读数=\"第 2/3 页\"(章节共 3 页)"
},
{
"name": "底栏: 拖动滑杆切到对应页",
"ok": true,
"detail": "拖到 4 后读数=\"第 4/4 页\""
"detail": "拖到 3 后读数=\"第 3/3 页\""
},
{
"name": "翻页模式: 滑杆拖到第 3 页,仍在本章",
"ok": true,
"detail": "min=1 max=4 value=3 拖前=\"第 4/4 页\" 拖后=\"第 3/4 页\""
"detail": "min=1 max=3 value=3 拖前=\"第 3/3 页\" 拖后=\"第 3/3 页\""
},
{
"name": "状态提示不推动正文",
"ok": true,
"detail": "正文顶部 y: 54.390625 / 54.390625 / 54.390625"
"detail": "正文顶部 y: 0 / 0 / 0"
},
{
"name": "触控目标不小于 28px",
"ok": true,
"detail": "12 个控件全部合格"
"detail": "8 个控件全部合格"
},
{
"name": "每个控件都有可读的名字",
"ok": true,
"detail": "12 个控件全部有名字"
"detail": "8 个控件全部有名字"
},
{
"name": "没有横向溢出",
+62 -24
View File
@@ -656,12 +656,17 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
内容会上下滑动」:当时两条栏在正常流里,stage 是 `flex: 1 1 auto`,于是每次
开关工具栏页面就重新排版、正在读的那一行上下滑 53px。工具栏是浮层,和正文不在
同一个流里——读者的判断是对的。
- **正文给浮层留的是固定边距**(`.stage-host` 的 `padding-block:
var(--reader-chrome-top) var(--reader-chrome-bottom)`)。浮层会盖住页面,
所以版心必须让开两条栏的高度;而「固定」是这里的关键词:这个边距**不随工具栏
状态变化**,否则读者一点按钮页面就重新排版,那是同一个 bug 换了件衣服。
两个高度各自只声明一次(`.reader-screen` 上的 `--reader-chrome-top` /
`--reader-chrome-bottom`),栏本身和让位规则读同一个数,不会各说各话。
- **页面铺满整屏,不为浮层预留任何高度。** `.stage-host` 是 `inset: 0`,
**没有** `padding-block`。这是读者报的「内容要铺满,不要预留 stage」:版心
曾经按两条栏的高度留固定边距,于是页面在**两种状态下**都永久比手机矮 9rem
——读者收起了工具栏、要的就是整屏,顶上顶下却还是两块空白纸。
留边距的理由(浮层会盖住正文)本身没错,所以浮层的解法是浮层自己的:两条栏
用半透明底加 `backdrop-filter`(`--reader-surface-float`),读者能**透过**
工具栏看到它盖住的那一行。`.book-flow` 自己的 `padding-block` 是**页面**
边距,上下对称、与工具栏无关,一直在。
- **两条栏的高度各自只声明一次**(`.reader-screen` 上的 `--reader-chrome-top` /
`--reader-chrome-bottom`):栏本身、以及底栏上方那条朗读条(`.tts-bar` 的
`bottom`)读同一个数,不会各说各话。
- **`visibility` 在动画结束后才生效**(`transition: visibility 0s linear
var(--motion-duration)`),过渡期间由 `pointer-events: none` 兜住:滑出的过程
中标题还在,动画走完它就既看不见也点不到了。少写这一行,读者能点到一个看不见的
@@ -671,15 +676,16 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
(宽屏时用 `--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:
这些动作和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在
正文右边缘的按钮,就不是读者要的「只剩正文」。
- **快捷列装的是整组调整,而不是其中的几个**:目录 / 主题 / 字号 / 行距 / 页边距 /
听书 / 设置。每个按钮是该项设置的**一档**,值和设置面板里的行取自**同一张
阶梯**(`LINE_HEIGHT_LADDER`、`PAGE_MARGIN_*`),所以两处不会各自漂移出一个
对方到不了的值。
这条是读者报的「竖排工具栏显示为 主题切换、听书」逼出来的:当时这一列
**没有高度上限也不会滚**,屏幕一矮就直接画出屏幕外——读者数得到几个就以为这一列
只有几个,于是报告了他看得见的那两个。所以有三条实现约束:
`max-height` 由两条栏的高度算出来(和正文让开的是同两个数),装不下时
`overflow-y: auto`,并且**滚动条是画出来的**(3px 细线而不是
- **快捷列只留页面内就能完成的调整**:夜间/日间(主题)、字号、听书。
这条是读者报的「竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮」定下来的:
被去掉的五个都是**目的地**而不是**调整**——目录、设置各自打开半屏面板,字体/行距/
页边距是值的一档而其完整范围住在设置面板里——所以这一列同时也装着它们的时候,
它就是一列浮在正文上的、更差的设置面板。留下的是读者**不想打开任何东西**就会去动的
那几个(尤其听书:他伸手的时候正要离开屏幕)。
每个按钮仍然是该项设置的一档,值和设置面板里的行取自**同一张阶梯**
(`FONT_SCALE_LADDER`、`THEME_LADDER`),所以两处不会各自漂移出一个对方到不了的值。
列本身仍要保证整组控件在页内:`max-height` 由两条栏的高度算出来,
装不下时 `overflow-y: auto`,并且**滚动条是画出来的**(3px 细线而不是
`scrollbar-width: none`)——一个只能靠滚动找到的控件必须看起来能滚,否则
对读者就等于不存在。`overscroll-behavior: contain` 也在,免得滑到头那一下把
手势交给下面的正文。
@@ -709,16 +715,43 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
所以拖动滑杆**不会跨章**:它只移动当前章节内的位置(见
`ReaderView.seekPageInChapter`)。
手势上,**中间三分之一在两个方向上都是开关**;**左右三分之一翻页,且收起状态下
顺带把工具栏叫回来**。
手势上,**三个区各管一件事,互不重叠**:左右三分之一**只翻页**,中间三分之一
**只切换工具栏显隐**。
这一条改过,起因是读者报的「点击左右侧翻页时工具栏不能显示出来」。原来的行为是:
收起状态下左右三分之一**不翻页**,只当作「叫回来」的出口(理由是没有别的出路)。
理由成立,结果不成立——读者点右侧是想看下一页,得到的却是没有下一页、外加一个他
没在找的工具栏;于是同一根手指同时说出了「翻页没反应」和「工具栏不显示」两句抱怨。
现在这一次点击两件事都做:翻页,并且把工具栏叫回来。两者本来就不是二选一。
工具栏**已经显示时**,左右三分之一仍然只翻页:那才是 §3.8 说的「把翻页没反应做成
常态」,而且每翻一页闪一下两条栏是立刻会被注意到的毛病。
这一条改过两轮,第二轮的起因是读者报的「在点击左右两边时只需要翻页、不需要显示
工具栏,只有在中间点击时才需要切换工具栏显隐」。第一轮的行为是:收起状态下左右
三分之一翻页**并且**把工具栏叫回来(理由是没有别的出路)。理由成立,结果和手势
打架——读者只是想往后翻,得到的却是新的一页**外加**一条压在刚读到的那行上的工具栏,
于是沉浸态根本待不住,每翻一页都被撤销一次;而真正想要控件的读者有一个专门的、
一眼可见的表达方式:点中间。
所以现在两个手势是**互斥**的,各有一个含义,这正是原来缺的性质:
左/右 = 翻页,中 = 显隐工具栏。翻页不会带出任何控件,要控件的点击也不会翻页。
### 3.11 朗读条
朗读条(`.tts-bar`)**和两条栏一样是浮层**,贴在底栏正上方(`bottom:
var(--reader-chrome-bottom)`),z-index 用同一个 `--reader-layer-bar`;但它
**不跟工具栏一起收起**——读者是边听边走的人,收起工具栏是为了看正文,不是为了
控制不了播放。
这条是读者报的「TTS 阅读体验需要优化,没法停止」逼出来的两条实现约束:
- **它必须画在正文之上,才算存在。** 它原本是 `.reader-screen` 这个 flex 列的
普通子元素,而第一个子元素 `.stage-host` 是 `inset: 0` 的绝对定位、正文又画在
`--reader-layer-page`。静态兄弟排在浮层**下面**:条画出来了、按钮能聚焦、
`aria-label` 也对,**没有任何一个能被点到**。所以它显式声明 `position: absolute`
加 z-index——一个实心按钮点不到,读者只有一个说法:「没法停止」。
- **控件自己一行,正文和滑杆另起一行。** 原本七个东西挤在一行里,句子拿
`flex: 1`,于是它成了唯一会被压缩的东西,而最后一个(停止)在 360px 上被推出
右边缘裁掉。现在上行是 `flex: 1 1 0` 的五个等宽档(上一句 / 播放暂停 / 下一句 /
句数 chip / 停止),下行是句子加滑杆:控件的宽度是等分出来的,屏幕多宽都放得下。
- **停止是方块不是 ✕。** ✕ 读起来是「关掉这条」,而那正是暂停不做的事——想要
声音停下的读者按了它,条消失了,书还在念。停止单独用主色底(`.tts-stop`)标出,
它是一行五个控件里唯一一个破坏性的。
播放器元素本身(`<audio>`)**永远不进文档**,并且显式带 `display: none` 与
`aria-hidden`:媒体元素没有读者想要的控件(条上都有),而一个会渲染它的 WebView
只会在书上画一个黑色播放器方块。这是读者报的「audio 需要隐藏」。
---
@@ -784,6 +817,11 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
| 图标按钮自己拼的 | ✅ 点击有反应 | 图标和文字差半个字高 |
| 底栏滑杆量的是全书百分比 | ✅ 拖了会动、页码也变了 | 想翻到本章第 7 页,被带到了第 40 章 |
| 快捷按钮列只在沉浸态出现 | ✅ 收起工具栏后按钮还在 | 收起后正文右边仍压着一列浮层 |
| 朗读条是 flex 里的一行 | ✅ 按钮渲染出来了、`aria-label` 也在 | 它是静态兄弟、被 `inset: 0` 的 stage 盖住,实心按钮一个都点不到 |
| 朗读条的控件挤在一行里 | ✅ 每个控件单独看都在 DOM 里 | 手机宽度下最后一个(停止)被挤出屏幕外,那就是「没法停止」 |
| 快捷列装的是打开面板的入口 | ✅ 每个按钮都能点开东西 | 浮在正文上的第二份设置面板,比面板本身更难用 |
| 版心按工具栏高度留固定边距 | ✅ 开关工具栏时正文一行不动 | 两种状态下页面都矮 9rem,收起了工具栏也还是两块空白纸 |
| 分栏的 column-gap 是 0、页边距是内边距 | ✅ 滑杆能拖、页码会变 | 每页右边缘露出下一页的头几个字,翻页「样式不对」 |
所以 `npm run ui:review` 做两件事,两件都要:
+46
View File
@@ -21,3 +21,49 @@
* string comparison.
*/
export const BOOK_RESOURCE_MARKER = '__reader-book-resource__';
/**
* The chapter reference a link inside a book points at, or null.
*
* A chapter's own cross-references are the one place the reader has to read the
* *server's* rewrite backwards. The server turns `<a href="ch2.xhtml">` into
* `/api/v1/books/<id>/assets?__reader-book-resource__=1&ref=ch2.xhtml` (see
* `server/src/indexer/formats/epub.ts`), because a relative href resolves to
* nothing once the document is served from outside the archive. Left alone, that
* URL is an *asset* request: the reader would download chapter two as a file and
* the WebView would try to render it as an image, which is how a tap on an EPUB's
* own table of contents produced a broken page instead of chapter two.
*
* The `ref` this reads is exactly the string the manifest publishes as a content
* item's `href` (`xhtml:<path>`), so the answer composes with `indexOfSection`
* and with the windowed jump path unchanged — a link is a chapter change like any
* other, and it must go through the same code so that a chapter outside the
* loaded window still fetches its window.
*
* `null` covers everything that is *not* a chapter: an image the book linked to, a
* footnote fragment in the same document, a third-party address (which the
* sanitiser has already dropped by the time this is called).
*/
export function chapterRefFromLink(href: string, baseURI?: string): string | null {
const trimmed = href.trim();
if (trimmed === '' || trimmed.startsWith('#')) return null;
let url: URL;
try {
url = new URL(trimmed, baseURI ?? document.baseURI);
} catch {
return null;
}
// The marker is a query parameter of the server's own rewrite. A URL without it
// is either the book's own relative reference (resolved against the document,
// which is not something this client can address as a chapter) or an address the
// sanitiser already decided may not be fetched.
if (!url.searchParams.has(BOOK_RESOURCE_MARKER)) return null;
const ref = url.searchParams.get('ref');
if (!ref) return null;
// A fragment means "a position inside a document", which for an EPUB is a
// chapter and would for a footnote be the same chapter. Either way the chapter
// is what the reader asked to go to, and the fragment is left to the anchor
// resolver.
if (!/\.x?html?$/i.test(ref) && !/\.xml$/i.test(ref)) return null;
return `xhtml:${ref}`;
}
+29 -2
View File
@@ -372,9 +372,36 @@ export class HttpTtsEngine {
this.options.onError?.(message);
}
/**
* A detached, hidden audio element.
*
* `new Audio()` is already detached, and it is given `display: none` anyway —
* belt and braces, for the host that hands one in. A media element is not a
* control the reader should ever see: it has no transport the reader wants
* (the bar has play/pause/stop, and the sentence scrubber), and a WebView that
* *does* render one draws a black rectangle with a native player in it over the
* book. The reader reported exactly that as "audio 需要隐藏", and the honest fix
* is that no code path may put a visible element on screen at all.
*
* `aria-hidden` is the other half: a screen reader must not be told about a
* player that is not part of the UI, or it announces a second set of controls
* beside the bar's.
*/
private createAudio(): HTMLAudioElement {
if (this.options.createAudio) return this.options.createAudio();
return new Audio();
const audio = this.options.createAudio ? this.options.createAudio() : new Audio();
// Written defensively: this runs against a real media element in every
// production host, and against a test double that models only the media API
// the engine actually uses. A bare `audio.style.display` would make every
// engine test depend on an element implementation that has nothing to do with
// what is under test.
try {
audio.setAttribute('aria-hidden', 'true');
audio.setAttribute('tabindex', '-1');
if (audio.style) audio.style.display = 'none';
} catch {
// A double with no element API; the element is not in the document either.
}
return audio;
}
private stopAudio(): void {
+147 -31
View File
@@ -178,6 +178,19 @@
*/
--reader-band-inset: 0.75rem;
/*
* The surface a floating band is drawn on.
*
* Translucent, with a blur, and that is what makes a bar over the text usable
* now that the page is not inset for it: the reader can see the line the bar
* covers *through* the bar, so the toolbar stops being a thing that hides a
* paragraph. `color-mix` against the theme's own surface rather than a fixed
* rgba, so the three themes get their own tint from one value; the fallback is
* the opaque surface, which is what a host without `color-mix` draws and which
* is exactly the old behaviour.
*/
--reader-surface-float: color-mix(in srgb, var(--reader-surface) 88%, transparent);
/*
* The reader's own paint order, weakest first.
*
@@ -288,26 +301,29 @@ body {
* and that is the whole of the "内容会上下滑动" fix: with the stage in flow, the
* two bars took height out of it, so showing or hiding the chrome resized the page
* and moved every line on it. Here the page is the screen, always, in both states.
*
* ## No block inset: the page *is* the screen
* —
* This rule used to reserve the bars' own heights as `padding-block`, on the
* reasoning that a floating bar must not cover a line the reader is reading. The
* reasoning was sound and the result was the report: "内容要铺满,不要预留 stage".
* Reserving the bars' height means the reading column is permanently 9rem shorter
* than the phone, in *both* chrome states — so a reader who has hidden the toolbar
* and wants the whole screen for the book still loses the top and bottom bands to
* empty paper, and the last line of every page sits 5.6rem above the bottom edge
* for no reason at all.
*
* The column keeps its own `padding-block` (see `FLOW_STYLESHEET`), which is the
* reader's *page* padding and is symmetric — the text is not flush with the glass
* even when the bars are gone. What it is not is a variable reserve for chrome
* that may not be there: the page occupies the whole slot and the bars are drawn
* over it, which is what "浮动" means and what makes the text stand still when
* they come and go.
*/
.stage-host {
display: flex;
position: absolute;
inset: 0;
/*
* The space the two floating bars occupy, kept clear of the text.
*
* Constant, and that is the entire point. The bars are a layer now, so they no
* longer take height out of the page — but they do cover it, and a bar drawn over
* the first line of a chapter is a bar that has hidden something the reader was
* reading. So the page is inset by exactly the bars' own heights: the text is
* never underneath them, and the inset does not change when they come or go, so
* the text never moves either.
*
* `--reader-chrome-top` is the top bar's height and `--reader-chrome-bottom` the
* footer's, both declared on `.reader-screen`: one number per bar, read by the bar
* itself and by this rule, so the clearance and the bar cannot drift apart.
*/
padding-block: var(--reader-chrome-top) var(--reader-chrome-bottom);
}
/*
@@ -513,7 +529,11 @@ book-content {
/* The band inset on both sides, so the outermost glyphs are the same distance
from the device edge as every control in the footer below them. */
padding-inline: var(--reader-band-inset);
background: var(--reader-surface);
/* A floating band over the page, not a solid shelf above it: the reader has to be
able to read the line it covers. See `--reader-surface-float`. */
background: var(--reader-surface-float);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
/* Same hairline treatment as the footer, so the two edges of the reading area
are the same weight. */
border-bottom: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent);
@@ -2156,7 +2176,10 @@ book-content {
inset-inline: 0;
bottom: 0;
z-index: var(--reader-layer-bar);
background: var(--reader-surface);
/* The same floating surface as the top bar — over the page, see above. */
background: var(--reader-surface-float);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
/* A hairline that survives a dark theme and a bright page equally: one solid
colour has to fight whichever side it borders, and this is the one place the
footer meets the paper. */
@@ -2325,8 +2348,11 @@ book-content {
* 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.
* the two adjustments a reader makes from the page itself (主题 / 字号) plus 听书, so it
* belongs to the same state the bars do — the reader has asked for the controls — and
* it goes with them. The five destinations that used to be here (目录 / 字体 / 行距 /
* 页边距 / 设置) are gone: all five open or reach a sheet, and a second, worse copy of
* the settings panel floating over the text is not a shortcut.
*
* 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
@@ -2522,17 +2548,40 @@ book-content {
/* ---- read-aloud bar ---- */
/*
* The bar sits above the footer and, unlike the footer, is *not* hidden with the
* chrome: a reader listening while walking needs the play/pause without the
* header covering the sentence being read.
* The bar floats over the bottom of the page, and that is a *fix*, not a style
* choice.
*
* It used to be an ordinary flex child of `.reader-screen`. The screen is a flex
* column whose first child is `.stage-host`, and the stage host is
* `position: absolute; inset: 0` — it covers the whole screen so the page does not
* resize when the bars come and go. A static sibling in that column is therefore
* laid out *behind* the reading surface, which paints at `--reader-layer-page`.
* The bar was drawn, its buttons were focusable, and the stage was painted over
* all of it: the reader could see the sentence being read and could not press
* anything. That is the whole of "没法停止" — the stop button existed and was
* unreachable, which reads as a control that does nothing.
*
* So it is a layer with the bars' own z-index (`--reader-layer-bar`), pinned above
* the bottom edge and above the footer's own height, which is where its controls
* have to be to be hit by a thumb. Unlike the footer it is *not* hidden with the
* chrome: a reader listening while walking needs play/pause without the header
* covering the sentence being read.
*
* `--reader-chrome-bottom` is the footer's own height, read here rather than
* guessed, so the two bands cannot overlap however the footer's rows change.
*/
.tts-bar {
position: absolute;
inset-inline: 0;
bottom: var(--reader-chrome-bottom);
z-index: var(--reader-layer-bar);
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.5rem;
padding-bottom: calc(0.35rem + env(safe-area-inset-bottom));
background: var(--reader-surface);
background: var(--reader-surface-float);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-top: 1px solid var(--reader-line);
}
@@ -2540,24 +2589,91 @@ book-content {
display: none;
}
.tts-bar .tts-main {
flex: 1 1 auto;
min-width: 0;
/*
* Two rows, stacked: the transport controls, then the sentence with its scrubber.
*
* A column rather than a single flex row because the single row could not fit —
* see the comment on `.tts-bar`. The two rows take the full width of the bar, so
* the five controls split it evenly and none of them is ever clipped.
*/
.tts-bar .tts-row {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.tts-bar .tts-controls {
/* Every control the same width, including the chip: a row whose cells are
sized by their contents leaves the widest one (the ✕, whose label is a
glyph) flush to the edge and the others bunched. Equal cells read as one
transport, which is what the row is. */
gap: var(--space-1);
}
.tts-bar .tts-controls > * {
flex: 1 1 0;
min-width: 0;
}
/* The chip is a text control in a row of icons, so its own padding is dropped:
the cell's width is the equal share, not the label's. */
.tts-bar .tts-chip {
appearance: none;
border: 1px solid color-mix(in srgb, var(--reader-line) 90%, transparent);
background: var(--reader-surface);
color: var(--reader-muted);
border-radius: 999px;
height: 2.75rem;
min-width: 0;
padding: 0 0.4rem;
font: inherit;
font-size: var(--text-meta);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
touch-action: manipulation;
}
.tts-bar .tts-chip:active {
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
}
/*
* Stop is the one destructive control on the bar and it is drawn as such.
*
* It is not a warning colour: this is a reading app, and a red button in the
* middle of a page is a louder statement than "stop the voice". The accent fill
* is enough to separate it from the three transport buttons beside it, which is
* the whole job — the reader who wants the voice to stop has to be able to pick
* this control out of a row of five at a glance.
*/
.tts-bar .tts-stop {
background: color-mix(in srgb, var(--reader-accent) 16%, transparent);
border-color: color-mix(in srgb, var(--reader-accent) 45%, transparent);
color: var(--reader-ink);
}
.tts-bar .tts-progress {
gap: var(--space-2);
padding-inline: 0.15rem;
}
.tts-bar .tts-text {
flex: 0 1 auto;
max-width: 40%;
font-size: 0.78rem;
color: var(--reader-ink);
color: var(--reader-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tts-range {
width: 100%;
flex: 1 1 auto;
min-width: 0;
margin: 0;
accent-color: var(--reader-accent);
}
+19 -1
View File
@@ -50,4 +50,22 @@ export const ICON_CODEPOINTS = {
'tune': '\ue928',
} as const;
export type IconName = keyof typeof ICON_CODEPOINTS;
/** Code points kept as aliases, the way the two spellings of 退出 coexist. */
export const ICON_ALIASES = {
'font': '\ue924',
'volume-high': '\ue916',
'backward-step': '\ue913',
'forward-step': '\ue914',
'bars': '\ue900',
'gear': '\ue926',
'xmark': '\ue908',
'magnifying-glass': '\ue907',
'books': '\ue927',
} as const;
export const ICON_CODEPOINT_TABLE = {
...ICON_CODEPOINTS,
...ICON_ALIASES,
} as const;
export type IconName = keyof typeof ICON_CODEPOINT_TABLE;
+2 -2
View File
@@ -25,7 +25,7 @@
* is decorative", rather than omitting the attribute and leaving it ambiguous.
*/
import { ICON_CODEPOINTS, type IconName } from './icon-names.ts';
import { ICON_CODEPOINT_TABLE, type IconName } from './icon-names.ts';
import { type JSX } from './vendor/preact.ts';
export type { IconName };
@@ -55,7 +55,7 @@ export function Icon({ name, label, class: className }: IconProps): JSX.Element
role={label ? 'img' : undefined}
aria-label={label ?? undefined}
>
{ICON_CODEPOINTS[name]}
{ICON_CODEPOINT_TABLE[name]}
</span>
);
}
+88 -85
View File
@@ -19,7 +19,7 @@ import type { AppSettings } from '../store/settings.ts';
import type { SpeechEngineKind } from '../render/speech.ts';
import { type ComponentChildren, type JSX } from './vendor/preact.ts';
import { IconButton, SectionTitle } from './toolkit.tsx';
import { ICON_CODEPOINTS, type IconName } from './icon-names.ts';
import { ICON_CODEPOINT_TABLE, type IconName } from './icon-names.ts';
/**
* A table-of-contents row.
@@ -213,63 +213,53 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
—
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 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 controls a
reader nudges while reading 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.
of what the rail is for: it is a second way to reach the two things a
reader changes *while reading* — the theme and the voice — so it belongs
to the same "the reader has asked for the controls" state as the bars.
Drawn while the chrome was hidden, it was the one piece of chrome that
survived immersion — a reader who tapped to hide the navigation still had
controls floating over the right edge of the text, which is not the "only
the page" they asked for.
It is 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.
## What is in it, and why the list is not shorter
## What is in it, and why the list is exactly this short
—
The rail used to hold four controls, and the standing report was "竖排
工具栏显示为 主题切换、听书" — which is what a reader sees when the row
they are looking at is a *scroll container* that nothing told them was
one. Every control past the fourth on a short phone screen was below the
fold of a 200px column with no scrollbar, no fade and no hint that it
continued; a rail whose contents depend on the height of the phone is a
rail whose contents the reader cannot enumerate. Two things follow, and
both are stated in CSS rather than left to the layout:
- the column scrolls when it must (`.reader-rail`), and its scrollbar is
drawn rather than hidden — a control the reader must scroll to reach
has to look scrollable, or it is a control that does not exist;
- the buttons are one *step* of the settings each, so the rail is the
whole set of adjustments the top bar reaches, not a sample of them:
目录 / 主题 / 字号 / 行距 / 页边距 / 听书 / 设置. The read-aloud control
was the one a reader could only reach by discovering the settings sheet,
and it is the one they reach for *without* leaving the page.
Every one of them is a destination the screen can actually reach: a
button that does nothing is worse than a missing one. */}
Three controls, and the report that decided them is the whole of it:
"竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮". Those five are
destinations, not adjustments: 目录 opens a sheet, 设置 opens a sheet, and
字体/行距/边距 are *steps* of values whose full range lives in the 设置
sheet — so a reader who wanted any of the five was already going to open a
sheet, and putting them on the rail as well made it a second, worse copy of
the settings panel floating over the text.
What is left is the two that are genuinely adjusted *in place*, plus 听书:
- 主题 (light → sepia → night): a single tap that changes the whole page,
and the one control a reader in the dark reaches for without wanting to
open anything;
- 字号 (one step): the other half of the same "the text is not right"
moment, and a step up/down is faster than finding a slider;
- 听书: the one control a reader reaches for *without* leaving the page,
because they are about to look away from the screen.
Every one of them is a destination the screen can actually reach: a button
that does nothing is worse than a missing one. */}
<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-size" label="字号" onClick={() => handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })} />
<RailButton
icon="line-height"
label="行距"
onClick={() => handlers.onSetting({ lineHeight: stepLineHeight(state.lineHeight) })}
/>
<RailButton
icon="indent"
label="页边距"
onClick={() => handlers.onSetting({ pageMargin: stepPageMargin(state.pageMargin) })}
icon="text-size"
label="字号"
onClick={() => handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })}
/>
<RailButton
icon={state.tts.active ? 'pause' : 'volume'}
@@ -277,7 +267,6 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
pressed={state.tts.active}
onClick={handlers.onSpeechToggle}
/>
<RailButton icon="sliders" label="阅读设置" onClick={handlers.toggleSettings} />
</div>
{/* The bottom bar.
@@ -765,16 +754,55 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr
function SpeechBar({ state, handlers }: { state: SpeechBarState; handlers: ChromeHandlers }): JSX.Element {
const max = Math.max(0, (state.sentenceTotal || state.total) - 1);
const value = Math.max(0, state.sentenceIndex >= 0 ? state.sentenceIndex : state.index);
const playing = state.state === 'playing';
return (
<div className="tts-bar" data-state={state.state}>
<IconButton label="上一句" icon="step-backward" onClick={handlers.onSpeechPrevious} />
<IconButton
label={state.state === 'playing' ? '暂停朗读' : '开始朗读'}
icon={state.state === 'playing' ? 'pause' : 'play'}
onClick={handlers.onSpeechToggle}
/>
<IconButton label="下一句" icon="step-forward" onClick={handlers.onSpeechNext} />
<div className="tts-main">
<div className="tts-bar" data-state={state.state} role="group" aria-label="朗读控制">
{/* The controls are one row of their own, and the sentence with its scrubber
is the row below.
The bar used to be a single row: previous, play/pause, next, the sentence
and its range, the count chip, and an ✕ — seven things across a phone.
The flex row gave the sentence `flex: 1`, so *it* was the only thing that
shrank, and on a 360px screen the last control (停止) was pushed past the
right edge and clipped. The reader's report is the honest description of
the result: "没法停止" — there was a stop button, and it was off the
screen. Two rows rather than a scrollable one, because a control the
reader has to go looking for is a control that does not exist (the same
argument the rail's own scrollbar is built on).
Every control is `flex: 1` so the five of them divide the width evenly
and the row always fits: no fixed widths to run out of, and the targets
grow with the screen instead of huddling in the middle of a tablet. */}
<div className="tts-row tts-controls">
<IconButton label="上一句" icon="step-backward" onClick={handlers.onSpeechPrevious} />
<IconButton
label={playing ? '暂停朗读' : '继续朗读'}
icon={playing ? 'pause' : 'play'}
onClick={handlers.onSpeechToggle}
/>
<IconButton label="下一句" icon="step-forward" onClick={handlers.onSpeechNext} />
<button
type="button"
className="tts-chip"
aria-label="从头朗读这一章"
onClick={handlers.onSpeakFromHere}
>
{state.chip}
</button>
{/* Stop is last and it is the one destructive control, so it is separated
by its own colour rather than by position alone. `stop` rather than
`close`: the ✕ read as "close this bar", which is what pause does not
do — the reader who wanted the voice to stop pressed it, got a hidden
bar, and heard the book keep reading. Stop is now a square, which is
the universal transport glyph for exactly this. */}
<IconButton
label="停止朗读"
icon="stop"
class="tts-stop"
onClick={handlers.onStopSpeech}
/>
</div>
<div className="tts-row tts-progress">
<span className="tts-text" title={state.label}>
{state.label}
</span>
@@ -789,15 +817,6 @@ function SpeechBar({ state, handlers }: { state: SpeechBarState; handlers: Chrom
onInput={(event) => handlers.onSpeechScrub(Number((event.currentTarget as HTMLInputElement).value))}
/>
</div>
<button
type="button"
className="chip"
aria-label={state.total > 0 ? '朗读句数' : '从头朗读'}
onClick={handlers.onSpeakFromHere}
>
{state.chip}
</button>
<IconButton label="停止朗读" icon="close" onClick={handlers.onStopSpeech} />
</div>
);
}
@@ -946,7 +965,7 @@ function RailButton({
/** The glyph for an icon name, from the generated code point table. */
function iconGlyph(name: IconName): string {
return ICON_CODEPOINTS[name];
return ICON_CODEPOINT_TABLE[name];
}
/**
@@ -1017,33 +1036,17 @@ function indentLabel(indent: number): string {
}
/**
* Line height, cycling the settings row's own ladder.
* Line height, as the settings sheet's own ladder.
*
* The value is a *string*, because "原书" (`inherit`) is one of the choices and is
* not a number — the book's own line height is the default and the setting has to
* be able to mean "do not touch it". So the rail steps through the same list the
* sheet renders rather than through arithmetic on a number: a rail that computed
* its own values would sooner or later land on one the sheet's rows do not offer,
* and the reader would see the control jump to a position its own options do not
* include.
* be able to mean "do not touch it". The sheet's `SegmentedRow` is the only reader
* of this list now that the rail no longer steps it, and it stays declared here
* because it *is* the ladder: a second copy inside the sheet is how the two would
* drift apart.
*/
const LINE_HEIGHT_LADDER = ['inherit', '1.4', '1.6', '1.8', '2.1'];
function stepLineHeight(current: string): string {
const index = LINE_HEIGHT_LADDER.indexOf(current);
return LINE_HEIGHT_LADDER[(index + 1) % LINE_HEIGHT_LADDER.length] ?? 'inherit';
}
/** Page margin, cycling the slider's own range and wrapping at both ends. */
const PAGE_MARGIN_MIN = 0;
const PAGE_MARGIN_MAX = 4;
const PAGE_MARGIN_STEP = 0.25;
function stepPageMargin(current: number): number {
const next = Number((current + PAGE_MARGIN_STEP).toFixed(2));
return next > PAGE_MARGIN_MAX + 0.001 ? PAGE_MARGIN_MIN : next;
}
const THEME_LADDER: Array<AppSettings['theme']> = ['light', 'sepia', 'dark'];
const THEME_LABELS: Record<string, string> = { light: '白', sepia: '米黄', dark: '夜间' };
+27 -23
View File
@@ -518,6 +518,15 @@ export class ReaderScreen {
...(this.options.pageHost ? { pageHost: this.options.pageHost } : {}),
...(signAssetUrl ? { signAssetUrl } : {}),
onPositionChange: (position) => this.onPosition(position),
// A book's own table-of-contents page is a chapter like any other, and its
// links are the same destination the 目录 panel offers. Routing them through
// `goToChapterRef` rather than through the browser is what makes a tap on
// "第三章" a chapter change — window fetch included — instead of a download of
// another chapter's document.
onChapterLink: (ref) => {
void this.goToChapterRef(ref);
return true;
},
onChapterChange: (index, section) => {
// One patch, and `syncTocPosition` folds its own correction into the same
// one rather than issuing a second. A chapter change is the one moment
@@ -631,27 +640,22 @@ export class ReaderScreen {
* The gesture layer cannot enforce this — it sees the stage, and the panel
* is a sibling — so it is enforced here.
* 2. **The middle third toggles the chrome**, in both directions.
* 3. **The outer thirds turn the page**, honouring the reader's handedness —
* and while the chrome is hidden they *also* bring it back.
* 3. **The outer thirds turn the page**, and *only* that.
*
* The third rule is the one that changed, and the report behind it is the reason:
* "点击左右侧翻页时工具栏不能显示出来". The outer thirds used to *replace* the page
* turn with a reveal whenever the chrome was hidden, on the reasoning that a
* hidden bar leaves the reader no other way back. That reasoning was sound and the
* result was not: the reader tapped the right third to read the next page, got no
* next page, and — because they were already reading, not looking for controls —
* did not notice a toolbar had arrived. Both complaints ("翻页没反应" and "工具栏
* 显示不出来") are the same tap described from the two directions it can fail.
* The third rule is the one that changed, and the report behind it is the whole
* of it: "在点击左右两边时只需要翻页、不需要显示工具栏,只有在中间点击时才需要切换
* 工具栏显隐". The outer thirds used to page-turn *and* force the chrome back on
* whenever it was hidden, on the reasoning that a hidden bar leaves the reader no
* other way back. The reasoning was sound and the result fights the gesture: a
* reader who tapped the right third to go forward got the next page *and* a
* toolbar across the text they had just turned to, so the immersive state was
* impossible to stay in — every page turn undid it. And the reader who *did* want
* the toolbar back had a dedicated, discoverable way to ask for it: the middle
* third.
*
* So the tap does both: it turns the page, and it brings the chrome back. There is
* no ambiguity to resolve, because the two are not alternatives — the reader wants
* to go forward *and* to have the controls, and one gesture can serve both without
* either being guessed at.
*
* While the chrome is *shown* the outer thirds still only turn the page. Letting a
* tap re-show a bar that is already on screen would be the other half of the same
* mistake, and it is the behaviour the reader would notice immediately: every
* page turn would flash the top and bottom bars.
* So the two gestures are now disjoint, one meaning each, which is the property
* the old version lacked: 左/右 = 翻页, 中 = 显隐工具栏. Nothing is revealed by a
* page turn, and nothing is turned by a tap that asked for the controls.
*/
private onTapZone(zone: 'previous' | 'toggle-chrome' | 'next'): void {
if (this.chrome.tocOpen || this.chrome.settingsOpen) return;
@@ -661,13 +665,13 @@ export class ReaderScreen {
return;
}
// Read before the turn, because a page turn does not change it and the reveal
// must be decided from the state the reader was in when they tapped.
const wasHidden = !this.chromeVisible;
// The page turn, and nothing else: the chrome's visibility is the middle
// third's business alone (see the doc comment above). `tapZone` decides which
// physical side is *forward*, so a left-handed reader can put next-page under
// the thumb that holds the phone.
const reversed = this.settings.tapZone === 'reversed';
const forward = reversed ? zone === 'previous' : zone === 'next';
void this.turnPage(forward ? 'next' : 'previous');
if (wasHidden) this.setChromeVisible(true);
}
private async restorePosition(book: Book, token: number): Promise<void> {
+130 -2
View File
@@ -13,6 +13,7 @@ import { escapeHtml, textToChapterHtml } from '../formats/segments.ts';
import { bookPercentage, formatLocator, parseLocator } from './locator.ts';
import { collectSpokenChunks, type SpokenChunk } from '../render/tts-text.ts';
import { adoptWindow } from '../formats/windowed.ts';
import { BOOK_RESOURCE_MARKER, chapterRefFromLink } from '../formats/book-resource.ts';
export interface ReaderViewOptions {
container: HTMLElement;
@@ -35,6 +36,21 @@ export interface ReaderViewOptions {
/** Called on every position change, throttled by the caller via the view. */
onPositionChange?: (position: Position) => void;
onChapterChange?: (index: number, section: Section) => void;
/**
* A link *inside* a chapter that points at another chapter.
*
* The reader's own table of contents is not the only one a book has: an EPUB
* ships a `nav`/`toc` document as a spine item like any other chapter, and a
* reader who opens it and taps "第三章" is asking for a chapter change, not for
* a file download. The href the tap carries has already been rewritten by the
* server to point at the asset endpoint, so the view answers it with the
* chapter reference (`xhtml:<path>`) the same way the 目录 panel does, and the
* screen routes it through the same jump — window fetch included.
*
* Returns whether the reference was understood; a link this view cannot place
* is left to the gesture layer rather than swallowed silently.
*/
onChapterLink?(ref: string): boolean;
/**
* Native renderer for fixed-layout pages, when the host has one.
*
@@ -165,6 +181,16 @@ export class ReaderView {
private animationTimer: ReturnType<typeof setTimeout> | null = null;
private spokenChunks: SpokenChunk[] = [];
private speechHighlight: HTMLElement | null = null;
/**
* The chapter-link listener, bound once to the shadow root.
*
* Once, not per chapter: `setContent` replaces the *children* of `.book-flow`
* and never the shadow root, so a listener on the root survives every chapter
* change while the anchors it sees are always the current chapter's. A listener
* added per render would be a listener per chapter, and a book of 1200 chapters
* read end to end would end up with 1200 of them.
*/
private detachChapterLinks: (() => void) | null = null;
constructor(options: ReaderViewOptions) {
this.options = options;
@@ -179,9 +205,64 @@ export class ReaderView {
this.host.className = 'book-host';
this.resolver = new ResourceResolver(options.doc.resources);
this.container.append(this.host);
this.bindChapterLinks();
this.applySettings();
}
/**
* Routes a tap on a link inside the book.
*
* Captured on the shadow root rather than on each anchor, and in the *capture*
* phase so it runs before the browser's own action. Two things have to be true
* and only one of them is about this client:
*
* - **A chapter link must be a chapter change.** The server rewrote the href to
* the asset endpoint, so without this the tap is a download of another
* chapter's *document* rendered as a file — which is what "点击目录页的章节没
* 有跳转" looks like from the reader's side. `onChapterLink` answers whether
* the reference was understood, and only an understood one is consumed, so a
* footnote target or an image the book linked to still behaves like a link.
* - **Everything else must not escape the reader.** An unhandled `<a>` in a book
* is, by default, a navigation: a tap on an outbound link would replace the
* whole app with a web page, and the reader would have to find their way back
* to a book they were halfway through. In a WebView shell there is no way
* back at all. So a link this client does not recognise is prevented from
* navigating, which is the same rule `sanitiseInjectedContent` applies to
* `target="_blank"`.
*/
private bindChapterLinks(): void {
const onClick = (event: Event): void => {
const target = event.target;
const anchor = target instanceof Element ? target.closest('a[href]') : null;
// Walked from the *event's* target, which is inside the shadow tree because
// this listener is on the root that owns it — a `closest` on the host would
// stop at the shadow boundary and find nothing.
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href) return;
const ref = chapterRefFromLink(href, anchor.baseURI);
if (ref && this.options.onChapterLink?.(ref)) {
event.preventDefault();
event.stopPropagation();
return;
}
// A fragment-only link is the book's own footnote machinery, and the browser
// has to perform it: scrolling to the note is exactly what the reader asked
// for. Anything else would leave the reader, so it is refused.
if (href.trim().startsWith('#')) return;
const resolved = resolveWithinBook(href, anchor.baseURI);
if (resolved === 'external') {
event.preventDefault();
event.stopPropagation();
}
// A link to a resource the book owns (an image, a stylesheet) is left alone:
// the browser's own download/preview behaviour is the honest answer to "open
// this picture", and it cannot leave the book.
};
this.host.shadow.addEventListener('click', onClick, true);
this.detachChapterLinks = () => this.host.shadow.removeEventListener('click', onClick, true);
}
get settingsSnapshot(): ViewSettings {
return { ...this.settings };
}
@@ -314,13 +395,26 @@ export class ReaderView {
animatePage(direction: 'next' | 'previous' | 'none'): void {
if (this.settings.pageAnimation === 'none' || direction === 'none') return;
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const value = `${this.settings.pageAnimation}-${direction}`;
// The *axis* is the mode's, and the attribute carries it.
//
// A page turn moves the reader along the axis the page is defined on: in paged
// mode a page is a column, so a turn is horizontal; in scroll mode a page is a
// screenful, so the same turn is vertical. Animating both with the horizontal
// keyframes meant a scroll-mode reader saw the text slide sideways and land
// where it always was — the animation described a movement that had not
// happened, which is exactly what "翻页的样式不对" looks like.
//
// Fixed layout has no page *positions* to animate between (each page is a
// different document), so it takes the horizontal pair: a comic page sliding in
// from the direction the reader turned is the convention the format has.
const axis = this.doc.layout === 'fixed' || this.settings.mode === 'paged' ? 'x' : 'y';
const value = `${this.settings.pageAnimation}-${direction}-${axis}`;
this.host.setAttribute('data-animating', value);
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
this.animationTimer = setTimeout(() => {
this.animationTimer = null;
this.host.removeAttribute('data-animating');
}, 220);
}, 240);
}
/**
@@ -541,6 +635,8 @@ export class ReaderView {
this.resizeObserver = null;
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
this.animationTimer = null;
this.detachChapterLinks?.();
this.detachChapterLinks = null;
this.clearSpeechHighlight();
this.releaseObjectUrl();
this.resolver.dispose();
@@ -1246,3 +1342,35 @@ function rangeRect(range: Range): DOMRect | null {
return null;
}
}
/**
* Whether a link in a chapter stays inside the book.
*
* Three answers, because the three cases are handled differently:
*
* - `'internal'` — a fragment (`#note7`) or the book's own resource. The browser
* performs it: the note anchors have to work, and an image the book linked to
* is a page the reader may want to see.
* - `'external'` — anything else, which the click handler refuses. A book that
* contains a link to a website must not be able to replace the reader with one.
* - `'unknown'` — a URL that cannot even be parsed, which is refused the same way
* as external: an unparseable href has no correct destination.
*/
function resolveWithinBook(href: string, baseURI: string): 'internal' | 'external' | 'unknown' {
const trimmed = href.trim();
if (trimmed.startsWith('#')) return 'internal';
let url: URL;
try {
url = new URL(trimmed, baseURI);
} catch {
return 'unknown';
}
if (url.protocol === 'blob:' || url.protocol === 'data:') return 'internal';
if (url.origin !== window.location.origin) return 'external';
// A same-origin address is not automatically the book's: the app itself is served
// from this origin, and a link to `/` would leave the reader as surely as a link
// to another host. The book's own resources are the ones the sanitiser kept, which
// the marker identifies.
return url.searchParams.has(BOOK_RESOURCE_MARKER) ? 'internal' : 'external';
}
+116 -15
View File
@@ -211,6 +211,33 @@ const CHAPTER_CONTENT_STYLESHEET = `
`;
const FLOW_STYLESHEET = `
/*
* The box model, re-declared for the shadow tree.
*
* The document's own universal box-sizing rule (see styles/reader.css) does **not**
* reach in here: a shadow root is a separate tree, and a document selector cannot
* match a shadow tree's elements. So every element inside the book was laid out with
* the initial content-box, while every element outside it was border-box — and the
* one place that difference shows is the reading column, which is width 100% *plus*
* the reader's page margin. On a 390px phone that is a 390px column with 24px of
* padding on each side: a 438px box inside a 390px scroller, so the column is 48px
* wider than the page it is paginated into and the next column bleeds into the page
* margin. The reader sees the first characters of the next page down the right edge
* of the current one, which is the reported "左右翻页的样式不对" as much as the
* animation is.
*
* Declared on the host as well as on its descendants, because the book's own markup
* is inside this tree too and has to agree with the app's box model about every box
* the reader's own CSS sizes.
*/
:host {
box-sizing: border-box;
}
:host *,
:host *::before,
:host *::after {
box-sizing: border-box;
}
.book-flow {
position: relative;
max-inline-size: var(--reader-measure, 42rem);
@@ -242,12 +269,30 @@ const FLOW_STYLESHEET = `
* page boundaries and the last column of every chapter is unreachable. A percentage
* is the width of the box the column actually sits in, which is what "one page"
* means.
*
* ## The gap is the page margin, and it is load-bearing
*
* The page margin is padding-inline on the flow, which means it is *inside* the
* scrolling box: the first column's text starts M from the left edge, and the next
* column's text starts column-width + gap further along. With a gap of zero that
* distance is clientWidth - 2M, so the page the reader is looking at is
* clientWidth wide while a page *step* is clientWidth - 2M — and the M px at the
* right edge of every page are the first characters of the next one. That is the
* bleed the reader photographed down the right edge ("左右翻页的样式不对"), and it is
* a geometry bug rather than an animation one.
*
* Setting the gap to *both* margins makes the two agree: a step is
* (clientWidth - 2M) + 2M = clientWidth, exactly one screen, and the M px between
* two columns is the margin that belongs between them anyway — the page margin on
* the *inside* edge of a two-page spread, which is the shape a paginated book has.
* So one declaration makes the step equal the page and draws the gutter at the same
* time, and the reader's own page-margin control moves both together.
*/
:host([data-paginated='true']) .book-flow {
height: 100%;
max-inline-size: none;
padding-block: 0;
column-gap: 0;
column-gap: calc(var(--reader-page-margin, 1.5rem) * 2);
columns: 1;
column-width: 100%;
column-fill: auto;
@@ -256,26 +301,82 @@ const FLOW_STYLESHEET = `
:host([data-paginated='true']) .book-flow > * {
break-inside: auto;
}
:host([data-animating='slide-next']) .book-flow {
animation: slide-next 180ms ease-out;
/*
* The page-turn animations.
*
* The attribute carries three things — style, direction and *axis* — because the
* axis is not the animation's to guess. A page turn moves the reader along the axis
* the page is defined on: paged mode's page is a column, so the move is horizontal;
* scroll mode's page is a screenful, so the same turn is vertical. ReaderView puts
* the mode in the attribute (see animatePage) and the selectors below are the whole
* of the mapping, which keeps the arithmetic out of CSS and the keyframes free of
* data-mode branching.
*/
:host([data-animating]) .book-flow {
/* The animation is a *page* move, so it is clipped to the page: without this a
column that slides in from the right is drawn outside the reading surface for
the length of the animation, over the page margin and the rail. */
overflow: clip;
}
:host([data-animating='slide-previous']) .book-flow {
animation: slide-previous 180ms ease-out;
:host([data-animating^='slide']) .book-flow {
animation-duration: 200ms;
animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
}
:host([data-animating='fade-next']) .book-flow {
animation: fade-next 160ms ease-out;
:host([data-animating='slide-next-x']) .book-flow { animation-name: slide-next-x; }
:host([data-animating='slide-previous-x']) .book-flow { animation-name: slide-previous-x; }
:host([data-animating='slide-next-y']) .book-flow { animation-name: slide-next-y; }
:host([data-animating='slide-previous-y']) .book-flow { animation-name: slide-previous-y; }
:host([data-animating^='fade']) .book-flow {
animation-duration: 160ms;
animation-timing-function: ease-out;
}
:host([data-animating='fade-previous']) .book-flow {
animation: fade-previous 160ms ease-out;
:host([data-animating='fade-next-x']) .book-flow,
:host([data-animating='fade-next-y']) .book-flow { animation-name: fade-next; }
:host([data-animating='fade-previous-x']) .book-flow,
:host([data-animating='fade-previous-y']) .book-flow { animation-name: fade-previous; }
/*
* A page turn moves a page's worth of text, not a few pixels of wobble.
*
* The old keyframes translated by 8px and dropped the opacity to 0.4, and that is
* what the report — "左右翻页的样式不对" — is looking at. Eight pixels on a 390px
* screen is 2% of a page: it reads as the text twitching rather than as a page
* being turned, and the 0.4 opacity makes the *whole* page flash grey on every
* turn, which is worse in a night theme than no animation at all.
*
* The correction is a real slide of one page's extent, expressed as a percentage of
* the flow rather than in pixels: in paged mode the flow *is* one column wide (see
* the paged rule above), so a 100% translate is exactly one page and the incoming
* page starts one page away. The animation is a transform on the already-
* repositioned flow, which is why it composites and why it cannot disturb the
* column layout the paginator measured — the scroll offset is set *first*, and the
* animation only draws the result. The -y pair is the same statement about a
* screenful in scroll mode.
*
* No opacity on the slide. A fade belongs to the fade-* pair, which is the setting's
* own alternative for a reader who finds motion distracting; combining the two means
* neither style is available on its own.
*/
@keyframes slide-next-x {
from { transform: translateX(100%); }
to { transform: none; }
}
@keyframes slide-next {
from { transform: translateX(8px); opacity: 0.4; }
to { transform: none; opacity: 1; }
@keyframes slide-previous-x {
from { transform: translateX(-100%); }
to { transform: none; }
}
@keyframes slide-previous {
from { transform: translateX(-8px); opacity: 0.4; }
to { transform: none; opacity: 1; }
@keyframes slide-next-y {
from { transform: translateY(100%); }
to { transform: none; }
}
@keyframes slide-previous-y {
from { transform: translateY(-100%); }
to { transform: none; }
}
/*
* The fade stays subtle on purpose: it is the *no-motion* alternative, and a deep
* fade on a settings choice called "淡入" is a second, louder animation. The pair
* is asymmetric with the slide for that reason, not by oversight.
*/
@keyframes fade-next {
from { opacity: 0.35; }
to { opacity: 1; }
+51
View File
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
/**
* The reverse of the server's chapter-link rewrite.
*
* The server turns a chapter's own cross-reference (`<a href="ch2.xhtml">`) into an
* absolute asset URL, because a relative href resolves to nothing once the document
* is served from outside the archive. The client has to read that URL back as *a
* chapter* — otherwise a tap on an EPUB's own table of contents downloads another
* document and renders it as an asset, which is the reported "点击章节没有反应" in
* its most literal form.
*/
import { describe, expect, it } from 'vitest';
import { BOOK_RESOURCE_MARKER, chapterRefFromLink } from '../src/formats/book-resource.ts';
const BASE = 'http://reader.local/';
function assetUrl(ref: string, fragment = ''): string {
return `/api/v1/books/b1/assets?${BOOK_RESOURCE_MARKER}=1&ref=${encodeURIComponent(ref)}${fragment}`;
}
describe('chapterRefFromLink', () => {
it('reads the server-ref of a chapter document back as a section id', () => {
expect(chapterRefFromLink(assetUrl('OEBPS/ch2.xhtml'), BASE)).toBe('xhtml:OEBPS/ch2.xhtml');
expect(chapterRefFromLink(assetUrl('OEBPS/ch2.htm'), BASE)).toBe('xhtml:OEBPS/ch2.htm');
expect(chapterRefFromLink(assetUrl('OEBPS/ch2.html'), BASE)).toBe('xhtml:OEBPS/ch2.html');
expect(chapterRefFromLink(assetUrl('OEBPS/nav.xml'), BASE)).toBe('xhtml:OEBPS/nav.xml');
});
it('keeps a fragment out of the section id', () => {
// The fragment is "a position *inside* that chapter" — the chapter is the
// destination, and the id has to be exactly what the manifest publishes or the
// jump cannot find the window it lives in.
expect(chapterRefFromLink(assetUrl('OEBPS/ch2.xhtml', '#note3'), BASE)).toBe('xhtml:OEBPS/ch2.xhtml');
});
it('answers null for an image the book linked to', () => {
// An image is not a chapter: the browser's own preview of it is the honest
// answer to "open this picture", and pretending it is a chapter would jump the
// reader somewhere arbitrary.
expect(chapterRefFromLink(assetUrl('OEBPS/pic.png'), BASE)).toBeNull();
});
it('answers null for a footnote fragment and for links it does not own', () => {
expect(chapterRefFromLink('#note3', BASE)).toBeNull();
expect(chapterRefFromLink('', BASE)).toBeNull();
expect(chapterRefFromLink('https://example.com/ch2.xhtml', BASE)).toBeNull();
// A same-origin URL without the marker is not the book's: the marker is the
// only thing that says "the server wrote this address".
expect(chapterRefFromLink('/whatever/ch2.xhtml', BASE)).toBeNull();
});
});
+28 -5
View File
@@ -2,7 +2,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { ICON_CODEPOINTS } from '../src/ui/icon-names.ts';
import { ICON_ALIASES, ICON_CODEPOINTS, ICON_CODEPOINT_TABLE } from '../src/ui/icon-names.ts';
import { GLYPHS } from '../tools/icons/paths.mjs';
import { pathToSubpaths } from '../tools/icons/path.mjs';
@@ -55,7 +55,7 @@ function fontCodePoints(): Set<number> {
describe('the icon font and its code point table', () => {
const mapped = fontCodePoints();
const table = Object.values(ICON_CODEPOINTS).map((value) => value.codePointAt(0)!);
const table = Object.values(ICON_CODEPOINT_TABLE).map((value) => value.codePointAt(0)!);
it('maps every code point the table declares', () => {
// A code point in the table that the font does not map is a missing-glyph box.
@@ -74,8 +74,14 @@ describe('the icon font and its code point table', () => {
});
it('has one code point per icon, with no duplicates', () => {
expect(new Set(table).size).toBe(table.length);
expect(mapped.size).toBe(table.length);
// Counted on the *distinct* code points rather than on the table's length,
// because the table carries aliases: `font` and `text-size` are two spellings
// of one glyph, and a set that maps a code point twice is a rename, not a
// second icon. What has to hold either way is that the font draws exactly the
// code points the table names — no more, no fewer.
const distinct = new Set(table);
expect(distinct.size).toBe(Object.keys(ICON_CODEPOINTS).length);
expect(mapped.size).toBe(distinct.size);
});
it('was built from the same glyph names as the table', () => {
@@ -153,6 +159,23 @@ describe('the icon font and its code point table', () => {
const names = Object.keys(GLYPHS);
const borrowed = names.filter((name) => BORROWED.includes(name));
expect(borrowed, `these names are another set's: ${borrowed.join(', ')}`).toEqual([]);
// The *table* may keep a borrowed spelling as an alias so that code written
// against the set this repository is based on keeps compiling. What it may not
// do is add a glyph under one: a borrowed name on our own artwork is what the
// list above is about.
const aliasNames = Object.keys(ICON_ALIASES);
const borrowedAliases = aliasNames.filter((name) => BORROWED.includes(name));
expect(borrowedAliases.length, `an alias is a borrowed name: ${borrowedAliases.join(', ')}`).toBeGreaterThan(0);
});
it('points every alias at a code point the font already draws', () => {
// An alias is a second *spelling*, not a second glyph: two names for one code
// point is a rename, a name for a code point nothing draws is a missing-glyph
// box that no other assertion here can see.
const drawn = new Set(Object.values(ICON_CODEPOINTS));
for (const [name, code] of Object.entries(ICON_ALIASES)) {
expect(drawn.has(code), `${name} points at 0x${code.codePointAt(0)!.toString(16)}, which no glyph uses`).toBe(true);
}
});
it('names every glyph after the thing it draws, in the vocabulary the UI uses', () => {
@@ -174,7 +197,7 @@ describe('the icon font and its code point table', () => {
const names = Object.keys(GLYPHS);
names.forEach((name, index) => {
const expected = 0xe900 + index;
expect((ICON_CODEPOINTS as Record<string, string>)[name]!.codePointAt(0)).toBe(expected);
expect((ICON_CODEPOINT_TABLE as Record<string, string>)[name]!.codePointAt(0)).toBe(expected);
});
});
});
+189
View File
@@ -0,0 +1,189 @@
// @vitest-environment jsdom
/**
* The reader's chrome, as a function of its state.
*
* Two of the reported defects live entirely in this tree, and both are the kind a
* screenshot review cannot separate from a styling problem:
*
* - the quick-action rail held five sheet-opening destinations, so it was a second
* settings panel floating over the text (竖排工具栏去掉 目录、字体、行距、边距、设置);
* - the read-aloud bar's controls were a single flex row, so on a phone the last
* of them — stop — was pushed off the right edge and the reader could not stop
* the voice at all (没法停止).
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { createElement as h, render } from '../src/ui/vendor/preact.ts';
import { ReaderChrome, type ChromeState, type ChromeHandlers } from '../src/ui/reader-chrome.tsx';
function chromeState(overrides: Partial<ChromeState> = {}): ChromeState {
return {
title: '书',
author: '',
chromeVisible: true,
statusText: '',
statusState: 'idle',
progress: 0.2,
chapterLabel: '第 1 章',
tocOpen: false,
settingsOpen: false,
toc: [],
currentSectionId: '',
pageInChapter: 1,
chapterPages: 3,
chapterIndex: 1,
chapterCount: 10,
navigating: false,
tts: {
active: false,
state: 'idle',
label: '',
chip: '从头朗读',
index: 0,
total: 0,
sentenceIndex: -1,
sentenceTotal: 0,
},
layout: 'reflowable',
format: 'epub',
mode: 'scroll',
fontScale: 1,
lineHeight: 'inherit',
theme: 'light',
fit: 'contain',
fontFamily: 'inherit',
pageMargin: 1.5,
textAlign: 'inherit',
brightness: 1,
pageAnimation: 'slide',
tapZone: 'standard',
txtEncoding: '',
txtIndent: 2,
txtParagraphGap: 0.55,
comicDirection: 'ltr',
ttsRate: 1,
ttsPitch: 1,
ttsVolume: 1,
ttsVoice: '',
ttsAutoAdvance: true,
ttsEngine: 'auto',
showFitRow: false,
showDirectionRow: false,
showEncodingRow: false,
showTxtRows: false,
showEngineRow: false,
showPitchRow: true,
showVoiceRow: true,
speechUnavailable: false,
engineOptions: [{ value: 'auto', label: '自动' }],
voices: [{ value: '', label: '跟随系统' }],
...overrides,
} as ChromeState;
}
/** Every handler is a no-op: this file asserts the tree, not the behaviour. */
function handlers(): ChromeHandlers {
const noop = (): void => undefined;
return {
onBack: noop,
toggleToc: noop,
toggleSettings: noop,
onSetting: noop,
onSpeechSetting: noop,
onVoice: noop,
onSpeakFromHere: noop,
onSwitchEngine: noop,
onTocEntry: noop,
onChapter: noop,
onScrubPage: noop,
onTurnPage: noop,
onSpeechToggle: noop,
onSpeechPrevious: noop,
onSpeechNext: noop,
onSpeechScrub: noop,
onStopSpeech: noop,
} as unknown as ChromeHandlers;
}
describe('ReaderChrome', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
});
function paint(state: Partial<ChromeState>): HTMLElement {
const stage = document.createElement('div');
stage.className = 'stage';
render(h(ReaderChrome, { state: chromeState(state), stage, handlers: handlers() }), container);
return container;
}
it('keeps only the page-local controls on the rail', () => {
// 主题 / 字号 / 听书. The five that were removed all open or reach the settings
// sheet, so having them on the rail as well made it a worse copy of that sheet
// floating over the text.
const tree = paint({});
const rail = tree.querySelector('.reader-rail');
expect(rail).not.toBeNull();
const labels = [...(rail?.querySelectorAll('.rail-button') ?? [])].map((button) =>
button.getAttribute('aria-label'),
);
expect(labels).toEqual(['夜间', '字号', '听书']);
for (const gone of ['目录', '字体', '行距', '页边距', '阅读设置']) {
expect(labels).not.toContain(gone);
}
});
it('draws every read-aloud control, including stop, in their own row', () => {
const tree = paint({
tts: {
active: true,
state: 'playing',
label: '正文',
chip: '3/10',
index: 2,
total: 10,
sentenceIndex: 2,
sentenceTotal: 10,
},
});
const bar = tree.querySelector('.tts-bar');
expect(bar).not.toBeNull();
// The controls are one row of their own, so they divide the width evenly and
// none of them is clipped — which is what made 停止 unreachable.
const controls = bar?.querySelector('.tts-controls');
expect(controls).not.toBeNull();
const labels = [...(controls?.querySelectorAll('button') ?? [])].map((button) =>
button.getAttribute('aria-label'),
);
expect(labels).toContain('停止朗读');
expect(labels).toContain('暂停朗读');
expect(labels).toContain('上一句');
expect(labels).toContain('下一句');
// The sentence and its scrubber are the second row, so the sentence can never
// push a control out of the bar.
expect(bar?.querySelector('.tts-progress .tts-range')).not.toBeNull();
});
it('stops the voice with a square, not an ✕', () => {
// ✕ read as "close this bar", which is not what stop does: the reader who wanted
// the voice to stop pressed it, the bar disappeared, and the book kept reading.
const tree = paint({
tts: {
active: true,
state: 'playing',
label: '正文',
chip: '1/2',
index: 0,
total: 2,
sentenceIndex: 0,
sentenceTotal: 2,
},
});
const stop = [...tree.querySelectorAll('.tts-controls button')].find(
(button) => button.getAttribute('aria-label') === '停止朗读',
);
expect(stop?.getAttribute('class')).toContain('tts-stop');
});
});
+133
View File
@@ -0,0 +1,133 @@
// @vitest-environment node
/**
* The reader's layout rules, read out of the real stylesheet.
*
* These are the rules a *screenshot review* cannot adjudicate, because all four of
* the defects below look like styling choices and are actually statements about
* geometry: whether the page reserves space for chrome that may not be there,
* whether a floating band can cover the line it sits on, and whether a control
* that must be reachable is painted above or below the page.
*
* Read from `reader.css` rather than restated, so a test cannot agree with a rule
* that no longer exists.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const css = readFileSync(join(process.cwd(), 'src/styles/reader.css'), 'utf8');
/**
* The body of the top-level rule whose selector is exactly `selector`.
*
* Top-level only, and exactly, because both restrictions answer a real ambiguity:
* `.tts-bar` is a prefix of `.tts-bar .tts-main` in the sheet, and a *media query*
* at the end of it (`prefers-reduced-motion`) lists `.topbar, .footer, .reader-rail`
* together and would otherwise be the rule a search for `.footer` found first —
* a test that then agreed with a transition duration instead of a background.
*
* Comments are stripped first: several of them contain braces, `.class` names and
* even whole selectors.
*/
function rule(selector: string): string {
const text = css.replace(/\/\*[\s\S]*?\*\//g, '');
let index = 0;
let depth = 0;
while (index < text.length) {
const character = text[index]!;
if (character === '}') {
depth -= 1;
index += 1;
continue;
}
if (character !== '{') {
index += 1;
continue;
}
const selectorText = text.slice(lastBoundary(text, index), index).trim();
const close = matchingBrace(text, index);
if (depth === 0 && !selectorText.startsWith('@')) {
const selectors = selectorText.split(',').map((part) => part.trim());
if (selectors.includes(selector)) return text.slice(index + 1, close);
}
// An at-rule's block is walked *into*; a plain rule's is skipped whole, since
// its declarations are not selectors.
if (selectorText.startsWith('@')) {
depth += 1;
index += 1;
} else {
index = close + 1;
}
}
return '';
}
/** Where the selector text of the `{` at `open` begins. */
function lastBoundary(text: string, open: number): number {
for (let i = open - 1; i >= 0; i -= 1) {
if (text[i] === '}' || text[i] === '{' || text[i] === ';') return i + 1;
}
return 0;
}
/** The index of the `}` that closes the `{` at `open`, depth-counted. */
function matchingBrace(text: string, open: number): number {
let depth = 0;
for (let i = open; i < text.length; i += 1) {
if (text[i] === '{') depth += 1;
else if (text[i] === '}') {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
function declaration(body: string, property: string): string | undefined {
return new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`).exec(body)?.[1]?.trim();
}
describe('the reading surface fills the screen', () => {
it('reserves no space for the chrome', () => {
// "内容要铺满,不要预留 stage": the bars float, so the page must not keep their
// height clear. Reserving it shortens the reading column in *both* chrome
// states, so a reader who hid the toolbar still loses the top and bottom bands
// to empty paper.
const body = rule('.stage-host');
expect(body).not.toBe('');
expect(declaration(body, 'padding')).toBeUndefined();
expect(declaration(body, 'padding-block')).toBeUndefined();
expect(declaration(body, 'inset')).toBe('0');
});
it('draws the floating bands on a surface the reader can read through', () => {
// The consequence of the rule above: the bars now overlap text, so they have to
// be translucent or they hide the paragraph they cover. The surface comes from
// one token so the three themes cannot drift apart.
for (const selector of ['.topbar', '.footer']) {
const body = rule(selector);
expect(declaration(body, 'background'), selector).toBe('var(--reader-surface-float)');
expect(declaration(body, 'backdrop-filter'), selector).toContain('blur');
}
});
it('paints the read-aloud bar above the page it floats over', () => {
// The bar is a sibling of the absolutely positioned stage host. A static
// sibling is laid out behind the stage, which paints at --reader-layer-page:
// the bar was drawn, its buttons were focusable, and none of it could be hit.
// That is "没法停止" — a stop button that existed and was unreachable.
const body = rule('.tts-bar');
expect(declaration(body, 'position')).toBe('absolute');
expect(declaration(body, 'z-index')).toBe('var(--reader-layer-bar)');
// Above the footer, which is its own height from the bottom edge — read from
// the footer's own token so the two bands cannot overlap.
expect(declaration(body, 'bottom')).toBe('var(--reader-chrome-bottom)');
});
it('gives the read-aloud controls their own row so none of them is clipped', () => {
// One flex row of seven things gave the sentence `flex: 1`, so it was the only
// thing that shrank and the last control was pushed past the edge on a phone.
const controls = rule('.tts-bar .tts-controls > *');
expect(declaration(controls, 'flex')).toBe('1 1 0');
});
});
+102 -2
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReaderView } from '../src/ui/reader-view.ts';
import { createStagedDoc } from '../src/formats/windowed.ts';
import type { BookContent, ContentItem } from '../src/net/api.ts';
@@ -406,7 +406,9 @@ describe('ReaderView appearance settings', () => {
const view = make(container, reflowableDoc(2));
view.applySettings({ pageAnimation: 'slide' });
view.animatePage('next');
expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBe('slide-next');
// Scroll mode (the default) turns a *screenful*, so the axis is `y`; the
// paged case is asserted in the paged-mode test below.
expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBe('slide-next-y');
vi.runAllTimers();
expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBeNull();
} finally {
@@ -414,6 +416,16 @@ describe('ReaderView appearance settings', () => {
}
});
it('turns a *page* on a paged turn but a *screenful* on a scroll-mode one', () => {
// The axis is the mode's, and the attribute is where the stylesheet reads it
// from: a horizontal keyframe in scroll mode animated a movement that had not
// happened, which is the reported "翻页的样式不对".
const view = make(container, reflowableDoc(2));
view.applySettings({ pageAnimation: 'slide', mode: 'paged' });
view.animatePage('previous');
expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBe('slide-previous-x');
});
it('does not animate when the reader turned animation off', () => {
const view = make(container, reflowableDoc(2));
view.applySettings({ pageAnimation: 'none' });
@@ -594,3 +606,91 @@ describe('plain-text chapters are typeset by the reader', () => {
expect(shadow.querySelector('p')?.getAttribute('data-authored')).toBe('1');
});
});
/**
* A link *inside* a chapter that points at another chapter.
*
* An EPUB ships its own table of contents as a spine item, so a reader who opens it
* and taps 第三章 is asking for a chapter change. The href they tap has already been
* rewritten by the server to point at the asset endpoint — left unhandled, the tap
* downloads another chapter's document and the reader sees a broken page instead of
* the chapter they asked for.
*/
describe('ReaderView chapter links', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
});
afterEach(() => {
container.remove();
});
/** A chapter document whose body contains one link the server rewrote. */
function docWithLink(href: string, id = 'nav.xhtml'): BookDoc {
const doc = reflowableDoc(1);
return {
...doc,
sections: [{ id, label: '目录', html: `<p><a href="${href}">第三章</a></p>`, depth: 0 }],
};
}
function clickLink(view: ReaderView): boolean {
const anchor = view.elementHost.shadow.querySelector('a');
if (!anchor) throw new Error('no anchor rendered');
return anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }));
}
it('routes a chapter link to the screen instead of letting the browser fetch it', async () => {
// The marker is in the URL, so the sanitiser keeps it — that is the point of
// the pair (marker + same origin). `document.baseURI` is jsdom's about:blank,
// so the URL is built against it rather than against a made-up origin.
const origin = document.baseURI;
const href = new URL(
`/api/v1/books/b1/assets?__reader-book-resource__=1&ref=OEBPS/ch3.xhtml`,
origin,
).href;
const seen: string[] = [];
const view = new ReaderView({
container,
doc: docWithLink(href),
onChapterLink: (ref) => {
seen.push(ref);
return true;
},
});
await view.open(0, 0);
// The click is *not* dispatched by default: a consumed chapter link must not
// also be a navigation.
expect(clickLink(view)).toBe(false);
expect(seen).toEqual(['xhtml:OEBPS/ch3.xhtml']);
view.dispose();
});
it('refuses a link that would leave the reader', async () => {
// An outbound link in a book must not be able to replace the app with a web
// page: in a WebView shell there is no way back to the book at all. The
// sanitiser drops the attribute outright, which is stronger than refusing the
// tap — there is no href left for the browser to follow, and no listener that
// has to be correct for the rule to hold.
const view = new ReaderView({
container,
doc: docWithLink('https://example.com/'),
});
await view.open(0, 0);
const anchor = view.elementHost.shadow.querySelector('a');
expect(anchor?.hasAttribute('href')).toBe(false);
expect(clickLink(view)).toBe(true);
view.dispose();
});
it('lets the browser perform an in-document footnote link', async () => {
const view = new ReaderView({ container, doc: docWithLink('#note3') });
await view.open(0, 0);
expect(clickLink(view)).toBe(true);
view.dispose();
});
});
+27 -1
View File
@@ -323,10 +323,36 @@ describe('BookShadowHost', () => {
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
expect(sheet).toContain(":host([data-paginated='true']) .book-flow");
expect(sheet).toContain(":host([data-animating='slide-next']) .book-flow");
// The page-turn selector carries the *axis* as well as the style and direction:
// a paged turn is horizontal and a scroll-mode turn is vertical, and the axis
// has to come from the selector because the two modes share one host.
expect(sheet).toContain(":host([data-animating='slide-next-x']) .book-flow");
expect(sheet).toContain(":host([data-animating='slide-next-y']) .book-flow");
expect(sheet).not.toContain('.book-host[');
});
it('separates two paginated columns by exactly two page margins', () => {
// The page margin is padding *inside* the scrolling box, so the page the reader
// sees is `clientWidth` wide while a page *step* was `clientWidth - 2 * margin`.
// The two disagree by the margin, which is why the right edge of every page
// showed the first characters of the next one — the bleed reported as
// "左右翻页的样式不对". A gap of both margins makes the step equal the page and
// draws the gutter between columns at the same time.
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
expect(sheet).toContain('column-gap: calc(var(--reader-page-margin, 1.5rem) * 2)');
});
it('declares border-box for the shadow tree, which the document cannot reach', () => {
// The app's own universal `box-sizing: border-box` is a *document* selector, and
// a document selector cannot match a shadow tree's elements. The reading column
// is width 100% plus the reader's page margin, which under the initial
// `content-box` is a box wider than the page it is paginated into.
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
expect(sheet).toContain('box-sizing: border-box');
});
it('keeps the layout sheet when a chapter replaces the book styles', () => {
// The book's styles change with every chapter; the reader's layout rules must
// not, or the column loses its measure for a frame on every chapter change.
+79 -40
View File
@@ -184,20 +184,20 @@ async function audit(cdp, origin, scenes, results) {
);
/*
* The rail holds the whole set of controls the reader reaches for, and every one
* of them is reachable.
* The rail holds the two adjustments a reader makes *from the page* plus 听书, and
* nothing that opens a sheet.
*
* The standing report is "竖排工具栏显示为 主题切换、听书", and its shape decided
* these checks. The rail was a column with no height ceiling and no scrolling, so
* on a screen short enough it simply ran off the bottom: the reader sees what fits,
* concludes that is what the rail does, and reports the two they can see. Two
* properties answer that, and both are measured rather than read from the CSS:
* The report is "竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮", and the
* reasoning behind it is the one worth keeping in a comment: all five are
* *destinations*, not adjustments — 目录 and 设置 open a sheet, and 字体/行距/边距 are
* steps of values whose full range lives in that sheet — so a rail that also carried
* them was a second, worse copy of the settings panel floating over the text. What
* is left is what a reader changes without wanting to open anything.
*
* - **the column is inside the page**, so its last control is not drawn off the
* bottom of the screen;
* - **the controls it cannot fit are reachable** — the column scrolls, and it
* looks scrollable, because a control that can only be found by scrolling has to
* advertise the scroll or it does not exist as far as the reader is concerned.
* The column still has to stay inside the page however short the screen is, and the
* controls it cannot fit still have to be reachable — the column scrolls, and it
* looks scrollable, because a control that can only be found by scrolling has to
* advertise the scroll or it does not exist as far as the reader is concerned.
*/
const rail = await cdp.evaluate(`(() => {
const el = document.querySelector('.reader-rail');
@@ -229,10 +229,20 @@ async function audit(cdp, origin, scenes, results) {
: `溢出 ${rail.overflows ? '是' : '否'} overflow-y=${rail.scrollable} 滚动条 ${rail.scrollbarWidth}px`,
);
check(
'快捷列: 一列里含整组快捷操作,而不是只露出前几个',
!rail.missing && rail.labels.length >= 7,
'快捷列: 只留页面内就能完成的调整',
!rail.missing &&
rail.labels.includes('听书') &&
rail.labels.some((label) => label === '夜间' || label === '日间') &&
rail.labels.includes('字号'),
rail.missing ? '没有找到快捷按钮列' : `按钮 ${rail.labels.length} 个:${rail.labels.join(' / ')}`,
);
// The five the report asked to remove. Asserted by *name* rather than by count,
// because "four controls" would pass on a rail that had dropped the wrong one.
check(
'快捷列: 不含打开面板的五个入口',
!rail.missing && !rail.labels.some((label) => ['目录', '字体', '行距', '页边距', '设置', '阅读设置'].includes(label)),
rail.missing ? '没有找到快捷按钮列' : `列内控件:${rail.labels.join(' / ')}`,
);
// The read-aloud control is on the rail, which is the report's own example
// ("听书"). It matters because it is the one adjustment a reader makes *without*
// leaving the page: reaching it only through the settings sheet is a different
@@ -348,13 +358,11 @@ async function audit(cdp, origin, scenes, results) {
!hidden.missing && hidden.topbarPointerEvents === 'none' && hidden.footerPointerEvents === 'none',
hidden.missing ? 'n/a' : `topbar=${hidden.topbarPointerEvents} footer=${hidden.footerPointerEvents}`,
);
// The page is the screen in *both* states, and the assertion is on the text rather
// than on the stage: the stage is inset by the bars' height deliberately (see
// `--reader-chrome-*`), so "the page fills the viewport" is the wrong property to
// ask for. The property that matters is the one the reader reported: showing or
// hiding the chrome must not move a single line. It has to be measured on the
// *chapter's own first block* — the element the reader is reading — and compared
// against the same measurement taken with the chrome visible.
// The page is the screen in *both* states, and the property asserted is the one
// the reader reported: showing or hiding the chrome must not move a single line.
// It has to be measured on the *chapter's own first block* — the element the
// reader is reading — and compared against the same measurement taken with the
// chrome visible.
check(
'收起工具栏: 正文一行的位置不变(工具栏是浮层)',
!hidden.missing && visibleChromeTextTop !== null && hiddenTextTop !== null && Math.abs(hiddenTextTop - visibleChromeTextTop) <= 1,
@@ -362,6 +370,37 @@ async function audit(cdp, origin, scenes, results) {
? 'n/a'
: `正文首行 y: 显示时 ${visibleChromeTextTop} / 收起后 ${hiddenTextTop}`,
);
// And the page *fills* the screen, which is the other half of the report:
// "内容要铺满,不要预留 stage". The stage used to carry the bars' own heights as
// `padding-block`, so the reading column was permanently 9rem shorter than the
// phone in *both* chrome states — a reader who had hidden the toolbar and wanted
// the whole screen for the book still lost the top and bottom bands to empty
// paper. Measured as "the stage reserves no block padding", which is the cause,
// rather than as "the text reaches the edge", which a chapter's own margin makes
// false.
const stageBox = await cdp.evaluate(`(() => {
const stage = document.querySelector('.stage-host');
const host = document.querySelector('.stage');
if (!stage || !host) return null;
const style = getComputedStyle(stage);
return {
paddingTop: parseFloat(style.paddingTop) || 0,
paddingBottom: parseFloat(style.paddingBottom) || 0,
stageTop: Math.round(host.getBoundingClientRect().top),
stageBottom: Math.round(host.getBoundingClientRect().bottom),
viewport: window.innerHeight,
};
})()`);
check(
'正文铺满: 页面不为工具栏预留高度',
stageBox !== null &&
stageBox.paddingTop <= 0.5 &&
stageBox.paddingBottom <= 0.5 &&
stageBox.stageBottom - stageBox.stageTop >= stageBox.viewport - 2,
stageBox === null
? 'n/a'
: `padding ${stageBox.paddingTop}/${stageBox.paddingBottom}px, 页面 ${stageBox.stageBottom - stageBox.stageTop}px / 屏高 ${stageBox.viewport}`,
);
// The bars float over the page, so they must be *positioned* — a bar back in the
// flow is a bar that takes height out of the page and moves the text, which is the
// whole of the reported defect. Asserted on the computed `position` rather than on
@@ -490,20 +529,18 @@ async function audit(cdp, origin, scenes, results) {
: '没有找到阅读指示',
);
// A tap in an outer third pages *and* brings the chrome back.
// A tap in an outer third pages, and *only* pages.
//
// The report is "点击左右侧翻页时工具栏不能显示出来", and it is one tap described
// from both of the directions it can fail. The outer thirds used to *replace* the
// page turn with a reveal while the chrome was hidden, on the reasoning that a
// hidden bar leaves no other way back — so a reader who tapped the right third to
// read on got no next page, and a toolbar they were not looking for. Asserting
// both halves is the only way to state the fix: either one alone passes on the
// behaviour that was reported.
// The report is "在点击左右两边时只需要翻页、不需要显示工具栏,只有在中间点击时才需要
// 切换工具栏显隐", and it is one tap described from both of the directions it can
// fail. The outer thirds used to page-turn *and* force the chrome back while it
// was hidden, on the reasoning that a hidden bar leaves no other way back — so the
// immersive state was impossible to stay in, because every page turn undid it.
// Both halves have to be asserted: a check on the page number alone passes on the
// behaviour that was reported, and so does a check on the chrome alone.
//
// The page readout is read before and after, and the chrome is hidden first, so the
// assertion is about a state the reader can actually be in. The measurement is
// skipped rather than failed when the chapter has only one page — there is nothing
// to turn, and a check that failed there would be asserting about the fixture.
// The chrome is hidden first, so the assertion is about a state the reader can
// actually be in, and the page readout is read before and after.
const beforeThird = await cdp.evaluate(
`(() => ({ chrome: document.querySelector('.reader-screen').dataset.chrome, page: document.querySelector('.progress-page')?.textContent ?? '', value: Number(document.querySelector('.progress-scrubber')?.value ?? '0') }))()`,
);
@@ -513,16 +550,18 @@ async function audit(cdp, origin, scenes, results) {
`(() => ({ chrome: document.querySelector('.reader-screen').dataset.chrome, page: document.querySelector('.progress-page')?.textContent ?? '', value: Number(document.querySelector('.progress-scrubber')?.value ?? '0') }))()`,
);
check(
'点右侧翻页: 既翻页又把工具栏叫回来',
afterThird.chrome === 'visible' && beforeThird.chrome === 'hidden' && afterThird.value > beforeThird.value,
'点右侧只翻页: 页码前进, 工具栏保持收起',
afterThird.chrome === 'hidden' && beforeThird.chrome === 'hidden' && afterThird.value > beforeThird.value,
`收起前 ${beforeThird.chrome}(${beforeThird.page}) → 点击后 ${afterThird.chrome}(${afterThird.page})`,
);
// The panel checks that follow need the chrome *up*, and it already is: the check
// above deliberately ends with it visible, because that is half of what it asserts.
// Tapping the middle to "restore" it would hide it and leave every panel check
// waiting for a button it cannot reach.
await cdp.sleep(200);
// The middle third is the only way back to the controls, so it is asserted as
// such right after: the outer-third check above leaves the chrome hidden, and the
// panel checks below need it up.
await cdp.tapMiddle();
await cdp.sleep(600);
const afterMiddle = await cdp.evaluate(`document.querySelector('.reader-screen').dataset.chrome`);
check('点中间切换工具栏', afterMiddle === 'visible', `点击中间后 ${afterMiddle}`);
for (const scene of scenes) {
if (scene.openPanel) {