mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +08:00
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:
20 files changed
+1350
-273
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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` 做两件事,两件都要:
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '夜间' };
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user