Files
reader/web/test/reader-paging.test.ts
T
hectorqin cdc95d8d27 fix(web,server): 阅读页可读性与面板、翻页、同步频率、事件层级
用户报的八条里,这一提交覆盖了除图标对齐之外的七条。每一条都先做成能复现的
测试或量出来的数字,再改。

## 2. txt 没有处理就渲染,没有默认样式,也无法调整

三层都缺,所以修了三层。

服务端新增 `chapter-html:<n>`,把纯文本渲染成带 p 的片段
(`indexer/formats/text-html.ts`)。空行断段;「上一行以句末标点结尾、下一行以
句子开头」也断段——抓取来的网文常常每行一段且没有空行,少了这条整本书是一块。
章节标题行提升成 h3,否则正文会变成「第二章 落雨雨来了。」。引用仍是
`chapter:<n>`(它同时是阅读位置的标识,改它会把现有读者打回第一章),新增
`format` 字段说明取哪一份;`chapter:` 的 256KB 截断也不再作用于这份渲染结果——
那个截断会把长章节切一半。

客户端新增 `TXT_STYLESHEET`,只在 `format === 'txt'` 时注入 shadow root,作用域
`.txt-body`,所以 EPUB 作者自己的样式一点不受影响。默认两字符首行缩进,用
`2em` 而不是 `2rem`:缩进要跟字号缩放。

阅读设置新增「正文」一节(段落缩进 / 段间距 / 编码),只在 TXT 上出现,前两项
是写在 stage 上的自定义属性,所以调整不需要重新注入章节。

## 3. 翻页异常,往前翻页还会来回跳

`previous()` 用 `scrollTop > 8` 判断「还有没有地方可退」,`next()` 用
`scrollTop < limit - 8` 判断「还有没有地方可进」,然后两边都以 0.9 屏步进。
这三个数字互不相容:forward 落到的位置,back 不认为那是边界。读者看到的就是
来回跳。

改成按**页序号**算术:整屏步进,页边界由滚动**范围**(`scrollHeight -
clientHeight`)而不是内容高度推出。这一个区别是页数和可翻次数对不上的原因——
2.31 屏的一章,按内容高度算 3 页并请求 offset 0/723/1446/2169,而 2169 会被
夹到 1674,也就是第 2 个边界,于是第四次按下的位置计数器不认。

`stepSection` 改成 await:它在跨章时是 fire-and-forget,而 `turnPage` 在
`previous()` 返回后就播放动画——动画演的是读者刚离开的那一章。

## 4. sync 接口会不停的重复渲染,sync 接口调用太过频繁

两处。`onPosition` 每个位置调三次 `patch()`,而 `patch` 会重绘整棵 chrome
树,滚动时每帧三次;合并成一次。

`flushProgress` 无条件 `syncNow()`,于是 1.5 秒一轮 POST+GET,读者只要在读就
一直发。`SyncEngine.schedule()` 去抖并**保证尾次**——没有尾次的话,读者翻完
最后一页就关书,那一页永远发不出去。

## 5/6. 布局样式,以及目录/设置改半屏

面板从 `inset: 0` 改成底部半屏 + 遮罩 + 把手,上半屏正文仍然看得见:选章节、
调字号都是对下面那一页的调整,全屏让读者为了做调整而离开那一页。

状态行从 flex 列里的一行改成浮在正文上的药丸。横条每出现一次就把正文推下去
一行再收回来,也就是把读者正在读的那句挪走。`ui:review` 有一条断言盯着它。

页脚改成两行不同职责:控制行居中、读数是三个固定位置的等宽数字。目录行改成
「序号 + 标题」,当前章高亮一次而不是每行都划线;当前窗口到不了的行标记出来
而不是静静地没反应。

## 7. 点击页面的事件处理层次

一次触摸只属于一个目标,最里面那个赢。两个真缺陷:

- 必须用 `composedPath()` 而不是 `event.target`。书的正文在 shadow root 里,
  shadow 边界会**重定向**事件,所以 target 是 shadow host——用 target 写不出
  「段落」和「同一个 shadow 树里的按钮」的区别。
- **阅读面自己是个滚动容器**。滚动模式就是让 shadow host 滚起来,于是「可滚动
  的祖先拿走手势」这条规则让它吃掉了每一个手势:翻页层级完全正确,但永远
  到不了。这是「点击页面没反应」的真正原因。

另外,按到控件的触摸不再被吞掉 click——之前工具栏按钮可见、可点、没反应。

面板打开时它拥有点击:旁边露出的那一条正文不翻页。

## 8. 端到端测试要做 UI 评审

新增 `web/tools/ui-review/`:真实 Chromium(390×844、2x)加载**生产产物**,
对着替身 API 逐屏截图并测量。截图给人看(「只是丑」不是测试失败,但是缺陷),
尺寸给流水线断言(面板不遮住整页、留出 ≥120px 正文、有遮罩和把手、状态提示
不推动正文、控件 ≥28px 且有名字、没有横向溢出)。接进 `client-ui-review`
流水线,只在界面相关文件变化时触发。

这套工具第一次跑就发现了四个真缺陷:`api.toc()` 返回形状不符时 `openStaged`
用 TypeError 打死整个 open(现在回退到 manifest 自己的条目);状态行一旦进入
loading 就再也清不掉(`hideStatus` 的守卫挡住了唯一的出口);翻页步进的 0.9 屏;
以及上面那条阅读面吃掉手势。

## 测试

web 351 项、server 206 项全过。新增:`gestures.test.ts`(事件层级优先级)、
`reader-paging.test.ts`(页序号算术,含子像素取整与滚动范围)、
`mount.test.ts`(单次渲染与卸载后丢弃更新)、`text-html.test.ts`(分段、
转义、标题提升、不截断)。
2026-09-18 12:39:45 +08:00

298 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { ReaderView, type ViewSettings } from '../src/ui/reader-view.ts';
import type { BookDoc } from '../src/formats/types.ts';
/**
* Page turns against a *laid-out* scroll container.
*
* jsdom reports every scroll extent as zero, so the interesting half of paging —
* "am I at the top yet", "which column am I in" — cannot be exercised on a bare
* jsdom element. This file installs a small scroll model instead: extents are
* real numbers, `scrollTop` assigns are rounded and clamped the way a browser
* rounds them, and `scrollLeft` does the same. Everything else about the view is
* the production code.
*
* The bugs it exists to catch are the ones that only appear *between* two
* measurements: a `previous()` that decides where it is by comparing `scrollTop`
* against a constant, and a `restoreOffset()` that writes an offset the same
* comparison then refuses to recognise.
*/
beforeAll(() => {
if (!URL.createObjectURL) {
Object.defineProperty(URL, 'createObjectURL', { value: () => 'blob:stub', writable: true });
}
if (!URL.revokeObjectURL) {
Object.defineProperty(URL, 'revokeObjectURL', { value: () => undefined, writable: true });
}
globalThis.ResizeObserver ??= class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
// `requestAnimationFrame` is used by `applySettings`; jsdom provides it, but the
// tests below never need the frame to have run.
globalThis.requestAnimationFrame ??= ((cb: FrameRequestCallback) => {
void cb;
return 0;
}) as typeof requestAnimationFrame;
});
/**
* A scrollable element with the geometry a browser would give it.
*
* `scrollTop`/`scrollLeft` are rounded to whole pixels on assignment, which is
* what Chromium does and exactly what makes a `> 8` comparison unreliable: a
* restore that computes 1000.4 lands on 1000, and a step that computes 0.4 lands
* on 0.
*/
function scrollable(element: HTMLElement, geometry: { clientHeight: number; scrollHeight: number; clientWidth?: number; scrollWidth?: number }): void {
const state = { top: 0, left: 0 };
Object.defineProperty(element, 'clientHeight', { get: () => geometry.clientHeight, configurable: true });
Object.defineProperty(element, 'scrollHeight', { get: () => geometry.scrollHeight, configurable: true });
Object.defineProperty(element, 'clientWidth', { get: () => geometry.clientWidth ?? 400, configurable: true });
Object.defineProperty(element, 'scrollWidth', { get: () => geometry.scrollWidth ?? 400, configurable: true });
Object.defineProperty(element, 'scrollTop', {
get: () => Math.round(state.top),
set: (value: number) => {
state.top = Math.min(Math.max(0, value), Math.max(0, geometry.scrollHeight - geometry.clientHeight));
},
configurable: true,
});
Object.defineProperty(element, 'scrollLeft', {
get: () => Math.round(state.left),
set: (value: number) => {
state.left = Math.min(Math.max(0, value), Math.max(0, (geometry.scrollWidth ?? 400) - (geometry.clientWidth ?? 400)));
},
configurable: true,
});
}
function doc(sectionCount: number, override: Partial<BookDoc> = {}): BookDoc {
return {
format: 'epub',
layout: 'reflowable',
render: 'reflowable',
direction: 'ltr',
sections: Array.from({ length: sectionCount }, (_v, i) => ({
id: `ch${i}.xhtml`,
label: `第 ${i + 1} 章`,
html: '<p>正文</p>',
depth: 0,
})),
toc: [],
styles: [],
resources: new Map(),
orderedByBook: true,
...override,
};
}
const SCREEN = 800;
/** A view whose chapter is `screens` viewports tall, in scroll mode. */
function scrollingView(container: HTMLDivElement, screens: number, sectionCount = 4): ReaderView {
const view = new ReaderView({ container, doc: doc(sectionCount) });
const element = view.elementHost;
scrollable(element, { clientHeight: SCREEN, scrollHeight: SCREEN * screens });
return view;
}
/** A view whose chapter is `columns` viewport widths wide, in paged mode. */
function pagedView(container: HTMLDivElement, columns: number, sectionCount = 4): ReaderView {
const view = new ReaderView({ container, doc: doc(sectionCount) });
const settings: Partial<ViewSettings> = { mode: 'paged' };
view.applySettings(settings);
const flow = view.elementHost.flow;
scrollable(flow, {
clientHeight: SCREEN,
scrollHeight: SCREEN,
clientWidth: 400,
scrollWidth: 400 * columns,
});
return view;
}
describe('page turns in scroll mode', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
});
it('walks back page by page and reports the page it is on', async () => {
// The regression: `previous()` decided whether it had room by comparing
// `scrollTop` against 8, then *subtracted* 0.9 of a screen. The two numbers do
// not agree, so the offsets a *forward* press left behind were not offsets a
// *backward* press recognised as boundaries — the page appeared to bounce.
//
// A chapter of 2.6 screens was the case that made it visible: the partial last
// screen meant the 0.9 arithmetic left a remainder above the threshold, so an
// extra press slid back down instead of crossing the chapter.
const view = scrollingView(container, 2.6);
await view.open(1, 1);
// Offset 1 is the end of the chapter, which is its last *screen*, not its
// bottom edge: the reader sees the top of page 3.
expect(view.position().pageInChapter).toBe(3);
expect(view.elementHost.scrollTop).toBeGreaterThan(0);
await view.previous();
expect(view.position().pageInChapter).toBe(2);
await view.previous();
expect(view.position().pageInChapter).toBe(1);
expect(view.elementHost.scrollTop).toBe(0);
// Now, and only now, is there nothing left to go back to inside this chapter.
expect(view.currentSectionIndex()).toBe(1);
await view.previous();
expect(view.currentSectionIndex()).toBe(0);
});
it('takes as many presses to cross a chapter as the footer says it has pages', async () => {
// The number in the footer and the number of presses remaining are the same
// measurement; if they come from different arithmetic the reader counts presses
// and finds one missing.
const view = scrollingView(container, 3);
await view.open(1, 0);
const pages = view.position().chapterPages;
expect(pages).toBe(3);
// Three screens: from page 1, two forward presses reach page 3, and the next
// one crosses the boundary rather than sliding half a page further.
const visited: number[] = [];
for (let press = 0; press < pages; press += 1) {
visited.push(view.position().pageInChapter);
await view.next();
}
expect(visited).toEqual([1, 2, 3]);
expect(view.currentSectionIndex()).toBe(2);
});
it('does not move backwards past the top of the chapter', async () => {
const view = scrollingView(container, 2);
await view.open(1, 1);
await view.previous();
expect(view.elementHost.scrollTop).toBe(0);
// Already at the top: the next press must cross the chapter boundary rather
// than nudging the scroll by a fraction of a pixel and counting that as a move.
const before = view.elementHost.scrollTop;
const moved = await view.previous();
expect(moved).toBe(true);
expect(view.currentSectionIndex()).toBe(0);
expect(before).toBe(0);
});
it('never leaves the reader at a scroll offset that still has room to go back into', async () => {
// The "goes forward, then jumps back" symptom. Every offset a page turn leaves
// behind has to be one the *opposite* turn agrees is a page boundary, or two
// presses in a row undo each other.
const view = scrollingView(container, 3);
await view.open(1, 0);
for (let i = 0; i < 3; i += 1) await view.next();
expect(view.currentSectionIndex()).toBe(2);
// Walking back must retrace the same number of steps, not fewer.
for (let i = 0; i < 3; i += 1) await view.previous();
expect(view.currentSectionIndex()).toBe(1);
});
it('lands on the last screen of the previous chapter going back', async () => {
// `stepSection(-1, true)` asks for offset 1, which is the *end* of the chapter
// — the screen a reader who pressed "back" at the top of chapter two expects to
// see, not the first screen of chapter one.
const view = scrollingView(container, 3);
await view.open(1, 0);
await view.previous();
expect(view.currentSectionIndex()).toBe(0);
expect(view.elementHost.scrollTop).toBe(SCREEN * 3 - SCREEN);
});
it('steps forward by a whole screen, not by 90% of one', async () => {
// A 0.9-of-a-screen step leaves a sliver of the previous screen on every page,
// which reads as "the page did not turn". A whole screen is what every reader
// means by a page.
const view = scrollingView(container, 4);
await view.open(1, 0);
await view.next();
expect(view.elementHost.scrollTop).toBe(SCREEN);
});
});
describe('page turns in paged mode', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
});
it('advances one column and stops at the last one', async () => {
const view = pagedView(container, 3);
await view.open(1, 0);
const flow = view.elementHost.flow;
expect(flow.scrollLeft).toBe(0);
await view.next();
expect(flow.scrollLeft).toBe(400);
await view.next();
expect(flow.scrollLeft).toBe(800);
await view.next();
// No fourth column: the turn crosses into the next chapter.
expect(view.currentSectionIndex()).toBe(2);
});
it('walks back to the first column without overshooting into the previous chapter', async () => {
// The reported symptom, in paged mode: `stepColumn(-1)` computed the target
// from `clientWidth`, while the layout's stride includes the column gap, so the
// target drifted off the boundary and a *back* press could land on a position
// the forward press did not recognise — the page appeared to bounce.
const view = pagedView(container, 3);
await view.open(1, 0);
const flow = view.elementHost.flow;
await view.next();
await view.next();
expect(flow.scrollLeft).toBe(800);
await view.previous();
expect(flow.scrollLeft).toBe(400);
await view.previous();
expect(flow.scrollLeft).toBe(0);
await view.previous();
expect(view.currentSectionIndex()).toBe(0);
});
it('counts pages from the scrollable range, not from the content height', async () => {
// The subtle one. A chapter taller than a whole number of screens has a scroll
// range smaller than its content height, and a page count computed from the
// *content* asks for offsets past the end: the last one clamps, so the final
// press leaves the reader on a page the counter does not admit exists, and the
// press after that crosses the chapter from what the footer still calls page 3.
//
// 2.31 screens: the range is 1.31 screens, so the pages are 1, 2 and 3 — and
// three presses have to reach the end.
const view = scrollingView(container, 2.31);
await view.open(1, 0);
const total = view.position().chapterPages;
expect(total).toBe(3);
for (let page = 2; page <= total; page += 1) {
await view.next();
expect(view.position().pageInChapter).toBe(page);
}
// On the last page, and the *next* press crosses the chapter rather than
// sliding to a position the counter cannot name.
expect(view.currentSectionIndex()).toBe(1);
await view.next();
expect(view.currentSectionIndex()).toBe(2);
});
it('reports a page count that matches where the turns land', async () => {
// The footer's "page 2/3" and the number of presses it takes to reach the end
// are the same measurement; a chapter reported as 3 pages that needs 4 presses
// is the bug, and this is the assertion that ties them together.
const view = pagedView(container, 3);
await view.open(1, 0);
expect(view.position().chapterPages).toBe(3);
for (let page = 2; page <= 3; page += 1) {
await view.next();
expect(view.position().pageInChapter).toBe(page);
}
});
});