mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 14:01:00 +08:00
用户报的八条里,这一提交覆盖了除图标对齐之外的七条。每一条都先做成能复现的 测试或量出来的数字,再改。 ## 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`(分段、 转义、标题提升、不截断)。
244 lines
9.7 KiB
TypeScript
244 lines
9.7 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { attachGestures, type GestureHandlers } from '../src/ui/gestures.ts';
|
||
|
||
/**
|
||
* The click/gesture layering.
|
||
*
|
||
* This is the layer that decides what a touch *means*, and the reported symptom
|
||
* was the one every reader hits first: tapping a control also turned the page. The
|
||
* contract it has to keep is a priority order, and the tests below are written as
|
||
* that order rather than as a list of cases:
|
||
*
|
||
* 1. a control the reader actually touched owns the gesture, whatever it is;
|
||
* 2. otherwise a drag is a swipe;
|
||
* 3. otherwise a short press is a tap in one of the three zones.
|
||
*
|
||
* Everything here is a *pointer* sequence, because the layer tracks pointers: a
|
||
* `click` arrives after a drag that ended on a link, which is exactly why a tap
|
||
* cannot be recognised from it.
|
||
*/
|
||
|
||
/**
|
||
* A pointer event jsdom will accept and the layer can read.
|
||
*
|
||
* `composed: true` is not optional. The book's markup lives in a shadow root, and a
|
||
* real browser composes every pointer event across that boundary — which is the
|
||
* only reason the stage's own listener sees a tap on a paragraph at all. A test
|
||
* event without it simply never arrives, and the suite would then be asserting
|
||
* about a gesture layer that was never called.
|
||
*/
|
||
function pointer(type: string, init: PointerEventInit): PointerEvent {
|
||
return new window.PointerEvent(type, {
|
||
bubbles: true,
|
||
composed: true,
|
||
cancelable: true,
|
||
pointerType: 'touch',
|
||
...init,
|
||
});
|
||
}
|
||
|
||
interface Harness {
|
||
stage: HTMLDivElement;
|
||
saw: {
|
||
zones: string[];
|
||
swipes: string[];
|
||
drags: number;
|
||
longPresses: number;
|
||
};
|
||
handlers: GestureHandlers;
|
||
}
|
||
|
||
/** A stage 300px wide with a 300px-wide, 3-zone tap model. */
|
||
function harness(options: { selection?: boolean; width?: number } = {}): Harness {
|
||
const width = options.width ?? 300;
|
||
const stage = document.createElement('div');
|
||
document.body.append(stage);
|
||
// jsdom has no layout, so the zone arithmetic reads a stubbed width.
|
||
Object.defineProperty(stage, 'clientWidth', { get: () => width, configurable: true });
|
||
stage.getBoundingClientRect = () => ({ left: 0, top: 0, width, height: 600, right: width, bottom: 600, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
|
||
|
||
const saw = { zones: [] as string[], swipes: [] as string[], drags: 0, longPresses: 0 };
|
||
const handlers: GestureHandlers = {
|
||
onTapZone: (zone) => saw.zones.push(zone),
|
||
onSwipe: (direction) => saw.swipes.push(direction),
|
||
onDragDelta: () => { saw.drags += 1; },
|
||
onLongPress: () => { saw.longPresses += 1; },
|
||
hasSelection: () => options.selection === true,
|
||
};
|
||
attachGestures(stage, handlers);
|
||
return { stage, saw, handlers };
|
||
}
|
||
|
||
/** Presses, moves if told to, and releases. */
|
||
function press(target: EventTarget, from: [number, number], to?: [number, number], holdMs = 40): void {
|
||
const start = Date.now();
|
||
target.dispatchEvent(pointer('pointerdown', { clientX: from[0], clientY: from[1] }));
|
||
if (to) target.dispatchEvent(pointer('pointermove', { clientX: to[0], clientY: to[1] }));
|
||
// The layer measures duration with `Date.now()`, so the clock has to move by the
|
||
// intended hold — otherwise a "tap" is however long the suite took to get here.
|
||
vi.setSystemTime(start + holdMs);
|
||
target.dispatchEvent(pointer('pointerup', { clientX: to?.[0] ?? from[0], clientY: to?.[1] ?? from[1] }));
|
||
}
|
||
|
||
beforeEach(() => {
|
||
// `Date` is faked as well as the timers, because the layer measures duration with
|
||
// `Date.now()`: with only the timer queue faked, a "tap" in a slow suite is
|
||
// hundreds of milliseconds long and stops being a tap.
|
||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
|
||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||
document.body.replaceChildren();
|
||
});
|
||
|
||
describe('tap zones', () => {
|
||
it('turns the page on the outer thirds and toggles the chrome in the middle', () => {
|
||
const h = harness();
|
||
press(h.stage, [40, 300]);
|
||
press(h.stage, [150, 300]);
|
||
press(h.stage, [260, 300]);
|
||
expect(h.saw.zones).toEqual(['previous', 'toggle-chrome', 'next']);
|
||
});
|
||
|
||
it('does not turn a page when the tap lands on a control inside the stage', () => {
|
||
// The bug: the gesture layer treats *every* pointer it sees on the stage as a
|
||
// page turn, so a tap on a button that lives inside the reading surface — a
|
||
// footnote link, a control in an injected panel — also turned the page behind
|
||
// it. The reader pressed one thing and two happened.
|
||
const h = harness();
|
||
const button = document.createElement('button');
|
||
h.stage.append(button);
|
||
press(button, [40, 300]);
|
||
expect(h.saw.zones).toEqual([]);
|
||
});
|
||
|
||
it('does not turn a page when the tap lands on a link inside the book', () => {
|
||
// A footnote is an `<a>`, and an `<a>` inside the shadow root is still the
|
||
// reader's target. The check therefore has to cross the shadow boundary.
|
||
const h = harness();
|
||
const host = document.createElement('div');
|
||
h.stage.append(host);
|
||
const shadow = host.attachShadow({ mode: 'open' });
|
||
const link = document.createElement('a');
|
||
link.href = '#note1';
|
||
shadow.append(link);
|
||
press(link, [260, 300]);
|
||
expect(h.saw.zones).toEqual([]);
|
||
});
|
||
|
||
it('does not turn a page when the tap lands on scrollable book text', () => {
|
||
// Plain text is *not* a control: a tap on a paragraph is a page turn, which is
|
||
// the whole model. Stating it keeps the "controls win" rule from being
|
||
// over-applied into "nothing inside the stage ever turns a page".
|
||
const h = harness();
|
||
const host = document.createElement('div');
|
||
h.stage.append(host);
|
||
const shadow = host.attachShadow({ mode: 'open' });
|
||
const paragraph = document.createElement('p');
|
||
paragraph.textContent = '正文';
|
||
shadow.append(paragraph);
|
||
press(paragraph, [260, 300]);
|
||
expect(h.saw.zones).toEqual(['next']);
|
||
});
|
||
|
||
it('leaves a range input alone, which is how the panels are built', () => {
|
||
const h = harness();
|
||
const input = document.createElement('input');
|
||
input.type = 'range';
|
||
h.stage.append(input);
|
||
press(input, [150, 300]);
|
||
expect(h.saw.zones).toEqual([]);
|
||
expect(h.saw.swipes).toEqual([]);
|
||
});
|
||
|
||
it('honours the reader’s handedness by leaving the mapping to the caller', () => {
|
||
// The layer reports *sides*, not directions. Handedness is the screen's
|
||
// business; a layer that reversed them itself would have to know a setting it
|
||
// has no access to, and would be wrong for a left-handed reader who never
|
||
// opened the settings panel.
|
||
const h = harness();
|
||
press(h.stage, [40, 300]);
|
||
expect(h.saw.zones).toEqual(['previous']);
|
||
});
|
||
});
|
||
|
||
describe('swipes', () => {
|
||
it('reports a mostly-horizontal drag as a horizontal swipe', () => {
|
||
const h = harness();
|
||
press(h.stage, [250, 300], [50, 320]);
|
||
expect(h.saw.swipes).toEqual(['left']);
|
||
});
|
||
|
||
it('reports a mostly-vertical drag as a vertical swipe', () => {
|
||
// A diagonal drag is almost always an attempt to scroll; treating it as a page
|
||
// turn makes a reflowable book feel like it is fighting back.
|
||
const h = harness();
|
||
press(h.stage, [150, 500], [180, 200]);
|
||
expect(h.saw.swipes).toEqual(['up']);
|
||
});
|
||
|
||
it('ignores a drag too short to be a swipe', () => {
|
||
const h = harness();
|
||
press(h.stage, [150, 300], [170, 300]);
|
||
expect(h.saw.swipes).toEqual([]);
|
||
expect(h.saw.zones).toEqual([]);
|
||
});
|
||
|
||
it('suppresses the click that follows a swipe', () => {
|
||
// The browser fires `click` after a drag that ended on a link. Without the
|
||
// suppression a swipe that happens to end on a footnote opens it.
|
||
const h = harness();
|
||
const link = document.createElement('a');
|
||
h.stage.append(link);
|
||
press(link, [250, 300], [50, 300]);
|
||
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||
link.dispatchEvent(click);
|
||
expect(click.defaultPrevented).toBe(true);
|
||
expect(h.saw.swipes).toEqual(['left']);
|
||
});
|
||
});
|
||
|
||
describe('selection and long press', () => {
|
||
it('stands aside while a selection is active', () => {
|
||
// Long-press selection is how a reader copies a sentence; a page turn on the
|
||
// release would lose the selection they just made.
|
||
const h = harness({ selection: true });
|
||
press(h.stage, [260, 300]);
|
||
expect(h.saw.zones).toEqual([]);
|
||
expect(h.saw.swipes).toEqual([]);
|
||
});
|
||
|
||
it('turns a long press into a long press, not a page turn', () => {
|
||
const h = harness();
|
||
const start = Date.now();
|
||
h.stage.dispatchEvent(pointer('pointerdown', { clientX: 260, clientY: 300 }));
|
||
vi.setSystemTime(start + 600);
|
||
vi.advanceTimersByTime(600);
|
||
expect(h.saw.longPresses).toBe(1);
|
||
h.stage.dispatchEvent(pointer('pointerup', { clientX: 260, clientY: 300 }));
|
||
expect(h.saw.zones).toEqual([]);
|
||
});
|
||
|
||
it('lets a control keep its own click when the reader tapped it', () => {
|
||
// The other half of "controls win": the gesture layer suppresses the click that
|
||
// follows a page turn, and it must not suppress the click a *button* is waiting
|
||
// for. Otherwise the toolbar is visible, tappable, and does nothing.
|
||
const h = harness();
|
||
const button = document.createElement('button');
|
||
h.stage.append(button);
|
||
let clicks = 0;
|
||
button.addEventListener('click', () => { clicks += 1; });
|
||
press(button, [260, 300]);
|
||
button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||
expect(clicks).toBe(1);
|
||
expect(h.saw.zones).toEqual([]);
|
||
});
|
||
|
||
it('cancels a pending long press once the finger moves', () => {
|
||
const h = harness();
|
||
h.stage.dispatchEvent(pointer('pointerdown', { clientX: 260, clientY: 300 }));
|
||
h.stage.dispatchEvent(pointer('pointermove', { clientX: 260, clientY: 400 }));
|
||
vi.advanceTimersByTime(600);
|
||
expect(h.saw.longPresses).toBe(0);
|
||
});
|
||
});
|