Files
reader/web/src/ui/gestures.ts
T
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

333 lines
16 KiB
TypeScript
Raw 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.
/**
* Touch handling, isolated from the view so it can be reasoned about alone.
*
* The rules that matter on a phone, and why:
*
* - A tap is reported as a cell of a 3×3 grid over the stage; what each cell
* does is the reader's 点击区域 setting (`tap-zones.ts`), by default the
* convention every mobile reader uses: the middle column toggles the chrome,
* the outer columns turn the page.
* - A swipe must be mostly horizontal to count. A diagonal drag is almost
* always an attempt to scroll, and treating it as a page turn makes a
* reflowable book feel like it is fighting back.
* - Distinguishing a tap from a drag cannot use `click`: on a scrollable page
* the browser fires `click` after a drag that ended on a link. The gesture
* layer tracks movement itself and suppresses the click.
* - Pinch-to-zoom is deliberately not implemented. Text size belongs to the
* reader's own control, and a visual zoom fights CSS pagination.
*
* ## One gesture belongs to one target, and the innermost one wins
*
* This layer sits on the *stage* — the whole reading surface — so it sees every
* touch, including the ones aimed at things drawn on top of it. Turning all of
* them into page turns is what made a tap on a footnote open the footnote *and*
* turn the page behind it, and it is the reason this file has a hit test at all.
*
* The test is `composedPath()` rather than `event.target`, because the book lives
* in a shadow root and a shadow boundary *retargets* the event: a tap on a `<p>`
* inside the book arrives at this listener with the shadow host as its target, so
* a check written against `target` cannot tell a paragraph from a button inside
* the same shadow tree. The composed path is the whole chain — the real element
* first, then its ancestors, across every shadow boundary — which is the only
* view from which "what did the reader actually touch" has an answer.
*/
export interface GestureHandlers {
/** A tap, as the cell of a 3×3 grid it fell in: `column` and `row` are each 0–2. */
onTapZone(column: number, row: number): void;
onSwipe(direction: 'left' | 'right' | 'up' | 'down'): void;
/** Fires continuously while a finger is down and moving; paged mode follows it. */
onDragDelta(deltaX: number, deltaY: number, pointerType: string): void;
/**
* The pointer lifted (or the browser cancelled it). Return true when a drag the
* screen was following has already decided the gesture, so it is not also read as a
* swipe or a tap — the page that followed the finger has turned, or sprung back.
*/
onRelease?(deltaX: number, deltaY: number, duration: number): boolean;
onLongPress(x: number, y: number): void;
/** True when a text selection is active: gestures must then stand aside. */
hasSelection(): boolean;
}
const TAP_MAX_DISTANCE = 12;
// A tap is decided by the finger staying put; the time limit only has to stay under
// the long press. At 260 ms a deliberate tap (an older reader, an e-ink screen's slow
// panel) fell between tap and long press and did nothing at all.
const TAP_MAX_DURATION = 450;
const SWIPE_MIN_DISTANCE = 48;
const SWIPE_MAX_OFF_AXIS = 0.8;
const LONG_PRESS_MS = 500;
/**
* Elements that own a gesture outright.
*
* An interactive control the reader touched is the thing they meant, and the layer
* must not reinterpret the touch as a page turn: a link is a footnote or a
* cross-reference, a button is a control, an input is a control that also *drags*
* (a range thumb is how the read-aloud scrubber is used, and turning the page on
* every scrub would make it unusable).
*
* `label` and `summary` are in the list because they are controls too; `[role]`
* covers the panel's composite widgets; `contenteditable` covers a text field the
* book brought with it.
*/
const INTERACTIVE = [
'a[href]',
'button',
'input',
'select',
'textarea',
'label',
'summary',
'[role="button"]',
'[role="slider"]',
'[role="link"]',
'[contenteditable="true"]',
].join(',');
/**
* Whether the touch landed on something that owns it.
*
* The path is walked innermost-first and the first element that is either
* interactive or a *nested* scroller decides. Reaching the stage means nothing was
* in the way, which is the case where a tap is a page turn.
*/
function ownsGesture(path: EventTarget[], stage: HTMLElement, skipScrollable: ReadonlySet<Element>): boolean {
for (const node of path) {
if (!(node instanceof Element)) continue;
if (node === stage) return false;
// `matches` on the element itself, not `closest`: `closest` would walk up out
// of the element's own tree and find an ancestor control in a *different*
// subtree, which would make a tap on a paragraph inside a link-styled section
// behave like a tap on the link.
if (node.matches(INTERACTIVE)) return true;
// Any *nested* scrollable area also owns the touch — a horizontally scrollable
// table inside a chapter, an embedded overflow box. Hijacking those is how a
// reader loses the ability to pan them.
//
// "Nested" is the load-bearing word. The reading surface itself is a scroll
// container (that is what scroll mode *is*), and it sits on the path between
// the book's markup and the stage, so a rule that asked only "is it scrollable"
// made the reading surface claim every gesture it received. Every tap then
// belonged to a scroller and turned no pages — the layers below were correct and
// unreachable, which is the hardest kind of layering bug to see from the code.
if (node instanceof HTMLElement && !skipScrollable.has(node) && isScrollable(node)) return true;
}
return false;
}
/** True when an element scrolls its own content on either axis. */
function isScrollable(element: HTMLElement): boolean {
const style = element.ownerDocument.defaultView?.getComputedStyle(element);
if (!style) return false;
const scrolls = (value: string): boolean => value === 'auto' || value === 'scroll';
const vertical = scrolls(style.overflowY) && element.scrollHeight > element.clientHeight + 1;
const horizontal = scrolls(style.overflowX) && element.scrollWidth > element.clientWidth + 1;
return vertical || horizontal;
}
/**
* The scrollers that are not "a nested scroller".
*
* The reading surface, found from the stage rather than passed in: it is the shadow
* host the reader injects the book into, and it is the *only* element between the
* markup and the stage that is deliberately scrollable. Discovering it here keeps
* the caller from having to know, and keeps this file's rule — "a nested scroller
* outranks a page turn" — true for every scroller that is genuinely nested.
*/
function readingSurface(stage: HTMLElement): Element[] {
const found: Element[] = [];
for (const host of stage.querySelectorAll('*')) {
if (host.shadowRoot) found.push(host);
}
// A host with no shadow root yet (the book is still loading) still exists as a
// custom element; `book-content` is the tag the reader creates.
for (const host of stage.querySelectorAll('book-content')) {
if (!found.includes(host)) found.push(host);
}
return found;
}
export function attachGestures(element: HTMLElement, handlers: GestureHandlers): () => void {
let startX = 0;
let startY = 0;
let startTime = 0;
let tracking = false;
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let suppressClick = false;
/**
* True while the gesture belongs to a control rather than to this layer.
*
* Decided once on `pointerdown` and remembered, rather than re-tested on every
* `pointermove`: the path of a moving finger can end somewhere different from
* where it started, and re-deciding mid-drag would let a swipe that began on a
* control become a page turn halfway through.
*/
let owned = false;
const clearLongPress = (): void => {
if (longPressTimer === null) return;
clearTimeout(longPressTimer);
longPressTimer = null;
};
// Computed once per attachment: the reading surface is created before this layer
// is attached and does not change identity for the life of the screen.
const skipScrollable = new Set(readingSurface(element));
const onPointerDown = (event: PointerEvent): void => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
tracking = true;
suppressClick = false;
startX = event.clientX;
startY = event.clientY;
startTime = Date.now();
clearLongPress();
// A selection in progress outranks everything: the reader is choosing text,
// and any of these gestures would discard the choice they are making.
owned = handlers.hasSelection() || ownsGesture(event.composedPath(), element, skipScrollable);
// Deliberately *not* `suppressClick = true` here. Suppressing the click is how
// this layer stops the browser turning a swipe into a tap on whatever link the
// finger happened to end on — but a gesture that belonged to a control was
// never turned into anything, so the control's own click has to arrive.
// Setting the flag here is what made a toolbar button visible, tappable, and
// inert: the layer ate the click it was waiting for.
if (owned) return;
longPressTimer = setTimeout(() => {
longPressTimer = null;
// A long press is the reader asking to select text, so gestures must not
// turn it into a page turn when the finger lifts.
suppressClick = true;
// The system's own long press selects a word at about the same moment; the
// bars would then open over the text being selected.
if (!handlers.hasSelection()) handlers.onLongPress(event.clientX, event.clientY);
}, LONG_PRESS_MS);
};
const onPointerMove = (event: PointerEvent): void => {
if (!tracking || owned) return;
const deltaX = event.clientX - startX;
const deltaY = event.clientY - startY;
if (Math.hypot(deltaX, deltaY) > TAP_MAX_DISTANCE) clearLongPress();
handlers.onDragDelta(deltaX, deltaY, event.pointerType);
};
const onPointerUp = (event: PointerEvent): void => {
if (!tracking) return;
tracking = false;
const wasOwned = owned;
owned = false;
clearLongPress();
if (wasOwned) return;
const deltaX = event.clientX - startX;
const deltaY = event.clientY - startY;
const duration = Date.now() - startTime;
const distance = Math.hypot(deltaX, deltaY);
if (handlers.onRelease?.(deltaX, deltaY, duration)) {
suppressClick = true;
return;
}
if (suppressClick || handlers.hasSelection()) return;
const horizontal = Math.abs(deltaX) > Math.abs(deltaY) * SWIPE_MAX_OFF_AXIS;
if (distance >= SWIPE_MIN_DISTANCE && duration < 800) {
suppressClick = true;
if (horizontal) handlers.onSwipe(deltaX < 0 ? 'left' : 'right');
else handlers.onSwipe(deltaY < 0 ? 'up' : 'down');
return;
}
if (distance <= TAP_MAX_DISTANCE && duration <= TAP_MAX_DURATION) {
suppressClick = true;
// The zone is a *layout* box, so it is read from layout geometry rather than
// from client coordinates.
//
// The two agree only while the page is unscaled. `getBoundingClientRect()`
// and `event.clientX` are both in *visual viewport* units, so under a pinch
// zoom — or any transformed ancestor — they disagree with up to half the
// tolerance above, and a tap lands in the neighbouring zone: the reader taps
// the middle third to hide the toolbar and turns a page instead, or taps the
// right third to turn a page and gets nothing but a toolbar. `offsetWidth`
// and `offsetLeft` are in the element's own untransformed pixel space, which
// is the space the three zones are defined in.
// Two boxes, and the reason is that they answer two different questions.
//
// The *content* box (`offsetWidth`) is what the three zones are a division of,
// and `offsetWidth` is in the element's own untransformed pixel space — which
// is the space the zones are defined in. The *border* box
// (`getBoundingClientRect`) is where the element is on screen right now, which
// is the space `event.clientX` is in. Dividing one by the other is exact while
// the page is unscaled and wrong under a pinch zoom, where half a third of a
// 390px screen is several pixels of error and the arithmetic above has already
// accepted the tap.
//
// `offsetWidth` is preferred and falls back to the border box: a host with no
// layout (jsdom, an embedded WebView before its first layout) reports zero for
// it, and a stage with no measurable width cannot have zones — reporting a tap
// in one would be inventing a position.
const rect = element.getBoundingClientRect();
const layoutWidth = element.offsetWidth;
const width = layoutWidth > 0 ? layoutWidth : rect.width || element.clientWidth;
if (width <= 0) return;
// Where the tap landed, in the element's own space: on the border box's left
// edge, less the border, less anything the element has been scrolled by.
//
// `rect` is in visual units and so is `clientX`, so the *difference* is a
// visual distance; it is divided by the ratio between the two boxes to bring
// it back into the space the zones are measured in. Without an ancestor
// transform and without a zoom the ratio is exactly 1 and the line is a
// no-op — which is what makes this a correction rather than a conversion.
const scale = layoutWidth > 0 && rect.width > 0 ? rect.width / layoutWidth : 1;
const x = (event.clientX - rect.left - element.clientLeft * scale) / scale + element.scrollLeft;
// The row is the same arithmetic on the other axis. The stage itself does not
// scroll vertically (in scroll mode the shadow host inside it does), so the
// rows divide what is on screen, not the chapter.
const layoutHeight = element.offsetHeight;
const height = layoutHeight > 0 ? layoutHeight : rect.height || element.clientHeight;
const scaleY = layoutHeight > 0 && rect.height > 0 ? rect.height / layoutHeight : 1;
const y = (event.clientY - rect.top - element.clientTop * scaleY) / scaleY;
const cell = (offset: number, size: number): number => (offset < size / 3 ? 0 : offset > (size * 2) / 3 ? 2 : 1);
handlers.onTapZone(cell(x, width), height > 0 ? cell(y, height) : 1);
}
};
const onPointerCancel = (): void => {
const wasTracking = tracking && !owned;
tracking = false;
owned = false;
clearLongPress();
// A followed drag the browser took over springs back: no distance, no turn.
if (wasTracking) handlers.onRelease?.(0, 0, Number.POSITIVE_INFINITY);
};
const onClickCapture = (event: MouseEvent): void => {
if (!suppressClick) return;
suppressClick = false;
// Only a click this layer already accounted for is stopped. A click the reader
// aimed at a control was never suppressed, so the control still receives it —
// the `owned` branch sets `suppressClick` and returns before any gesture is
// recognised, and this method then clears the flag on the very first click.
event.preventDefault();
event.stopPropagation();
};
element.addEventListener('pointerdown', onPointerDown);
element.addEventListener('pointermove', onPointerMove);
element.addEventListener('pointerup', onPointerUp);
element.addEventListener('pointercancel', onPointerCancel);
element.addEventListener('click', onClickCapture, true);
return () => {
clearLongPress();
element.removeEventListener('pointerdown', onPointerDown);
element.removeEventListener('pointermove', onPointerMove);
element.removeEventListener('pointerup', onPointerUp);
element.removeEventListener('pointercancel', onPointerCancel);
element.removeEventListener('click', onClickCapture, true);
};
}