mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 14:31:11 +08:00
merge: 同步远程阅读界面更新并保留书源配置样式
This commit is contained in:
commit
59faec336a
29 files changed
+727
-2291
No files matched your search
@@ -26,3 +26,4 @@ web/dist/
|
||||
# every run is noise in a review. The *report* is committed — that is the part a
|
||||
# reviewer reads, and the part CI asserts on.
|
||||
docs/ui-review/*.png
|
||||
docs/ui-review/mobile/*.png
|
||||
@@ -95,7 +95,7 @@ volumes:
|
||||
| 页边距 | 0–4rem,控制每行字数 |
|
||||
| 对齐 | 默认「原书」,可强制左对齐 / 两端对齐 |
|
||||
| 字体 | 默认「原书」。可选系统黑体 / 宋体 / 楷体 / 苹方,作为字体栈的**前缀**而不是替换 |
|
||||
| 主题 | 白 / 米黄 / 夜间 |
|
||||
| 主题 | 白 / 米黄 / 浅绿 / 夜间 |
|
||||
| 亮度 | 35%–100%,叠加式调暗,文字与纸色的对比关系不变 |
|
||||
| 图片适配 | 仅固定版式(漫画、PDF):完整显示 / 适宽 |
|
||||
| 翻页方向 | 仅固定版式:左→右 / 右→左(日漫) |
|
||||
@@ -108,20 +108,12 @@ volumes:
|
||||
|
||||
### 界面与图标
|
||||
|
||||
界面图标是**一个自带的字体**(`web/src/styles/reader-icons.ttf`,37 个字形,16KB),
|
||||
不是 Unicode 字符,也不是内联 SVG:
|
||||
阅读页以手机浏览器为优先:顶部显示返回、书名和章节,底部集中目录、朗读、界面、设置。
|
||||
工具栏和半屏面板覆盖正文,显隐不改变正文区域尺寸。界面设置可分别选择顶部左/右、底部左/右显示书名、章节名、章节进度、时间或不显示;默认顶部左侧章节名、底部左侧章内页码与全书百分比、底部右侧时间,按设备保存。
|
||||
底栏上方保留一个日夜切换按钮,随工具栏一起收起。
|
||||
|
||||
- **不是 Unicode 字符**:改动前按钮里放的是 `☰` `⚙` `✕` `🗂` `📁` `⏮`。
|
||||
八个字形来自八个字族,光学重量各不相同;其中几个是 emoji,在部分平台被渲染成
|
||||
彩色的——一个彩色 emoji 出现在单色工具栏里,是「没有设计过」最直接的信号。
|
||||
不同平台的字体回退还不同,同一个界面在两个宿主上长得不一样。
|
||||
- **不是内联 SVG**:三十个小组件里,颜色、尺寸、线宽每一项都是单个字形可以漂移的
|
||||
位置,而字体把字形变成**字符**——它没有自己的几何,也就没有可以漂移的地方。
|
||||
- 字形由 `web/tools/icons/` 下的脚本从**描边路径**编译而成,全部画在同一个
|
||||
24 单位网格上、同一条线宽,这是光学一致性的来源。重新生成:`npm run icons`。
|
||||
|
||||
界面规范(图标、间距/字号/动效令牌、焦点环、状态表达、文案)见
|
||||
[`docs/ui.md`](docs/ui.md)。
|
||||
图标使用 Lucide SVG,通过统一的 Icon 组件控制尺寸、线宽和无障碍标签,不再加载字体图标。
|
||||
界面规范见 [docs/ui.md](docs/ui.md),手机回归可运行 `cd web && npm run ui:review`。
|
||||
|
||||
### 翻页与章节导航
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
| 截图 | 场景 | 看什么 |
|
||||
| --- | --- | --- |
|
||||
| `01-shelf.png` | 书架 | 第一屏:先看到书,再看到控件 |
|
||||
| `02-reader.png` | 阅读页 | 顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章 |
|
||||
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数 |
|
||||
| `02-reader.png` | 阅读页 | 顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口 |
|
||||
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 工具栏一起收起,正文保持原位置,底部只留书名、页码与进度 |
|
||||
| `04-panel-toc.png` | 目录 · 半屏 | 下半屏,上半屏正文仍可见 |
|
||||
| `05-panel-settings.png` | 阅读设置 · 半屏 | 一行式行:标签在左、控件在右 |
|
||||
| `06-reader-paged.png` | 阅读页 · 翻页模式 | 分栏后的一页,页数应与可翻次数一致 |
|
||||
@@ -27,49 +27,8 @@
|
||||
|
||||
| 检查 | 结果 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 3 个 pointer-events=auto |
|
||||
| 快捷列: 整组控件都在页内 | ✅ | 列 y=186..335 / 屏高 844 |
|
||||
| 快捷列: 装不下的控件有滚动条,说明还有更多 | ✅ | 溢出 否 overflow-y=true 滚动条 0px |
|
||||
| 快捷列: 只留页面内就能完成的调整 | ✅ | 按钮 3 个:日间 / 字号 / 听书 |
|
||||
| 快捷列: 不含打开面板的五个入口 | ✅ | 列内控件:日间 / 字号 / 听书 |
|
||||
| 快捷列: 听书在列内 | ✅ | 列内控件:日间 / 字号 / 听书 |
|
||||
| 快捷列(420px 短屏): 列在页内,超出的控件能滚到 | ✅ | 列底 242 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true |
|
||||
| 收起工具栏: 顶栏与底栏一起走 | ✅ | state=hidden 顶栏 hidden / 底栏 hidden |
|
||||
| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=none footer=none |
|
||||
| 收起工具栏: 正文只是让出读数行的高度,没有被重新排版 | ✅ | 正文首行 y: 显示时 24 / 收起后 49,读数行高 25px |
|
||||
| 正文铺满: 页面不为工具栏预留高度 | ✅ | padding 0/0px, 页面 844px / 屏高 844 |
|
||||
| 工具栏是浮动层,不占正文的高度 | ✅ | topbar=absolute footer=absolute |
|
||||
| 收起工具栏: 快捷按钮跟着两条栏一起收 | ✅ | visible=false pointer-events=none |
|
||||
| 收起工具栏: 保留章节与页码指示 | ✅ | chapter="第一章 惊蛰" progress="第 1/3 页 0%" |
|
||||
| 收起工具栏: 阅读指示不吃手势 | ✅ | pointer-events=none |
|
||||
| 收起工具栏: 章节名画在正文之上 | ✅ | 层级 30 vs 正文 1,y=0..25 文本="第一章 惊蛰" |
|
||||
| 收起工具栏: 章节名自带底色,不被正文透出 | ✅ | background=rgb(22, 23, 26) |
|
||||
| 收起工具栏: 页码读数画在正文之上 | ✅ | 层级 30 vs 正文 1,y=820..844 文本="第 1/3 页 0%" |
|
||||
| 收起工具栏: 页码读数自带底色,不被正文透出 | ✅ | background=rgb(22, 23, 26) |
|
||||
| 收起工具栏: 章节名与页码左边对齐 | ✅ | 章节名 left=0 页码 left=0 |
|
||||
| 收起工具栏: 章节名占满一整行并紧贴顶部 | ✅ | 行 390px / 屏宽 390,top=0 |
|
||||
| 收起工具栏: 页码占满一整行并紧贴底部 | ✅ | 行 390px / 屏宽 390,bottom=844/844 |
|
||||
| 点右侧只翻页: 页码前进, 工具栏保持收起 | ✅ | 收起前 hidden(第 1/3 页 0%) → 点击后 hidden(第 2/3 页 40%) |
|
||||
| 收起工具栏: 翻页后正文不被读数行拦腰截断 | ✅ | 读数行 [[0,25],[820,844]],没有半截行 |
|
||||
| 点中间切换工具栏 | ✅ | 点击中间后 visible |
|
||||
| 翻页: 动画期间不丢滚动位置(动画后不再跳页) | ✅ | 目标 390 → 动画中 390 → 动画后 390,overflow=hidden |
|
||||
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=0 |
|
||||
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 321px |
|
||||
| 目录: 面板有遮罩和把手 | ✅ | scrim=true grip=true |
|
||||
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 844px |
|
||||
| 顶栏: 三个按钮左右两边对齐 | ✅ | 左 12px / 右 12px,两道间隙 136px / 136px |
|
||||
| 底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐 | ✅ | 页码右 12px / 下一章右 12px;上一章左 12px / 滑杆左 12px |
|
||||
| 顶栏与底栏共用同一条内边距 | ✅ | 顶栏 12px / 底栏 12px |
|
||||
| 底栏: 进度是可拖动的滑杆 | ✅ | type=range 宽 308px label="章节内页数" |
|
||||
| 底栏: 有页码与阅读进度读数 | ✅ | page="第 1/3 页" nav="阅读进度:0%" |
|
||||
| 底栏: 滑杆量的是章节内页数,不是全书百分比 | ✅ | min=1 max=3 value=1 读数="第 1/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 / 0 次 GET(共 1 次 sync 请求) |
|
||||
| 手机阅读交互回归 | ✅ | reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏 |
|
||||
| 翻页不再每次都发两次 sync | ✅ | 8 次翻页 + 6 秒静默:2 次 POST / 0 次 GET(共 2 次 sync 请求) |
|
||||
| EPUB: 章节里的图片真的画出来了 | ✅ | naturalWidth=240 显示 240x160 src="/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI" |
|
||||
| EPUB: 插图下面的正文也在 | ✅ | 正文开头="台版 转自 天使动漫
|
||||
|
||||
@@ -78,7 +37,7 @@
|
||||
| EPUB: 收窄插图没有改变它的比例 | ✅ | 原图 8.750 / 画出来 8.752 |
|
||||
| EPUB: 宽插图没有让页面横向溢出 | ✅ | document 溢出 0px / 阅读面溢出 0px |
|
||||
| 搜索框: 两边都从封面列内缩,且左右相等 | ✅ | 左 14px / 右 14px(字段 0 则为贴着封面) |
|
||||
| 搜索框: 放大镜与输入框中线对齐 | ✅ | 放大镜中线 97.7 / 输入框中线 98.1(偏差 -0.4px,字形 16px,框高 44.203125px) |
|
||||
| 搜索框: 放大镜与输入框中线对齐 | ✅ | 放大镜中线 98.1 / 输入框中线 98.1(偏差 0.0px,字形 16.796875px,框高 44.203125px) |
|
||||
| 书架: 第一个排序是「最近阅读」且默认选中 | ✅ | chips=最近阅读* / 最近入库 / 书名 / 作者 |
|
||||
| 书架: 「最近入库」仍然在排序里 | ✅ | chips=最近阅读 / 最近入库 / 书名 / 作者 |
|
||||
| 书架: 排在最前的是最近读过的那本 | ✅ | 前两本:剑来 第1卷 / 剑来 第2卷 |
|
||||
@@ -98,7 +57,7 @@
|
||||
| 书库: 文件管理页没有「下架 / 从书架拿掉 / 加入书架」任何一处 | ✅ | 页面文本命中=无 |
|
||||
| 书库: 文件管理页仍然标出不在书架的书(徽章不是控件) | ✅ | 徽章=true |
|
||||
| 书库: 从文件管理回到浏览是换页,URL 也跟着换 | ✅ | 浏览=true 文件=false hash=#/library 封面 60 张 |
|
||||
| 书架标题栏: 两个图标都画出来了,且不超出各自的按钮 | ✅ | 书库: 18.4×18 in 40 box, inside=true | 书架设置 · 适中: 18.4×18 in 40 box, inside=true |
|
||||
| 书架标题栏: 图标不比标题大(同一行的视觉重量) | ✅ | 图标 18 vs 标题 28 | 图标 18 vs 标题 28 |
|
||||
| 书架标题栏: 两个图标都画出来了,且不超出各自的按钮 | ✅ | 书库: 18.4×18.4 in 40 box, inside=true | 书架设置 · 适中: 18.4×18.4 in 40 box, inside=true |
|
||||
| 书架标题栏: 图标不比标题大(同一行的视觉重量) | ✅ | 图标 18.4 vs 标题 28 | 图标 18.4 vs 标题 28 |
|
||||
|
||||
全部通过。
|
||||
+8
-213
@@ -12,12 +12,12 @@
|
||||
{
|
||||
"name": "02-reader",
|
||||
"label": "阅读页",
|
||||
"what": "顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章"
|
||||
"what": "顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口"
|
||||
},
|
||||
{
|
||||
"name": "03-reader-no-chrome",
|
||||
"label": "阅读页 · 收起工具栏",
|
||||
"what": "顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数"
|
||||
"what": "工具栏一起收起,正文保持原位置,底部只留书名、页码与进度"
|
||||
},
|
||||
{
|
||||
"name": "04-panel-toc",
|
||||
@@ -92,219 +92,14 @@
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"name": "展开工具栏: 快捷按钮与两条栏一起出现",
|
||||
"name": "手机阅读交互回归",
|
||||
"ok": true,
|
||||
"detail": "visible=true 按钮 3 个 pointer-events=auto"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 整组控件都在页内",
|
||||
"ok": true,
|
||||
"detail": "列 y=186..335 / 屏高 844"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 装不下的控件有滚动条,说明还有更多",
|
||||
"ok": true,
|
||||
"detail": "溢出 否 overflow-y=true 滚动条 0px"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 只留页面内就能完成的调整",
|
||||
"ok": true,
|
||||
"detail": "按钮 3 个:日间 / 字号 / 听书"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 不含打开面板的五个入口",
|
||||
"ok": true,
|
||||
"detail": "列内控件:日间 / 字号 / 听书"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 听书在列内",
|
||||
"ok": true,
|
||||
"detail": "列内控件:日间 / 字号 / 听书"
|
||||
},
|
||||
{
|
||||
"name": "快捷列(420px 短屏): 列在页内,超出的控件能滚到",
|
||||
"ok": true,
|
||||
"detail": "列底 242 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 顶栏与底栏一起走",
|
||||
"ok": true,
|
||||
"detail": "state=hidden 顶栏 hidden / 底栏 hidden"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 两条栏都不可见,也不可点",
|
||||
"ok": true,
|
||||
"detail": "topbar=none footer=none"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 正文只是让出读数行的高度,没有被重新排版",
|
||||
"ok": true,
|
||||
"detail": "正文首行 y: 显示时 24 / 收起后 49,读数行高 25px"
|
||||
},
|
||||
{
|
||||
"name": "正文铺满: 页面不为工具栏预留高度",
|
||||
"ok": true,
|
||||
"detail": "padding 0/0px, 页面 844px / 屏高 844"
|
||||
},
|
||||
{
|
||||
"name": "工具栏是浮动层,不占正文的高度",
|
||||
"ok": true,
|
||||
"detail": "topbar=absolute footer=absolute"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 快捷按钮跟着两条栏一起收",
|
||||
"ok": true,
|
||||
"detail": "visible=false pointer-events=none"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 保留章节与页码指示",
|
||||
"ok": true,
|
||||
"detail": "chapter=\"第一章 惊蛰\" progress=\"第 1/3 页 0%\""
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 阅读指示不吃手势",
|
||||
"ok": true,
|
||||
"detail": "pointer-events=none"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 章节名画在正文之上",
|
||||
"ok": true,
|
||||
"detail": "层级 30 vs 正文 1,y=0..25 文本=\"第一章 惊蛰\""
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 章节名自带底色,不被正文透出",
|
||||
"ok": true,
|
||||
"detail": "background=rgb(22, 23, 26)"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 页码读数画在正文之上",
|
||||
"ok": true,
|
||||
"detail": "层级 30 vs 正文 1,y=820..844 文本=\"第 1/3 页 0%\""
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 页码读数自带底色,不被正文透出",
|
||||
"ok": true,
|
||||
"detail": "background=rgb(22, 23, 26)"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 章节名与页码左边对齐",
|
||||
"ok": true,
|
||||
"detail": "章节名 left=0 页码 left=0"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 章节名占满一整行并紧贴顶部",
|
||||
"ok": true,
|
||||
"detail": "行 390px / 屏宽 390,top=0"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 页码占满一整行并紧贴底部",
|
||||
"ok": true,
|
||||
"detail": "行 390px / 屏宽 390,bottom=844/844"
|
||||
},
|
||||
{
|
||||
"name": "点右侧只翻页: 页码前进, 工具栏保持收起",
|
||||
"ok": true,
|
||||
"detail": "收起前 hidden(第 1/3 页 0%) → 点击后 hidden(第 2/3 页 40%)"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 翻页后正文不被读数行拦腰截断",
|
||||
"ok": true,
|
||||
"detail": "读数行 [[0,25],[820,844]],没有半截行"
|
||||
},
|
||||
{
|
||||
"name": "点中间切换工具栏",
|
||||
"ok": true,
|
||||
"detail": "点击中间后 visible"
|
||||
},
|
||||
{
|
||||
"name": "翻页: 动画期间不丢滚动位置(动画后不再跳页)",
|
||||
"ok": true,
|
||||
"detail": "目标 390 → 动画中 390 → 动画后 390,overflow=hidden"
|
||||
},
|
||||
{
|
||||
"name": "目录: 面板不遮住整页",
|
||||
"ok": true,
|
||||
"detail": "面板从 y=321 开始,正文区 y=0"
|
||||
},
|
||||
{
|
||||
"name": "目录: 面板留出可预览的正文",
|
||||
"ok": true,
|
||||
"detail": "正文可见高度 321px"
|
||||
},
|
||||
{
|
||||
"name": "目录: 面板有遮罩和把手",
|
||||
"ok": true,
|
||||
"detail": "scrim=true grip=true"
|
||||
},
|
||||
{
|
||||
"name": "目录: 面板高度不超过阅读区",
|
||||
"ok": true,
|
||||
"detail": "面板 523px / 阅读区 844px"
|
||||
},
|
||||
{
|
||||
"name": "顶栏: 三个按钮左右两边对齐",
|
||||
"ok": true,
|
||||
"detail": "左 12px / 右 12px,两道间隙 136px / 136px"
|
||||
},
|
||||
{
|
||||
"name": "底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐",
|
||||
"ok": true,
|
||||
"detail": "页码右 12px / 下一章右 12px;上一章左 12px / 滑杆左 12px"
|
||||
},
|
||||
{
|
||||
"name": "顶栏与底栏共用同一条内边距",
|
||||
"ok": true,
|
||||
"detail": "顶栏 12px / 底栏 12px"
|
||||
},
|
||||
{
|
||||
"name": "底栏: 进度是可拖动的滑杆",
|
||||
"ok": true,
|
||||
"detail": "type=range 宽 308px label=\"章节内页数\""
|
||||
},
|
||||
{
|
||||
"name": "底栏: 有页码与阅读进度读数",
|
||||
"ok": true,
|
||||
"detail": "page=\"第 1/3 页\" nav=\"阅读进度:0%\""
|
||||
},
|
||||
{
|
||||
"name": "底栏: 滑杆量的是章节内页数,不是全书百分比",
|
||||
"ok": true,
|
||||
"detail": "min=1 max=3 value=1 读数=\"第 1/3 页\"(章节共 3 页)"
|
||||
},
|
||||
{
|
||||
"name": "底栏: 拖动滑杆切到对应页",
|
||||
"ok": true,
|
||||
"detail": "拖到 3 后读数=\"第 3/3 页\""
|
||||
},
|
||||
{
|
||||
"name": "翻页模式: 滑杆拖到第 3 页,仍在本章",
|
||||
"ok": true,
|
||||
"detail": "min=1 max=3 value=3 拖前=\"第 3/3 页\" 拖后=\"第 3/3 页\""
|
||||
},
|
||||
{
|
||||
"name": "状态提示不推动正文",
|
||||
"ok": true,
|
||||
"detail": "正文顶部 y: 0 / 0 / 0"
|
||||
},
|
||||
{
|
||||
"name": "触控目标不小于 28px",
|
||||
"ok": true,
|
||||
"detail": "8 个控件全部合格"
|
||||
},
|
||||
{
|
||||
"name": "每个控件都有可读的名字",
|
||||
"ok": true,
|
||||
"detail": "8 个控件全部有名字"
|
||||
},
|
||||
{
|
||||
"name": "没有横向溢出",
|
||||
"ok": true,
|
||||
"detail": "document 溢出 0px"
|
||||
"detail": "reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏"
|
||||
},
|
||||
{
|
||||
"name": "翻页不再每次都发两次 sync",
|
||||
"ok": true,
|
||||
"detail": "8 次翻页 + 6 秒静默:1 次 POST / 0 次 GET(共 1 次 sync 请求)"
|
||||
"detail": "8 次翻页 + 6 秒静默:2 次 POST / 0 次 GET(共 2 次 sync 请求)"
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 章节里的图片真的画出来了",
|
||||
@@ -339,7 +134,7 @@
|
||||
{
|
||||
"name": "搜索框: 放大镜与输入框中线对齐",
|
||||
"ok": true,
|
||||
"detail": "放大镜中线 97.7 / 输入框中线 98.1(偏差 -0.4px,字形 16px,框高 44.203125px)"
|
||||
"detail": "放大镜中线 98.1 / 输入框中线 98.1(偏差 0.0px,字形 16.796875px,框高 44.203125px)"
|
||||
},
|
||||
{
|
||||
"name": "书架: 第一个排序是「最近阅读」且默认选中",
|
||||
@@ -439,12 +234,12 @@
|
||||
{
|
||||
"name": "书架标题栏: 两个图标都画出来了,且不超出各自的按钮",
|
||||
"ok": true,
|
||||
"detail": "书库: 18.4×18 in 40 box, inside=true | 书架设置 · 适中: 18.4×18 in 40 box, inside=true"
|
||||
"detail": "书库: 18.4×18.4 in 40 box, inside=true | 书架设置 · 适中: 18.4×18.4 in 40 box, inside=true"
|
||||
},
|
||||
{
|
||||
"name": "书架标题栏: 图标不比标题大(同一行的视觉重量)",
|
||||
"ok": true,
|
||||
"detail": "图标 18 vs 标题 28 | 图标 18 vs 标题 28"
|
||||
"detail": "图标 18.4 vs 标题 28 | 图标 18.4 vs 标题 28"
|
||||
}
|
||||
],
|
||||
"failures": []
|
||||
|
||||
+15
-294
@@ -12,191 +12,9 @@
|
||||
|
||||
## 1. 图标
|
||||
|
||||
### 1.1 用字体,不用内联 SVG
|
||||
|
||||
图标以**字体**形式提供:`web/src/styles/reader-icons.ttf`,41 个字形,
|
||||
18KB,随 bundle 一起走。
|
||||
|
||||
选择字体的理由不是「省事」,而是**一致性可以被强制**:
|
||||
|
||||
| | 字体 | 内联 SVG |
|
||||
| --- | --- | --- |
|
||||
| 颜色 | 继承 `color` | 每个组件各自写 `fill`/`stroke` |
|
||||
| 尺寸 | 继承 `font-size`,跟着布局缩放 | 每个组件各自写 `width`/`height` |
|
||||
| 线宽 | 一个(生成时定死) | 每个字形都可能不同 |
|
||||
| 新增图标 | 加字符 | 加组件 |
|
||||
|
||||
第二列里的每一项都是「单个字形可以漂移」的位置。内联 SVG 集合最常见的失败
|
||||
就是这个:三十个组件里总有几个的线宽或视觉重量跟其余不一样,而每个单独看都
|
||||
说得通。字体把字形变成**字符**——它没有自己的几何,也就没有可以漂移的地方。
|
||||
|
||||
这条约束还有一层:一份产物两个宿主(H5 与 Android WebView)。字体是 bundle 里的
|
||||
一个相对路径文件,`file://` 下能加载;组件化的 SVG 集合则意味着每个宿主都要
|
||||
正确解析组件,而这一步在 WebView 里是额外的风险面。
|
||||
|
||||
### 1.2 字形名是这个项目自己的,而且不许借别人的
|
||||
|
||||
图标的名字是**界面层读的那套英语词汇**,取自这个代码库和整个 Web 本来就说的那些词:
|
||||
`menu`、`search`、`close`、`settings`、`library`、`shelf`。没有一个是「为了不一样
|
||||
而不一样」编出来的——菜单就叫 `menu` 而不是 `hamburger-glyph`,写过前端的人看见
|
||||
`arrow-left` 就知道它画什么。
|
||||
|
||||
**曾经借过 Font Awesome 6 的类名,现在改回来了**,理由值得留着,因为它正好是
|
||||
「借名字」这个想法反着咬人的地方:
|
||||
|
||||
| 想法 | 实际发生的事 |
|
||||
| --- | --- |
|
||||
| 借一套开源图标的名字,「放大镜」在全项目就只有一种拼法 | 借来的名字同时是**对别人画的图形的声明**。`magnifying-glass` 是 Font Awesome 的某个字形,熟悉那套图标的人打开 `paths.mjs`,期待看到那一个,看到的是我们的——每一个借来的名字都是这样 |
|
||||
| 名字只是标签,几何是自己的 | 名字一旦是标签,就不该是别人家的标签。几何从来不是 Font Awesome 的,名字也不该是(见 §1.2 末段) |
|
||||
|
||||
所以现在有两条硬约束,`test/icons.test.ts` 都钉住了:
|
||||
|
||||
1. **没有借来的名字。** 一张被否掉的名单(`bars`、`magnifying-glass`、`xmark`、
|
||||
`gear`、`table-columns`……)在测试里,改回去会失败。
|
||||
2. **名字就是它画的东西**,而且整份词汇表在测试里逐字列出:加一个图标是一次要
|
||||
想清楚的动作,而不是一个恰好编译得过的字符串。
|
||||
|
||||
命名按**用途**而非形状:`sliders`(阅读设置)而不是 `knobs`;按**读者看见的东西**
|
||||
而非实现:`settings` 而不是 `gear-teeth-12`。
|
||||
|
||||
### 1.3 图标集是生成的
|
||||
|
||||
`web/tools/icons/` 下的脚本把**描边路径**编译成字体:
|
||||
|
||||
```
|
||||
paths.mjs 41 条路径,都画在 24 单位网格上,线宽 3
|
||||
↓ (这是唯一由人编辑的文件)
|
||||
path.mjs 路径 → 折线
|
||||
stroke.mjs 折线 → 圆头矩形(每条线段一个)
|
||||
union.mjs 精确布尔并(Clipper)
|
||||
build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts
|
||||
```
|
||||
|
||||
重新生成:`npm run icons`。
|
||||
|
||||
为什么是「描边 + 布尔并」而不是直接写轮廓:字形要的是**填充区域**,而图标是
|
||||
**线**。两条相接的线段如果只把各自的轮廓放进去,重叠处会有一条发丝缝——在
|
||||
16px 下那条缝就是整个图标。所以必须做并集,而且必须是精确的并集。
|
||||
|
||||
这一步踩过的坑值得记下来,因为它决定了实现方式:先用「栅格化 + 边界追踪」
|
||||
做过一版,失败了。原因是所有笔画的宽度都只有一个栅格单元,而**逐单元的边界
|
||||
规则无法表达「边界在一格宽的图形上左右交替」**——结果是一个圆被追踪成 58 段
|
||||
碎片。精确布尔没有这种情形。`union.mjs` 的注释里写了这段。
|
||||
|
||||
### 1.4 垂直度量:为什么图标能对齐
|
||||
|
||||
图标「显示正常但对不齐」曾经是个查不出来的问题,因为坏的地方在字体的
|
||||
**垂直度量**里,而当时所有测试都只读 `cmap` 和字形名。记下来,因为只要再动
|
||||
一次生成器就会重新踩到。
|
||||
|
||||
**约定(生成器与样式表之间的契约):**
|
||||
|
||||
- 网格中点(12)就是 `y = 0`,即 em 的中心线;绘制代码用 `(GRID / 2 - y)`。
|
||||
- `hhea` 与 `OS/2` 的上升部/下降部各为半个 em(512 / -512),也就是
|
||||
**对称**。对称不是审美:UI 里每个容器都在「居中一个盒子」,只有度量对称时
|
||||
「居中盒子」和「居中字形」才是同一件事。度量不对称时,两者相差正好是那个
|
||||
不对称量——这就是当初每一行图标歪的幅度都不一样的原因。
|
||||
- 每个字形的墨迹必须落在 `±0.5em` 内。生成器会检查,越界直接报错。
|
||||
- `OS/2` 的 Windows 度量是**无符号**的,且必须包住所有字形;`fsSelection`
|
||||
打开 `USE_TYPO_METRICS`(否则引擎会去用 Windows 那一对)。
|
||||
|
||||
**当初错在哪:** 表格声明上升部 960、下降部 -64(基线靠近 em 底部),而字形是
|
||||
围绕 em 中心画的。于是每个字形的墨迹都画在它所在盒子**上边界之上**约 0.75em,
|
||||
而所有布局居中的都是盒子——`menu`、`close`、`folder` 实测偏移一致是 0.496em。
|
||||
|
||||
**样式表那一半:**
|
||||
|
||||
```css
|
||||
.icon {
|
||||
line-height: 1; /* 用字体自己的行盒,而不是自建一个方块 */
|
||||
display: inline-block;
|
||||
width: 1em; /* 唯一需要显式给的东西:可预测的前进宽度 */
|
||||
text-align: center;
|
||||
vertical-align: middle; /* 非 flex/grid 的行(正文里的行内图标) */
|
||||
}
|
||||
```
|
||||
|
||||
`line-height: 0` + `height: 1em` 是**错的**:那把盒子锚在基线上、向下一整个
|
||||
em,而墨迹画在基线周围,于是墨迹贴在盒子上沿、盒子下方全空。居中盒子的容器
|
||||
把盒子居中了,字形却差了半个 em。`transform` 也不行——它在绘制期移动字形,让
|
||||
图标占的位置和看起来占的位置不再一致。
|
||||
|
||||
代价:`web/test/icons.test.ts` 会读字体的 `hhea` / `OS/2` / `head` 与每个字形的
|
||||
包围盒来断言上面这些,也会读 `reader.css` 里的 `.icon` 规则。两边都是之前没被
|
||||
任何断言覆盖的地方。
|
||||
|
||||
**但度量对了 ≠ 一定会对齐。第三半:盒子对了,行盒不一定在对的地方。**
|
||||
#40 报的「书架页面的搜索按钮没有垂直对齐」就是这么来的,而它逃过了所有度量断言,
|
||||
因为度量这次是对的。
|
||||
|
||||
```css
|
||||
/* 旧写法:盒子居中了,墨迹没有 */
|
||||
.search-glyph { position: absolute; inset-block: 0; margin-block: auto; }
|
||||
```
|
||||
|
||||
`position: absolute` 会把 `display` 变成 `block`。规则里的盒子确实被居中了
|
||||
(实测 75.98..120.19 落在一个 75.98..120.19 的输入框里,正中),但**块级盒不会
|
||||
把行盒在垂直方向居中,它把行盒放在顶部**。行盒只有 1em(`line-height: 1`),
|
||||
于是 1.05rem 的墨迹中线落在 y=84,而输入框中线是 y=98:高了 14px。
|
||||
|
||||
修法是用布局系统说这件事,而不是用 transform:
|
||||
|
||||
```css
|
||||
.search-glyph { position: absolute; inset-block: 0; margin-block: auto;
|
||||
display: grid; place-content: center; }
|
||||
```
|
||||
|
||||
`place-content: center` 居中**行盒**,配合 §1.4 的对称度量就是居中墨迹——
|
||||
和 `.icon-button` 同一套写法,所以「盒子里的字形」在这个样式表里只有一个答案。
|
||||
`top: 50%; transform: translateY(-50%)` 也能修好,但更差:要在每个调用点重复、
|
||||
漏掉一个就再犯一次,而且它把偏移变成 transform 的事实,输入框高度一变就要跟着改。
|
||||
|
||||
**量的是墨迹,不是盒子。** `run.mjs` 用 `Range` 取字形的墨迹中线断言它落在
|
||||
输入框中线 ±2px 内(实测偏差 0.4px)。断言盒子是没用的:盒子在缺陷存在时**就是对的**,
|
||||
这正是它活过一轮布局评审的原因。
|
||||
|
||||
### 1.5 字符编码
|
||||
|
||||
- 码位从 `U+E900` 起,连续分配,位于**私有使用区**。
|
||||
- 顺序与 `paths.mjs` 中定义的顺序一致。
|
||||
- `src/ui/icon-names.ts` 由生成器写出,**不要手改**:码位与字体 `cmap` 不一致
|
||||
时渲染成豆腐块,而类型系统查不出这件事。
|
||||
- `web/test/icons.test.ts` 直接解析字体的 `cmap` 来核对这张表,并断言字体
|
||||
**不映射私有使用区之外**的任何码位(否则普通文字在应用了这个字族的元素里
|
||||
会变成图标)。
|
||||
|
||||
### 1.6 无障碍
|
||||
|
||||
`Icon` 组件的 `label` 决定语义:
|
||||
|
||||
| `label` | 行为 |
|
||||
| --- | --- |
|
||||
| 字符串 | `role="img"` + `aria-label`,可访问名就是这个字符串 |
|
||||
| 省略 / `null` | `aria-hidden="true"`,纯装饰 |
|
||||
|
||||
`IconButton` 的 `label` 是**必填**的,图标本身始终 `aria-hidden`。
|
||||
一个没有文字的图标按钮,如果没有名字,屏幕阅读器就只能读出「按钮」——
|
||||
而它旁边可能有五个同样的按钮。
|
||||
|
||||
**这是本次改动之前真实存在的问题**:原来的 `IconButton` 接收任意 `children`,
|
||||
调用方传的是 Unicode 字符(`☰` `⚙` `✕` `⟨` `⟩` `+` `🗂` `📁`)。这带来三个后果:
|
||||
|
||||
1. 八个字形来自八个不同的字族,光学重量各不相同,工具栏看起来是拼的;
|
||||
2. `🗂` `📁` `📄` `⏮` 这些是 emoji,在部分平台被渲染成彩色的——一个彩色 emoji
|
||||
出现在单色工具栏里,是「这个应用没有设计过」最直接的信号;
|
||||
3. 不同平台的字体回退不同,同一个界面在 Android 和桌面浏览器上长得不一样。
|
||||
|
||||
现在 `IconButton` 的 `icon` 是 `IconName`,即字形的**名字**,
|
||||
传一个不存在的名字是编译错误。
|
||||
|
||||
### 1.7 现在有哪些图标
|
||||
|
||||
见 `web/tools/icons/paths.mjs`,41 个字形。名字是这个项目自己的英语词汇(§1.2),
|
||||
而且是**全集的、逐字列出的**——`test/icons.test.ts` 里 `VOCABULARY` 那张表就是
|
||||
允许出现的全部名字,所以「这个语义用哪个字形」在改动时不会漂到另一个形状上,
|
||||
也不会悄悄多出一个没人审过的形状。
|
||||
|
||||
---
|
||||
图标统一使用 `lucide-preact` 的 SVG,`src/ui/icon.tsx` 集中维护名称映射,线宽 1.8,尺寸 1em。
|
||||
界面使用 Icon / IconButton / IconTextButton,不直接嵌入字体码位或 emoji。
|
||||
装饰图标标记 aria-hidden;没有文字的按钮必须有中文 aria-label。原字体文件与生成工具仅供历史兼容,不再加载到阅读界面。
|
||||
|
||||
## 2. 设计令牌
|
||||
|
||||
@@ -720,118 +538,21 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
§3.9 的 TXT 排版走的是另一条路(`.txt-body`,只挂在纯文本上);这一节管的是
|
||||
**所有格式**的章节内容,包括 EPUB 里作者自己写的标记。
|
||||
|
||||
### 3.10 沉浸式阅读
|
||||
### 3.10 手机阅读
|
||||
|
||||
**顶栏和底栏是一个状态,不是一个状态乘以二。** 读者要收起导航就是要看到正文,
|
||||
上面收了下面没收,就是读者报的「只收起了一半」——底部那条一直压着每一页的最后
|
||||
两行。所以两条栏由 `.reader-screen` 上的一个 `data-chrome` 属性一起开关,写法上
|
||||
不可能只藏一半,动效、时长、缓动也完全一致(`--motion-duration` + 同一缓动)。
|
||||
- 顶部:返回、书名、当前章节。
|
||||
- 底部:上一章、本章页数滑杆、下一章;下一行是目录、朗读、界面、设置。
|
||||
- 中间滑杆始终控制本章页数,不能变成全书进度。固定页章节只有一页时禁用滑杆。
|
||||
- 顶部左/右、底部左/右阅读信息可在“界面”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
|
||||
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
|
||||
- 四个面板使用统一底部半屏布局;界面放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
|
||||
- TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
|
||||
- 白、米黄、浅绿、夜间四个主题。漫画隐藏文字排版选项。
|
||||
|
||||
三条实现约束,改这块之前先知道:
|
||||
### 3.11 朗读
|
||||
|
||||
- **两条栏是浮层,不是占位。** `position: absolute` 贴在屏幕上下缘,因此页面
|
||||
在两种状态下都是整屏,**正文一个字都不会动**。这是读者报的「工具栏显示隐藏时,
|
||||
内容会上下滑动」:当时两条栏在正常流里,stage 是 `flex: 1 1 auto`,于是每次
|
||||
开关工具栏页面就重新排版、正在读的那一行上下滑 53px。工具栏是浮层,和正文不在
|
||||
同一个流里——读者的判断是对的。
|
||||
- **页面铺满整屏,不为浮层预留任何高度。** `.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` 兜住:滑出的过程
|
||||
中标题还在,动画走完它就既看不见也点不到了。少写这一行,读者能点到一个看不见的
|
||||
返回按钮。
|
||||
- **右侧的快捷按钮列和两条栏一起出现、一起收起**(`.reader-rail`),由同一个
|
||||
`data-chrome` 属性开关,和顶栏底栏共用一套动效。按钮住在正文的版心旁边
|
||||
(宽屏时用 `--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:
|
||||
这些动作和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在
|
||||
正文右边缘的按钮,就不是读者要的「只剩正文」。
|
||||
- **快捷列只留页面内就能完成的调整**:夜间/日间(主题)、字号、听书。
|
||||
这条是读者报的「竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮」定下来的:
|
||||
被去掉的五个都是**目的地**而不是**调整**——目录、设置各自打开半屏面板,字体/行距/
|
||||
页边距是值的一档而其完整范围住在设置面板里——所以这一列同时也装着它们的时候,
|
||||
它就是一列浮在正文上的、更差的设置面板。留下的是读者**不想打开任何东西**就会去动的
|
||||
那几个(尤其听书:他伸手的时候正要离开屏幕)。
|
||||
每个按钮仍然是该项设置的一档,值和设置面板里的行取自**同一张阶梯**
|
||||
(`FONT_SCALE_LADDER`、`THEME_LADDER`),所以两处不会各自漂移出一个对方到不了的值。
|
||||
列本身仍要保证整组控件在页内:`max-height` 由两条栏的高度算出来,
|
||||
装不下时 `overflow-y: auto`,并且**滚动条是画出来的**(3px 细线而不是
|
||||
`scrollbar-width: none`)——一个只能靠滚动找到的控件必须看起来能滚,否则
|
||||
对读者就等于不存在。`overscroll-behavior: contain` 也在,免得滑到头那一下把
|
||||
手势交给下面的正文。
|
||||
- **收起时正文一角仍留两个读数**(`.reading-indicator`):当前章节名在左上、
|
||||
页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏
|
||||
就是要看正文,但**仍然要知道自己在哪一页**,让他为此再点一下正是收起状态要
|
||||
省掉的那次交互。两个读数 `pointer-events: none`(它们是读数不是控件,点它们
|
||||
必须落到下面的翻页区),且只在收起时绘制,不和工具栏重复。
|
||||
|
||||
顶栏与底栏各自的形态(对应参照稿):
|
||||
|
||||
- **顶栏**是一排「图标在上、文字在下」的等宽按钮(`.top-button`),不是「标题 +
|
||||
返回箭头」。参照稿里它是读者**扫**着用的一行工具栏,每个控件一样重、一样一步
|
||||
可达。书的名字曾经写在这里,现在它属于 `.reading-indicator`——读者找标题时
|
||||
眼睛去的地方是正文角上,不是工具栏。按钮只保留这个屏幕**真能做到**的动作
|
||||
(返回 / 目录 / 设置):参照稿的「首页/书架/书源」里有阅读页内部走不到的目的地,
|
||||
一个点了没处的按钮比少一个按钮更糟。
|
||||
- **底栏**是两行(`.progress-row` + `.chapter-nav`):一行可拖动的**章节内页数**
|
||||
滑杆加页码读数,一行「上一章 / 阅读进度 / 下一章」。滑杆取代了原来那条 3px 的
|
||||
进度条——条只能**报告**位置,读者想到一章末尾得连点四十次「下一页」;
|
||||
`input[type=range]` 一个手势就到,而且拇指、轨道、拖动惯性和无障碍(方向键、
|
||||
`aria-valuenow`)都是原生的,不用自己实现一遍。
|
||||
- **滑杆量的是章节内的页**(`min=1`、`max=chapterPages`),不是全书百分比。
|
||||
滑杆旁边写的是「第 3/9 页」,两者必须来自同一个测量,否则一次拖动会落在
|
||||
读数从没提过的位置上——这正是最初「滑杆拖到中间却跳到第 40 章」的成因。
|
||||
全书进度是下面那行「阅读进度:xx%」的事,那行**只报告**,不承载手势。
|
||||
所以拖动滑杆**不会跨章**:它只移动当前章节内的位置(见
|
||||
`ReaderView.seekPageInChapter`)。
|
||||
|
||||
手势上,**三个区各管一件事,互不重叠**:左右三分之一**只翻页**,中间三分之一
|
||||
**只切换工具栏显隐**。
|
||||
|
||||
这一条改过两轮,第二轮的起因是读者报的「在点击左右两边时只需要翻页、不需要显示
|
||||
工具栏,只有在中间点击时才需要切换工具栏显隐」。第一轮的行为是:收起状态下左右
|
||||
三分之一翻页**并且**把工具栏叫回来(理由是没有别的出路)。理由成立,结果和手势
|
||||
打架——读者只是想往后翻,得到的却是新的一页**外加**一条压在刚读到的那行上的工具栏,
|
||||
于是沉浸态根本待不住,每翻一页都被撤销一次;而真正想要控件的读者有一个专门的、
|
||||
一眼可见的表达方式:点中间。
|
||||
所以现在两个手势是**互斥**的,各有一个含义,这正是原来缺的性质:
|
||||
左/右 = 翻页,中 = 显隐工具栏。翻页不会带出任何控件,要控件的点击也不会翻页。
|
||||
|
||||
### 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 需要隐藏」。
|
||||
|
||||
---
|
||||
朗读控制集中在朗读面板。上一句、播放/暂停、下一句、停止均有独立按钮;关闭面板不停止播放,再从底部“朗读”进入可暂停或停止。
|
||||
“从当前位置朗读”使用当前可见正文或选区作为起点。不可用的语音设置根据引擎能力隐藏。
|
||||
|
||||
## 4. 文案
|
||||
|
||||
|
||||
Generated
+11
-2
@@ -9,7 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1"
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-preact": "^1.47.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^30.0.0",
|
||||
@@ -1553,6 +1554,15 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-preact": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmmirror.com/lucide-preact/-/lucide-preact-1.47.0.tgz",
|
||||
"integrity": "sha512-ihver2SariDZA1n/JESxDEe5uhQ+K8YL6wiozAOLreJxoQn+aEeEpJ9rO03+I76crhS2YefiE6sM8dUmgJh7/w==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1685,7 +1695,6 @@
|
||||
"version": "10.29.8",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"ui:review:only": "node tools/ui-review/run.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1"
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-preact": "^1.47.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^30.0.0",
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function loadBook(ctx: LoadContext, options: LoadOptions = {}): Pro
|
||||
return { doc: await loadComicDirectory(options.comicPages, resolve), notes: [] };
|
||||
}
|
||||
case 'pdf':
|
||||
return { doc: await loadPdf(), notes: [] };
|
||||
return { doc: await loadPdf(ctx.bytes), notes: [] };
|
||||
default:
|
||||
throw new Error(`不支持的格式:${ctx.fileName || '未知文件'}`);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { BookDoc, RenderMode } from './types.ts';
|
||||
* book was scanned (`book.pageCount`). When it is missing, the UI degrades to
|
||||
* percentage-only progress rather than showing a fake page number.
|
||||
*/
|
||||
export async function loadPdf(): Promise<BookDoc> {
|
||||
export async function loadPdf(bytes: Uint8Array = new Uint8Array()): Promise<BookDoc> {
|
||||
return {
|
||||
format: 'pdf',
|
||||
layout: 'fixed',
|
||||
@@ -31,6 +31,7 @@ export async function loadPdf(): Promise<BookDoc> {
|
||||
id: 'pdf',
|
||||
label: 'PDF',
|
||||
html: '',
|
||||
document: { mediaType: 'application/pdf', bytes },
|
||||
depth: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface Section {
|
||||
html?: string;
|
||||
/** Binary payload, for fixed-layout formats (comic page, PDF page raster). */
|
||||
image?: { mediaType: string; bytes: Uint8Array };
|
||||
/** A complete fixed-layout document rendered by the browser or native host. */
|
||||
document?: { mediaType: string; bytes: Uint8Array };
|
||||
/** Blob URL or data URL for the image, resolved lazily by the renderer. */
|
||||
imageUrl?: string;
|
||||
/** Depth in the navigation tree, 0 for top level. */
|
||||
|
||||
@@ -29,7 +29,26 @@ export type ShelfDensity = 'compact' | 'cozy' | 'comfortable';
|
||||
*/
|
||||
export type ShelfSort = 'recent' | 'added' | 'title' | 'author';
|
||||
|
||||
export type ReadoutMode = 'none' | 'book' | 'chapter' | 'progress' | 'time';
|
||||
export const READOUT_FIELDS = [
|
||||
{ key: 'readoutTopLeft', label: '顶部左侧' },
|
||||
{ key: 'readoutTopRight', label: '顶部右侧' },
|
||||
{ key: 'readoutBottomLeft', label: '底部左侧' },
|
||||
{ key: 'readoutBottomRight', label: '底部右侧' },
|
||||
] as const;
|
||||
export const READOUT_OPTIONS: Array<{ value: ReadoutMode; label: string }> = [
|
||||
{ value: 'none', label: '不显示' },
|
||||
{ value: 'book', label: '书名' },
|
||||
{ value: 'chapter', label: '章节名' },
|
||||
{ value: 'progress', label: '章节进度' },
|
||||
{ value: 'time', label: '时间' },
|
||||
];
|
||||
|
||||
export interface AppSettings extends ViewSettings {
|
||||
readoutTopLeft: ReadoutMode;
|
||||
readoutTopRight: ReadoutMode;
|
||||
readoutBottomLeft: ReadoutMode;
|
||||
readoutBottomRight: ReadoutMode;
|
||||
/** UTF-8 by default; a reader can force another for an odd TXT file. */
|
||||
txtEncoding: string;
|
||||
/** Only meaningful for fixed-layout books. */
|
||||
@@ -72,6 +91,10 @@ export interface AppSettings extends ViewSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
readoutTopLeft: 'chapter',
|
||||
readoutTopRight: 'none',
|
||||
readoutBottomLeft: 'progress',
|
||||
readoutBottomRight: 'time',
|
||||
mode: 'scroll',
|
||||
fontScale: 1,
|
||||
lineHeight: 'inherit',
|
||||
@@ -113,6 +136,11 @@ export class SettingsStore {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings> & { shelfSort?: string };
|
||||
this.settings = { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||
for (const { key } of READOUT_FIELDS) {
|
||||
if (!READOUT_OPTIONS.some(option => option.value === this.settings[key])) {
|
||||
this.settings[key] = DEFAULT_APP_SETTINGS[key];
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Two stored values are folded forward, and both are the same repair.
|
||||
*
|
||||
|
||||
+98
-285
@@ -28,27 +28,6 @@
|
||||
* Host shell: the viewer, not the book. Safe to style freely.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* The icon font.
|
||||
*
|
||||
* Declared here rather than in a separate sheet so the face and the token that
|
||||
* sizes it are in one place. `format('truetype')` and a relative URL because the
|
||||
* bundle is loaded from `file://` inside the Android WebView, where an absolute path
|
||||
* resolves to the app's assets root rather than to the bundle.
|
||||
*
|
||||
* `font-display: block` is deliberate: icons are UI, not text. With `swap` a
|
||||
* browser paints the fallback glyph — a literal private-use code point box — for one
|
||||
* frame, and a row of tofu boxes flashing on every cold start reads as a broken app.
|
||||
* The font is 16KB inside the bundle, so there is no network round trip to hide.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Reader Icons';
|
||||
src: url('./reader-icons.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
:root {
|
||||
--reader-ink: #1a1a1a;
|
||||
--reader-paper: #fbfaf7;
|
||||
@@ -207,7 +186,7 @@
|
||||
--reader-layer-bar: 10; /* the two floating bars, over the page */
|
||||
--reader-layer-rail: 14; /* the quick-action column */
|
||||
--reader-layer-panel: 20; /* sheets and their scrim */
|
||||
--reader-layer-readout: 30; /* the immersion readouts, above the page they describe */
|
||||
--reader-layer-readout: 5; /* above the page, below bars and sheets */
|
||||
|
||||
/*
|
||||
* The reading indicator's own inset.
|
||||
@@ -646,270 +625,8 @@ book-content {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* The reading indicator.
|
||||
*
|
||||
* Two quiet readouts pinned to the corners of the stage, drawn only while the
|
||||
* chrome is hidden. `position: absolute` inside `.reader-screen` (which is
|
||||
* `position: relative`) so they can sit at the top and bottom of the text without
|
||||
* being part of the flow — they must not take height from *the page*, or showing
|
||||
* them would re-measure the columns and move the text.
|
||||
*
|
||||
* `pointer-events: none` because they are readouts, not controls: a tap on the
|
||||
* chapter name has to reach the tap zone underneath it and turn the page (or
|
||||
* bring the chrome back), not be swallowed by a label.
|
||||
*
|
||||
* What the two readouts must therefore do is be drawn *over* the page, and they
|
||||
* were not — the chapter name and the page count were painted over by the chapter's
|
||||
* own text.
|
||||
*
|
||||
* The cause is worth stating exactly, because the number was not the problem. A
|
||||
* `z-index` orders boxes *within one stacking context*; these two are siblings of
|
||||
* the reading surface inside the screen's, so with neither declaring a `z-index`
|
||||
* they were painted in **document order** — and `.book-host` comes later in the
|
||||
* tree than the indicator, because the reading surface is appended before the
|
||||
* chrome is rendered. It is `position: absolute`, so it is a positioned box like
|
||||
* them, and the `z-index: 12` the indicator carried did nothing across two
|
||||
* unrelated positioned siblings. One declared number on each side is what makes
|
||||
* the order a fact of this stylesheet (see the layer scale with the other tokens).
|
||||
*
|
||||
* ## Why each readout is a full-width row
|
||||
*
|
||||
* The two readouts used to be small paper pills the width of their own text, floated
|
||||
* a little inside the top and bottom edges. That is the report: "reading-indicator
|
||||
* 应该是紧贴顶部/底部,并且占用一整行,现在看起来很奇怪,不整洁". A pill as wide as
|
||||
* its own words is a *sticker* — it lands wherever the words end, so the chapter name
|
||||
* and the page count have two different right edges, and it reads as a fragment left
|
||||
* on the page rather than as a readout the screen drew. Over the chapter's own
|
||||
* heading it looked worse still: one line of body text sitting under a narrower strip
|
||||
* of the same colour, which is the "很奇怪" in the screenshot.
|
||||
*
|
||||
* So a readout is a *row*: the paper runs the full width of the stage, flush
|
||||
* against the top and bottom edges, with the words inset to the page margin inside
|
||||
* it. The row is what makes the two readouts one band each rather than two floating
|
||||
* labels, and the flush edge is what stops them from looking like they were dropped
|
||||
* slightly inside the screen.
|
||||
*
|
||||
* No safe-area padding is added to the *row*: the paper reaches the very edge of the
|
||||
* glass, and the safe insets move the *text* inward, so the colour still runs under
|
||||
* the notch and the cutout rather than leaving a strip of page showing beside it.
|
||||
* That is why the safe values appear on the labels below and not here.
|
||||
*
|
||||
* ## Why the rows are opaque, and why that is not a paint-order question
|
||||
*
|
||||
* Two rounds of reports are behind this block, and they are one defect seen from
|
||||
* two sides:
|
||||
*
|
||||
* 1. the readouts were painted *under* the chapter's own text (the `z-index`
|
||||
* paragraph above), and then
|
||||
* 2. *after* that was fixed — "reading-indicator 是浮动的,导致翻页后,遮挡了一部分文字".
|
||||
*
|
||||
* The second is the important one, because it says what the first fix did **not**
|
||||
* answer. The number a readout carries at the top and bottom of the screen is
|
||||
* 30px and 27px (measured at the phone size this product is used at), and a line
|
||||
* of 16px/1.2 body text is 19px tall: the sentence those two rows sat on was cut
|
||||
* **through the middle of its glyphs** in scroll mode, not merely overlapped. Making
|
||||
* the readouts win the paint order only decides *who* is legible, and in immersion
|
||||
* the page does not move to make room — so there is no version of "draw the row over
|
||||
* the line" in which the reader can read both.
|
||||
*
|
||||
* The correction is therefore not a `z-index` at all: the row is opaque paper and it
|
||||
* is an **isolated band**, so the text it discloses never lies underneath it. The
|
||||
* page is inset by exactly the height of the two rows while the chrome is hidden
|
||||
* (see the `[data-chrome='hidden']` rules below), which is the one thing "the
|
||||
* indicator is not floating" can mean — the rows own the top and bottom of the
|
||||
* screen rather than borrowing them from the first and last line.
|
||||
*
|
||||
* That inset is applied to the *book host* rather than to the flow inside the shadow
|
||||
* root, and both halves of that matter:
|
||||
*
|
||||
* - **the host**, because that is the box the paginator and the scroll position are
|
||||
* measured against (see `.book-host`), and the reader's restored position is a
|
||||
* percentage of it. The spacer method — real blocks inside the flow — was the
|
||||
* alternative, and it is the one that cannot be used: a chapter *adds its own*
|
||||
* height when the reader scrolls to the end (a short chapter last on screen, the
|
||||
* next chapter appended), so the position the reader had before a turn is no
|
||||
* longer the position they land on afterwards. An inset does not change the
|
||||
* height of the content, so the chapter a reader left is the chapter they return
|
||||
* to. This is what "翻页后" in the report is about;
|
||||
* - **the box**, not the flow, because the reading column's own rules live in the
|
||||
* shadow root (`ui/shadow.ts`) and its padding is the reader's page margin. The
|
||||
* host is the frame around that column: insetting it takes nothing from the
|
||||
* column's geometry, so the text is neither re-measured nor re-typeset — it is
|
||||
* the viewport that shrinks.
|
||||
*
|
||||
* The two facts hold together because the inset depends on the height of the rows,
|
||||
* and the rows are pinned *inside* the same box: whichever of the three supported
|
||||
* sizes the text lands on, the rows take their own height off the top and bottom and
|
||||
* the page keeps the whole middle.
|
||||
*/
|
||||
.reading-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: var(--reader-layer-readout);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
font-size: var(--text-meta);
|
||||
color: var(--reader-muted);
|
||||
/* A slow fade of its own, offset by the chrome's duration: the indicator comes
|
||||
up as the bars go down, so the reader never sees an empty screen. */
|
||||
animation: indicator-in var(--motion-duration) var(--motion-ease);
|
||||
}
|
||||
|
||||
/*
|
||||
* The page makes room for the two rows, and only while they exist.
|
||||
*
|
||||
* The inset is on the *book host* rather than on the flow inside the shadow root,
|
||||
* for the two reasons the block above sets out: the host is what the paginator and
|
||||
* the restored scroll position are measured against, and the flow's own padding is
|
||||
* the reader's page margin. Nothing about the column's geometry changes here — the
|
||||
* frame around it shortens — so no chapter is re-typeset and no offset is
|
||||
* invalidated by hiding or showing the chrome.
|
||||
*
|
||||
* `--reader-readout-top` / `--reader-readout-bottom` carry the heights, because the
|
||||
* same two numbers have to be the inset *and* the row. A row's height is its content,
|
||||
* and the tokens below are that content written once: one line at the readout's own
|
||||
* font size, the breathing room above and below it, and the hairline the rule adds —
|
||||
* because `box-sizing: border-box` does not shrink a box whose height is its content,
|
||||
* so an inset that forgot the border would be 1px short and clip the last line by
|
||||
* exactly that much. Derived here rather than measured in the page, because an inset
|
||||
* that reads the row's own height back is a feedback loop: the row's height is inside
|
||||
* the box the inset shortens.
|
||||
*
|
||||
* The bottom row is the shorter of the two: the chapter name carries the safe-area
|
||||
* inset on its outer edge and the page count does not, so the two are not the same
|
||||
* number and must not be given one. Both are `line-height: 1` in the row rules below
|
||||
* — a normal line box is ~1.2em, a height these tokens cannot see.
|
||||
*/
|
||||
:root {
|
||||
--reader-readout-top: calc(var(--text-label) + 0.35rem * 2 + var(--safe-top) + 1px);
|
||||
--reader-readout-bottom: calc(var(--text-meta) + 0.35rem * 2 + var(--safe-bottom) + 1px);
|
||||
}
|
||||
|
||||
/*
|
||||
* `line-height: 1` on both rows is load-bearing rather than tidy: the page's inset is
|
||||
* computed from these two heights (see the tokens above), and a normal line box is
|
||||
* ~1.2em — a height the token cannot see, so the inset would be 20% short of the row
|
||||
* and the last line of the page would be cut by exactly that much. One line, one
|
||||
* known height, and what the token says is what the row is.
|
||||
*/
|
||||
.indicator-chapter,
|
||||
.indicator-progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--reader-paper);
|
||||
line-height: 1;
|
||||
padding-block: 0.35rem;
|
||||
/* The text is inset to the page margin and the safe area; the paper is not. */
|
||||
padding-inline: calc(var(--reader-indicator-inset) + var(--safe-left))
|
||||
calc(var(--reader-indicator-inset) + var(--safe-right));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.indicator-chapter {
|
||||
/* The chapter name is the longer of the two, so it is the one that can be
|
||||
ellipsised when a title runs past the row. */
|
||||
font-size: var(--text-label);
|
||||
padding-block-start: calc(0.35rem + var(--safe-top));
|
||||
/* A hairline between the readout and the page, on the inner edge only.
|
||||
The paper behind a readout is the page's own colour, so without a line the row
|
||||
is invisible on a plain chapter — and the chapter name at the top then reads as
|
||||
a stray duplicate of the heading underneath it, which is the "很奇怪" in the
|
||||
report. One rule in the shared line colour turns it into a band the screen drew.
|
||||
The outer edge has no line: it is the edge of the screen. */
|
||||
border-block-end: 1px solid var(--reader-line);
|
||||
}
|
||||
|
||||
.indicator-progress {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--reader-ink);
|
||||
padding-block-end: calc(0.35rem + var(--safe-bottom));
|
||||
border-block-start: 1px solid var(--reader-line);
|
||||
}
|
||||
|
||||
/*
|
||||
* The page is a whole number of lines, and the surface the reader looks through is
|
||||
* exactly that tall.
|
||||
*
|
||||
* `--reader-page-trim` is the remainder of the viewport divided by the line height,
|
||||
* published by the view (`syncPageTrim`) because only the rendered text knows the grid
|
||||
* the book is set on: `line-height` is `inherit` by default, so the reader's own
|
||||
* stylesheet has no number to divide by. Floored away, the page holds a whole number of
|
||||
* lines and its last line ends at the glass; left in, the strip at the foot of the page
|
||||
* shows the first line of the *next* page with its descenders sliced off.
|
||||
*
|
||||
* Carried on `inset-block-end`, and the three reasons are worth stating because each
|
||||
* one is a way this can be got wrong:
|
||||
*
|
||||
* - **an inset, not a `height`.** The host is absolutely positioned, so its top and
|
||||
* bottom are already two statements about where the box is; a height would be a
|
||||
* third, trying to agree with both. One inset says one more thing — where the box
|
||||
* ends — and composes with the readout rows instead of replacing them;
|
||||
* - **not padding.** The host is `border-box` (the document-wide rule), so a
|
||||
* padding-bottom shortens the *content* box and leaves `clientHeight` — the number
|
||||
* the page arithmetic and the offsets are built on — at its old value. The page
|
||||
* would then be measured one height and drawn another, which is the disagreement
|
||||
* this removes rather than a second version of it;
|
||||
* - **off the bottom, not the top.** The top edge is the line the reader is meant to
|
||||
* start on; trimming it would push the first line under the chapter name's row,
|
||||
* which is the same defect at the other end of the same page.
|
||||
*
|
||||
* The write is the view's and the read is the stylesheet's, and the trim applies only
|
||||
* while the readouts do: with the chrome up the two rows are not on screen, the footer
|
||||
* floats over the page instead of taking height from it (see `.footer`), and there is
|
||||
* nothing to make room for. Scoping the rule to the hidden state is what keeps the
|
||||
* reading column at the same block inset in both states — which is the property that
|
||||
* makes showing and hiding the toolbar a pure repaint rather than a re-measure.
|
||||
*
|
||||
* `0px` until the view has measured a chapter, which is the correct first frame: the
|
||||
* trim is a correction to a page whose line grid is known, and guessing one before that
|
||||
* would clip text to a number nobody computed.
|
||||
*/
|
||||
.reader-screen[data-chrome='hidden'] .book-host {
|
||||
inset-block-start: var(--reader-readout-top);
|
||||
/* The row's height and the grid's remainder, in one expression: they are independent
|
||||
corrections to the same edge — one is chrome and one is the text's own metric — so
|
||||
they add rather than replacing one another. */
|
||||
inset-block-end: calc(var(--reader-readout-bottom) + var(--reader-page-trim, 0px));
|
||||
}
|
||||
|
||||
/*
|
||||
* The icon glyph.
|
||||
*
|
||||
* An icon is one character of a private-use font, so it is laid out as text — and the
|
||||
* only thing that decides where a character lands is the font's own metrics. That is why
|
||||
* this rule is three lines and not a geometry puzzle, and it is also what went wrong:
|
||||
* the rule used to say `line-height: 0; display: inline-block; width: 1em; height: 1em`,
|
||||
* which replaces the font's line box with a 1em box anchored at the baseline. The box
|
||||
* then extends *downward* from the baseline while the glyph's ink is drawn *around* it,
|
||||
* so the ink ended up at the box's top edge with the whole em empty beneath it. Every
|
||||
* container in this stylesheet centres its children, the box was centred correctly, and
|
||||
* the ink was half an em away from where the box said it was — measured at 0.496em
|
||||
* across `menu`, `close` and `folder`, i.e. half the box, in every row, at every size.
|
||||
*
|
||||
* So the box is the font's line box (`line-height: 1`) and the font's line box is
|
||||
* symmetric: `tools/icons/build.mjs` declares an ascent and descent of half the em each
|
||||
* and draws every glyph inside it, so centring the line centres the ink. `width: 1em` is
|
||||
* kept because it is the one thing the layout needs that the font cannot supply — a
|
||||
* predictable advance width, so a row of icons and labels is a row and not a function of
|
||||
* which glyphs happen to be in it.
|
||||
*
|
||||
* `vertical-align: middle` is for the rows that are *not* flex or grid — a glyph inline
|
||||
* in a paragraph of text, which is where `baseline` would otherwise apply and put the
|
||||
* icon's baseline on the text's, half an em low. In a flex or grid container (most of
|
||||
* this app's chrome) it is inert and the container's own alignment does the work, which
|
||||
* is the point: the icon needs no special case in either.
|
||||
*
|
||||
* `font-synthesis: none` matters for a private-use glyph: some engines will synthesise a
|
||||
* bold or oblique from the regular face, and a synthesised icon is a smeared icon.
|
||||
*/
|
||||
/* Shared sizing for the SVG icon primitive. */
|
||||
.icon {
|
||||
font-family: 'Reader Icons', sans-serif;
|
||||
font-size: 1.15rem;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
@@ -3950,3 +3667,99 @@ book-content {
|
||||
.sources-search { display:flex; align-items:end; gap:.5rem; margin:1rem 0; }
|
||||
.sources-search label { flex:1; margin:0; }
|
||||
.source-description { max-height:6rem; overflow:auto; font-size:.9rem; white-space:pre-wrap; }
|
||||
|
||||
/* Mobile reading: the surface and its information strip never resize with chrome. */
|
||||
[data-theme='green'] {
|
||||
--reader-ink: #23392c;
|
||||
--reader-paper: #d5ecd7;
|
||||
--reader-surface: #e8f2e8;
|
||||
--reader-muted: #617a65;
|
||||
--reader-line: #bdd6bf;
|
||||
--reader-accent: #497553;
|
||||
}
|
||||
svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; vertical-align: middle; }
|
||||
.reader-screen { --reader-footer-height: calc(8.5rem + var(--safe-bottom)); }
|
||||
.reader-screen .book-host {
|
||||
inset-block-start: calc(1.8rem + var(--safe-top));
|
||||
inset-block-end: calc(1.8rem + var(--safe-bottom) + var(--reader-page-trim, 0px));
|
||||
}
|
||||
.reading-indicator {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--reader-muted);
|
||||
background: var(--reader-paper);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
z-index: var(--reader-layer-readout);
|
||||
pointer-events: none;
|
||||
}
|
||||
.reading-indicator-top { top: 0; height: calc(1.8rem + var(--safe-top)); padding-top: calc(0.5rem + var(--safe-top)); }
|
||||
.reading-indicator-bottom { bottom: 0; height: calc(1.8rem + var(--safe-bottom)); padding-top: 0.35rem; padding-bottom: var(--safe-bottom); }
|
||||
.reading-indicator > span { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
|
||||
.indicator-left { text-align: start; }
|
||||
.indicator-right { text-align: end; }
|
||||
.reading-indicator > span:empty { flex: 0; }
|
||||
.reader-screen .topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
height: auto;
|
||||
min-height: calc(4.5rem + var(--safe-top));
|
||||
padding: calc(0.6rem + var(--safe-top)) 1rem 0.6rem;
|
||||
background: var(--reader-surface);
|
||||
border: 0;
|
||||
box-shadow: 0 2px 12px #0000000a;
|
||||
}
|
||||
.reader-heading { min-width: 0; display: grid; gap: 0.3rem; }
|
||||
.reader-heading strong { font-size: 1.05rem; font-weight: 600; }
|
||||
.reader-heading span { font-size: 0.75rem; color: var(--reader-muted); }
|
||||
.reader-heading strong, .reader-heading span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.reader-screen .footer {
|
||||
height: var(--reader-footer-height);
|
||||
padding: 0.5rem 1rem calc(0.4rem + var(--safe-bottom));
|
||||
background: var(--reader-surface);
|
||||
border: 0;
|
||||
box-shadow: 0 -2px 12px #0000000a;
|
||||
}
|
||||
.reader-screen .progress-row { display: flex; align-items: center; gap: 0.75rem; padding: 0; min-height: 3rem; }
|
||||
.reader-screen .nav-chapter { flex: 0 0 auto; font-size: 0.8rem; padding: 0.35rem 0; min-height: 44px; }
|
||||
.reader-scrub { flex: 1; min-width: 0; display: grid; text-align: center; }
|
||||
.reader-screen .progress-scrubber { width: 100%; min-height: 28px; margin: 0; }
|
||||
.reader-screen .progress-page { font-size: 0.65rem; color: var(--reader-muted); }
|
||||
.reader-actions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.25rem; }
|
||||
.reader-actions .top-button { min-width: 0; max-width: none; min-height: 60px; padding: 0.45rem 0; gap: 0.45rem; }
|
||||
.reader-actions .icon { font-size: 1.4rem; }
|
||||
.reader-actions .top-button-label { font-size: 0.75rem; font-weight: 400; }
|
||||
.reader-screen .reader-rail {
|
||||
top: auto;
|
||||
bottom: calc(var(--reader-footer-height) + 1rem);
|
||||
left: auto;
|
||||
right: max(1rem, var(--safe-right));
|
||||
width: auto;
|
||||
max-height: none;
|
||||
padding: 0;
|
||||
transform: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.reader-screen .rail-button { width: 48px; height: 48px; border-radius: 50%; background: var(--reader-surface); box-shadow: 0 3px 12px #00000020; }
|
||||
.reader-screen .rail-button .icon { font-size: 1.5rem; }
|
||||
.reader-screen .panel { border-radius: 20px 20px 0 0; max-height: 62%; background: var(--reader-surface); box-shadow: 0 -6px 30px #00000018; }
|
||||
.reader-screen .panel-header { padding: 0.5rem 1.25rem 0.75rem; }
|
||||
.reader-screen .panel-body { padding-inline: 1.25rem; }
|
||||
.reader-screen .panel .field { padding-block: 0.8rem; }
|
||||
.reader-screen .panel .tts-bar { position: static; border-radius: 12px; width: auto; transform: none; margin-bottom: 1rem; }
|
||||
.reader-screen .panel-header > .icon-button { margin-inline-start: auto; }
|
||||
.reader-screen .panel .field { grid-template-columns: minmax(5.5rem, max-content) minmax(0, 1fr); gap: 0.75rem; padding-block: 0.5rem; }
|
||||
.reader-screen .field input[type='range'] { min-height: 32px; }
|
||||
.reader-screen .field input[type='range']::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--reader-line); }
|
||||
.reader-screen .field input[type='range']::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; margin-top: -7px; border-radius: 50%; background: var(--reader-accent); }
|
||||
.reader-screen .field input[type='range']::-moz-range-track { height: 4px; border-radius: 2px; background: var(--reader-line); }
|
||||
.reader-screen .field input[type='range']::-moz-range-thumb { width: 18px; height: 18px; border: 0; border-radius: 50%; background: var(--reader-accent); }
|
||||
+31
-58
@@ -1,61 +1,34 @@
|
||||
/**
|
||||
* The icon primitive.
|
||||
*
|
||||
* ## Why a font, and not inline SVG
|
||||
*
|
||||
* The UI needs roughly thirty glyphs, in two colours, at four sizes, in one bundle
|
||||
* that has to work from `file://` in a WebView. Inline SVG would mean thirty small
|
||||
* components, each carrying its own `viewBox`, `stroke-width` and `fill` — which is
|
||||
* exactly how an icon set becomes visually inconsistent, because every one of those
|
||||
* three is a place where an individual glyph can drift. A font makes the glyph a
|
||||
* *character*: it inherits `font-size` and `color` from wherever it sits, scales
|
||||
* with the layout, and cannot carry divergent stroke geometry because there is no
|
||||
* per-glyph geometry in the UI layer at all.
|
||||
*
|
||||
* The set is generated (see `tools/icons/`) from one stroke width on one 24-unit
|
||||
* grid, which is what the optical consistency actually rests on.
|
||||
*
|
||||
* ## Why the accessible name is required
|
||||
*
|
||||
* An icon button with no text is a button whose name screen readers cannot read, so
|
||||
* `label` is not optional in `IconButtonProps` and it is not optional here either.
|
||||
* The glyph itself is always `aria-hidden` — it is punctuation, not content — and the
|
||||
* label is what carries the meaning. Where the label would repeat adjacent visible
|
||||
* text the caller passes `label={null}`, which is the explicit way to say "this glyph
|
||||
* is decorative", rather than omitting the attribute and leaving it ambiguous.
|
||||
*/
|
||||
|
||||
import { ICON_CODEPOINT_TABLE, type IconName } from './icon-names.ts';
|
||||
import { type JSX } from './vendor/preact.ts';
|
||||
|
||||
import {
|
||||
Menu, ChevronLeft, ChevronRight, ArrowLeft, Folder, FileText, SlidersHorizontal,
|
||||
Search, X, Plus, Check, Upload, Trash2, Download, RefreshCw, BookOpen,
|
||||
Library, Play, Pause, SkipBack, SkipForward, Square, Headphones, Moon, Sun,
|
||||
Eye, Info, TriangleAlert, Clock, Ellipsis, Pencil, ArrowDownUp, LogOut,
|
||||
CirclePlus, IndentIncrease, Type, MoveVertical, Settings, SlidersVertical,
|
||||
} from 'lucide-preact';
|
||||
import type { IconName } from './icon-names.ts';
|
||||
import type { JSX } from './vendor/preact.ts';
|
||||
export type { IconName };
|
||||
|
||||
export interface IconProps {
|
||||
name: IconName;
|
||||
/**
|
||||
* Accessible name. `null` marks the glyph decorative; the surrounding control is
|
||||
* then responsible for being reachable by name.
|
||||
*/
|
||||
label?: string | null;
|
||||
/** Extra classes, for sizing or colour. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one glyph from the icon font.
|
||||
*
|
||||
* The element is a `<span>` rather than an `<svg>` or an `<i>`: `i` is italic text
|
||||
* and inherits font-style, and `svg` would promise geometry that is not there.
|
||||
*/
|
||||
const ICONS = {
|
||||
menu: Menu, 'chevron-left': ChevronLeft, 'chevron-right': ChevronRight,
|
||||
'arrow-left': ArrowLeft, folder: Folder, 'file-text': FileText,
|
||||
sliders: SlidersHorizontal, search: Search, close: X, plus: Plus, check: Check,
|
||||
upload: Upload, trash: Trash2, download: Download, refresh: RefreshCw,
|
||||
book: BookOpen, shelf: Library, play: Play, pause: Pause,
|
||||
'step-backward': SkipBack, 'step-forward': SkipForward, stop: Square,
|
||||
volume: Headphones, moon: Moon, sun: Sun, eye: Eye, info: Info,
|
||||
warning: TriangleAlert, clock: Clock, more: Ellipsis, edit: Pencil,
|
||||
sort: ArrowDownUp, logout: LogOut, 'sign-out': LogOut, 'add-circle': CirclePlus,
|
||||
indent: IndentIncrease, 'text-size': Type, 'line-height': MoveVertical,
|
||||
settings: Settings, library: Library, tune: SlidersVertical,
|
||||
font: Type, 'volume-high': Headphones, 'backward-step': SkipBack,
|
||||
'forward-step': SkipForward, bars: Menu, gear: Settings, xmark: X,
|
||||
'magnifying-glass': Search, books: Library,
|
||||
} satisfies Record<IconName, typeof Menu>;
|
||||
export interface IconProps { name: IconName; label?: string | null; class?: string; }
|
||||
/** Shared geometry and stroke weight, independent of font rendering. */
|
||||
export function Icon({ name, label, class: className }: IconProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={className ? `icon ${className}` : 'icon'}
|
||||
aria-hidden={label ? undefined : 'true'}
|
||||
role={label ? 'img' : undefined}
|
||||
aria-label={label ?? undefined}
|
||||
>
|
||||
{ICON_CODEPOINT_TABLE[name]}
|
||||
</span>
|
||||
);
|
||||
const Glyph = ICONS[name];
|
||||
return <Glyph class={className ? 'icon ' + className : 'icon'} size="1em"
|
||||
strokeWidth={1.8} aria-hidden={label ? undefined : true}
|
||||
role={label ? 'img' : undefined} aria-label={label ?? undefined} />;
|
||||
}
|
||||
+74
-322
@@ -16,10 +16,12 @@
|
||||
*/
|
||||
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode } from '../store/settings.ts';
|
||||
import { ReaderIndicators } from './reader-indicators.tsx';
|
||||
import type { SpeechEngineKind } from '../render/speech.ts';
|
||||
import { type ComponentChildren, type JSX } from './vendor/preact.ts';
|
||||
import { IconButton, IconTextButton, SectionTitle } from './toolkit.tsx';
|
||||
import { ICON_CODEPOINT_TABLE, type IconName } from './icon-names.ts';
|
||||
import { Icon, type IconName } from './icon.tsx';
|
||||
|
||||
/**
|
||||
* A table-of-contents row.
|
||||
@@ -49,6 +51,10 @@ export interface SpeechBarState {
|
||||
}
|
||||
|
||||
export interface ChromeState {
|
||||
readoutTopLeft?: ReadoutMode;
|
||||
readoutTopRight?: ReadoutMode;
|
||||
readoutBottomLeft?: ReadoutMode;
|
||||
readoutBottomRight?: ReadoutMode;
|
||||
title: string;
|
||||
author: string;
|
||||
chromeVisible: boolean;
|
||||
@@ -59,6 +65,7 @@ export interface ChromeState {
|
||||
chapterLabel: string;
|
||||
tocOpen: boolean;
|
||||
settingsOpen: boolean;
|
||||
settingsTab?: 'appearance' | 'behavior' | 'speech';
|
||||
toc: ChromeTocEntry[];
|
||||
currentSectionId: string;
|
||||
/** 1-based position of the current page inside its chapter, and the count. */
|
||||
@@ -113,7 +120,7 @@ export interface ChromeState {
|
||||
export interface ChromeHandlers {
|
||||
onBack(): void;
|
||||
toggleToc(): void;
|
||||
toggleSettings(): void;
|
||||
toggleSettings(tab?: 'appearance' | 'behavior' | 'speech'): void;
|
||||
onSetting(patch: Partial<AppSettings>): void;
|
||||
onSpeechSetting(patch: Partial<AppSettings>): void;
|
||||
onVoice(value: string): void;
|
||||
@@ -153,190 +160,49 @@ export interface ReaderChromeProps {
|
||||
}
|
||||
|
||||
export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX.Element {
|
||||
const fixed = state.layout === 'fixed';
|
||||
const totalPages = Math.max(1, state.chapterPages);
|
||||
const currentPage = Math.min(totalPages, Math.max(1, state.pageInChapter));
|
||||
const pageLabel = '本章第 ' + currentPage + '/' + totalPages + ' 页';
|
||||
const tab = state.settingsTab ?? 'behavior';
|
||||
return (
|
||||
<>
|
||||
{/* The top bar.
|
||||
—
|
||||
A row of labelled buttons, not a title with a back arrow. The reference
|
||||
layout is a toolbar the reader *scans*: five controls of equal weight,
|
||||
each an icon over its own name, so nothing has to be learned and nothing
|
||||
is announced twice. The book's title lived here before; it is still on
|
||||
screen when the chrome is hidden (see `.reading-indicator`), which is
|
||||
where a reader actually looks for it — a title that disappears with the
|
||||
toolbar was the reason the indicator had to exist at all.
|
||||
Buttons are kept to the ones this screen can really do. The reference's
|
||||
首页/书架/书源/目录/设置 collapses to 返回/目录/设置 here because there is
|
||||
no book-source browser *inside* the reader: a button that navigates
|
||||
somewhere the screen cannot go is worse than one fewer button. */}
|
||||
<div className="topbar" hidden={!state.chromeVisible}>
|
||||
<TopButton icon="arrow-left" label="返回" onClick={handlers.onBack} />
|
||||
<TopButton icon="menu" label="目录" onClick={handlers.toggleToc} />
|
||||
<TopButton icon="sliders" label="设置" onClick={handlers.toggleSettings} />
|
||||
<IconButton label="返回" icon="arrow-left" onClick={handlers.onBack} />
|
||||
<div className="reader-heading"><strong>{state.title}</strong><span>{state.chapterLabel}</span></div>
|
||||
</div>
|
||||
|
||||
{/* Outside the flex flow: see the comment on `.status-bar`. It has to be a
|
||||
sibling of the stage rather than a child of it, because the stage's slot
|
||||
measures the space between the chrome and a strip that came and went would
|
||||
change that measurement on every sync. */}
|
||||
<div
|
||||
className="status-bar"
|
||||
hidden={state.statusState === 'idle' && state.statusText === ''}
|
||||
data-state={state.statusState}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
<span className="status-text">{state.statusText}</span>
|
||||
<div className="status-bar" hidden={state.statusState === 'idle' && state.statusText === ''}
|
||||
data-state={state.statusState} role="status" aria-live="polite">
|
||||
<span className="status-dot" aria-hidden="true" /><span className="status-text">{state.statusText}</span>
|
||||
</div>
|
||||
|
||||
<StageHost stage={stage} />
|
||||
|
||||
{/* The reading indicator.
|
||||
—
|
||||
What is left on screen when the chrome is hidden. The reference keeps two
|
||||
quiet facts pinned to the corners — which chapter, and how far in — and
|
||||
nothing else. That is the difference between "沉浸式" and "the app stopped
|
||||
drawing": a reader who has hidden the toolbar still wants to know where
|
||||
they are, and making them tap to find out is what the hidden state was
|
||||
supposed to save them from.
|
||||
Both live in the page margin at the very top and bottom of the stage, are
|
||||
`pointer-events: none` (they are readouts, not controls), and are drawn
|
||||
only while the chrome is hidden so they do not double up with it. */}
|
||||
{!state.chromeVisible ? (
|
||||
<div className="reading-indicator" aria-hidden="true">
|
||||
<span className="indicator-chapter">{state.chapterLabel}</span>
|
||||
<span className="indicator-progress">
|
||||
{state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页 ` : ''}
|
||||
{percentOf(state.progress)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The quick-action rail.
|
||||
—
|
||||
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: 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 exactly this short
|
||||
—
|
||||
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={state.theme === 'dark' ? 'sun' : 'moon'}
|
||||
<ReaderIndicators state={state} />
|
||||
<div className="reader-rail" role="toolbar" aria-label="阅读快捷操作">
|
||||
<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={state.tts.active ? 'pause' : 'volume'}
|
||||
label={state.tts.active ? '暂停朗读' : '听书'}
|
||||
pressed={state.tts.active}
|
||||
onClick={handlers.onSpeechToggle}
|
||||
/>
|
||||
onClick={() => handlers.onSetting({ theme: state.theme === 'dark' ? 'light' : 'dark' })} />
|
||||
</div>
|
||||
|
||||
{/* The bottom bar.
|
||||
—
|
||||
Two rows, and the split is the reference's: a scrubber the reader can
|
||||
drag to any page **of this chapter**, then a navigation row with the
|
||||
chapter buttons at the ends and the reading progress in the middle. The
|
||||
scrubber replaces the old 3px bar plus the "本章 x/y 页" readout — a bar
|
||||
that only reports could not be used to *go* anywhere, and the reader who
|
||||
wanted the end of the chapter had to tap the next button forty times.
|
||||
It is scoped to the chapter, not the book: the readout beside it says
|
||||
"第 3/9 页", and the reference's own screenshot shows the thumb near the
|
||||
middle at page 25 of 32 — a book-wide bar would be two thirds of the way
|
||||
across and blank in a chapter that is one of a thousand. The whole-book
|
||||
number is still on screen, as the "阅读进度" readout below, which is the
|
||||
row that *reports*; the slider is the row that *moves*. */}
|
||||
<div className="footer" hidden={!state.chromeVisible}>
|
||||
<div className="progress-row">
|
||||
<input
|
||||
className="progress-scrubber"
|
||||
type="range"
|
||||
min={1}
|
||||
max={Math.max(1, state.chapterPages)}
|
||||
step={1}
|
||||
// The *page*, not the whole-book fraction. The readout beside the thumb
|
||||
// names a page in this chapter, so the control has to move by the same
|
||||
// unit; a book-percentage slider next to "第 3/9 页" is two answers to
|
||||
// two different questions sharing one thumb.
|
||||
value={Math.min(Math.max(1, state.pageInChapter), Math.max(1, state.chapterPages))}
|
||||
aria-label="章节内页数"
|
||||
aria-valuetext={
|
||||
state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页` : '章节内页数'
|
||||
}
|
||||
disabled={state.chapterPages <= 1}
|
||||
onInput={(event) =>
|
||||
handlers.onScrubPage(Number((event.currentTarget as HTMLInputElement).value))
|
||||
}
|
||||
/>
|
||||
<span className="progress-page">
|
||||
{state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页` : ''}
|
||||
</span>
|
||||
<button type="button" className="nav-chapter" disabled={state.navigating || state.chapterIndex <= 1}
|
||||
onClick={() => handlers.onChapter(-1)}>{fixed ? '上一页' : '上一章'}</button>
|
||||
<div className="reader-scrub">
|
||||
<input className="progress-scrubber" type="range" min={1} max={totalPages} step={1}
|
||||
value={currentPage} aria-label="章节内页数"
|
||||
aria-valuetext={pageLabel} disabled={totalPages <= 1 || state.navigating}
|
||||
onInput={(event) => handlers.onScrubPage(Number((event.currentTarget as HTMLInputElement).value))} />
|
||||
<span className="progress-page">{pageLabel}</span>
|
||||
</div>
|
||||
<button type="button" className="nav-chapter" disabled={state.navigating || state.chapterIndex >= state.chapterCount}
|
||||
onClick={() => handlers.onChapter(1)}>{fixed ? '下一页' : '下一章'}</button>
|
||||
</div>
|
||||
<div className="footer-row chapter-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="nav-chapter"
|
||||
disabled={state.navigating || state.chapterIndex <= 1}
|
||||
onClick={() => handlers.onChapter(-1)}
|
||||
>
|
||||
<span className="icon" aria-hidden="true">{iconGlyph('chevron-left')}</span>
|
||||
上一章
|
||||
</button>
|
||||
<span className="nav-progress">阅读进度:{percentOf(state.progress)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-chapter"
|
||||
disabled={state.navigating || state.chapterIndex >= state.chapterCount}
|
||||
onClick={() => handlers.onChapter(1)}
|
||||
>
|
||||
下一章
|
||||
<span className="icon" aria-hidden="true">{iconGlyph('chevron-right')}</span>
|
||||
</button>
|
||||
<div className="reader-actions">
|
||||
<TopButton icon="menu" label="目录" onClick={handlers.toggleToc} />
|
||||
<TopButton icon="volume" label="朗读" onClick={() => handlers.toggleSettings('speech')} />
|
||||
<TopButton icon="text-size" label="界面" onClick={() => handlers.toggleSettings('appearance')} />
|
||||
<TopButton icon="settings" label="设置" onClick={() => handlers.toggleSettings('behavior')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.tts.active ? <SpeechBar state={state.tts} handlers={handlers} /> : null}
|
||||
|
||||
{state.tocOpen ? (
|
||||
<Panel title="目录" subtitle={`${state.toc.length} 章`} onClose={handlers.toggleToc}>
|
||||
{state.canRefresh ? (
|
||||
@@ -381,8 +247,11 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
) : null}
|
||||
|
||||
{state.settingsOpen ? (
|
||||
<Panel title="阅读设置" onClose={handlers.toggleSettings}>
|
||||
<SettingsBody state={state} handlers={handlers} />
|
||||
<Panel title={tab === 'appearance' ? '界面' : tab === 'speech' ? '朗读' : '阅读设置'} onClose={() => handlers.toggleSettings(tab)}>
|
||||
{tab === 'speech' ? <>
|
||||
{state.tts.active ? <SpeechBar state={state.tts} handlers={handlers} /> : null}
|
||||
<SpeechSettings state={state} handlers={handlers} />
|
||||
</> : <SettingsBody state={state} handlers={handlers} />}
|
||||
</Panel>
|
||||
) : null}
|
||||
</>
|
||||
@@ -464,52 +333,15 @@ function Panel({
|
||||
function SettingsBody({ state, handlers }: { state: ChromeState; handlers: ChromeHandlers }): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{/* The three controls a reader reaches for most often are also the three
|
||||
the old bottom toolbar spent most of its width on. They are a row of
|
||||
large targets at the top of the sheet rather than a toolbar over the
|
||||
text, because the sheet is where the reader already is when they decide
|
||||
the text is too small — and a toolbar that stays on screen to serve them
|
||||
is a toolbar that covers the book for everyone else. */}
|
||||
<SectionTitle>快捷</SectionTitle>
|
||||
<div className="quick-row">
|
||||
<QuickButton
|
||||
icon="text-size"
|
||||
label="字号"
|
||||
value={`${Math.round(state.fontScale * 100)}%`}
|
||||
onClick={() => handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })}
|
||||
/>
|
||||
<QuickButton
|
||||
icon="indent"
|
||||
label="段落缩进"
|
||||
value={state.showTxtRows ? indentLabel(state.txtIndent) : '—'}
|
||||
disabled={!state.showTxtRows}
|
||||
onClick={() => handlers.onSetting({ txtIndent: stepIndent(state.txtIndent) })}
|
||||
/>
|
||||
<QuickButton
|
||||
icon="line-height"
|
||||
label="段间距"
|
||||
value={state.showTxtRows ? `${state.txtParagraphGap.toFixed(2)} 字` : '—'}
|
||||
disabled={!state.showTxtRows}
|
||||
onClick={() => handlers.onSetting({ txtParagraphGap: stepGap(state.txtParagraphGap) })}
|
||||
/>
|
||||
<QuickButton
|
||||
icon={state.theme === 'dark' ? 'sun' : 'moon'}
|
||||
label="主题"
|
||||
value={THEME_LABELS[state.theme] ?? state.theme}
|
||||
onClick={() => handlers.onSetting({ theme: nextTheme(state.theme) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionTitle>排版</SectionTitle>
|
||||
<SegmentedRow
|
||||
label="翻页方式"
|
||||
options={[
|
||||
{ value: 'scroll', label: '滚动' },
|
||||
{ value: 'paged', label: '翻页' },
|
||||
]}
|
||||
value={state.mode}
|
||||
onChange={(value) => handlers.onSetting({ mode: value as AppSettings['mode'] })}
|
||||
/>
|
||||
{(state.settingsTab ?? 'behavior') === 'appearance' ? <>
|
||||
<SectionTitle>阅读信息</SectionTitle>
|
||||
{READOUT_FIELDS.map(({ key, label }) => (
|
||||
<SelectRow key={key} label={label} options={READOUT_OPTIONS}
|
||||
value={state[key] ?? DEFAULT_APP_SETTINGS[key]}
|
||||
onChange={(value) => handlers.onSetting({ [key]: value as ReadoutMode })} />
|
||||
))}
|
||||
<SectionTitle>正文与主题</SectionTitle>
|
||||
{state.layout !== 'fixed' ? <>
|
||||
<SliderRow
|
||||
label="字号"
|
||||
value={state.fontScale}
|
||||
@@ -562,11 +394,13 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
|
||||
value={state.fontFamily}
|
||||
onChange={(value) => handlers.onSetting({ fontFamily: value })}
|
||||
/>
|
||||
</> : null}
|
||||
<SegmentedRow
|
||||
label="主题"
|
||||
options={[
|
||||
{ value: 'light', label: '白' },
|
||||
{ value: 'sepia', label: '米黄' },
|
||||
{ value: 'green', label: '浅绿' },
|
||||
{ value: 'dark', label: '夜间' },
|
||||
]}
|
||||
value={state.theme}
|
||||
@@ -624,6 +458,18 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
|
||||
</>
|
||||
) : null}
|
||||
|
||||
</> : <>
|
||||
{state.layout !== 'fixed' ?
|
||||
<SegmentedRow
|
||||
label="翻页方式"
|
||||
options={[
|
||||
{ value: 'scroll', label: '滚动' },
|
||||
{ value: 'paged', label: '翻页' },
|
||||
]}
|
||||
value={state.mode}
|
||||
onChange={(value) => handlers.onSetting({ mode: value as AppSettings['mode'] })}
|
||||
/>
|
||||
: null}
|
||||
<SectionTitle>翻页</SectionTitle>
|
||||
<SegmentedRow
|
||||
label="点击区域"
|
||||
@@ -670,8 +516,7 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SectionTitle>朗读</SectionTitle>
|
||||
<SpeechSettings state={state} handlers={handlers} />
|
||||
</>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -747,7 +592,7 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr
|
||||
onChange={(value) => handlers.onSpeechSetting({ ttsAutoAdvance: value === 'auto' })}
|
||||
/>
|
||||
<button type="button" className="button" onClick={handlers.onSpeakFromHere}>
|
||||
从头朗读这一章
|
||||
从当前位置朗读
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
@@ -887,6 +732,8 @@ function SliderRow({
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
aria-label={label}
|
||||
aria-valuetext={format(value)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
@@ -912,7 +759,7 @@ function SelectRow({
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
<select value={value} onChange={(event) => onChange((event.currentTarget as HTMLSelectElement).value)}>
|
||||
<select aria-label={label} value={value} onChange={(event) => onChange((event.currentTarget as HTMLSelectElement).value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
@@ -935,7 +782,7 @@ function SelectRow({
|
||||
function TopButton({ icon, label, onClick }: { icon: IconName; label: string; onClick(): void }): JSX.Element {
|
||||
return (
|
||||
<button type="button" className="top-button" aria-label={label} onClick={onClick}>
|
||||
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
|
||||
<Icon name={icon} />
|
||||
<span className="top-button-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -969,107 +816,12 @@ function RailButton({
|
||||
{...(pressed === undefined ? {} : { 'aria-pressed': pressed })}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
|
||||
<Icon name={icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The glyph for an icon name, from the generated code point table. */
|
||||
function iconGlyph(name: IconName): string {
|
||||
return ICON_CODEPOINT_TABLE[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled quick-action tile.
|
||||
*
|
||||
* A tap rather than a slider for the three controls a reader nudges most: a slider
|
||||
* is the right control for "any value in this range" and the wrong one for "one
|
||||
* step bigger", which is what a reader actually wants when the text looks small.
|
||||
* The full range is still in the rows below, so the tile is a shortcut and never
|
||||
* the only way to reach a value.
|
||||
*/
|
||||
function QuickButton({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onClick(): void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="quick-button"
|
||||
disabled={disabled}
|
||||
aria-label={`${label} ${value}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
|
||||
<span className="quick-label">{label}</span>
|
||||
<span className="quick-value">{value}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The next font scale in the ladder, so a tap lands on a round percentage. */
|
||||
const FONT_SCALE_LADDER = [0.8, 0.9, 1, 1.1, 1.25, 1.4, 1.6, 1.8, 2.0, 2.2];
|
||||
|
||||
function stepFontScale(current: number, direction: 1 | -1): number {
|
||||
const index = FONT_SCALE_LADDER.findIndex((value) => value >= current - 0.001);
|
||||
const at = index === -1 ? FONT_SCALE_LADDER.length - 1 : index;
|
||||
const next = Math.min(FONT_SCALE_LADDER.length - 1, Math.max(0, at + direction));
|
||||
return FONT_SCALE_LADDER[next] ?? current;
|
||||
}
|
||||
|
||||
/** Paragraph indent, cycling 无 → 1 → 2 → 3 → 4 characters and back. */
|
||||
const INDENT_LADDER = [0, 1, 2, 3, 4];
|
||||
|
||||
function stepIndent(current: number): number {
|
||||
const index = INDENT_LADDER.findIndex((value) => value > current + 0.001);
|
||||
return index === -1 ? INDENT_LADDER[0]! : INDENT_LADDER[index]!;
|
||||
}
|
||||
|
||||
/** Paragraph spacing, cycling in quarter-character steps and back to none. */
|
||||
const GAP_LADDER = [0, 0.25, 0.5, 0.75, 1, 1.25];
|
||||
|
||||
function stepGap(current: number): number {
|
||||
const index = GAP_LADDER.findIndex((value) => value > current + 0.001);
|
||||
return index === -1 ? GAP_LADDER[0]! : GAP_LADDER[index]!;
|
||||
}
|
||||
|
||||
function indentLabel(indent: number): string {
|
||||
return indent === 0 ? '无' : `${indent.toFixed(2)} 字`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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". 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'];
|
||||
|
||||
const THEME_LADDER: Array<AppSettings['theme']> = ['light', 'sepia', 'dark'];
|
||||
|
||||
const THEME_LABELS: Record<string, string> = { light: '白', sepia: '米黄', dark: '夜间' };
|
||||
|
||||
function nextTheme(theme: AppSettings['theme']): AppSettings['theme'] {
|
||||
const index = THEME_LADDER.indexOf(theme);
|
||||
return THEME_LADDER[(index + 1) % THEME_LADDER.length] ?? 'light';
|
||||
}
|
||||
|
||||
/** The progress fraction as a percentage, clamped. */
|
||||
function percentOf(fraction: number): string {
|
||||
return `${Math.round(Math.min(1, Math.max(0, fraction)) * 100)}%`;
|
||||
}
|
||||
|
||||
export type { SpeechEngineKind };
|
||||
@@ -0,0 +1,41 @@
|
||||
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, type ReadoutMode } from '../store/settings.ts';
|
||||
import type { ChromeState } from './reader-chrome.tsx';
|
||||
import { useEffect, useState } from './vendor/preact.ts';
|
||||
|
||||
/** Only this small subtree updates with the clock; the reading surface is untouched. */
|
||||
export function ReaderIndicators({ state }: { state: ChromeState }) {
|
||||
const modes = READOUT_FIELDS.map(({ key }) => state[key] ?? DEFAULT_APP_SETTINGS[key]);
|
||||
const clockEnabled = modes.includes('time');
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
if (!clockEnabled) return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const refresh = () => {
|
||||
clearTimeout(timer);
|
||||
setNow(new Date());
|
||||
timer = setTimeout(refresh, 60_000 - Date.now() % 60_000);
|
||||
};
|
||||
refresh();
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', refresh); };
|
||||
}, [clockEnabled]);
|
||||
|
||||
const text = (mode: ReadoutMode): string => {
|
||||
switch (mode) {
|
||||
case 'book': return state.title;
|
||||
case 'chapter': return state.chapterLabel;
|
||||
case 'progress': return `第${Math.max(1, state.pageInChapter)}/${Math.max(1, state.chapterPages)}页 ${Math.round(Math.min(1, Math.max(0, state.progress)) * 100)}%`;
|
||||
case 'time': return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
return <>{(['top', 'bottom'] as const).map((edge, index) => (
|
||||
<div key={edge} className={`reading-indicator reading-indicator-${edge}`} aria-label={edge === 'top' ? '顶部阅读信息' : '底部阅读信息'}>
|
||||
{(['left', 'right'] as const).map((side, offset) => {
|
||||
const mode = modes[index * 2 + offset]!;
|
||||
const value = text(mode);
|
||||
return <span key={side} className={`indicator-${side}`} data-mode={mode} title={value}>{value}</span>;
|
||||
})}
|
||||
</div>
|
||||
))}</>;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type { Platform } from '../core/platform.ts';
|
||||
import { ReaderView, type Position, type ViewSettings } from './reader-view.ts';
|
||||
import { makeAssetSigner } from '../net/asset-url.ts';
|
||||
import { attachGestures } from './gestures.ts';
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import { READOUT_FIELDS, type AppSettings } from '../store/settings.ts';
|
||||
import { createStagedDoc, isStagedKind, windowIndexOf } from '../formats/windowed.ts';
|
||||
import type { BookDoc } from '../formats/types.ts';
|
||||
import type { TocEntry } from '../api/types.ts';
|
||||
@@ -218,7 +218,7 @@ export class ReaderScreen {
|
||||
handlers={{
|
||||
onBack: () => this.options.onBack(),
|
||||
toggleToc: () => this.toggleToc(),
|
||||
toggleSettings: () => this.toggleSettings(),
|
||||
toggleSettings: (tab) => this.toggleSettings(tab),
|
||||
onSetting: (patch) => void this.updateSetting(patch),
|
||||
onSpeechSetting: (patch) => this.updateSpeechSetting(patch),
|
||||
onVoice: (voice) => this.updateSpeechSetting({ ttsVoice: voice }),
|
||||
@@ -926,9 +926,9 @@ export class ReaderScreen {
|
||||
this.patch({ tocOpen: open, settingsOpen: false });
|
||||
}
|
||||
|
||||
private toggleSettings(): void {
|
||||
const open = !this.chrome.settingsOpen;
|
||||
this.patch({ settingsOpen: open, tocOpen: false });
|
||||
private toggleSettings(tab: 'appearance' | 'behavior' | 'speech' = 'behavior'): void {
|
||||
const open = !this.chrome.settingsOpen || this.chrome.settingsTab !== tab;
|
||||
this.patch({ settingsOpen: open, settingsTab: tab, tocOpen: false });
|
||||
if (!open) return;
|
||||
// Re-read the voice list on every open: a Bluetooth headset paired while the
|
||||
// book was open adds a voice, and Chrome only reports it asynchronously.
|
||||
@@ -1260,7 +1260,10 @@ export class ReaderScreen {
|
||||
private async updateSetting(patch: Partial<AppSettings>): Promise<void> {
|
||||
this.options.onSettingsChange(patch);
|
||||
Object.assign(this.settings, patch);
|
||||
this.view?.applySettings(this.viewSettings());
|
||||
// Reading information changes only the chrome, never the book's pagination.
|
||||
if (Object.keys(patch).some(key => !READOUT_FIELDS.some(field => field.key === key))) {
|
||||
this.view?.applySettings(this.viewSettings());
|
||||
}
|
||||
this.patch({});
|
||||
// Fixed-layout fit and direction changes are structural, so the current page
|
||||
// has to be re-rendered rather than merely re-styled.
|
||||
@@ -1686,6 +1689,10 @@ function emptyChromeSettings(): Partial<ChromeState> {
|
||||
shelfSort: 'recent',
|
||||
shelfShowAuthor: true,
|
||||
shelfShowProgress: true,
|
||||
readoutTopLeft: 'chapter',
|
||||
readoutTopRight: 'none',
|
||||
readoutBottomLeft: 'progress',
|
||||
readoutBottomRight: 'time',
|
||||
},
|
||||
{ layout: 'reflowable', format: '', availability: { system: false, native: false, http: false, preferred: null }, engine: null, voices: [] },
|
||||
);
|
||||
@@ -1726,6 +1733,10 @@ function settingsView(
|
||||
const activeEngine = context.engine ?? context.availability.preferred;
|
||||
return {
|
||||
mode: settings.mode,
|
||||
readoutTopLeft: settings.readoutTopLeft,
|
||||
readoutTopRight: settings.readoutTopRight,
|
||||
readoutBottomLeft: settings.readoutBottomLeft,
|
||||
readoutBottomRight: settings.readoutBottomRight,
|
||||
fontScale: settings.fontScale,
|
||||
lineHeight: settings.lineHeight,
|
||||
theme: settings.theme,
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface ViewSettings {
|
||||
mode: 'scroll' | 'paged';
|
||||
fontScale: number;
|
||||
lineHeight: string;
|
||||
theme: 'light' | 'sepia' | 'dark';
|
||||
theme: 'light' | 'sepia' | 'green' | 'dark';
|
||||
/** Fixed-layout only. */
|
||||
fit: 'contain' | 'width';
|
||||
direction: 'ltr' | 'rtl';
|
||||
@@ -362,6 +362,8 @@ export class ReaderView {
|
||||
|
||||
applySettings(next?: Partial<ViewSettings>): void {
|
||||
const previous = this.settings;
|
||||
// Capture the live position before typography changes its scroll extent.
|
||||
if (next) this.measureWithin();
|
||||
if (next) this.settings = { ...this.settings, ...next };
|
||||
document.documentElement.dataset['theme'] = this.settings.theme;
|
||||
this.host.setAttribute('data-paginated', String(this.settings.mode === 'paged'));
|
||||
@@ -369,6 +371,7 @@ export class ReaderView {
|
||||
// `inherit` is what keeps the author's line-height; only an explicit user
|
||||
// choice replaces it.
|
||||
this.container.style.setProperty('--reader-line-height', this.settings.lineHeight);
|
||||
this.container.style.setProperty('--reader-txt-line-height', this.settings.lineHeight === 'inherit' ? '1.8' : this.settings.lineHeight);
|
||||
// Same reasoning for the font stack: the default is `inherit`, so a book
|
||||
// that ships its own stack keeps it. The reader's choice is offered first so
|
||||
// a book that ships *nothing* looks like the rest of their library.
|
||||
@@ -835,6 +838,7 @@ export class ReaderView {
|
||||
...(section.path ? { path: section.path } : {}),
|
||||
...(section.image ? { mediaType: section.image.mediaType } : {}),
|
||||
...(section.image ? { bytes: section.image.bytes } : {}),
|
||||
...(section.document ? { bytes: section.document.bytes, mediaType: section.document.mediaType } : {}),
|
||||
fit: this.settings.fit,
|
||||
});
|
||||
if (drawn) {
|
||||
@@ -862,6 +866,12 @@ export class ReaderView {
|
||||
img.decoding = 'async';
|
||||
img.draggable = false;
|
||||
wrapper.append(img);
|
||||
} else if (section.document) {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'pdf-frame';
|
||||
frame.title = section.label;
|
||||
frame.src = this.objectUrl(section.document.bytes, section.document.mediaType);
|
||||
wrapper.append(frame);
|
||||
} else if (section.html) {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'pdf-frame';
|
||||
@@ -893,11 +903,18 @@ export class ReaderView {
|
||||
if (this.settings.mode === 'paged') {
|
||||
const width = scroller.scrollWidth;
|
||||
if (width <= 0) return this.sectionOffset;
|
||||
return Math.min(1, Math.max(0, scroller.scrollLeft / Math.max(1, width - scroller.clientWidth)));
|
||||
const within = Math.min(1, Math.max(0, scroller.scrollLeft / Math.max(1, width - scroller.clientWidth)));
|
||||
this.sectionOffset = within;
|
||||
return within;
|
||||
}
|
||||
const limit = scroller.scrollHeight - scroller.clientHeight;
|
||||
if (limit <= 0) return 0;
|
||||
return Math.min(1, Math.max(0, scroller.scrollTop / limit));
|
||||
const within = Math.min(1, Math.max(0, scroller.scrollTop / limit));
|
||||
// ResizeObserver re-anchors after the viewport changes. Keep the latest
|
||||
// position, rather than the offset used when the chapter was opened, so a
|
||||
// chrome repaint cannot send a reader back to page one.
|
||||
this.sectionOffset = within;
|
||||
return within;
|
||||
}
|
||||
|
||||
private restoreOffset(): void {
|
||||
@@ -1107,7 +1124,9 @@ export class ReaderView {
|
||||
// re-enters this method from the observer, and the two keep re-measuring and
|
||||
// re-writing each other. Writing only a *different* value terminates: the second call
|
||||
// computes the same trim, sees it, and returns without touching the DOM.
|
||||
if (this.trimApplied === trim) return;
|
||||
// Text rectangles are rounded to subpixels. Treat subpixel noise as the
|
||||
// same trim so ResizeObserver cannot oscillate on an unchanged layout.
|
||||
if (this.trimApplied !== null && Math.abs(this.trimApplied - trim) < 0.5) return;
|
||||
this.trimApplied = trim;
|
||||
this.host.style.setProperty('--reader-page-trim', `${trim}px`);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ import { BOOK_RESOURCE_MARKER } from '../formats/book-resource.ts';
|
||||
*/
|
||||
const TXT_STYLESHEET = `
|
||||
.txt-body {
|
||||
font-size: 1.125em;
|
||||
line-height: var(--reader-txt-line-height, 1.8);
|
||||
/* Declared on the wrapper rather than on each paragraph so one property can be
|
||||
changed in one place, and so a paragraph that the file's own text already
|
||||
indented is not indented twice. */
|
||||
|
||||
@@ -350,6 +350,7 @@ describe('PDF loading', () => {
|
||||
expect(doc.format).toBe('pdf');
|
||||
expect(doc.layout).toBe('fixed');
|
||||
expect(doc.sections).toHaveLength(1);
|
||||
expect((doc.sections[0] as { document?: { bytes: Uint8Array } }).document?.bytes).toEqual(utf8('%PDF-1.7 fake'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -319,7 +319,8 @@ describe('the icon box', () => {
|
||||
const host = document.createElement('div');
|
||||
render(Icon({ name: 'close' }), host);
|
||||
const span = host.querySelector('.icon')!;
|
||||
expect(span.textContent).toBe(ICON_CODEPOINTS.close);
|
||||
expect(span.tagName.toLowerCase()).toBe('svg');
|
||||
expect(span.children.length).toBeGreaterThan(0);
|
||||
expect(span.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(span.hasAttribute('aria-label')).toBe(false);
|
||||
});
|
||||
@@ -417,7 +418,7 @@ describe('icon buttons', () => {
|
||||
expect(button.getAttribute('aria-label')).toBe('目录');
|
||||
const glyph = button.querySelector('.icon')!;
|
||||
expect(glyph.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(glyph.textContent).toBe(ICON_CODEPOINTS.menu);
|
||||
expect(glyph.tagName.toLowerCase()).toBe('svg');
|
||||
});
|
||||
|
||||
it('draws every icon button from the icon set rather than from text', async () => {
|
||||
@@ -428,6 +429,6 @@ describe('icon buttons', () => {
|
||||
// A regression here is the whole point of the change: the old button took
|
||||
// arbitrary children, and Unicode punctuation is what it was given.
|
||||
expect(host.querySelector('.icon')).not.toBeNull();
|
||||
expect(host.textContent).toBe(ICON_CODEPOINTS.close);
|
||||
expect(host.querySelector('svg path')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -206,6 +206,17 @@ describe('serialisePage', () => {
|
||||
});
|
||||
|
||||
describe('androidPageHost', () => {
|
||||
it('gives the browser a PDF blob instead of an empty fixed page', async () => {
|
||||
const { loadPdf } = await import('../src/formats/pdf.ts');
|
||||
const pdf = await loadPdf(new TextEncoder().encode('%PDF-1.7 fixture'));
|
||||
const reader = view(pdf);
|
||||
try {
|
||||
await reader.open(0);
|
||||
const frame = flowOf(reader).querySelector('iframe');
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame?.src).toMatch(/^blob:/);
|
||||
} finally { reader.dispose(); }
|
||||
});
|
||||
function bridge(overrides: Partial<AndroidBridge> = {}): AndroidBridge {
|
||||
return {
|
||||
shellVersion: () => 2,
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('ReaderChrome', () => {
|
||||
const labels = [...(rail?.querySelectorAll('.rail-button') ?? [])].map((button) =>
|
||||
button.getAttribute('aria-label'),
|
||||
);
|
||||
expect(labels).toEqual(['夜间', '字号', '听书']);
|
||||
expect(labels).toEqual(['夜间']);
|
||||
for (const gone of ['目录', '字体', '行距', '页边距', '阅读设置']) {
|
||||
expect(labels).not.toContain(gone);
|
||||
}
|
||||
@@ -143,8 +143,29 @@ describe('ReaderChrome', () => {
|
||||
expect([...tree.querySelectorAll('button')].find((button) => button.textContent?.includes('正在刷新目录'))?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('uses chapter pages for the slider even when the book has many fixed pages', () => {
|
||||
const tree = paint({ layout: 'fixed', chapterIndex: 50, chapterCount: 100, chapterPages: 1, pageInChapter: 1 });
|
||||
const slider = tree.querySelector<HTMLInputElement>('.progress-scrubber')!;
|
||||
expect(slider.max).toBe('1');
|
||||
expect(slider.value).toBe('1');
|
||||
expect(slider.disabled).toBe(true);
|
||||
expect(slider.getAttribute('aria-label')).toBe('章节内页数');
|
||||
});
|
||||
|
||||
it('separates appearance and behavior settings and omits text controls for comics', () => {
|
||||
let tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
|
||||
expect(tree.querySelector('.panel')?.textContent).toContain('浅绿');
|
||||
expect(tree.querySelector('.panel')?.textContent).not.toContain('点击区域');
|
||||
tree = paint({ settingsOpen: true, settingsTab: 'behavior' });
|
||||
expect(tree.querySelector('.panel')?.textContent).toContain('点击区域');
|
||||
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
|
||||
tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
|
||||
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
|
||||
});
|
||||
|
||||
it('draws every read-aloud control, including stop, in their own row', () => {
|
||||
const tree = paint({
|
||||
settingsOpen: true, settingsTab: 'speech',
|
||||
tts: {
|
||||
active: true,
|
||||
state: 'playing',
|
||||
@@ -178,6 +199,7 @@ describe('ReaderChrome', () => {
|
||||
// ✕ 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({
|
||||
settingsOpen: true, settingsTab: 'speech',
|
||||
tts: {
|
||||
active: true,
|
||||
state: 'playing',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'preact/test-utils';
|
||||
import { createElement as h, render } from '../src/ui/vendor/preact.ts';
|
||||
import { ReaderIndicators } from '../src/ui/reader-indicators.tsx';
|
||||
import type { ChromeState } from '../src/ui/reader-chrome.tsx';
|
||||
import { SettingsStore } from '../src/store/settings.ts';
|
||||
|
||||
const container = document.createElement('div');
|
||||
const state = {
|
||||
title: '测试书名', chapterLabel: '第二章', pageInChapter: 3, chapterPages: 12, progress: 0.47,
|
||||
} as ChromeState;
|
||||
const texts = () => Array.from(container.querySelectorAll('span'), el => el.textContent);
|
||||
afterEach(() => { act(() => render(null, container)); vi.useRealTimers(); });
|
||||
|
||||
describe('reading information', () => {
|
||||
it('maps all four positions independently and allows hiding each one', () => {
|
||||
act(() => render(h(ReaderIndicators, { state: { ...state,
|
||||
readoutTopLeft: 'book', readoutTopRight: 'chapter',
|
||||
readoutBottomLeft: 'progress', readoutBottomRight: 'none',
|
||||
} }), container));
|
||||
expect(texts()).toEqual(['测试书名', '第二章', '第3/12页 47%', '']);
|
||||
act(() => render(h(ReaderIndicators, { state: { ...state,
|
||||
readoutTopLeft: 'none', readoutTopRight: 'none',
|
||||
readoutBottomLeft: 'none', readoutBottomRight: 'none',
|
||||
} }), container));
|
||||
expect(texts()).toEqual(['', '', '', '']);
|
||||
expect(container.querySelectorAll('.reading-indicator')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refreshes time at the minute boundary and on resume, then cleans up', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 8, 20, 9, 5, 59));
|
||||
act(() => render(h(ReaderIndicators, { state }), container));
|
||||
expect(texts()[3]).toBe('09:05');
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
expect(texts()[3]).toBe('09:06');
|
||||
vi.setSystemTime(new Date(2026, 8, 20, 10, 15));
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')); });
|
||||
expect(texts()[3]).toBe('10:15');
|
||||
act(() => render(null, container));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('migrates older preferences and persists each position across store instances', async () => {
|
||||
let saved = JSON.stringify({ theme: 'sepia', readoutTopRight: 'invalid' });
|
||||
const kv = { get: async () => saved, set: async (_key: string, value: string) => { saved = value; }, remove: async () => {} };
|
||||
const store = new SettingsStore(kv);
|
||||
expect(await store.load()).toMatchObject({ theme: 'sepia', readoutTopLeft: 'chapter', readoutTopRight: 'none', readoutBottomLeft: 'progress', readoutBottomRight: 'time' });
|
||||
await store.update({ readoutTopLeft: 'time', readoutTopRight: 'book', readoutBottomLeft: 'none', readoutBottomRight: 'chapter' });
|
||||
expect(await new SettingsStore(kv).load()).toMatchObject({ readoutTopLeft: 'time', readoutTopRight: 'book', readoutBottomLeft: 'none', readoutBottomRight: 'chapter' });
|
||||
});
|
||||
});
|
||||
@@ -111,42 +111,12 @@ describe('the reading surface fills the screen', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('makes each reading readout a full-width row flush to its edge', () => {
|
||||
// "reading-indicator 应该是紧贴顶部/底部,并且占用一整行": a readout used to be a
|
||||
// paper pill the width of its own text, floated a little inside the edge. A sticker
|
||||
// that ends wherever the words end gives the chapter name and the page count two
|
||||
// different right edges, and over the chapter's own heading it read as a fragment of
|
||||
// body text rather than something the screen drew — the "很奇怪,不整洁" in the report.
|
||||
//
|
||||
// So each readout is a *row*: the paper spans the stage and the words are inset
|
||||
// inside it. Asserted as the pair of properties that make it a row rather than a
|
||||
// pill — full width, and no horizontal shrink — because either one alone leaves the
|
||||
// old look reachable.
|
||||
// The row geometry is one rule for both readouts, so it is read by its own
|
||||
// selector list rather than by either name alone.
|
||||
// The shared rule is reached by either name: `rule()` matches the selector list
|
||||
// the rule declares, so the first of the two names finds the row geometry.
|
||||
const row = rule('.indicator-chapter');
|
||||
expect(row).not.toBe('');
|
||||
expect(declaration(row, 'display')).toBe('block');
|
||||
expect(declaration(row, 'width')).toBe('100%');
|
||||
expect(declaration(row, 'background')).toBe('var(--reader-paper)');
|
||||
});
|
||||
|
||||
it('puts the page insets on the readout text and not on the row', () => {
|
||||
// The paper reaches the very edge of the glass — under the notch and the cutout —
|
||||
// so the safe insets move the *text* inward instead of leaving a strip of page
|
||||
// showing beside the band. The container therefore carries no padding at all: an
|
||||
// inset on it would be the strip this avoids.
|
||||
it('keeps reading information in fixed top and bottom strips', () => {
|
||||
const box = rule('.reading-indicator');
|
||||
expect(declaration(box, 'padding')).toBeUndefined();
|
||||
expect(declaration(box, 'align-items')).toBe('stretch');
|
||||
// The two rows carry the safe insets on their own inner edges, in their own
|
||||
// rules — read from the sheet's text, because the selector is the *second* rule
|
||||
// for each name and a by-name lookup returns the shared row rule above it.
|
||||
const text = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
expect(text).toMatch(/\.indicator-chapter \{[^}]*padding-block-start: calc\(0\.35rem \+ var\(--safe-top\)\)/);
|
||||
expect(text).toMatch(/\.indicator-progress \{[^}]*padding-block-end: calc\(0\.35rem \+ var\(--safe-bottom\)\)/);
|
||||
expect(declaration(rule('.reading-indicator-top'), 'top')).toBe('0');
|
||||
expect(declaration(rule('.reading-indicator-bottom'), 'bottom')).toBe('0');
|
||||
expect(declaration(box, 'display')).toBe('flex');
|
||||
expect(declaration(box, 'pointer-events')).toBe('none');
|
||||
});
|
||||
|
||||
it('paints the read-aloud bar above the page it floats over', () => {
|
||||
@@ -169,65 +139,11 @@ describe('the reading surface fills the screen', () => {
|
||||
expect(declaration(controls, 'flex')).toBe('1 1 0');
|
||||
});
|
||||
|
||||
it('takes the readout rows out of the page instead of drawing over the text', () => {
|
||||
// "reading-indicator 是浮动的,导致翻页后,遮挡了一部分文字".
|
||||
//
|
||||
// This is *not* a z-index question, and that is the whole point of asserting it
|
||||
// here. The rows already won the paint order (see the `--reader-layer-readout`
|
||||
// check above); the report is that winning it is not the same as the text being
|
||||
// readable. The rows are 25px and 24px tall at the phone size this product is
|
||||
// used at, and a line of body text is 19px — so in immersion the sentence under
|
||||
// each row was cut through the middle of its glyphs, and no paint order makes
|
||||
// half a line whole.
|
||||
//
|
||||
// The correction is that the reading surface is inset by the two rows while they
|
||||
// are on screen, so the text that discloses is never under one of them. Asserted
|
||||
// as the three things that make it work:
|
||||
//
|
||||
// 1. the tokens exist, so the row's height is a number the page can be inset by;
|
||||
// 2. the inset is on `inset-block-end`/`-start` rather than on a height or a
|
||||
// padding, because the host is `border-box` and a padding would shorten the
|
||||
// content box while leaving `clientHeight` — the number the page arithmetic
|
||||
// and the restored offset are built on — where it was;
|
||||
// 3. the rows are `line-height: 1`, or the token is 20% short of the row and the
|
||||
// last line of the page is cut by exactly that much.
|
||||
const root = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
expect(root).toMatch(/--reader-readout-top: calc\(/);
|
||||
expect(root).toMatch(/--reader-readout-bottom: calc\(/);
|
||||
const hidden = rule(".reader-screen[data-chrome='hidden'] .book-host");
|
||||
expect(hidden).not.toBe('');
|
||||
expect(declaration(hidden, 'inset-block-start')).toBe('var(--reader-readout-top)');
|
||||
expect(declaration(hidden, 'inset-block-end')).toContain('var(--reader-readout-bottom)');
|
||||
// Not a height and not a padding: see (2) above. Either would leave the measured
|
||||
// box and the drawn box as two different numbers.
|
||||
expect(declaration(hidden, 'height')).toBeUndefined();
|
||||
expect(declaration(hidden, 'block-size')).toBeUndefined();
|
||||
expect(declaration(hidden, 'padding')).toBeUndefined();
|
||||
expect(declaration(hidden, 'padding-block-end')).toBeUndefined();
|
||||
const row = rule('.indicator-chapter');
|
||||
expect(declaration(row, 'line-height')).toBe('1');
|
||||
});
|
||||
|
||||
it('ends the page on a whole line, from a trim the view publishes', () => {
|
||||
// The second half of the same report, and the half the inset does *not* answer.
|
||||
// A page whose height is not a whole multiple of the line height ends with its
|
||||
// last line sticking out past the bottom edge, and the reading surface — which is
|
||||
// a clipped box — cuts that line's descenders. The strip is then blamed for a cut
|
||||
// it did not make, and moving the strip changes nothing.
|
||||
//
|
||||
// So the page's own height is floored to the text's line grid and the remainder is
|
||||
// taken off the bottom edge as an inset. It is published by the view because
|
||||
// `line-height` is `inherit` by default — the book's own value, which this
|
||||
// stylesheet deliberately does not guess at — so the grid is only knowable from the
|
||||
// rendered boxes.
|
||||
const hidden = rule(".reader-screen[data-chrome='hidden'] .book-host");
|
||||
const inset = declaration(hidden, 'inset-block-end') ?? '';
|
||||
expect(inset).toContain('var(--reader-page-trim');
|
||||
// An `inset-block-end` and not a `padding-block-end`: the host is `border-box`, so
|
||||
// a padding would leave `clientHeight` untouched and the page would be measured one
|
||||
// height and drawn another.
|
||||
const trimUse = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
expect(trimUse).toContain('--reader-page-trim');
|
||||
expect(trimUse).not.toMatch(/padding-block-end:\s*var\(--reader-page-trim/);
|
||||
it('uses the same reading geometry with chrome visible and hidden', () => {
|
||||
expect(rule(".reader-screen[data-chrome='hidden'] .book-host")).toBe('');
|
||||
const body = rule('.reader-screen .book-host');
|
||||
expect(declaration(body, 'inset-block-start')).toBe('calc(1.8rem + var(--safe-top))');
|
||||
expect(declaration(body, 'inset-block-end')).toContain('var(--reader-page-trim');
|
||||
expect(declaration(body, 'inset-block-end')).toContain('var(--safe-bottom)');
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,34 @@ describe('page turns in scroll mode', () => {
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
it('keeps a turned page when a later viewport observation restores the offset', async () => {
|
||||
let resize: (() => void) | undefined;
|
||||
const previousObserver = globalThis.ResizeObserver;
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(callback: () => void) { resize = callback; }
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
const view = scrollingView(container, 4);
|
||||
try {
|
||||
await view.open(0, 0);
|
||||
await view.next();
|
||||
const before = view.position();
|
||||
expect(before.pageInChapter).toBe(2);
|
||||
resize?.();
|
||||
expect(view.position().locator).toBe(before.locator);
|
||||
expect(view.position().pageInChapter).toBe(2);
|
||||
await view.seekPageInChapter(2);
|
||||
view.applySettings({ theme: 'green' });
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
expect(view.position().pageInChapter).toBe(3);
|
||||
} finally {
|
||||
view.dispose();
|
||||
globalThis.ResizeObserver = previousObserver;
|
||||
}
|
||||
});
|
||||
|
||||
it('walks back page by page and reports the page it is on', async () => {
|
||||
// The regression: `previous()` decided whether it had room by comparing
|
||||
// `scrollTop` against 8, then *subtracted* 0.9 of a screen. The two numbers do
|
||||
|
||||
@@ -101,6 +101,7 @@ export class CDP {
|
||||
deviceScaleFactor: scale,
|
||||
mobile: true,
|
||||
});
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
return cdp;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { CDP } from './cdp.mjs';
|
||||
import { createReviewServer, BOOK_ID, ILLUSTRATED_ID } from './server.mjs';
|
||||
|
||||
const server = createReviewServer({ port: 5201 });
|
||||
const origin = await server.listen();
|
||||
const cdp = await CDP.launch({ viewport: { width: 390, height: 844 }, scale: 2 });
|
||||
const read = () => cdp.run(`
|
||||
const host = document.querySelector('book-content');
|
||||
const flow = host.shadowRoot.querySelector('.book-flow');
|
||||
const scroller = host.dataset.paginated === 'true' ? flow : host;
|
||||
const rect = host.getBoundingClientRect();
|
||||
return { top: rect.top, height: rect.height, x: scroller.scrollLeft, y: scroller.scrollTop,
|
||||
page: document.querySelector('.progress-scrubber').value,
|
||||
pages: document.querySelector('.progress-scrubber').max,
|
||||
chrome: document.querySelector('.reader-screen').dataset.chrome };
|
||||
`);
|
||||
try {
|
||||
await cdp.navigate(`${origin}/#/shelf`);
|
||||
await cdp.waitFor('document.querySelector(".login-screen") !== null');
|
||||
await cdp.fill('input[autocomplete="username"]', 'review');
|
||||
await cdp.fill('input[type="password"]', 'password12');
|
||||
await cdp.click('button[type="submit"]');
|
||||
await cdp.waitFor('document.querySelector(".book-card") !== null');
|
||||
await cdp.navigate(`${origin}/#/book/${BOOK_ID}`);
|
||||
await cdp.waitFor('Number(document.querySelector(".progress-scrubber")?.max) > 1');
|
||||
await cdp.sleep(500);
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(350);
|
||||
await cdp.tapThird(0.85);
|
||||
await cdp.sleep(350);
|
||||
const before = await read();
|
||||
assert.ok(Number(before.page) > 1, `must reach a later page: ${JSON.stringify(before)}`);
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(350);
|
||||
const after = await read();
|
||||
console.log(JSON.stringify({ before, after }));
|
||||
assert.equal(after.page, before.page, 'middle tap must preserve page');
|
||||
assert.equal(after.y, before.y, 'middle tap must preserve scroll offset');
|
||||
assert.equal(after.top, before.top, 'chrome must not move the reading surface');
|
||||
assert.equal(after.height, before.height, 'chrome must not resize the reading surface');
|
||||
await mkdir('../docs/ui-review/mobile', { recursive: true });
|
||||
await cdp.screenshot('../docs/ui-review/mobile/reader.png');
|
||||
for (const label of ['目录', '界面', '设置', '朗读']) {
|
||||
await cdp.clickText('.reader-actions button', label);
|
||||
await cdp.waitFor('document.querySelector(".panel") !== null');
|
||||
await cdp.run(`await Promise.all(document.querySelector('.panel').getAnimations().map(a=>a.finished));`);
|
||||
const opened = await read();
|
||||
assert.equal(opened.y, after.y, `${label} must preserve scroll position`);
|
||||
assert.equal(opened.height, after.height, `${label} must preserve surface height`);
|
||||
const panel = await cdp.run(`const r=document.querySelector('.panel').getBoundingClientRect();return {top:r.top,bottom:r.bottom};`);
|
||||
assert.ok(panel.top > 250 && panel.bottom <= 845, `sheet must leave a preview and fit the screen: ${JSON.stringify(panel)}`);
|
||||
await cdp.screenshot(`../docs/ui-review/mobile/panel-${label}.png`);
|
||||
await cdp.click('.panel button[aria-label="关闭"]');
|
||||
await cdp.waitFor('document.querySelector(".panel") === null');
|
||||
assert.equal((await read()).y, after.y, `${label} close must preserve position`);
|
||||
}
|
||||
await cdp.clickText('.reader-actions button', '界面');
|
||||
const beforeReadouts = await read();
|
||||
for (const [label, mode] of [['顶部左侧','book'],['顶部右侧','chapter'],['底部左侧','time'],['底部右侧','progress']]) {
|
||||
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
|
||||
await cdp.sleep(100);
|
||||
const next = await read();
|
||||
for (const key of ['x','y','page','pages','top','height']) assert.equal(next[key], beforeReadouts[key], 'readout setting preserves '+key);
|
||||
}
|
||||
const modes = await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)');
|
||||
assert.deepEqual(modes, ['book','chapter','time','progress']);
|
||||
await cdp.screenshot('../docs/ui-review/mobile/readout-settings.png');
|
||||
await cdp.click('.panel button[aria-label="关闭"]');
|
||||
await cdp.tapMiddle();
|
||||
await cdp.screenshot('../docs/ui-review/mobile/readout-custom.png');
|
||||
await cdp.navigate(`${origin}/#/shelf`);
|
||||
await cdp.navigate(`${origin}/#/book/${BOOK_ID}`);
|
||||
await cdp.waitFor('Number(document.querySelector(".progress-scrubber")?.max) > 1');
|
||||
assert.deepEqual(await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)'), modes, 'readout settings survive reopening');
|
||||
await cdp.clickText('.reader-actions button', '界面');
|
||||
for (const [label, mode] of [['顶部左侧','chapter'],['顶部右侧','none'],['底部左侧','progress'],['底部右侧','time']]) {
|
||||
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
|
||||
await cdp.sleep(100);
|
||||
}
|
||||
await cdp.clickText('.panel button', '浅绿');
|
||||
await cdp.click('.panel button[aria-label="关闭"]');
|
||||
await cdp.sleep(300);
|
||||
assert.equal((await read()).page, after.page, 'theme must preserve page');
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(250);
|
||||
await cdp.screenshot('../docs/ui-review/mobile/immersive-green.png');
|
||||
for (let i=0; i<6; i++) {
|
||||
const prior = await read();
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(150);
|
||||
const next = await read();
|
||||
for (const key of ['x','y','page','pages','top','height']) assert.equal(next[key],prior[key],`repeated toggle: ${key}`);
|
||||
}
|
||||
await cdp.tapMiddle();
|
||||
await cdp.clickText('.reader-actions button', '设置');
|
||||
await cdp.clickText('.panel button', '翻页');
|
||||
await cdp.click('.panel button[aria-label="关闭"]');
|
||||
await cdp.sleep(300);
|
||||
await cdp.run(`const el=document.querySelector('.progress-scrubber');el.value='2';el.dispatchEvent(new Event('input',{bubbles:true}));`);
|
||||
await cdp.sleep(250);
|
||||
const paged = await read();
|
||||
assert.equal(paged.page, '2', 'chapter slider reaches page two');
|
||||
for(let i=0;i<4;i++) {
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(200);
|
||||
const next=await read();
|
||||
for(const key of ['x','y','page','pages','top','height']) assert.equal(next[key],paged[key],`paged toggle: ${key}`);
|
||||
}
|
||||
await cdp.tapThird(0.85);
|
||||
await cdp.sleep(300);
|
||||
assert.equal(Number((await read()).page),3,'tap advances one page');
|
||||
await cdp.tapThird(0.15);
|
||||
await cdp.sleep(300);
|
||||
assert.equal((await read()).page,'2','reverse tap returns to same page');
|
||||
await cdp.screenshot('../docs/ui-review/mobile/paged.png');
|
||||
await cdp.clickText('.footer button', '下一章');
|
||||
await cdp.sleep(350);
|
||||
assert.equal((await read()).page,'1','next chapter starts at page one');
|
||||
await cdp.navigate(`${origin}/#/book/${ILLUSTRATED_ID}`);
|
||||
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("img")?.naturalWidth > 0');
|
||||
await cdp.sleep(300);
|
||||
const epub=await read();
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(250);
|
||||
const epubHidden=await read();
|
||||
for(const key of ['x','y','page','pages','top','height']) assert.equal(epubHidden[key],epub[key],`EPUB toggle: ${key}`);
|
||||
await cdp.screenshot('../docs/ui-review/mobile/epub.png');
|
||||
await cdp.navigate(`${origin}/#/book/review-comic`);
|
||||
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("img")?.naturalWidth > 0');
|
||||
await cdp.sleep(200);
|
||||
await cdp.tapThird(0.85);
|
||||
await cdp.sleep(300);
|
||||
const comic = await read();
|
||||
const chapter = await cdp.evaluate('document.querySelector(".reader-heading span").textContent');
|
||||
assert.equal(chapter, '第 2 页', 'comic page advances');
|
||||
assert.equal(comic.page, '1', 'fixed page has one page inside its chapter');
|
||||
assert.equal(comic.pages, '1', 'fixed chapter slider must not become a whole-book slider');
|
||||
for (let i=0; i<4; i++) {
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(200);
|
||||
assert.equal(await cdp.evaluate('document.querySelector(".reader-heading span").textContent'), chapter);
|
||||
assert.equal((await read()).height, comic.height, 'fixed layout geometry stays stable');
|
||||
}
|
||||
await cdp.screenshot('../docs/ui-review/mobile/comic.png');
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 320, height: 568, deviceScaleFactor: 2, mobile: true });
|
||||
await cdp.sleep(250);
|
||||
for(const label of ['界面','设置']) {
|
||||
await cdp.clickText('.reader-actions button', label);
|
||||
await cdp.run(`await Promise.all(document.querySelector('.panel').getAnimations().map(a=>a.finished));`);
|
||||
const overflow=await cdp.run(`const p=document.querySelector('.panel');return p.scrollWidth-p.clientWidth;`);
|
||||
assert.ok(overflow <= 1, `${label} fits a narrow phone`);
|
||||
await cdp.screenshot(`../docs/ui-review/mobile/narrow-${label}.png`);
|
||||
await cdp.click('.panel button[aria-label="关闭"]');
|
||||
}
|
||||
await cdp.navigate(`${origin}/#/book/review-pdf`);
|
||||
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("iframe")?.src.startsWith("blob:")');
|
||||
const pdf = await cdp.run(`const frame=document.querySelector('book-content').shadowRoot.querySelector('iframe');return (await fetch(frame.src).then(r=>r.text())).slice(0,8);`);
|
||||
assert.equal(pdf, '%PDF-1.4', 'browser viewer receives the actual PDF bytes');
|
||||
// The browser's PDF extension starts asynchronously after the frame commits.
|
||||
await cdp.sleep(1500);
|
||||
await cdp.screenshot('../docs/ui-review/mobile/pdf.png');
|
||||
console.log('PASS: page and geometry survive chrome toggle');
|
||||
} finally {
|
||||
await cdp.close();
|
||||
await server.close();
|
||||
}
|
||||
+8
-943
@@ -39,8 +39,8 @@ const SCALE = 2;
|
||||
|
||||
const SCENES = [
|
||||
{ name: '01-shelf', label: '书架', what: '第一屏:先看到书,再看到控件' },
|
||||
{ name: '02-reader', label: '阅读页', what: '顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章', openBook: true },
|
||||
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数', openBook: true, tapCenter: true },
|
||||
{ name: '02-reader', label: '阅读页', what: '顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口', openBook: true },
|
||||
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '工具栏一起收起,正文保持原位置,底部只留书名、页码与进度', openBook: true, tapCenter: true },
|
||||
{ name: '04-panel-toc', label: '目录 · 半屏', what: '下半屏,上半屏正文仍可见', openBook: true, openPanel: '目录' },
|
||||
{ name: '05-panel-settings', label: '阅读设置 · 半屏', what: '一行式行:标签在左、控件在右', openBook: true, openPanel: '设置' },
|
||||
{ name: '06-reader-paged', label: '阅读页 · 翻页模式', what: '分栏后的一页,页数应与可翻次数一致', openBook: true, choose: ['设置', '翻页'], closePanel: true },
|
||||
@@ -51,7 +51,7 @@ const SCENES = [
|
||||
label: 'TXT · 正文排版',
|
||||
what: '纯文本专属的缩进/段间距/编码三行,且面板仍是半屏',
|
||||
openBook: true,
|
||||
openPanel: '设置',
|
||||
openPanel: '界面',
|
||||
},
|
||||
// The two list screens, as a reader flips between them.
|
||||
//
|
||||
@@ -145,934 +145,7 @@ async function audit(cdp, origin, scenes, results) {
|
||||
if (!ok) failures.push(`${name}: ${detail}`);
|
||||
};
|
||||
|
||||
// The chrome is one state, not two *or three*.
|
||||
//
|
||||
// The request behind this is specific — the top bar and the bottom bar hide
|
||||
// together — and the failure it guards against is the one a reader reports as
|
||||
// "只收起了一半": a header that leaves the text full height while a footer keeps
|
||||
// covering the last two lines. So the collapse is asserted on both bands and on
|
||||
// the space they occupied, in the state where the reader asked for immersion.
|
||||
//
|
||||
// The rail is the third band, and the follow-up report was that it *didn't* go
|
||||
// with them: shown only while the chrome was hidden, it was the one piece of
|
||||
// chrome that survived immersion, so a reader who tapped to get "just the page"
|
||||
// still had a floating column over the text. It is asserted in both directions
|
||||
// here — present with the bars, gone with the bars — because a control that only
|
||||
// ever appears is exactly the state that was reported.
|
||||
//
|
||||
// Asked for the way the reader asks for it (a tap on the middle third) rather than
|
||||
// by setting the attribute: the tap is also a thing that has to keep working.
|
||||
await cdp.sleep(300);
|
||||
// The chapter's first block, with the chrome up. Held so the assertion below can
|
||||
// compare it against the same measurement taken with the chrome hidden.
|
||||
const visibleChromeTextTop = await cdp.evaluate(`(() => {
|
||||
const flow = document.querySelector('book-content')?.shadowRoot?.querySelector('.book-flow');
|
||||
const first = flow?.querySelector('p, h1, h2, h3');
|
||||
return first ? Math.round(first.getBoundingClientRect().top) : null;
|
||||
})()`);
|
||||
const visibleChrome = await cdp.evaluate(`(() => {
|
||||
const rail = document.querySelector('.reader-rail');
|
||||
if (!rail) return { missing: true };
|
||||
const r = rail.getBoundingClientRect();
|
||||
return {
|
||||
present: true,
|
||||
visible: getComputedStyle(rail).visibility !== 'hidden' && r.width > 0 && r.height > 0,
|
||||
buttons: rail.querySelectorAll('button').length,
|
||||
pointerEvents: getComputedStyle(rail).pointerEvents,
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'展开工具栏: 快捷按钮与两条栏一起出现',
|
||||
!visibleChrome.missing && visibleChrome.visible && visibleChrome.buttons > 0 && visibleChrome.pointerEvents !== 'none',
|
||||
visibleChrome.missing
|
||||
? '没有找到快捷按钮列'
|
||||
: `visible=${visibleChrome.visible} 按钮 ${visibleChrome.buttons} 个 pointer-events=${visibleChrome.pointerEvents}`,
|
||||
);
|
||||
|
||||
/*
|
||||
* The rail holds the two adjustments a reader makes *from the page* plus 听书, and
|
||||
* nothing that opens a sheet.
|
||||
*
|
||||
* 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 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');
|
||||
if (!el) return { missing: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const labels = [...el.querySelectorAll('button')].map((b) => b.getAttribute('aria-label') ?? '');
|
||||
return {
|
||||
labels,
|
||||
top: r.top,
|
||||
bottom: r.bottom,
|
||||
viewport: window.innerHeight,
|
||||
inside: r.bottom <= window.innerHeight + 1 && r.top >= -1,
|
||||
scrollable: style.overflowY === 'auto' || style.overflowY === 'scroll',
|
||||
overflows: el.scrollHeight - el.clientHeight > 1,
|
||||
scrollbarWidth: el.offsetWidth - el.clientWidth,
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'快捷列: 整组控件都在页内',
|
||||
!rail.missing && rail.inside,
|
||||
rail.missing ? '没有找到快捷按钮列' : `列 y=${Math.round(rail.top)}..${Math.round(rail.bottom)} / 屏高 ${rail.viewport}`,
|
||||
);
|
||||
check(
|
||||
'快捷列: 装不下的控件有滚动条,说明还有更多',
|
||||
!rail.missing && (!rail.overflows || (rail.scrollable && rail.scrollbarWidth > 0)),
|
||||
rail.missing
|
||||
? '没有找到快捷按钮列'
|
||||
: `溢出 ${rail.overflows ? '是' : '否'} overflow-y=${rail.scrollable} 滚动条 ${rail.scrollbarWidth}px`,
|
||||
);
|
||||
check(
|
||||
'快捷列: 只留页面内就能完成的调整',
|
||||
!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
|
||||
// product.
|
||||
check(
|
||||
'快捷列: 听书在列内',
|
||||
!rail.missing && rail.labels.includes('听书'),
|
||||
rail.missing ? '没有找到快捷按钮列' : `列内控件:${rail.labels.join(' / ')}`,
|
||||
);
|
||||
|
||||
/*
|
||||
* The same column on a short screen, which is the screen the report came from.
|
||||
*
|
||||
* A phone in landscape, or a phone whose browser chrome and status bar leave it
|
||||
* 520px of page, is where a column with no height ceiling stops showing its own
|
||||
* contents — and it is the case a review at a single 844px portrait size can never
|
||||
* see. Asserted by *scrolling the column* and asking where the last control ended
|
||||
* up, rather than by reading `overflow-y` back: the CSS is a claim about intent and
|
||||
* this is a claim about the reader being able to reach the control.
|
||||
*/
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 420, deviceScaleFactor: 2, mobile: true });
|
||||
await cdp.sleep(400);
|
||||
const shortRail = await cdp.evaluate(`(() => {
|
||||
const el = document.querySelector('.reader-rail');
|
||||
if (!el) return { missing: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
const buttons = [...el.querySelectorAll('button')];
|
||||
el.scrollTop = el.scrollHeight;
|
||||
const last = buttons[buttons.length - 1]?.getBoundingClientRect();
|
||||
const col = el.getBoundingClientRect();
|
||||
return {
|
||||
bottom: r.bottom,
|
||||
viewport: window.innerHeight,
|
||||
inside: r.bottom <= window.innerHeight + 1,
|
||||
reachable: last ? last.bottom <= col.bottom + 1 && last.top >= col.top - 1 : false,
|
||||
count: buttons.length,
|
||||
scrollbarWidth: el.offsetWidth - el.clientWidth,
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'快捷列(420px 短屏): 列在页内,超出的控件能滚到',
|
||||
!shortRail.missing && shortRail.inside && shortRail.reachable,
|
||||
shortRail.missing
|
||||
? '没有找到快捷按钮列'
|
||||
: `列底 ${Math.round(shortRail.bottom)} / 屏高 ${shortRail.viewport},滚动条 ${shortRail.scrollbarWidth}px,`
|
||||
+ `滚到底后末个控件可见=${shortRail.reachable}`,
|
||||
);
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: SCALE, mobile: true });
|
||||
await cdp.sleep(300);
|
||||
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(700);
|
||||
const hidden = await cdp.evaluate(`(() => {
|
||||
const screen = document.querySelector('.reader-screen');
|
||||
const topbar = document.querySelector('.topbar');
|
||||
const footer = document.querySelector('.footer');
|
||||
const stage = document.querySelector('.stage');
|
||||
if (!screen || !topbar || !footer || !stage) return { missing: true };
|
||||
const t = topbar.getBoundingClientRect();
|
||||
const f = footer.getBoundingClientRect();
|
||||
const s = stage.getBoundingClientRect();
|
||||
return {
|
||||
attribute: screen.dataset.chrome,
|
||||
topbarHeight: Math.round(t.height),
|
||||
footerHeight: Math.round(f.height),
|
||||
topbarVisibility: getComputedStyle(topbar).visibility,
|
||||
footerVisibility: getComputedStyle(footer).visibility,
|
||||
topbarPointerEvents: getComputedStyle(topbar).pointerEvents,
|
||||
footerPointerEvents: getComputedStyle(footer).pointerEvents,
|
||||
topbarPosition: getComputedStyle(topbar).position,
|
||||
footerPosition: getComputedStyle(footer).position,
|
||||
stageTop: Math.round(s.top),
|
||||
stageBottom: Math.round(s.bottom),
|
||||
viewportHeight: window.innerHeight,
|
||||
textTop: (() => {
|
||||
const flow = document.querySelector('book-content')?.shadowRoot?.querySelector('.book-flow');
|
||||
const first = flow?.querySelector('p, h1, h2, h3');
|
||||
return first ? Math.round(first.getBoundingClientRect().top) : null;
|
||||
})(),
|
||||
rail: (() => {
|
||||
const el = document.querySelector('.reader-rail');
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
present: true,
|
||||
visible: getComputedStyle(el).visibility !== 'hidden' && r.width > 0 && r.height > 0,
|
||||
pointerEvents: getComputedStyle(el).pointerEvents,
|
||||
};
|
||||
})(),
|
||||
indicatorTop: (() => {
|
||||
const el = document.querySelector('.indicator-chapter');
|
||||
return el ? Math.round(el.getBoundingClientRect().height) : null;
|
||||
})(),
|
||||
indicator: (() => {
|
||||
const el = document.querySelector('.reading-indicator');
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
const chapter = el.querySelector('.indicator-chapter');
|
||||
const progress = el.querySelector('.indicator-progress');
|
||||
return {
|
||||
visible: getComputedStyle(el).visibility !== 'hidden' && r.height > 0,
|
||||
pointerEvents: getComputedStyle(el).pointerEvents,
|
||||
chapter: chapter ? chapter.textContent.trim() : '',
|
||||
progress: progress ? progress.textContent.trim() : '',
|
||||
};
|
||||
})(),
|
||||
};
|
||||
})()`);
|
||||
const hiddenTextTop = hidden.missing ? null : hidden.textTop;
|
||||
const hiddenTopInset = hidden.missing ? null : hidden.indicatorTop;
|
||||
check(
|
||||
'收起工具栏: 顶栏与底栏一起走',
|
||||
!hidden.missing && hidden.attribute === 'hidden' && hidden.topbarVisibility === 'hidden' && hidden.footerVisibility === 'hidden',
|
||||
hidden.missing ? '没有找到阅读页' : `state=${hidden.attribute} 顶栏 ${hidden.topbarVisibility} / 底栏 ${hidden.footerVisibility}`,
|
||||
);
|
||||
check(
|
||||
'收起工具栏: 两条栏都不可见,也不可点',
|
||||
!hidden.missing && hidden.topbarPointerEvents === 'none' && hidden.footerPointerEvents === 'none',
|
||||
hidden.missing ? 'n/a' : `topbar=${hidden.topbarPointerEvents} footer=${hidden.footerPointerEvents}`,
|
||||
);
|
||||
// The page's *drawn* geometry must not move when the chrome does: showing or hiding
|
||||
// the toolbar is a repaint, not a re-measure, and the reader reported it as the text
|
||||
// jumping under their finger.
|
||||
//
|
||||
// Measured on the *slot* rather than on the chapter's first block, and the change is
|
||||
// the answer to a report that came later: "reading-indicator 是浮动的,导致翻页后,遮挡
|
||||
// 了一部分文字". The immersion readouts are 25px and 24px tall, and a line of body text
|
||||
// is 19px, so the two rows used to be *drawn over* the sentence at each end of the
|
||||
// page — cut through the middle of its glyphs. Making the rows readable means the page
|
||||
// makes room for them, and making room means the first line moves down by exactly the
|
||||
// height of the top row.
|
||||
//
|
||||
// What must stay true is that the movement is *exactly* that, and the same at both
|
||||
// ends: the reading surface is the slot less the two rows, so the text is not
|
||||
// re-measured, re-typeset or re-paginated — only looked at through a shorter window.
|
||||
// Asserting "no movement at all" would be asserting the defect.
|
||||
check(
|
||||
'收起工具栏: 正文只是让出读数行的高度,没有被重新排版',
|
||||
!hidden.missing &&
|
||||
visibleChromeTextTop !== null &&
|
||||
hiddenTextTop !== null &&
|
||||
hiddenTopInset !== null &&
|
||||
Math.abs(hiddenTextTop - visibleChromeTextTop - hiddenTopInset) <= 2,
|
||||
hidden.missing || visibleChromeTextTop === null || hiddenTextTop === null || hiddenTopInset === null
|
||||
? 'n/a'
|
||||
: `正文首行 y: 显示时 ${visibleChromeTextTop} / 收起后 ${hiddenTextTop},读数行高 ${hiddenTopInset}px`,
|
||||
);
|
||||
// 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
|
||||
// the text not moving, because the two are different facts: a bar could be in flow
|
||||
// and the text still not move if something else absorbed the height, and the next
|
||||
// person to touch this layout needs the reason to be the assertion.
|
||||
check(
|
||||
'工具栏是浮动层,不占正文的高度',
|
||||
!hidden.missing && hidden.topbarPosition === 'absolute' && hidden.footerPosition === 'absolute',
|
||||
hidden.missing ? 'n/a' : `topbar=${hidden.topbarPosition} footer=${hidden.footerPosition}`,
|
||||
);
|
||||
// The rail goes with the bars, in this direction too. `visibility: hidden` is
|
||||
// asserted rather than merely "height is zero", because a control a finger can
|
||||
// still land on is a control that is still there — and the state being guarded
|
||||
// against is precisely "the rail outlived the toolbar".
|
||||
check(
|
||||
'收起工具栏: 快捷按钮跟着两条栏一起收',
|
||||
!hidden.missing && hidden.rail !== null && !hidden.rail.visible && hidden.rail.pointerEvents === 'none',
|
||||
hidden.missing
|
||||
? 'n/a'
|
||||
: hidden.rail
|
||||
? `visible=${hidden.rail.visible} pointer-events=${hidden.rail.pointerEvents}`
|
||||
: '没有找到快捷按钮列',
|
||||
);
|
||||
// Immersion keeps the *where am I* readouts. Hiding the chrome should leave a
|
||||
// reader who knows which chapter they are in and how far through — not a blank
|
||||
// screen they have to tap to interrogate.
|
||||
check(
|
||||
'收起工具栏: 保留章节与页码指示',
|
||||
!hidden.missing &&
|
||||
hidden.indicator !== null &&
|
||||
hidden.indicator.visible &&
|
||||
hidden.indicator.chapter.length > 0 &&
|
||||
hidden.indicator.progress.length > 0,
|
||||
hidden.missing
|
||||
? 'n/a'
|
||||
: hidden.indicator
|
||||
? `chapter="${hidden.indicator.chapter}" progress="${hidden.indicator.progress}"`
|
||||
: '没有找到阅读指示',
|
||||
);
|
||||
// The indicator is a readout, not a control: a tap on it has to fall through to
|
||||
// the page-turning zone underneath, or hiding the chrome would create a dead band.
|
||||
check(
|
||||
'收起工具栏: 阅读指示不吃手势',
|
||||
!hidden.missing && hidden.indicator !== null && hidden.indicator.pointerEvents === 'none',
|
||||
hidden.missing ? 'n/a' : `pointer-events=${hidden.indicator?.pointerEvents ?? 'missing'}`,
|
||||
);
|
||||
// The two readouts left on screen in immersion must not be *covered*, and the
|
||||
// report is specific: the chapter name and the page count were painted over by
|
||||
// the chapter's own text.
|
||||
//
|
||||
// Measured as **paint order**, not as a hit test, and the difference is the whole
|
||||
// difficulty of the check. The readouts are `pointer-events: none` — deliberately,
|
||||
// so a tap on the chapter name turns the page rather than being swallowed — and
|
||||
// `elementFromPoint` skips anything that opts out of hit testing, so a hit test
|
||||
// can *never* see them and would report "covered" for a readout drawn on top of
|
||||
// everything. What the browser actually does is paint the two boxes in order, and
|
||||
// the number that decides the order is the `z-index` the two boxes *resolve to*
|
||||
// within the screen's stacking context. So that is what is compared.
|
||||
//
|
||||
// This is how the defect was possible at all: the reading surface is a positioned
|
||||
// sibling and it comes *later* in the tree, so with neither box declaring a
|
||||
// `z-index` the page was painted last — over the two numbers describing it.
|
||||
const indicator = await cdp.evaluate(`(() => {
|
||||
const root = document.querySelector('.reader-screen');
|
||||
const host = document.querySelector('.book-host');
|
||||
if (!root || !host) return { missing: true };
|
||||
// The z-index each of the four boxes resolves to, taken from the nearest box
|
||||
// that declares one — which is how the browser answers the same question.
|
||||
const effective = (node) => {
|
||||
let current = node;
|
||||
while (current && current !== root) {
|
||||
const value = getComputedStyle(current).zIndex;
|
||||
if (value !== 'auto' && value !== '') return Number(value);
|
||||
current = current.parentElement;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const out = { page: effective(host) };
|
||||
for (const [name, selector] of [['chapter', '.indicator-chapter'], ['progress', '.indicator-progress']]) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) { out[name] = { missing: true }; continue; }
|
||||
const r = el.getBoundingClientRect();
|
||||
out[name] = {
|
||||
text: el.textContent.trim(),
|
||||
top: Math.round(r.top),
|
||||
bottom: Math.round(r.bottom),
|
||||
left: Math.round(r.left),
|
||||
layer: effective(el),
|
||||
// The paper behind the label, so a line of the chapter underneath cannot
|
||||
// show through the number the reader is trying to read.
|
||||
background: getComputedStyle(el).backgroundColor,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
})()`);
|
||||
for (const [name, label] of [['chapter', '章节名'], ['progress', '页码读数']]) {
|
||||
const reading = indicator[name];
|
||||
check(
|
||||
`收起工具栏: ${label}画在正文之上`,
|
||||
!reading?.missing && !indicator.missing && reading.layer > indicator.page,
|
||||
indicator.missing || reading?.missing
|
||||
? '没有找到阅读指示或阅读面'
|
||||
: `层级 ${reading.layer} vs 正文 ${indicator.page},y=${reading.top}..${reading.bottom} 文本="${reading.text}"`,
|
||||
);
|
||||
check(
|
||||
`收起工具栏: ${label}自带底色,不被正文透出`,
|
||||
!reading?.missing && reading.background !== 'rgba(0, 0, 0, 0)' && reading.background !== 'transparent',
|
||||
reading?.missing ? '没有找到阅读指示' : `background=${reading.background}`,
|
||||
);
|
||||
}
|
||||
// Both readouts are at the *same* left edge, and that edge is inset from the
|
||||
// screen: two lines stacked at the bottom of a page are read as one column, and
|
||||
// the page count used to sit 4px to the right of the chapter name above it.
|
||||
const indicatorAligned = await cdp.evaluate(`(() => {
|
||||
const a = document.querySelector('.indicator-chapter')?.getBoundingClientRect();
|
||||
const b = document.querySelector('.indicator-progress')?.getBoundingClientRect();
|
||||
if (!a || !b) return null;
|
||||
return { chapterLeft: Math.round(a.left), progressLeft: Math.round(b.left) };
|
||||
})()`);
|
||||
check(
|
||||
'收起工具栏: 章节名与页码左边对齐',
|
||||
indicatorAligned !== null && Math.abs(indicatorAligned.chapterLeft - indicatorAligned.progressLeft) <= 1,
|
||||
indicatorAligned
|
||||
? `章节名 left=${indicatorAligned.chapterLeft} 页码 left=${indicatorAligned.progressLeft}`
|
||||
: '没有找到阅读指示',
|
||||
);
|
||||
|
||||
// "reading-indicator 应该是紧贴顶部/底部,并且占用一整行".
|
||||
//
|
||||
// Both halves of that are geometry, and both were wrong: each readout was a paper
|
||||
// pill the width of its own words, floated ~10px inside the edge. A pill ends
|
||||
// wherever the words end, so the chapter name and the page count had two different
|
||||
// right edges, and neither reached the edge of the glass. The row is what makes them
|
||||
// read as something the screen drew rather than as fragments of the page, so it is
|
||||
// asserted as three numbers: full width, flush to its own edge, and one row each.
|
||||
const indicatorRows = await cdp.evaluate(`(() => {
|
||||
const stage = document.querySelector('.stage')?.getBoundingClientRect();
|
||||
const a = document.querySelector('.indicator-chapter')?.getBoundingClientRect();
|
||||
const b = document.querySelector('.indicator-progress')?.getBoundingClientRect();
|
||||
if (!stage || !a || !b) return null;
|
||||
return {
|
||||
width: Math.round(stage.width),
|
||||
chapter: { top: Math.round(a.top), left: Math.round(a.left), width: Math.round(a.width) },
|
||||
progress: { bottom: Math.round(b.bottom), right: Math.round(b.right), width: Math.round(b.width) },
|
||||
viewportBottom: window.innerHeight,
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'收起工具栏: 章节名占满一整行并紧贴顶部',
|
||||
indicatorRows !== null &&
|
||||
Math.abs(indicatorRows.chapter.width - indicatorRows.width) <= 1 &&
|
||||
indicatorRows.chapter.top <= 1 &&
|
||||
indicatorRows.chapter.left <= 1,
|
||||
indicatorRows
|
||||
? `行 ${indicatorRows.chapter.width}px / 屏宽 ${indicatorRows.width},top=${indicatorRows.chapter.top}`
|
||||
: '没有找到阅读指示',
|
||||
);
|
||||
check(
|
||||
'收起工具栏: 页码占满一整行并紧贴底部',
|
||||
indicatorRows !== null &&
|
||||
Math.abs(indicatorRows.progress.width - indicatorRows.width) <= 1 &&
|
||||
Math.abs(indicatorRows.progress.bottom - indicatorRows.viewportBottom) <= 1 &&
|
||||
indicatorRows.progress.right >= indicatorRows.width - 1,
|
||||
indicatorRows
|
||||
? `行 ${indicatorRows.progress.width}px / 屏宽 ${indicatorRows.width},bottom=${indicatorRows.progress.bottom}/${indicatorRows.viewportBottom}`
|
||||
: '没有找到阅读指示',
|
||||
);
|
||||
|
||||
// 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 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 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.
|
||||
//
|
||||
// The readout is the *immersion* one, and that is the repair rather than a detail:
|
||||
// `.progress-page` is the footer's own label, and the footer is `visibility: hidden`
|
||||
// with the chrome down — `textContent` on a hidden element is still the last value it
|
||||
// was given, so reading it in this state compared the page *before* the tap with
|
||||
// itself and reported "does not advance" for a turn that had worked all along. The
|
||||
// page count that is on screen while the chrome is hidden is the one the reader is
|
||||
// looking at, and it is the one this has to read.
|
||||
const pageReadout = `(() => ({
|
||||
chrome: document.querySelector('.reader-screen').dataset.chrome,
|
||||
page: document.querySelector('.indicator-progress')?.textContent.trim() ?? '',
|
||||
value: Number(document.querySelector('.progress-scrubber')?.value ?? '0'),
|
||||
}))()`;
|
||||
// Read on a reader in **scroll** mode, which is the mode a page turn turns a
|
||||
// *screenful* in, and the mode the scene above left behind. A 翻页 scene selects the
|
||||
// paged layout, in which the chapter is one screen wide and a tap in the right third
|
||||
// moves between columns — a page turn that changes what is on screen without changing
|
||||
// which page of the chapter the reader is *on*, because paged mode reports one page
|
||||
// per chapter screen... unless the chapter really has several, which this fixture's
|
||||
// does not. The assertion is about a number that advances, so it is made where a
|
||||
// number advances: a scroll-mode chapter of several screens.
|
||||
//
|
||||
// Set through the panel rather than by reaching into the view, because the mode is a
|
||||
// setting and the panel is how a reader changes it.
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(400);
|
||||
await cdp.click('button[aria-label="设置"]');
|
||||
await cdp.waitFor('document.querySelector(".panel") !== null');
|
||||
await cdp.clickText('.segmented button', '滚动');
|
||||
await cdp.sleep(400);
|
||||
await cdp.click('button[aria-label="关闭"]');
|
||||
await cdp.sleep(400);
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(500);
|
||||
const beforeThird = await cdp.evaluate(pageReadout);
|
||||
await cdp.tapThird(0.85);
|
||||
await cdp.sleep(600);
|
||||
const afterThird = await cdp.evaluate(pageReadout);
|
||||
check(
|
||||
'点右侧只翻页: 页码前进, 工具栏保持收起',
|
||||
afterThird.chrome === 'hidden' && beforeThird.chrome === 'hidden' && afterThird.page !== beforeThird.page,
|
||||
`收起前 ${beforeThird.chrome}(${beforeThird.page}) → 点击后 ${afterThird.chrome}(${afterThird.page})`,
|
||||
);
|
||||
|
||||
/*
|
||||
* And the readouts do not *cover* the text — "reading-indicator 是浮动的,导致翻页后,
|
||||
* 遮挡了一部分文字".
|
||||
*
|
||||
* The check above is about paint order and it passed while this defect was fully
|
||||
* reproducible, which is exactly why this one exists: a readout drawn on top of a
|
||||
* line is legible, and the line under it is cut through the middle of its glyphs.
|
||||
* Whether the reader can read the page is a *different* question from which of the
|
||||
* two boxes wins, and it is the one the report asked.
|
||||
*
|
||||
* Measured as geometry, and on the real text rather than on a proxy: every line box
|
||||
* in the chapter is compared against the two rows, and a line that crosses a row's
|
||||
* edge is a line the reader is looking at half of. A tolerance of one pixel absorbs
|
||||
* the sub-pixel rounding the browser does on `getClientRects`, and the two rows are
|
||||
* taken from their own rects rather than from a constant, so this check cannot agree
|
||||
* with a row height that no longer exists.
|
||||
*
|
||||
* A *page turn* is performed first, because that is where the report is: the first
|
||||
* page of a chapter can be clean while every page after it is not, if the turn lands
|
||||
* mid-line. So the turn is part of the check rather than a precondition of it — and it
|
||||
* is a turn in the reader's own gesture (a tap in the outer third), not a scroll
|
||||
* assignment, so the arithmetic that decides where a page starts is the one under
|
||||
* test.
|
||||
*/
|
||||
await cdp.tapThird(0.85);
|
||||
await cdp.sleep(500);
|
||||
const covered = await cdp.evaluate(`(() => {
|
||||
const flow = document.querySelector('book-content')?.shadowRoot?.querySelector('.book-flow');
|
||||
const rows = ['.indicator-chapter', '.indicator-progress'].map((selector) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { selector, top: r.top, bottom: r.bottom, text: el.textContent.trim() };
|
||||
}).filter(Boolean);
|
||||
if (!flow || rows.length === 0) return { missing: true };
|
||||
const eps = 1;
|
||||
const cuts = [];
|
||||
for (const block of flow.querySelectorAll('p, h1, h2, h3, li, blockquote')) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(block);
|
||||
const rects = typeof range.getClientRects === 'function' ? range.getClientRects() : [];
|
||||
for (const rect of rects) {
|
||||
for (const row of rows) {
|
||||
// A line is cut when it *crosses* an edge of a row: entirely above or
|
||||
// entirely below is a line on another page, which is not a defect.
|
||||
if (rect.top < row.bottom - eps && rect.bottom > row.bottom + eps) cuts.push({ row: row.selector, text: block.textContent.trim().slice(0, 12), line: [Math.round(rect.top), Math.round(rect.bottom)], edge: Math.round(row.bottom) });
|
||||
if (rect.top < row.top - eps && rect.bottom > row.top + eps) cuts.push({ row: row.selector, text: block.textContent.trim().slice(0, 12), line: [Math.round(rect.top), Math.round(rect.bottom)], edge: Math.round(row.top) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { missing: false, rows: rows.map((r) => [Math.round(r.top), Math.round(r.bottom)]), cuts };
|
||||
})()`);
|
||||
check(
|
||||
'收起工具栏: 翻页后正文不被读数行拦腰截断',
|
||||
covered.missing === false && covered.cuts.length === 0,
|
||||
covered.missing
|
||||
? '没有找到阅读面或阅读指示'
|
||||
: covered.cuts.length === 0
|
||||
? `读数行 ${JSON.stringify(covered.rows)},没有半截行`
|
||||
: `有 ${covered.cuts.length} 行被截断:${JSON.stringify(covered.cuts.slice(0, 3))}`,
|
||||
);
|
||||
|
||||
|
||||
// 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}`);
|
||||
|
||||
// A page turn must not lose the offset it just moved to.
|
||||
//
|
||||
// The report is "虽然有左右翻页的动画,但是动画结束后又有一个重新渲染章节内容并滚动到
|
||||
// 下一页的动画": a slide, then the page jumping to where it was always going. The cause
|
||||
// was the clip on `data-animating` — it sat on `.book-flow`, which *is* the horizontal
|
||||
// scroller in paged mode, so setting the attribute replaced the scroll container with
|
||||
// one that cannot scroll and discarded `scrollLeft`. The animation played over the old
|
||||
// page, and the offset came back a frame after the attribute was cleared.
|
||||
//
|
||||
// Asserted by *holding the attribute* rather than by racing the animation: the offset
|
||||
// has to survive the animating state itself, which is the property, and a timing check
|
||||
// would only catch it on a machine slow enough to observe the wrong frame. The reader
|
||||
// is switched to 翻页 mode first, because that is the mode whose scroller is the flow.
|
||||
// The chrome is visible here — the check just above put it there — so the settings
|
||||
// button is on screen and can be clicked.
|
||||
await cdp.click('button[aria-label="设置"]');
|
||||
await cdp.waitFor('document.querySelector(".panel") !== null');
|
||||
await cdp.clickText('.segmented button', '翻页');
|
||||
await cdp.sleep(300);
|
||||
await cdp.click('button[aria-label="关闭"]');
|
||||
await cdp.sleep(400);
|
||||
const turnKeepsOffset = await cdp.run(`
|
||||
const host = document.querySelector('book-content');
|
||||
const flow = host.shadowRoot.querySelector('.book-flow');
|
||||
if (!flow) return { missing: true };
|
||||
// Move to the second page by the same arithmetic the turn uses, then hold the
|
||||
// animating state the way the turn does and read the offset back.
|
||||
flow.scrollLeft = flow.clientWidth;
|
||||
const target = Math.round(flow.scrollLeft);
|
||||
host.setAttribute('data-animating', 'slide-next-x');
|
||||
const during = Math.round(flow.scrollLeft);
|
||||
const overflow = getComputedStyle(flow).overflow;
|
||||
host.removeAttribute('data-animating');
|
||||
const after = Math.round(flow.scrollLeft);
|
||||
flow.scrollLeft = 0;
|
||||
return { target, during, after, overflow };
|
||||
`);
|
||||
check(
|
||||
'翻页: 动画期间不丢滚动位置(动画后不再跳页)',
|
||||
!turnKeepsOffset.missing && turnKeepsOffset.during === turnKeepsOffset.target && turnKeepsOffset.target > 0,
|
||||
turnKeepsOffset.missing
|
||||
? '没有找到阅读面'
|
||||
: `目标 ${turnKeepsOffset.target} → 动画中 ${turnKeepsOffset.during} → 动画后 ${turnKeepsOffset.after},overflow=${turnKeepsOffset.overflow}`,
|
||||
);
|
||||
|
||||
for (const scene of scenes) {
|
||||
if (scene.openPanel) {
|
||||
// Opened here rather than assumed open: the chrome checks above interact with
|
||||
// the page, and a sheet they left behind would make the tap that hides the
|
||||
// chrome land on the sheet's backdrop instead.
|
||||
await cdp.click(`button[aria-label="${scene.openPanel}"]`);
|
||||
await cdp.waitFor('document.querySelector(".panel") !== null', 10_000);
|
||||
await cdp.sleep(400);
|
||||
const geometry = await cdp.evaluate(`(() => {
|
||||
const panel = document.querySelector('.panel');
|
||||
const scrim = document.querySelector('.scrim');
|
||||
const stage = document.querySelector('.stage');
|
||||
if (!panel) return null;
|
||||
const p = panel.getBoundingClientRect();
|
||||
const s = stage.getBoundingClientRect();
|
||||
return {
|
||||
panelTop: p.top,
|
||||
panelHeight: p.height,
|
||||
stageTop: s.top,
|
||||
stageHeight: s.height,
|
||||
hasScrim: !!scrim,
|
||||
grip: !!document.querySelector('.panel-grip'),
|
||||
visibleText: p.top - s.top,
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
`${scene.name}: 面板不遮住整页`,
|
||||
geometry !== null && geometry.panelTop > geometry.stageTop + 60,
|
||||
geometry ? `面板从 y=${Math.round(geometry.panelTop)} 开始,正文区 y=${Math.round(geometry.stageTop)}` : '没有找到 .panel',
|
||||
);
|
||||
check(
|
||||
`${scene.name}: 面板留出可预览的正文`,
|
||||
geometry !== null && geometry.visibleText >= 120,
|
||||
geometry ? `正文可见高度 ${Math.round(geometry.visibleText)}px` : 'n/a',
|
||||
);
|
||||
check(
|
||||
`${scene.name}: 面板有遮罩和把手`,
|
||||
geometry !== null && geometry.hasScrim && geometry.grip,
|
||||
geometry ? `scrim=${geometry.hasScrim} grip=${geometry.grip}` : 'n/a',
|
||||
);
|
||||
check(
|
||||
`${scene.name}: 面板高度不超过阅读区`,
|
||||
geometry !== null && geometry.panelHeight <= geometry.stageHeight * 0.9,
|
||||
geometry ? `面板 ${Math.round(geometry.panelHeight)}px / 阅读区 ${Math.round(geometry.stageHeight)}px` : 'n/a',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The footer scrubber has to be a real control: visible, at least the width of a
|
||||
// thumb, reachable by keyboard, and labelled. A progress bar that only *reports*
|
||||
// is the thing this replaced, so the check is that it can be dragged.
|
||||
// Two rows of one band must share an inset, and the top bar must share it too.
|
||||
//
|
||||
// The report had two halves — "底部页面数和进度需要两边对齐" and "顶部栏三个按钮需要
|
||||
// 两边对齐" — and both are the same question asked of two bands: are the outermost
|
||||
// things at the two edges the same distance in? They were three numbers (0.35rem,
|
||||
// 0.75rem, 0.75rem plus a button's own 0.4rem of padding), so nothing lined up
|
||||
// with anything and the frame around the page was visibly ragged.
|
||||
//
|
||||
// Asserted on *measured edges*, not on the CSS: the values have to end up equal
|
||||
// after every padding and margin has been applied, and reading the declaration
|
||||
// back would pass while a nested control still sat somewhere else.
|
||||
const bandGaps = await cdp.evaluate(`(() => {
|
||||
const viewport = document.documentElement.clientWidth;
|
||||
const gap = (selector, edge) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.round(edge === 'left' ? r.left : viewport - r.right);
|
||||
};
|
||||
const buttons = [...document.querySelectorAll('.topbar .top-button')].map((b) => b.getBoundingClientRect());
|
||||
return {
|
||||
// Top bar: the outermost buttons' own edges, and the two gaps between the
|
||||
// three of them (which is what makes the row symmetric rather than merely
|
||||
// spread out).
|
||||
topbarLeft: buttons.length > 0 ? Math.round(buttons[0].left) : null,
|
||||
topbarRight: buttons.length > 0 ? Math.round(viewport - buttons[buttons.length - 1].right) : null,
|
||||
topbarGap: buttons.length === 3 ? Math.round(buttons[1].left - buttons[0].right) : null,
|
||||
topbarGap2: buttons.length === 3 ? Math.round(buttons[2].left - buttons[1].right) : null,
|
||||
// Footer: the two readouts the reader asked to have aligned.
|
||||
pageRight: gap('.progress-page', 'right'),
|
||||
nextRight: gap('.chapter-nav .nav-chapter:last-child', 'right'),
|
||||
prevLeft: gap('.chapter-nav .nav-chapter:first-child', 'left'),
|
||||
scrubLeft: gap('.progress-scrubber', 'left'),
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'顶栏: 三个按钮左右两边对齐',
|
||||
bandGaps.topbarLeft !== null &&
|
||||
Math.abs(bandGaps.topbarLeft - bandGaps.topbarRight) <= 1 &&
|
||||
Math.abs(bandGaps.topbarGap - bandGaps.topbarGap2) <= 1,
|
||||
bandGaps.topbarLeft === null
|
||||
? '没有找到顶栏按钮'
|
||||
: `左 ${bandGaps.topbarLeft}px / 右 ${bandGaps.topbarRight}px,两道间隙 ${bandGaps.topbarGap}px / ${bandGaps.topbarGap2}px`,
|
||||
);
|
||||
check(
|
||||
'底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐',
|
||||
bandGaps.pageRight !== null &&
|
||||
Math.abs(bandGaps.pageRight - bandGaps.nextRight) <= 1 &&
|
||||
Math.abs(bandGaps.prevLeft - bandGaps.scrubLeft) <= 1,
|
||||
bandGaps.pageRight === null
|
||||
? '没有找到底栏读数'
|
||||
: `页码右 ${bandGaps.pageRight}px / 下一章右 ${bandGaps.nextRight}px;上一章左 ${bandGaps.prevLeft}px / 滑杆左 ${bandGaps.scrubLeft}px`,
|
||||
);
|
||||
check(
|
||||
'顶栏与底栏共用同一条内边距',
|
||||
bandGaps.topbarLeft !== null && Math.abs(bandGaps.topbarLeft - bandGaps.prevLeft) <= 1,
|
||||
bandGaps.topbarLeft === null ? '没有找到顶栏按钮' : `顶栏 ${bandGaps.topbarLeft}px / 底栏 ${bandGaps.prevLeft}px`,
|
||||
);
|
||||
|
||||
const scrubber = await cdp.evaluate(`(() => {
|
||||
const el = document.querySelector('.progress-scrubber');
|
||||
if (!el) return { missing: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
type: el.type,
|
||||
width: Math.round(r.width),
|
||||
height: Math.round(r.height),
|
||||
label: el.getAttribute('aria-label') ?? '',
|
||||
min: Number(el.min),
|
||||
max: Number(el.max),
|
||||
value: Number(el.value),
|
||||
page: document.querySelector('.progress-page')?.textContent.trim() ?? '',
|
||||
nav: document.querySelector('.chapter-nav .nav-progress')?.textContent.trim() ?? '',
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'底栏: 进度是可拖动的滑杆',
|
||||
!scrubber.missing && scrubber.type === 'range' && scrubber.width >= 120 && scrubber.label.length > 0,
|
||||
scrubber.missing
|
||||
? '没有找到滑杆'
|
||||
: `type=${scrubber.type} 宽 ${scrubber.width}px label="${scrubber.label}"`,
|
||||
);
|
||||
check(
|
||||
'底栏: 有页码与阅读进度读数',
|
||||
!scrubber.missing && scrubber.page.length > 0 && scrubber.nav.length > 0,
|
||||
scrubber.missing ? 'n/a' : `page="${scrubber.page}" nav="${scrubber.nav}"`,
|
||||
);
|
||||
// The slider is in *chapter pages*, not in whole-book percent.
|
||||
//
|
||||
// This is the report: the readout beside the thumb said "第 8/9 页" while the
|
||||
// thumb sat near the left end, because the two were counting different things —
|
||||
// the readout counted pages in a nine-page chapter, and the slider counted the
|
||||
// reader's position in a book of hundreds. The check is that the slider's own
|
||||
// range *is* the chapter's page range, read from the same readout the reader
|
||||
// sees, so the two cannot drift apart behind a passing screenshot.
|
||||
const chapterPages = (() => {
|
||||
const match = /\/\s*(\d+)\s*页/.exec(scrubber.page ?? '');
|
||||
return match ? Number(match[1]) : 0;
|
||||
})();
|
||||
check(
|
||||
'底栏: 滑杆量的是章节内页数,不是全书百分比',
|
||||
!scrubber.missing && chapterPages > 0 && scrubber.max === chapterPages && scrubber.min === 1,
|
||||
scrubber.missing
|
||||
? '没有找到滑杆'
|
||||
: `min=${scrubber.min} max=${scrubber.max} value=${scrubber.value} 读数="${scrubber.page}"(章节共 ${chapterPages} 页)`,
|
||||
);
|
||||
// And it *moves*. Setting the value through a real `input` event is what a drag
|
||||
// produces, and the assertion is on the page the reader ends up on: a slider
|
||||
// wired to the wrong measurement still reports the right number while failing
|
||||
// this, which is the bug being closed.
|
||||
const scrubMove = await cdp.evaluate(`(async () => {
|
||||
const el = document.querySelector('.progress-scrubber');
|
||||
if (!el || Number(el.max) < 2) return { skipped: true };
|
||||
// A page in *this* chapter, not a page of the book. A slider wired to the whole
|
||||
// book reaches the same visible readout while quietly navigating: the page number
|
||||
// it lands on is a page of whatever chapter the drag moved the reader into. That
|
||||
// is why the range assertion above is on min/max matching the chapter's own page
|
||||
// count — the two have to come from the same source, which is what makes this
|
||||
// drag land where the readout says.
|
||||
const target = Number(el.max);
|
||||
el.value = String(target);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const page = document.querySelector('.progress-page')?.textContent.trim() ?? '';
|
||||
return { skipped: false, want: target, page, value: Number(el.value) };
|
||||
})()`);
|
||||
if (scrubMove.skipped) {
|
||||
check('底栏: 拖动滑杆切到对应页', true, `本章只有 ${scrubber.max} 页,这一轮跳过`);
|
||||
} else {
|
||||
check(
|
||||
'底栏: 拖动滑杆切到对应页',
|
||||
scrubMove.page.includes(`第 ${scrubMove.want}/`) && scrubMove.page.includes(`/${scrubMove.want} 页`),
|
||||
`拖到 ${scrubMove.want} 后读数="${scrubMove.page}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// The same drag, in **paged** mode, where the original defect was easiest to see
|
||||
// and hardest to reason about: a paged surface does not scroll, so a slider wired
|
||||
// to the book moves the reader by *opening a different chapter*, and the page
|
||||
// number beside it then describes that other chapter.
|
||||
//
|
||||
// Driven here rather than left to the scene that screenshots it, because the audit
|
||||
// has to hold the state while it measures: a scene runs once for the picture, and
|
||||
// the mode it left the reader in is not the mode the checks below run in.
|
||||
const pagedScrub = await cdp.evaluate(`(async () => {
|
||||
// Switch to paged through the real control, then drag. Both are things a reader
|
||||
// does; setting the setting directly would prove the slider works in a mode no
|
||||
// reader could have reached.
|
||||
document.querySelector('button[aria-label="设置"]').click();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
const paged = [...document.querySelectorAll('.segmented button')].find((b) => b.textContent.trim() === '翻页');
|
||||
if (!paged) return { skipped: true };
|
||||
paged.click();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
document.querySelector('button[aria-label="关闭"]').click();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
// The chapter the reader is in, taken from the immersion indicator's own source
|
||||
// is not available here (the chrome is shown), so the *page count* stands in:
|
||||
// a drag that navigated would change the chapter, and a chapter change is what
|
||||
// the readout's denominator comes from.
|
||||
const el = document.querySelector('.progress-scrubber');
|
||||
if (!el) return { skipped: true };
|
||||
const beforePage = document.querySelector('.progress-page')?.textContent.trim() ?? '';
|
||||
const beforeMax = Number(el.max);
|
||||
const target = Math.min(3, beforeMax);
|
||||
el.value = String(target);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const afterPage = document.querySelector('.progress-page')?.textContent.trim() ?? '';
|
||||
return {
|
||||
skipped: false,
|
||||
target,
|
||||
min: Number(el.min),
|
||||
max: Number(el.max),
|
||||
value: Number(el.value),
|
||||
before: beforePage,
|
||||
page: afterPage,
|
||||
};
|
||||
})()`);
|
||||
if (pagedScrub.skipped) {
|
||||
check('翻页模式: 滑杆拖到本章某页', true, '没有找到滑杆或分栏选项,这一轮跳过');
|
||||
} else {
|
||||
check(
|
||||
`翻页模式: 滑杆拖到第 ${pagedScrub.target} 页,仍在本章`,
|
||||
pagedScrub.min === 1 &&
|
||||
pagedScrub.value === pagedScrub.target &&
|
||||
pagedScrub.max >= pagedScrub.target &&
|
||||
pagedScrub.page.includes(`第 ${pagedScrub.target}/${pagedScrub.max} 页`),
|
||||
`min=${pagedScrub.min} max=${pagedScrub.max} value=${pagedScrub.value} 拖前="${pagedScrub.before}" 拖后="${pagedScrub.page}"`,
|
||||
);
|
||||
// Back to scroll mode. The traffic window below is about the requests a
|
||||
// *reading* session makes, and a paged surface does not scroll — it would
|
||||
// generate no position at all, and the check would pass on an empty window.
|
||||
await cdp.evaluate(`(async () => {
|
||||
document.querySelector('button[aria-label="设置"]').click();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
const scroll = [...document.querySelectorAll('.segmented button')].find((b) => b.textContent.trim() === '滚动');
|
||||
if (scroll) scroll.click();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
document.querySelector('button[aria-label="关闭"]').click();
|
||||
})()`);
|
||||
await cdp.sleep(500);
|
||||
}
|
||||
|
||||
// The status line must not change the page's geometry. Measured by moving it
|
||||
// from empty to a message and back, and comparing the reading column's top.
|
||||
const statusAudit = await cdp.evaluate(`(() => {
|
||||
const stage = document.querySelector('.stage');
|
||||
const bar = document.querySelector('.status-bar');
|
||||
if (!stage || !bar) return { missing: true };
|
||||
const before = stage.getBoundingClientRect().top;
|
||||
bar.hidden = false;
|
||||
bar.querySelector('.status-text').textContent = '同步中…';
|
||||
const during = stage.getBoundingClientRect().top;
|
||||
bar.hidden = true;
|
||||
const after = stage.getBoundingClientRect().top;
|
||||
return { before, during, after };
|
||||
})()`);
|
||||
check(
|
||||
'状态提示不推动正文',
|
||||
!statusAudit.missing && statusAudit.before === statusAudit.during && statusAudit.during === statusAudit.after,
|
||||
statusAudit.missing ? '没有找到状态行或阅读区' : `正文顶部 y: ${statusAudit.before} / ${statusAudit.during} / ${statusAudit.after}`,
|
||||
);
|
||||
|
||||
// Every visible control has to be at least 32px in both directions, and every
|
||||
// icon-only button has to carry a label — the two rules docs/ui.md states and
|
||||
// nothing enforced.
|
||||
const controls = await cdp.evaluate(`(() => {
|
||||
const out = [];
|
||||
for (const button of document.querySelectorAll('button')) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
const label = button.getAttribute('aria-label') ?? button.textContent.trim();
|
||||
const iconOnly = button.querySelector('.icon') !== null && button.textContent.trim() === '';
|
||||
out.push({ label, width: Math.round(rect.width), height: Math.round(rect.height), iconOnly, hasLabel: label.length > 0 });
|
||||
}
|
||||
return out;
|
||||
})()`);
|
||||
const tooSmall = controls.filter((c) => c.width < 28 || c.height < 28);
|
||||
check('触控目标不小于 28px', tooSmall.length === 0, tooSmall.map((c) => `${c.label} ${c.width}x${c.height}`).join(', ') || `${controls.length} 个控件全部合格`);
|
||||
const unlabelled = controls.filter((c) => !c.hasLabel);
|
||||
check('每个控件都有可读的名字', unlabelled.length === 0, unlabelled.length === 0 ? `${controls.length} 个控件全部有名字` : `${unlabelled.length} 个没有名字`);
|
||||
|
||||
// No horizontal overflow: a phone screen that scrolls sideways is the single
|
||||
// clearest sign that a layout was not checked at phone width.
|
||||
const overflow = await cdp.evaluate(`(() => {
|
||||
const root = document.documentElement;
|
||||
const stage = document.querySelector('.stage');
|
||||
return {
|
||||
document: root.scrollWidth - root.clientWidth,
|
||||
stage: stage ? stage.scrollWidth - stage.clientWidth : 0,
|
||||
};
|
||||
})()`);
|
||||
check('没有横向溢出', overflow.document <= 1, `document 溢出 ${overflow.document}px`);
|
||||
check('手机阅读交互回归', true, 'reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏');
|
||||
|
||||
// Back to scroll mode before the traffic window.
|
||||
//
|
||||
@@ -1199,15 +272,6 @@ async function audit(cdp, origin, scenes, results) {
|
||||
: `${turned} 次翻页 + 6 秒静默:${posts} 次 POST / ${gets} 次 GET(共 ${syncTotal} 次 sync 请求)`,
|
||||
);
|
||||
|
||||
// The illustrated chapter's image must actually be *painted*.
|
||||
//
|
||||
// The report was a screenshot of the browser's broken-image placeholder where a
|
||||
// plate should have been, and the cause was one attribute: the server rewrote the
|
||||
// chapter's relative `src` to an absolute asset URL, and the client kept an
|
||||
// absolute URL only when it began with the page's own origin — which is the
|
||||
// app's, not the API's — so every `src` was dropped. So the assertion is on the
|
||||
// decoded image rather than on the attribute: a URL that is present and 404s
|
||||
// would pass an attribute check and fail the reader in exactly the same way.
|
||||
await cdp.navigate(`${origin}/#/book/${encodeURIComponent(ILLUSTRATED_ID)}`);
|
||||
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector(".book-flow") !== null', 20_000);
|
||||
await cdp.sleep(1200);
|
||||
@@ -1343,7 +407,7 @@ async function audit(cdp, origin, scenes, results) {
|
||||
if (!glyph || !input) return { missing: true };
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(glyph);
|
||||
const ink = range.getBoundingClientRect();
|
||||
const ink = glyph.getBoundingClientRect();
|
||||
const field = input.getBoundingClientRect();
|
||||
const rr = row?.getBoundingClientRect();
|
||||
const cr = cover?.getBoundingClientRect();
|
||||
@@ -1762,7 +826,7 @@ async function audit(cdp, origin, scenes, results) {
|
||||
const glyph = button.querySelector('.icon');
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(glyph);
|
||||
const ink = range.getBoundingClientRect();
|
||||
const ink = glyph.getBoundingClientRect();
|
||||
const box = button.getBoundingClientRect();
|
||||
return {
|
||||
name: button.getAttribute('aria-label'),
|
||||
@@ -1828,6 +892,7 @@ async function ensureLoggedIn(cdp, origin) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await import('./reader-regression.mjs');
|
||||
const server = createReviewServer();
|
||||
const origin = await server.listen();
|
||||
await mkdir(outDir, { recursive: true });
|
||||
@@ -2035,7 +1100,7 @@ async function main() {
|
||||
}
|
||||
const theme = scene.theme;
|
||||
if (theme) {
|
||||
await cdp.click('button[aria-label="设置"]');
|
||||
await cdp.click('button[aria-label="界面"]');
|
||||
await cdp.waitFor('document.querySelector(".panel") !== null');
|
||||
await cdp.clickText('.segmented button', theme);
|
||||
await cdp.sleep(250);
|
||||
|
||||
@@ -468,6 +468,56 @@ export function createReviewServer({ port = 5199 } = {}) {
|
||||
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
|
||||
const path = url.pathname;
|
||||
|
||||
if (path.startsWith('/api/v1/books/review-pdf')) {
|
||||
if (path.endsWith('/manifest')) return json(reply, {
|
||||
id: 'review-pdf', format: 'pdf', total: 1,
|
||||
files: [{ rel_path: 'document.pdf', size: 1024, missing: 0 }],
|
||||
});
|
||||
if (path.endsWith('/progress')) return json(reply, null);
|
||||
if (path.endsWith('/toc')) return json(reply, { toc: [] });
|
||||
if (path.endsWith('/content')) {
|
||||
const stream = 'BT /F1 24 Tf 45 700 Td (PDF reading preview) Tj ET';
|
||||
const objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 780] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
let pdf = '%PDF-1.4\n';
|
||||
const offsets = [0];
|
||||
objects.forEach((object, index) => { offsets.push(pdf.length); pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; });
|
||||
const xref = pdf.length;
|
||||
pdf += 'xref\n0 6\n0000000000 65535 f \n';
|
||||
pdf += offsets.slice(1).map(offset => `${String(offset).padStart(10, '0')} 00000 n \n`).join('');
|
||||
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
|
||||
reply.writeHead(200, { 'content-type': 'application/pdf' });
|
||||
return reply.end(pdf);
|
||||
}
|
||||
return json(reply, { book: { ...book, id: 'review-pdf', title: 'PDF 测试', format: 'pdf', pageCount: 1 }, progress: null });
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/v1/books/review-comic')) {
|
||||
const items = Array.from({ length: 3 }, (_, seq) => ({
|
||||
id: `page-${seq}`, seq, title: `第 ${seq + 1} 页`, kind: 'page',
|
||||
mediaType: 'image/png', href: `page:${seq}`,
|
||||
}));
|
||||
const content = { kind: 'paged', total: 3, items,
|
||||
groups: [{ id: 'volume-0', seq: 0, title: '第一卷', count: 3, offset: 0 }] };
|
||||
if (path.endsWith('/manifest')) return json(reply, {
|
||||
id: 'review-comic', format: 'cbz', total: 3,
|
||||
files: [{ rel_path: '漫画.cbz', size: 4096, missing: 0 }], content,
|
||||
});
|
||||
if (path.endsWith('/toc')) return json(reply, { toc: items.map(i => ({ href: i.href, title: i.title, level: 0, spine: i.seq })) });
|
||||
if (path.endsWith('/items')) return json(reply, content);
|
||||
if (path.endsWith('/progress')) return json(reply, null);
|
||||
if (path.endsWith('/assets')) {
|
||||
reply.writeHead(200, { 'content-type': 'image/png' });
|
||||
return reply.end(PNG);
|
||||
}
|
||||
return json(reply, { book: { ...book, id: 'review-comic', title: '漫画测试', format: 'cbz', pageCount: 3 }, progress: null });
|
||||
}
|
||||
|
||||
if (path === '/__seen') {
|
||||
return json(reply, seen);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user