mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +08:00
合并来自 auto/immersive-toolbar-5fbf 的合并请求 #38
feat(web): 顶栏底栏按参照稿重做,沉浸态保留章节与页码 PR-URL: #38
This commit is contained in:
12 files changed
+516
-143
No files matched your search
@@ -6,8 +6,8 @@
|
||||
| 截图 | 场景 | 看什么 |
|
||||
| --- | --- | --- |
|
||||
| `01-shelf.png` | 书架 | 第一屏:先看到书,再看到控件 |
|
||||
| `02-reader.png` | 阅读页 | 顶栏 / 正文 / 底部工具栏 / 状态药丸 |
|
||||
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 顶栏与底栏同时收起,正文占满,右侧留出快捷按钮列 |
|
||||
| `02-reader.png` | 阅读页 | 顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸 |
|
||||
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码 |
|
||||
| `04-panel-toc.png` | 目录 · 半屏 | 下半屏,上半屏正文仍可见 |
|
||||
| `05-panel-settings.png` | 阅读设置 · 半屏 | 一行式行:标签在左、控件在右 |
|
||||
| `06-reader-paged.png` | 阅读页 · 翻页模式 | 分栏后的一页,页数应与可翻次数一致 |
|
||||
@@ -23,13 +23,17 @@
|
||||
| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=hidden footer=hidden |
|
||||
| 收起工具栏: 正文占满整屏 | ✅ | 正文 1..844 / 视口 844 |
|
||||
| 收起工具栏: 右侧仍有快捷按钮 | ✅ | rail=true |
|
||||
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=48 |
|
||||
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 273px |
|
||||
| 收起工具栏: 保留章节与页码指示 | ✅ | chapter="第一章 惊蛰" progress="第 1/4 页 0%" |
|
||||
| 收起工具栏: 阅读指示不吃手势 | ✅ | pointer-events=none |
|
||||
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=54 |
|
||||
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 266px |
|
||||
| 目录: 面板有遮罩和把手 | ✅ | scrim=true grip=true |
|
||||
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 727px |
|
||||
| 状态提示不推动正文 | ✅ | 正文顶部 y: 48 / 48 / 48 |
|
||||
| 触控目标不小于 28px | ✅ | 11 个控件全部合格 |
|
||||
| 每个控件都有可读的名字 | ✅ | 11 个控件全部有名字 |
|
||||
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 701px |
|
||||
| 底栏: 进度是可拖动的滑杆 | ✅ | type=range 宽 304px label="阅读进度" |
|
||||
| 底栏: 有页码与阅读进度读数 | ✅ | page="第 1/4 页" nav="阅读进度:0%" |
|
||||
| 状态提示不推动正文 | ✅ | 正文顶部 y: 54.390625 / 54.390625 / 54.390625 |
|
||||
| 触控目标不小于 28px | ✅ | 9 个控件全部合格 |
|
||||
| 每个控件都有可读的名字 | ✅ | 9 个控件全部有名字 |
|
||||
| 没有横向溢出 | ✅ | document 溢出 0px |
|
||||
|
||||
全部通过。
|
||||
@@ -12,12 +12,12 @@
|
||||
{
|
||||
"name": "02-reader",
|
||||
"label": "阅读页",
|
||||
"what": "顶栏 / 正文 / 底部工具栏 / 状态药丸"
|
||||
"what": "顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸"
|
||||
},
|
||||
{
|
||||
"name": "03-reader-no-chrome",
|
||||
"label": "阅读页 · 收起工具栏",
|
||||
"what": "顶栏与底栏同时收起,正文占满,右侧留出快捷按钮列"
|
||||
"what": "顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码"
|
||||
},
|
||||
{
|
||||
"name": "04-panel-toc",
|
||||
@@ -71,15 +71,25 @@
|
||||
"ok": true,
|
||||
"detail": "rail=true"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 保留章节与页码指示",
|
||||
"ok": true,
|
||||
"detail": "chapter=\"第一章 惊蛰\" progress=\"第 1/4 页 0%\""
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 阅读指示不吃手势",
|
||||
"ok": true,
|
||||
"detail": "pointer-events=none"
|
||||
},
|
||||
{
|
||||
"name": "目录: 面板不遮住整页",
|
||||
"ok": true,
|
||||
"detail": "面板从 y=321 开始,正文区 y=48"
|
||||
"detail": "面板从 y=321 开始,正文区 y=54"
|
||||
},
|
||||
{
|
||||
"name": "目录: 面板留出可预览的正文",
|
||||
"ok": true,
|
||||
"detail": "正文可见高度 273px"
|
||||
"detail": "正文可见高度 266px"
|
||||
},
|
||||
{
|
||||
"name": "目录: 面板有遮罩和把手",
|
||||
@@ -89,22 +99,32 @@
|
||||
{
|
||||
"name": "目录: 面板高度不超过阅读区",
|
||||
"ok": true,
|
||||
"detail": "面板 523px / 阅读区 727px"
|
||||
"detail": "面板 523px / 阅读区 701px"
|
||||
},
|
||||
{
|
||||
"name": "底栏: 进度是可拖动的滑杆",
|
||||
"ok": true,
|
||||
"detail": "type=range 宽 304px label=\"阅读进度\""
|
||||
},
|
||||
{
|
||||
"name": "底栏: 有页码与阅读进度读数",
|
||||
"ok": true,
|
||||
"detail": "page=\"第 1/4 页\" nav=\"阅读进度:0%\""
|
||||
},
|
||||
{
|
||||
"name": "状态提示不推动正文",
|
||||
"ok": true,
|
||||
"detail": "正文顶部 y: 48 / 48 / 48"
|
||||
"detail": "正文顶部 y: 54.390625 / 54.390625 / 54.390625"
|
||||
},
|
||||
{
|
||||
"name": "触控目标不小于 28px",
|
||||
"ok": true,
|
||||
"detail": "11 个控件全部合格"
|
||||
"detail": "9 个控件全部合格"
|
||||
},
|
||||
{
|
||||
"name": "每个控件都有可读的名字",
|
||||
"ok": true,
|
||||
"detail": "11 个控件全部有名字"
|
||||
"detail": "9 个控件全部有名字"
|
||||
},
|
||||
{
|
||||
"name": "没有横向溢出",
|
||||
|
||||
+19
@@ -353,6 +353,25 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
「必须离开才能操作」的状态:改个字号要「点回来 → 调 → 再点走」三次交互,
|
||||
而正文在每一次下面重排。有了它,读者最常用的四个动作永远一步可达,而且按钮
|
||||
住在正文的版心旁边(宽屏时用 `--reader-measure` 算出的天头地脚里),不压字。
|
||||
- **收起时正文一角仍留两个读数**(`.reading-indicator`):当前章节名在左上、
|
||||
页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏
|
||||
就是要看正文,但**仍然要知道自己在哪一页**,让他为此再点一下正是收起状态要
|
||||
省掉的那次交互。两个读数 `pointer-events: none`(它们是读数不是控件,点它们
|
||||
必须落到下面的翻页区),且只在收起时绘制,不和工具栏重复。
|
||||
|
||||
顶栏与底栏各自的形态(对应参照稿):
|
||||
|
||||
- **顶栏**是一排「图标在上、文字在下」的等宽按钮(`.top-button`),不是「标题 +
|
||||
返回箭头」。参照稿里它是读者**扫**着用的一行工具栏,每个控件一样重、一样一步
|
||||
可达。书的名字曾经写在这里,现在它属于 `.reading-indicator`——读者找标题时
|
||||
眼睛去的地方是正文角上,不是工具栏。按钮只保留这个屏幕**真能做到**的动作
|
||||
(返回 / 目录 / 设置):参照稿的「首页/书架/书源」里有阅读页内部走不到的目的地,
|
||||
一个点了没处的按钮比少一个按钮更糟。
|
||||
- **底栏**是两行(`.progress-row` + `.chapter-nav`):一行可拖动的进度滑杆加页码
|
||||
读数,一行「上一章 / 阅读进度 / 下一章」。滑杆取代了原来那条 3px 的进度条——
|
||||
条只能**报告**位置,读者想到一章末尾得连点四十次「下一页」;`input[type=range]`
|
||||
一个手势就到,而且拇指、轨道、拖动惯性和无障碍(方向键、`aria-valuenow`)都是
|
||||
原生的,不用自己实现一遍。
|
||||
|
||||
手势上,**中间三分之一在两个方向上都是开关**;左右三分之一只在 chrome 已经
|
||||
收起时才充当「叫回来」的出口(§3.6 的优先级第 2 条)。收起状态下任何一条区都
|
||||
|
||||
Binary file not shown.
+212
-86
@@ -352,24 +352,27 @@ book-content {
|
||||
* and zero, and the stage — which is `flex: 1 1 auto` — takes whatever is left,
|
||||
* so a single number here is what keeps the page from being measured mid-slide.
|
||||
*/
|
||||
--reader-chrome-top: 3rem;
|
||||
--reader-chrome-top: 3.4rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
/*
|
||||
* Centred, not baselined.
|
||||
*
|
||||
* `align-items: baseline` reads as the more careful choice for a row that mixes a
|
||||
* glyph and two lines of text, and it is the wrong one: the title block is two
|
||||
* lines, so its first baseline is the *title's*, and the icon buttons would then be
|
||||
* aligned to a line of text three quarters of a control away. Centre is the one
|
||||
* line every box in this row shares.
|
||||
* `stretch`, so each button owns the full height of the band. The buttons are
|
||||
* icon-over-label now, and a button that only owned its two lines would leave a
|
||||
* strip of the bar above and below that looked identical but did nothing — on
|
||||
* the one row where the reader is aiming at controls rather than at text.
|
||||
*/
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
/*
|
||||
* `space-evenly` rather than `space-between`: three controls spread to the two
|
||||
* ends of a wide screen stop reading as one toolbar and start reading as two
|
||||
* stray icons at opposite corners. Even gaps keep them a row at any width.
|
||||
*/
|
||||
justify-content: space-evenly;
|
||||
gap: var(--space-1);
|
||||
min-height: 3rem;
|
||||
padding: 0 0.5rem 0 0.35rem;
|
||||
min-height: 3.4rem;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--reader-surface);
|
||||
/* Same hairline treatment as the footer, so the two edges of the reading area
|
||||
are the same weight. */
|
||||
@@ -404,23 +407,96 @@ book-content {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
/*
|
||||
* One icon-over-label control in the top bar.
|
||||
*
|
||||
* A column, so the glyph and the word are one hit target rather than two; the word
|
||||
* is drawn rather than left to a tooltip because the glyphs here are not universal
|
||||
* (a list and a slider read as the same thing without it). The label is the small
|
||||
* `--text-meta` size — it is a caption for the glyph, not a second title.
|
||||
*/
|
||||
.top-button {
|
||||
flex: 1 1 0;
|
||||
max-width: 6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.3rem 0.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--reader-radius);
|
||||
background: transparent;
|
||||
color: var(--reader-ink);
|
||||
font: inherit;
|
||||
font-size: 1.15rem;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
transition: background var(--motion-duration) var(--motion-ease);
|
||||
}
|
||||
|
||||
.top-button:active {
|
||||
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
|
||||
}
|
||||
|
||||
.top-button-label {
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 400;
|
||||
color: var(--reader-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* The reading indicator.
|
||||
*
|
||||
* Two quiet readouts pinned to the corners of the stage, drawn only while the
|
||||
* chrome is hidden. `position: absolute` inside `.reader-screen` (which is
|
||||
* `position: relative`) so they can sit over the top and bottom of the text
|
||||
* without being part of the flow — they must not take height from the page, or
|
||||
* showing them would re-measure the columns and move the text.
|
||||
*
|
||||
* `pointer-events: none` because they are readouts, not controls: a tap on the
|
||||
* chapter name has to reach the tap zone underneath it and turn the page (or
|
||||
* bring the chrome back), not be swallowed by a label.
|
||||
*/
|
||||
.reading-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: calc(0.5rem + var(--safe-top)) calc(var(--reader-page-padding-inline) + var(--safe-right))
|
||||
calc(0.5rem + var(--safe-bottom)) calc(var(--reader-page-padding-inline) + var(--safe-left));
|
||||
font-size: var(--text-meta);
|
||||
color: var(--reader-muted);
|
||||
/* A slow fade of its own, offset by the chrome's duration: the indicator comes
|
||||
up as the bars go down, so the reader never sees an empty screen. */
|
||||
animation: indicator-in var(--motion-duration) var(--motion-ease);
|
||||
}
|
||||
|
||||
@keyframes indicator-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.indicator-chapter {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--text-label);
|
||||
}
|
||||
|
||||
.topbar .subtitle {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: var(--reader-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.indicator-progress {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--reader-ink);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reading-indicator { animation: none; }
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1717,95 +1793,137 @@ book-content {
|
||||
visibility 0s linear var(--motion-duration);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 3px;
|
||||
/*
|
||||
* The scrubber row: a draggable progress slider and the page readout beside it.
|
||||
*
|
||||
* The slider replaces the old 3px bar. That bar could only *report* the position;
|
||||
* a reader who wanted the end of a long chapter had to tap "next" forty times. A
|
||||
* range input is the one control that answers "take me there" in a single gesture,
|
||||
* and it is native — the thumb, the track, the drag physics and the accessibility
|
||||
* (arrow keys, `aria-valuenow`) all come with it rather than being reimplemented.
|
||||
*/
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0.75rem 0.25rem;
|
||||
}
|
||||
|
||||
.progress-scrubber {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
/* Native range inputs default to a chunky, theme-blind widget; `appearance: none`
|
||||
is what lets the tokens below draw it. The track is drawn on the element and
|
||||
the thumb on `::-webkit-slider-thumb` / `::-moz-range-thumb`, which are two
|
||||
separate rule sets because the two engines never agreed on a shared one. */
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
height: 1.5rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.progress-scrubber::-webkit-slider-runnable-track {
|
||||
height: 0.3rem;
|
||||
border-radius: 0.15rem;
|
||||
background: color-mix(in srgb, var(--reader-line) 70%, transparent);
|
||||
}
|
||||
|
||||
.progress-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--reader-accent);
|
||||
border-radius: 0 2px 2px 0;
|
||||
transition: width var(--motion-duration) var(--motion-ease);
|
||||
.progress-scrubber::-moz-range-track {
|
||||
height: 0.3rem;
|
||||
border-radius: 0.15rem;
|
||||
background: color-mix(in srgb, var(--reader-line) 70%, transparent);
|
||||
}
|
||||
|
||||
.footer-row {
|
||||
.progress-scrubber::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
/* Pull the thumb up onto the middle of the track: the track is 0.3rem and the
|
||||
thumb 1.1rem, so half the difference is the offset. Without this the thumb
|
||||
sits below the line it is supposed to ride. */
|
||||
margin-top: -0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--reader-surface);
|
||||
border: 2px solid var(--reader-accent);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, #000 18%, transparent);
|
||||
}
|
||||
|
||||
.progress-scrubber::-moz-range-thumb {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--reader-surface);
|
||||
border: 2px solid var(--reader-accent);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, #000 18%, transparent);
|
||||
}
|
||||
|
||||
.progress-scrubber:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
|
||||
/* The page readout: tabular so "第 9/32 页" does not twitch sideways as the
|
||||
denominator changes width. */
|
||||
.progress-page {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--text-meta);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--reader-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* The chapter navigation row.
|
||||
*
|
||||
* Three cells: previous chapter, the reading progress as a label, next chapter.
|
||||
* The two buttons are text-with-chevron rather than bare chevrons — the whole row
|
||||
* is a labelled destination, and a bare "‹" beside a bare "›" is a guess.
|
||||
*/
|
||||
.footer-row.chapter-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) 0.6rem 0;
|
||||
color: var(--reader-muted);
|
||||
gap: var(--space-2);
|
||||
padding: 0.15rem 0.75rem 0.65rem;
|
||||
}
|
||||
|
||||
.footer-row .chapter {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
/* The chapter label is the second way into the contents panel, because the ☰
|
||||
button beside it is a 2.1rem square and the label is the thing a reader
|
||||
actually aims at when they want to pick a chapter. */
|
||||
.nav-chapter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--reader-ink);
|
||||
font: inherit;
|
||||
font-size: var(--text-label);
|
||||
padding: 0.45rem 0.3rem;
|
||||
padding: 0.45rem 0.4rem;
|
||||
border-radius: var(--reader-radius);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer-row .chapter:active {
|
||||
.nav-chapter:active {
|
||||
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* The chapter buttons share the row with the chapter label, so they are a size
|
||||
* down from the topbar's icon buttons: four 2.5rem controls plus a label is more
|
||||
* than a 360px phone has, and the two that turn pages deserve the room.
|
||||
*/
|
||||
.footer-row .icon-button {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
font-size: 1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.footer-row .icon-button:disabled {
|
||||
opacity: 0.3;
|
||||
.nav-chapter:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The readout. One line, three positions, numbers that do not jitter as they
|
||||
change — `tabular-nums` is what stops "1/3 章" from shifting sideways when it
|
||||
becomes "2/3 章", which at this size is a visible twitch on every page turn. */
|
||||
.footer-meta {
|
||||
padding: 0.15rem 0.75rem 0.5rem;
|
||||
font-size: var(--text-meta);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.footer-meta span {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.footer-meta span:first-child {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.footer-meta span:nth-child(2) {
|
||||
.nav-progress {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-meta .progress-label {
|
||||
text-align: end;
|
||||
color: var(--reader-ink);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-label);
|
||||
color: var(--reader-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- immersion rail ----
|
||||
@@ -2075,7 +2193,15 @@ book-content {
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(var(--space-3) + var(--safe-top));
|
||||
/*
|
||||
* Just *below* the top bar rather than over it. It used to sit at
|
||||
* `--space-3 + safe-top`, i.e. inside the 3.4rem band — which put the middle of
|
||||
* the pill on top of the middle button of the top bar, so a reader who had just
|
||||
* opened a book could not see (or reach) 目录 until the message faded. The pill
|
||||
* belongs over the *page*, which is where the reader is looking, and it must not
|
||||
* cover the controls they might be reaching for.
|
||||
*/
|
||||
top: calc(var(--reader-chrome-top) + var(--space-2));
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -119,6 +119,8 @@ export interface ChromeHandlers {
|
||||
onSwitchEngine(kind: AppSettings['ttsEngine']): void;
|
||||
onTocEntry(ref: string): void;
|
||||
onChapter(delta: 1 | -1): void;
|
||||
/** Jump to a fraction of the whole book, from the footer scrubber. */
|
||||
onScrub(fraction: number): void;
|
||||
onTurnPage(direction: 'next' | 'previous'): void;
|
||||
onSpeechToggle(): void;
|
||||
onSpeechPrevious(): void;
|
||||
@@ -143,13 +145,23 @@ export interface ReaderChromeProps {
|
||||
export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{/* The top bar.
|
||||
—
|
||||
A row of labelled buttons, not a title with a back arrow. The reference
|
||||
layout is a toolbar the reader *scans*: five controls of equal weight,
|
||||
each an icon over its own name, so nothing has to be learned and nothing
|
||||
is announced twice. The book's title lived here before; it is still on
|
||||
screen when the chrome is hidden (see `.reading-indicator`), which is
|
||||
where a reader actually looks for it — a title that disappears with the
|
||||
toolbar was the reason the indicator had to exist at all.
|
||||
Buttons are kept to the ones this screen can really do. The reference's
|
||||
首页/书架/书源/目录/设置 collapses to 返回/目录/设置 here because there is
|
||||
no book-source browser *inside* the reader: a button that navigates
|
||||
somewhere the screen cannot go is worse than one fewer button. */}
|
||||
<div className="topbar" hidden={!state.chromeVisible}>
|
||||
<IconButton label="返回书架" icon="arrow-left" onClick={handlers.onBack} />
|
||||
<div className="title-block" style="flex:1 1 auto;min-width:0;">
|
||||
<h1>{state.title}</h1>
|
||||
{state.author ? <span className="subtitle">{state.author}</span> : null}
|
||||
</div>
|
||||
<IconButton label="目录" icon="menu" onClick={handlers.toggleToc} />
|
||||
<TopButton icon="arrow-left" label="返回" onClick={handlers.onBack} />
|
||||
<TopButton icon="menu" label="目录" onClick={handlers.toggleToc} />
|
||||
<TopButton icon="sliders" label="设置" onClick={handlers.toggleSettings} />
|
||||
</div>
|
||||
|
||||
{/* Outside the flex flow: see the comment on `.status-bar`. It has to be a
|
||||
@@ -169,6 +181,27 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
|
||||
<StageHost stage={stage} />
|
||||
|
||||
{/* The reading indicator.
|
||||
—
|
||||
What is left on screen when the chrome is hidden. The reference keeps two
|
||||
quiet facts pinned to the corners — which chapter, and how far in — and
|
||||
nothing else. That is the difference between "沉浸式" and "the app stopped
|
||||
drawing": a reader who has hidden the toolbar still wants to know where
|
||||
they are, and making them tap to find out is what the hidden state was
|
||||
supposed to save them from.
|
||||
Both live in the page margin at the very top and bottom of the stage, are
|
||||
`pointer-events: none` (they are readouts, not controls), and are drawn
|
||||
only while the chrome is hidden so they do not double up with it. */}
|
||||
{!state.chromeVisible ? (
|
||||
<div className="reading-indicator" aria-hidden="true">
|
||||
<span className="indicator-chapter">{state.chapterLabel}</span>
|
||||
<span className="indicator-progress">
|
||||
{state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页 ` : ''}
|
||||
{percentOf(state.progress)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The immersion rail.
|
||||
—
|
||||
A column of controls on the right edge, drawn *only* while the chrome is
|
||||
@@ -196,39 +229,52 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The bottom bar.
|
||||
—
|
||||
Two rows, and the split is the reference's: a scrubber the reader can
|
||||
drag to any page, then a navigation row with the chapter buttons at the
|
||||
ends and the reading progress in the middle. The scrubber replaces the
|
||||
old 3px bar plus the "本章 x/y 页" readout — a bar that only reports could
|
||||
not be used to *go* anywhere, and the reader who wanted the end of the
|
||||
chapter had to tap the next button forty times. */}
|
||||
<div className="footer" hidden={!state.chromeVisible}>
|
||||
<div className="progress-bar">
|
||||
<span style={`width:${percentOf(state.progress)}`} />
|
||||
<div className="progress-row">
|
||||
<input
|
||||
className="progress-scrubber"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={Math.round(Math.min(1, Math.max(0, state.progress)) * 100)}
|
||||
aria-label="阅读进度"
|
||||
onInput={(event) =>
|
||||
handlers.onScrub(Number((event.currentTarget as HTMLInputElement).value) / 100)
|
||||
}
|
||||
/>
|
||||
<span className="progress-page">
|
||||
{state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="footer-row">
|
||||
<IconButton label="目录" icon="menu" onClick={handlers.toggleToc} />
|
||||
<IconButton
|
||||
label="上一章"
|
||||
icon="chevron-left"
|
||||
<div className="footer-row chapter-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="nav-chapter"
|
||||
disabled={state.navigating || state.chapterIndex <= 1}
|
||||
onClick={() => handlers.onChapter(-1)}
|
||||
/>
|
||||
<button type="button" className="chapter" onClick={handlers.toggleToc}>
|
||||
{state.navigating ? '正在切换…' : state.chapterLabel || '目录'}
|
||||
>
|
||||
<span className="icon" aria-hidden="true">{iconGlyph('chevron-left')}</span>
|
||||
上一章
|
||||
</button>
|
||||
<IconButton
|
||||
label="下一章"
|
||||
icon="chevron-right"
|
||||
<span className="nav-progress">阅读进度:{percentOf(state.progress)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-chapter"
|
||||
disabled={state.navigating || state.chapterIndex >= state.chapterCount}
|
||||
onClick={() => handlers.onChapter(1)}
|
||||
/>
|
||||
<IconButton label="阅读设置" icon="sliders" onClick={handlers.toggleSettings} />
|
||||
</div>
|
||||
<div className="footer-row footer-meta">
|
||||
<span>
|
||||
{state.chapterCount > 0 ? `${state.chapterIndex}/${state.chapterCount} 章` : ''}
|
||||
</span>
|
||||
<span>
|
||||
{state.chapterPages > 0
|
||||
? `本章 ${state.pageInChapter}/${state.chapterPages} 页`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="progress-label">{percentOf(state.progress)}</span>
|
||||
>
|
||||
下一章
|
||||
<span className="icon" aria-hidden="true">{iconGlyph('chevron-right')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -782,6 +828,24 @@ function SelectRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One control in the top bar.
|
||||
*
|
||||
* An icon **over** a name, matching the reference: the label is not a tooltip and
|
||||
* not an `aria-label` alone, it is drawn, because the icons here are not universal
|
||||
* (a "目录" list glyph and a "设置" slider glyph read as the same thing when there
|
||||
* is no word under them). Centred, equal-width, and full-height so the whole
|
||||
* column of the bar is the target rather than the glyph.
|
||||
*/
|
||||
function TopButton({ icon, label, onClick }: { icon: IconName; label: string; onClick(): void }): JSX.Element {
|
||||
return (
|
||||
<button type="button" className="top-button" aria-label={label} onClick={onClick}>
|
||||
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
|
||||
<span className="top-button-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One control on the immersion rail.
|
||||
*
|
||||
|
||||
@@ -214,6 +214,7 @@ export class ReaderScreen {
|
||||
onSwitchEngine: (kind) => this.switchSpeechEngine(kind),
|
||||
onTocEntry: (ref) => void this.goToChapterRef(ref),
|
||||
onChapter: (delta) => void this.goToChapter(delta),
|
||||
onScrub: (fraction) => void this.scrubTo(fraction),
|
||||
onTurnPage: (direction) => void this.turnPage(direction),
|
||||
onSpeechToggle: () => this.toggleSpeech(),
|
||||
onSpeechPrevious: () => void this.tts?.previous(),
|
||||
@@ -979,6 +980,29 @@ export class ReaderScreen {
|
||||
return entry?.spine ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jumps to a fraction of the whole book, from the footer's scrubber.
|
||||
*
|
||||
* The slider is `input[type=range]`, which fires on every pixel of a drag, so
|
||||
* this is deliberately the *cheap* path: `seekPercentage` asks the view to open
|
||||
* the section at that fraction, and the view's own position reporting feeds the
|
||||
* numbers back through the same `onPosition` every other movement uses. There is
|
||||
* no separate "scrubbing" state to get out of sync with — the reader drags, the
|
||||
* book follows, and letting go leaves them where they let go.
|
||||
*
|
||||
* Position writes are untouched on purpose: `onPosition` already debounces the
|
||||
* network write by 1.5s, so a drag does not produce a request per frame.
|
||||
*/
|
||||
private async scrubTo(fraction: number): Promise<void> {
|
||||
const view = this.view;
|
||||
if (!view) return;
|
||||
await view.seekPercentage(fraction);
|
||||
// The drag can end between two scroll events, and a book with no reported
|
||||
// position after the seek would leave the bar showing the pre-drag value.
|
||||
this.pendingPosition = view.position();
|
||||
this.onPosition(view.position());
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps one chapter at a time, the way the footer's ‹ / › buttons do.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ICON_CODEPOINTS } from '../src/ui/icon-names.ts';
|
||||
import { GLYPHS } from '../tools/icons/paths.mjs';
|
||||
import { pathToSubpaths } from '../tools/icons/path.mjs';
|
||||
|
||||
/**
|
||||
* The icon set's contract with the font.
|
||||
@@ -85,6 +86,48 @@ describe('the icon font and its code point table', () => {
|
||||
expect(tableNames).toEqual(sourceNames);
|
||||
});
|
||||
|
||||
it('does not give two different icons the same path', () => {
|
||||
// A duplicated path is a wrong icon that no other assertion here can see: the
|
||||
// name is in the table, the code point maps, the glyph has ink and fits the em —
|
||||
// and it draws the other icon. `chevron-right` was exactly this, byte for byte
|
||||
// the same as `chevron-left`, so the "next chapter" button pointed backwards.
|
||||
const byPath = new Map<string, string[]>();
|
||||
for (const [name, path] of Object.entries(GLYPHS as Record<string, string>)) {
|
||||
const key = path.replace(/\s+/g, '');
|
||||
byPath.set(key, [...(byPath.get(key) ?? []), name]);
|
||||
}
|
||||
const duplicates = [...byPath.values()].filter((names) => names.length > 1);
|
||||
expect(duplicates, `these icons share a path: ${duplicates.map((n) => n.join(' = ')).join('; ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('points the two chevrons in opposite directions', () => {
|
||||
// The mirror is what makes this pair useful; two left chevrons are a working
|
||||
// glyph and a broken control. Measured on the flattened polylines rather than
|
||||
// on the strings, because "which way does it point" is a fact about the
|
||||
// geometry and a regex over the path data is not that fact.
|
||||
const points = (name: 'chevron-left' | 'chevron-right') =>
|
||||
pathToSubpaths((GLYPHS as Record<string, string>)[name]!).flat();
|
||||
const tip = (name: 'chevron-left' | 'chevron-right') => {
|
||||
const xs = points(name).map((p) => p[0]);
|
||||
// The tip is the extreme point along the direction the chevron points.
|
||||
return name === 'chevron-left' ? Math.min(...xs) : Math.max(...xs);
|
||||
};
|
||||
const back = (name: 'chevron-left' | 'chevron-right') => {
|
||||
const xs = points(name).map((p) => p[0]);
|
||||
return name === 'chevron-left' ? Math.max(...xs) : Math.min(...xs);
|
||||
};
|
||||
// A chevron's tip is its leading edge; for it to point left, the leftmost x
|
||||
// has to be the tip and the two back corners have to be further right.
|
||||
expect(tip('chevron-left'), 'chevron-left points left').toBeLessThan(back('chevron-left'));
|
||||
expect(tip('chevron-right'), 'chevron-right points right').toBeGreaterThan(back('chevron-right'));
|
||||
// And the two are a matched pair: the same span, mirrored about the centre.
|
||||
const span = (name: 'chevron-left' | 'chevron-right') => {
|
||||
const xs = points(name).map((p) => p[0]);
|
||||
return Math.max(...xs) - Math.min(...xs);
|
||||
};
|
||||
expect(span('chevron-right')).toBeCloseTo(span('chevron-left'), 5);
|
||||
});
|
||||
|
||||
it('assigns code points in the same order as the source, starting at 0xE900', () => {
|
||||
const names = Object.keys(GLYPHS);
|
||||
names.forEach((name, index) => {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Declarations for `path.mjs`, following the same convention as `paths.d.mts`:
|
||||
* the build scripts stay plain JavaScript and this states the shape the test
|
||||
* consumes.
|
||||
*/
|
||||
|
||||
/** Flattens an SVG path string into polylines of `[x, y]` points. */
|
||||
export function pathToSubpaths(d: string): Array<Array<[number, number]>>;
|
||||
@@ -13,7 +13,7 @@
|
||||
export const GLYPHS = {
|
||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||
'chevron-left': 'M15 5l-7 7 7 7',
|
||||
'chevron-right': 'M15 5l-7 7 7 7',
|
||||
'chevron-right': 'M9 5l7 7-7 7',
|
||||
'arrow-left': 'M11 5l-7 7 7 7M4 12h16',
|
||||
folder: 'M3 7h6l2 2.5h10V19H3z',
|
||||
file: 'M6.5 3h7l4.5 4.5V21h-11.5z',
|
||||
|
||||
@@ -39,11 +39,11 @@ const SCALE = 2;
|
||||
|
||||
const SCENES = [
|
||||
{ name: '01-shelf', label: '书架', what: '第一屏:先看到书,再看到控件' },
|
||||
{ name: '02-reader', label: '阅读页', what: '顶栏 / 正文 / 底部工具栏 / 状态药丸', openBook: true },
|
||||
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '顶栏与底栏同时收起,正文占满,右侧留出快捷按钮列', openBook: true, tapCenter: true },
|
||||
{ name: '02-reader', label: '阅读页', what: '顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸', openBook: true },
|
||||
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码', openBook: true, tapCenter: true },
|
||||
{ name: '04-panel-toc', label: '目录 · 半屏', what: '下半屏,上半屏正文仍可见', openBook: true, openPanel: '目录' },
|
||||
{ name: '05-panel-settings', label: '阅读设置 · 半屏', what: '一行式行:标签在左、控件在右', openBook: true, openPanel: '阅读设置' },
|
||||
{ name: '06-reader-paged', label: '阅读页 · 翻页模式', what: '分栏后的一页,页数应与可翻次数一致', openBook: true, choose: ['阅读设置', '翻页'], closePanel: true },
|
||||
{ name: '05-panel-settings', label: '阅读设置 · 半屏', what: '一行式行:标签在左、控件在右', openBook: true, openPanel: '设置' },
|
||||
{ name: '06-reader-paged', label: '阅读页 · 翻页模式', what: '分栏后的一页,页数应与可翻次数一致', openBook: true, choose: ['设置', '翻页'], closePanel: true },
|
||||
{ name: '07-reader-sepia', label: '阅读页 · 米黄', what: '主题切换后的同一页', openBook: true, theme: '米黄' },
|
||||
{ name: '08-reader-dark', label: '阅读页 · 夜间', what: '暗色下的正文与工具栏', openBook: true, theme: '夜间' },
|
||||
{
|
||||
@@ -51,7 +51,7 @@ const SCENES = [
|
||||
label: 'TXT · 正文排版',
|
||||
what: '纯文本专属的缩进/段间距/编码三行,且面板仍是半屏',
|
||||
openBook: true,
|
||||
openPanel: '阅读设置',
|
||||
openPanel: '设置',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -97,6 +97,19 @@ async function audit(cdp, scenes, results) {
|
||||
stageBottom: Math.round(s.bottom),
|
||||
viewportHeight: window.innerHeight,
|
||||
rail: !!document.querySelector('.reader-rail'),
|
||||
indicator: (() => {
|
||||
const el = document.querySelector('.reading-indicator');
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
const chapter = el.querySelector('.indicator-chapter');
|
||||
const progress = el.querySelector('.indicator-progress');
|
||||
return {
|
||||
visible: getComputedStyle(el).visibility !== 'hidden' && r.height > 0,
|
||||
pointerEvents: getComputedStyle(el).pointerEvents,
|
||||
chapter: chapter ? chapter.textContent.trim() : '',
|
||||
progress: progress ? progress.textContent.trim() : '',
|
||||
};
|
||||
})(),
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
@@ -119,6 +132,29 @@ async function audit(cdp, scenes, results) {
|
||||
!hidden.missing && hidden.rail,
|
||||
hidden.missing ? 'n/a' : `rail=${hidden.rail}`,
|
||||
);
|
||||
// Immersion keeps the *where am I* readouts. Hiding the chrome should leave a
|
||||
// reader who knows which chapter they are in and how far through — not a blank
|
||||
// screen they have to tap to interrogate.
|
||||
check(
|
||||
'收起工具栏: 保留章节与页码指示',
|
||||
!hidden.missing &&
|
||||
hidden.indicator !== null &&
|
||||
hidden.indicator.visible &&
|
||||
hidden.indicator.chapter.length > 0 &&
|
||||
hidden.indicator.progress.length > 0,
|
||||
hidden.missing
|
||||
? 'n/a'
|
||||
: hidden.indicator
|
||||
? `chapter="${hidden.indicator.chapter}" progress="${hidden.indicator.progress}"`
|
||||
: '没有找到阅读指示',
|
||||
);
|
||||
// The indicator is a readout, not a control: a tap on it has to fall through to
|
||||
// the page-turning zone underneath, or hiding the chrome would create a dead band.
|
||||
check(
|
||||
'收起工具栏: 阅读指示不吃手势',
|
||||
!hidden.missing && hidden.indicator !== null && hidden.indicator.pointerEvents === 'none',
|
||||
hidden.missing ? 'n/a' : `pointer-events=${hidden.indicator?.pointerEvents ?? 'missing'}`,
|
||||
);
|
||||
// Back to the chrome: the panel checks that follow need a page they can tap, and
|
||||
// the screen is left in the state a reader spends most of their time in.
|
||||
await cdp.tapMiddle();
|
||||
@@ -172,6 +208,35 @@ async function audit(cdp, scenes, results) {
|
||||
}
|
||||
}
|
||||
|
||||
// The footer scrubber has to be a real control: visible, at least the width of a
|
||||
// thumb, reachable by keyboard, and labelled. A progress bar that only *reports*
|
||||
// is the thing this replaced, so the check is that it can be dragged.
|
||||
const scrubber = await cdp.evaluate(`(() => {
|
||||
const el = document.querySelector('.progress-scrubber');
|
||||
if (!el) return { missing: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
type: el.type,
|
||||
width: Math.round(r.width),
|
||||
height: Math.round(r.height),
|
||||
label: el.getAttribute('aria-label') ?? '',
|
||||
page: document.querySelector('.progress-page')?.textContent.trim() ?? '',
|
||||
nav: document.querySelector('.chapter-nav .nav-progress')?.textContent.trim() ?? '',
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'底栏: 进度是可拖动的滑杆',
|
||||
!scrubber.missing && scrubber.type === 'range' && scrubber.width >= 120 && scrubber.label.length > 0,
|
||||
scrubber.missing
|
||||
? '没有找到滑杆'
|
||||
: `type=${scrubber.type} 宽 ${scrubber.width}px label="${scrubber.label}"`,
|
||||
);
|
||||
check(
|
||||
'底栏: 有页码与阅读进度读数',
|
||||
!scrubber.missing && scrubber.page.length > 0 && scrubber.nav.length > 0,
|
||||
scrubber.missing ? 'n/a' : `page="${scrubber.page}" nav="${scrubber.nav}"`,
|
||||
);
|
||||
|
||||
// The status line must not change the page's geometry. Measured by moving it
|
||||
// from empty to a message and back, and comparing the reading column's top.
|
||||
const statusAudit = await cdp.evaluate(`(() => {
|
||||
@@ -310,7 +375,7 @@ async function main() {
|
||||
}
|
||||
const theme = scene.theme ?? (scene.openBook ? '白' : null);
|
||||
if (theme) {
|
||||
await cdp.click('button[aria-label="阅读设置"]');
|
||||
await cdp.click('button[aria-label="设置"]');
|
||||
await cdp.waitFor('document.querySelector(".panel") !== null');
|
||||
await cdp.clickText('.segmented button', theme);
|
||||
await cdp.sleep(250);
|
||||
|
||||
@@ -33,13 +33,13 @@ export const SCENES = [
|
||||
{
|
||||
name: 'reader-light',
|
||||
label: '阅读页 · 白',
|
||||
note: '顶栏、正文、底部工具栏、状态药丸',
|
||||
note: '顶栏(图标+文字)、正文、底栏滑杆与章节导航、状态药丸',
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
name: 'reader-chrome-hidden',
|
||||
label: '阅读页 · 收起工具栏',
|
||||
note: '点中间三分之一收起后,正文应当占满,且没有布局跳动',
|
||||
note: '点中间三分之一收起后,正文占满、右侧留快捷列、角上留章节与页码',
|
||||
steps: [{ tap: { x: 'center', y: 'center' } }],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user