Files
reader/web/test/gestures.test.ts
hectorqinandClaude Opus 5.5 a904bb5a83 feat(web): 点击区域设置 — a 3×3 tap grid with a per-cell action
The two-way 左退右进 / 左进右退 switch becomes nine cells, each 上一页, 下一页,
菜单, 上一章, 下一章 or 无操作, edited as a small page in 阅读设置 → 翻页 with
左右互换 and 恢复默认. The default grid is the old standard layout and a stored
`tapZone: 'reversed'` is folded into its grid. At least one 菜单 cell is kept,
right-to-left books mirror the grid, and the gesture layer now reports the row
as well as the column, from layout geometry like before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:27:51 +00:00

324 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 cell of the 3×3 grid (the zone
* names below are the default grid's columns).
*
* 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[];
cells: Array<[number, number]>;
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[], cells: [] as Array<[number, number]>, swipes: [] as string[], drags: 0, longPresses: 0 };
const handlers: GestureHandlers = {
onTapZone: (column, row) => {
saw.zones.push(['previous', 'toggle-chrome', 'next'][column]!);
saw.cells.push([column, row]);
},
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('reports the row as well, so each of the nine cells can mean something', () => {
const h = harness();
press(h.stage, [40, 100]);
press(h.stage, [150, 300]);
press(h.stage, [260, 500]);
expect(h.saw.cells).toEqual([[0, 0], [1, 1], [2, 2]]);
});
it('reads the zones from layout geometry, so a scaled page still taps where the finger is', () => {
// The failure this closes is a *half* of the tap area, and it is invisible at
// 100% zoom: `getBoundingClientRect()` and `event.clientX` are both in visual
// viewport units, so under a pinch zoom the border box and the finger disagree
// while the zone arithmetic happily accepts the tap. The reader taps the middle
// third to hide the toolbar and turns a page instead.
//
// Stated as a *scaled* rect: the layout box is 300px and on screen it is
// reported as 600px wide, which is what a page at 2× looks like to this layer.
// A tap at the visual centre of the screen must be the *middle* zone, and the
// two taps at the outer quarters must be the outer zones — with the old
// arithmetic, which compared a visual offset against a layout width, the first
// of the three landed in the middle and the whole row shifted by one.
const h = harness();
// `offsetWidth` is what the zones divide; the rect is where the element *is*.
Object.defineProperty(h.stage, 'offsetWidth', { get: () => 300, configurable: true });
h.stage.getBoundingClientRect = () =>
({ left: 0, top: 0, width: 600, height: 1200, right: 600, bottom: 1200, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
// The tap coordinates are in *visual* units and the zones are in layout units.
// A third of the 300px layout box is 100px of *layout* and 200px of screen, so
// 150 is inside the first third, 300 (the visible centre) is the middle, and 450
// is the last.
press(h.stage, [150, 300]);
press(h.stage, [300, 300]);
press(h.stage, [450, 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);
});
});
describe('a drag the screen follows', () => {
it('passes the pointer type with every move', () => {
const types: string[] = [];
const h = harness();
h.handlers.onDragDelta = (_x, _y, type) => { types.push(type); };
press(h.stage, [200, 300], [80, 300]);
expect(types).toEqual(['touch']);
});
it('lets a consumed release stand instead of also counting as a swipe', () => {
const released: number[] = [];
const h = harness();
h.handlers.onRelease = (deltaX) => { released.push(deltaX); return true; };
press(h.stage, [200, 300], [80, 300]);
expect(released).toEqual([-120]);
expect(h.saw.swipes).toEqual([]);
expect(h.saw.zones).toEqual([]);
});
it('still swipes and taps when the release was not consumed', () => {
const h = harness();
h.handlers.onRelease = () => false;
press(h.stage, [200, 300], [80, 300]);
press(h.stage, [260, 300]);
expect(h.saw.swipes).toEqual(['left']);
expect(h.saw.zones).toEqual(['next']);
});
it('springs a drag back when the browser cancels the pointer', () => {
const released: Array<[number, number]> = [];
const h = harness();
h.handlers.onRelease = (deltaX, _y, duration) => { released.push([deltaX, duration]); return true; };
h.stage.dispatchEvent(pointer('pointerdown', { clientX: 200, clientY: 300 }));
h.stage.dispatchEvent(pointer('pointermove', { clientX: 120, clientY: 300 }));
h.stage.dispatchEvent(pointer('pointercancel', { clientX: 120, clientY: 300 }));
expect(released).toEqual([[0, Number.POSITIVE_INFINITY]]);
});
});