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

第三章

`, depth: 0 }], + }; + } + + function clickLink(view: ReaderView): boolean { + const anchor = view.elementHost.shadow.querySelector('a'); + if (!anchor) throw new Error('no anchor rendered'); + return anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, composed: true })); + } + + it('routes a chapter link to the screen instead of letting the browser fetch it', async () => { + // The marker is in the URL, so the sanitiser keeps it — that is the point of + // the pair (marker + same origin). `document.baseURI` is jsdom's about:blank, + // so the URL is built against it rather than against a made-up origin. + const origin = document.baseURI; + const href = new URL( + `/api/v1/books/b1/assets?__reader-book-resource__=1&ref=OEBPS/ch3.xhtml`, + origin, + ).href; + const seen: string[] = []; + const view = new ReaderView({ + container, + doc: docWithLink(href), + onChapterLink: (ref) => { + seen.push(ref); + return true; + }, + }); + await view.open(0, 0); + // The click is *not* dispatched by default: a consumed chapter link must not + // also be a navigation. + expect(clickLink(view)).toBe(false); + expect(seen).toEqual(['xhtml:OEBPS/ch3.xhtml']); + view.dispose(); + }); + + it('refuses a link that would leave the reader', async () => { + // An outbound link in a book must not be able to replace the app with a web + // page: in a WebView shell there is no way back to the book at all. The + // sanitiser drops the attribute outright, which is stronger than refusing the + // tap — there is no href left for the browser to follow, and no listener that + // has to be correct for the rule to hold. + const view = new ReaderView({ + container, + doc: docWithLink('https://example.com/'), + }); + await view.open(0, 0); + const anchor = view.elementHost.shadow.querySelector('a'); + expect(anchor?.hasAttribute('href')).toBe(false); + expect(clickLink(view)).toBe(true); + view.dispose(); + }); + + it('lets the browser perform an in-document footnote link', async () => { + const view = new ReaderView({ container, doc: docWithLink('#note3') }); + await view.open(0, 0); + expect(clickLink(view)).toBe(true); + view.dispose(); + }); +}); diff --git a/web/test/shadow.test.ts b/web/test/shadow.test.ts index 27f77ff..dc5a921 100644 --- a/web/test/shadow.test.ts +++ b/web/test/shadow.test.ts @@ -323,10 +323,36 @@ describe('BookShadowHost', () => { const host = createBookHost(); const sheet = host.shadow.querySelector('style')?.textContent ?? ''; expect(sheet).toContain(":host([data-paginated='true']) .book-flow"); - expect(sheet).toContain(":host([data-animating='slide-next']) .book-flow"); + // The page-turn selector carries the *axis* as well as the style and direction: + // a paged turn is horizontal and a scroll-mode turn is vertical, and the axis + // has to come from the selector because the two modes share one host. + expect(sheet).toContain(":host([data-animating='slide-next-x']) .book-flow"); + expect(sheet).toContain(":host([data-animating='slide-next-y']) .book-flow"); expect(sheet).not.toContain('.book-host['); }); + it('separates two paginated columns by exactly two page margins', () => { + // The page margin is padding *inside* the scrolling box, so the page the reader + // sees is `clientWidth` wide while a page *step* was `clientWidth - 2 * margin`. + // The two disagree by the margin, which is why the right edge of every page + // showed the first characters of the next one — the bleed reported as + // "左右翻页的样式不对". A gap of both margins makes the step equal the page and + // draws the gutter between columns at the same time. + const host = createBookHost(); + const sheet = host.shadow.querySelector('style')?.textContent ?? ''; + expect(sheet).toContain('column-gap: calc(var(--reader-page-margin, 1.5rem) * 2)'); + }); + + it('declares border-box for the shadow tree, which the document cannot reach', () => { + // The app's own universal `box-sizing: border-box` is a *document* selector, and + // a document selector cannot match a shadow tree's elements. The reading column + // is width 100% plus the reader's page margin, which under the initial + // `content-box` is a box wider than the page it is paginated into. + const host = createBookHost(); + const sheet = host.shadow.querySelector('style')?.textContent ?? ''; + expect(sheet).toContain('box-sizing: border-box'); + }); + it('keeps the layout sheet when a chapter replaces the book styles', () => { // The book's styles change with every chapter; the reader's layout rules must // not, or the column loses its measure for a frame on every chapter change. diff --git a/web/tools/ui-review/run.mjs b/web/tools/ui-review/run.mjs index 9bab0ef..cad905d 100644 --- a/web/tools/ui-review/run.mjs +++ b/web/tools/ui-review/run.mjs @@ -184,20 +184,20 @@ async function audit(cdp, origin, scenes, results) { ); /* - * The rail holds the whole set of controls the reader reaches for, and every one - * of them is reachable. + * The rail holds the two adjustments a reader makes *from the page* plus 听书, and + * nothing that opens a sheet. * - * The standing report is "竖排工具栏显示为 主题切换、听书", and its shape decided - * these checks. The rail was a column with no height ceiling and no scrolling, so - * on a screen short enough it simply ran off the bottom: the reader sees what fits, - * concludes that is what the rail does, and reports the two they can see. Two - * properties answer that, and both are measured rather than read from the CSS: + * The report is "竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮", and the + * reasoning behind it is the one worth keeping in a comment: all five are + * *destinations*, not adjustments — 目录 and 设置 open a sheet, and 字体/行距/边距 are + * steps of values whose full range lives in that sheet — so a rail that also carried + * them was a second, worse copy of the settings panel floating over the text. What + * is left is what a reader changes without wanting to open anything. * - * - **the column is inside the page**, so its last control is not drawn off the - * bottom of the screen; - * - **the controls it cannot fit are reachable** — the column scrolls, and it - * looks scrollable, because a control that can only be found by scrolling has to - * advertise the scroll or it does not exist as far as the reader is concerned. + * The column still has to stay inside the page however short the screen is, and the + * controls it cannot fit still have to be reachable — the column scrolls, and it + * looks scrollable, because a control that can only be found by scrolling has to + * advertise the scroll or it does not exist as far as the reader is concerned. */ const rail = await cdp.evaluate(`(() => { const el = document.querySelector('.reader-rail'); @@ -229,10 +229,20 @@ async function audit(cdp, origin, scenes, results) { : `溢出 ${rail.overflows ? '是' : '否'} overflow-y=${rail.scrollable} 滚动条 ${rail.scrollbarWidth}px`, ); check( - '快捷列: 一列里含整组快捷操作,而不是只露出前几个', - !rail.missing && rail.labels.length >= 7, + '快捷列: 只留页面内就能完成的调整', + !rail.missing && + rail.labels.includes('听书') && + rail.labels.some((label) => label === '夜间' || label === '日间') && + rail.labels.includes('字号'), rail.missing ? '没有找到快捷按钮列' : `按钮 ${rail.labels.length} 个:${rail.labels.join(' / ')}`, ); + // The five the report asked to remove. Asserted by *name* rather than by count, + // because "four controls" would pass on a rail that had dropped the wrong one. + check( + '快捷列: 不含打开面板的五个入口', + !rail.missing && !rail.labels.some((label) => ['目录', '字体', '行距', '页边距', '设置', '阅读设置'].includes(label)), + rail.missing ? '没有找到快捷按钮列' : `列内控件:${rail.labels.join(' / ')}`, + ); // The read-aloud control is on the rail, which is the report's own example // ("听书"). It matters because it is the one adjustment a reader makes *without* // leaving the page: reaching it only through the settings sheet is a different @@ -348,13 +358,11 @@ async function audit(cdp, origin, scenes, results) { !hidden.missing && hidden.topbarPointerEvents === 'none' && hidden.footerPointerEvents === 'none', hidden.missing ? 'n/a' : `topbar=${hidden.topbarPointerEvents} footer=${hidden.footerPointerEvents}`, ); - // The page is the screen in *both* states, and the assertion is on the text rather - // than on the stage: the stage is inset by the bars' height deliberately (see - // `--reader-chrome-*`), so "the page fills the viewport" is the wrong property to - // ask for. The property that matters is the one the reader reported: showing or - // hiding the chrome must not move a single line. It has to be measured on the - // *chapter's own first block* — the element the reader is reading — and compared - // against the same measurement taken with the chrome visible. + // The page is the screen in *both* states, and the property asserted is the one + // the reader reported: showing or hiding the chrome must not move a single line. + // It has to be measured on the *chapter's own first block* — the element the + // reader is reading — and compared against the same measurement taken with the + // chrome visible. check( '收起工具栏: 正文一行的位置不变(工具栏是浮层)', !hidden.missing && visibleChromeTextTop !== null && hiddenTextTop !== null && Math.abs(hiddenTextTop - visibleChromeTextTop) <= 1, @@ -362,6 +370,37 @@ async function audit(cdp, origin, scenes, results) { ? 'n/a' : `正文首行 y: 显示时 ${visibleChromeTextTop} / 收起后 ${hiddenTextTop}`, ); + // And the page *fills* the screen, which is the other half of the report: + // "内容要铺满,不要预留 stage". The stage used to carry the bars' own heights as + // `padding-block`, so the reading column was permanently 9rem shorter than the + // phone in *both* chrome states — a reader who had hidden the toolbar and wanted + // the whole screen for the book still lost the top and bottom bands to empty + // paper. Measured as "the stage reserves no block padding", which is the cause, + // rather than as "the text reaches the edge", which a chapter's own margin makes + // false. + const stageBox = await cdp.evaluate(`(() => { + const stage = document.querySelector('.stage-host'); + const host = document.querySelector('.stage'); + if (!stage || !host) return null; + const style = getComputedStyle(stage); + return { + paddingTop: parseFloat(style.paddingTop) || 0, + paddingBottom: parseFloat(style.paddingBottom) || 0, + stageTop: Math.round(host.getBoundingClientRect().top), + stageBottom: Math.round(host.getBoundingClientRect().bottom), + viewport: window.innerHeight, + }; + })()`); + check( + '正文铺满: 页面不为工具栏预留高度', + stageBox !== null && + stageBox.paddingTop <= 0.5 && + stageBox.paddingBottom <= 0.5 && + stageBox.stageBottom - stageBox.stageTop >= stageBox.viewport - 2, + stageBox === null + ? 'n/a' + : `padding ${stageBox.paddingTop}/${stageBox.paddingBottom}px, 页面 ${stageBox.stageBottom - stageBox.stageTop}px / 屏高 ${stageBox.viewport}`, + ); // The bars float over the page, so they must be *positioned* — a bar back in the // flow is a bar that takes height out of the page and moves the text, which is the // whole of the reported defect. Asserted on the computed `position` rather than on @@ -490,20 +529,18 @@ async function audit(cdp, origin, scenes, results) { : '没有找到阅读指示', ); - // A tap in an outer third pages *and* brings the chrome back. + // A tap in an outer third pages, and *only* pages. // - // The report is "点击左右侧翻页时工具栏不能显示出来", and it is one tap described - // from both of the directions it can fail. The outer thirds used to *replace* the - // page turn with a reveal while the chrome was hidden, on the reasoning that a - // hidden bar leaves no other way back — so a reader who tapped the right third to - // read on got no next page, and a toolbar they were not looking for. Asserting - // both halves is the only way to state the fix: either one alone passes on the - // behaviour that was reported. + // The report is "在点击左右两边时只需要翻页、不需要显示工具栏,只有在中间点击时才需要 + // 切换工具栏显隐", and it is one tap described from both of the directions it can + // fail. The outer thirds used to page-turn *and* force the chrome back while it + // was hidden, on the reasoning that a hidden bar leaves no other way back — so the + // immersive state was impossible to stay in, because every page turn undid it. + // Both halves have to be asserted: a check on the page number alone passes on the + // behaviour that was reported, and so does a check on the chrome alone. // - // The page readout is read before and after, and the chrome is hidden first, so the - // assertion is about a state the reader can actually be in. The measurement is - // skipped rather than failed when the chapter has only one page — there is nothing - // to turn, and a check that failed there would be asserting about the fixture. + // The chrome is hidden first, so the assertion is about a state the reader can + // actually be in, and the page readout is read before and after. const beforeThird = await cdp.evaluate( `(() => ({ chrome: document.querySelector('.reader-screen').dataset.chrome, page: document.querySelector('.progress-page')?.textContent ?? '', value: Number(document.querySelector('.progress-scrubber')?.value ?? '0') }))()`, ); @@ -513,16 +550,18 @@ async function audit(cdp, origin, scenes, results) { `(() => ({ chrome: document.querySelector('.reader-screen').dataset.chrome, page: document.querySelector('.progress-page')?.textContent ?? '', value: Number(document.querySelector('.progress-scrubber')?.value ?? '0') }))()`, ); check( - '点右侧翻页: 既翻页又把工具栏叫回来', - afterThird.chrome === 'visible' && beforeThird.chrome === 'hidden' && afterThird.value > beforeThird.value, + '点右侧只翻页: 页码前进, 工具栏保持收起', + afterThird.chrome === 'hidden' && beforeThird.chrome === 'hidden' && afterThird.value > beforeThird.value, `收起前 ${beforeThird.chrome}(${beforeThird.page}) → 点击后 ${afterThird.chrome}(${afterThird.page})`, ); - // The panel checks that follow need the chrome *up*, and it already is: the check - // above deliberately ends with it visible, because that is half of what it asserts. - // Tapping the middle to "restore" it would hide it and leave every panel check - // waiting for a button it cannot reach. - await cdp.sleep(200); + // The middle third is the only way back to the controls, so it is asserted as + // such right after: the outer-third check above leaves the chrome hidden, and the + // panel checks below need it up. + await cdp.tapMiddle(); + await cdp.sleep(600); + const afterMiddle = await cdp.evaluate(`document.querySelector('.reader-screen').dataset.chrome`); + check('点中间切换工具栏', afterMiddle === 'visible', `点击中间后 ${afterMiddle}`); for (const scene of scenes) { if (scene.openPanel) {