fix(reader): cap auto page-turn corner zone size (#4812) (#4820)

The corner-dwell auto page-turn zone is a quarter-ellipse whose radius is
a fraction (0.15) of the reading area on each axis. On wide screens such
as desktop or multi-column pages, that fraction grows the zone until it
reaches deep into the text, so selecting in a column and resting the
pointer there turns the page unexpectedly.

Cap each axis of the corner radius at 50px so the engagement zone stays a
real corner regardless of page width, while preserving the existing feel
on phones.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-27 13:51:24 +08:00
committed by GitHub
parent 9e93445336
commit 348c85f648
4 changed files with 56 additions and 40 deletions
@@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cleanup, renderHook } from '@testing-library/react';
const DWELL_MS = 500;
// The mocked reading frame is 1000x1000; with the 0.15 quarter-ellipse corner,
// (920,920) sits in the bottom-right and (80,80) in the top-left.
// The mocked reading frame is 1000x1000; the corner zone is capped at 50px, so
// (970,970) sits in the bottom-right and (30,30) in the top-left.
const VW = 1000;
const h = vi.hoisted(() => ({
@@ -58,7 +58,7 @@ afterEach(() => {
describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)', () => {
test('turns to the next page after a point dwells in the bottom-right corner', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
@@ -67,7 +67,7 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
test('turns to the previous page when a point dwells in the top-left corner', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 80, y: 80 });
result.current.noteAutoTurnPoint({ x: 30, y: 30 });
await advance();
expect(h.view.prev).toHaveBeenCalledTimes(1);
@@ -85,7 +85,7 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
test('does not turn until the dwell has elapsed', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
await vi.advanceTimersByTimeAsync(DWELL_MS - 100);
expect(h.view.next).not.toHaveBeenCalled();
@@ -93,7 +93,7 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
test('cancels the turn if the point leaves the corner before the dwell', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
result.current.noteAutoTurnPoint({ x: 500, y: 500 });
await advance();
@@ -102,7 +102,7 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
test('cancel() drops a pending turn', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
result.current.cancel();
await advance();
@@ -111,21 +111,21 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
test('turns one page per engagement and does not repeat while held', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
});
test('re-arms after the point leaves the corner and returns', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
await advance();
result.current.noteAutoTurnPoint({ x: 500, y: 500 });
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(2);
@@ -133,7 +133,7 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
test('null disengages the corner', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
result.current.noteAutoTurnPoint(null);
await advance();
@@ -146,7 +146,7 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
await advance();
expect(h.view.next).not.toHaveBeenCalled();
result.current.noteAutoTurnPoint({ x: 860, y: 860 });
result.current.noteAutoTurnPoint({ x: 870, y: 870 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
});
@@ -156,14 +156,14 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
const cb = vi.fn();
const unsub = result.current.onAfterTurn(cb);
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
await advance();
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith('br');
unsub();
result.current.noteAutoTurnPoint({ x: 500, y: 500 });
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint({ x: 970, y: 970 });
await advance();
expect(cb).toHaveBeenCalledTimes(1);
});
@@ -187,11 +187,22 @@ describe('useAutoPageTurn corner-dwell page turn (decoupled from DOM selection)'
test('cornerAtPoint maps a window point to its corner', () => {
const { result } = setup();
expect(result.current.cornerAtPoint({ x: 920, y: 920 })).toBe('br');
expect(result.current.cornerAtPoint({ x: 80, y: 80 })).toBe('tl');
expect(result.current.cornerAtPoint({ x: 970, y: 970 })).toBe('br');
expect(result.current.cornerAtPoint({ x: 30, y: 30 })).toBe('tl');
expect(result.current.cornerAtPoint({ x: 500, y: 500 })).toBe(null);
expect(result.current.cornerAtPoint(null)).toBe(null);
});
test('caps the corner zone to AUTO_TURN_CORNER_MAX_PX on a wide reading area', async () => {
// On the 1000px frame the 0.15 fraction would reach 150px from the corner,
// pulling (900,900) into the bottom-right zone; the 50px cap keeps it out so
// selections that merely end near the page edge on wide screens don't turn.
const { result } = setup();
expect(result.current.cornerAtPoint({ x: 900, y: 900 })).toBe(null);
result.current.noteAutoTurnPoint({ x: 900, y: 900 });
await advance();
expect(h.view.next).not.toHaveBeenCalled();
});
});
const fullArea = { left: 0, top: 0, right: VW, bottom: VW, width: VW, height: VW } as DOMRect;
@@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cleanup, renderHook } from '@testing-library/react';
const DWELL_MS = 500;
// The mocked reading frame is 1000x1000; with the 0.15 quarter-ellipse corner,
// (920,920) sits in the bottom-right and (80,80) in the top-left.
// The mocked reading frame is 1000x1000; the corner zone is capped at 50px, so
// (970,970) sits in the bottom-right and (30,30) in the top-left.
const VW = 1000;
const h = vi.hoisted(() => ({
@@ -148,7 +148,7 @@ afterEach(() => {
describe('useTextSelector auto page-turn on corner dwell (#1354)', () => {
test('turns to the next page when a signal dwells in the bottom-right corner', async () => {
const { result } = setup();
pointerMove(result, 920, 920);
pointerMove(result, 970, 970);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
@@ -157,7 +157,7 @@ describe('useTextSelector auto page-turn on corner dwell (#1354)', () => {
test('turns to the previous page (not goLeft) in the top-left corner', async () => {
const { result } = setup();
pointerMove(result, 80, 80);
pointerMove(result, 30, 30);
await advance();
expect(h.view.prev).toHaveBeenCalledTimes(1);
@@ -176,7 +176,7 @@ describe('useTextSelector auto page-turn on corner dwell (#1354)', () => {
test('does not turn until the dwell has elapsed', async () => {
const { result } = setup();
pointerMove(result, 920, 920);
pointerMove(result, 970, 970);
await vi.advanceTimersByTimeAsync(DWELL_MS - 100);
expect(h.view.next).not.toHaveBeenCalled();
@@ -184,7 +184,7 @@ describe('useTextSelector auto page-turn on corner dwell (#1354)', () => {
test('cancels the turn if the pointer leaves the corner before the dwell', async () => {
const { result } = setup();
pointerMove(result, 920, 920); // arms
pointerMove(result, 970, 970); // arms
pointerMove(result, 500, 500); // leaves the corner -> disengage
await advance();
@@ -193,24 +193,24 @@ describe('useTextSelector auto page-turn on corner dwell (#1354)', () => {
test('turns one page per engagement and does not repeat while held', async () => {
const { result } = setup();
pointerMove(result, 920, 920);
pointerMove(result, 970, 970);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
// Still held in the same corner -> no further turns.
pointerMove(result, 920, 920);
pointerMove(result, 970, 970);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
});
test('re-arms after the pointer leaves the corner and returns', async () => {
const { result } = setup();
pointerMove(result, 920, 920);
pointerMove(result, 970, 970);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
pointerMove(result, 500, 500); // leave
pointerMove(result, 920, 920); // return
pointerMove(result, 970, 970); // return
await advance();
expect(h.view.next).toHaveBeenCalledTimes(2);
});
@@ -226,7 +226,7 @@ describe('useTextSelector auto page-turn on corner dwell (#1354)', () => {
test('does not auto-turn in scrolled mode', async () => {
h.viewSettings = { scrolled: true };
const { result } = setup();
pointerMove(result, 920, 920);
pointerMove(result, 970, 970);
await advance();
expect(h.view.next).not.toHaveBeenCalled();
@@ -242,31 +242,31 @@ describe('useTextSelector auto page-turn on corner dwell (#1354)', () => {
test('the selection caret is also an engagement signal', async () => {
const { result } = setup();
caretMove(result, 920, 920);
caretMove(result, 970, 970);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
});
test('measures corners against the content-inset reading area', async () => {
// A 100px inset shrinks the frame to [100,900]: the old outer corner (960,960)
// now falls outside the area, while (860,860) is inside the inset corner.
// A 100px inset shrinks the frame to [100,900]: the outer corner (960,960)
// now falls outside the area, while (870,870) is inside the inset corner.
const { result } = setup({ top: 100, right: 100, bottom: 100, left: 100 });
pointerMove(result, 960, 960);
await advance();
expect(h.view.next).not.toHaveBeenCalled();
pointerMove(result, 860, 860);
pointerMove(result, 870, 870);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
});
test('maps the pointer through the iframe offset (multi-column page)', async () => {
// The iframe is scrolled into a later column: a pointer at clientX=1620 maps
// to window x=920 (1620-700), landing in the bottom-right corner.
// The iframe is scrolled into a later column: a pointer at clientX=1670 maps
// to window x=970 (1670-700), landing in the bottom-right corner.
frameOffset = { left: -700, top: 0 };
const { result } = setup();
pointerMove(result, 1620, 920);
pointerMove(result, 1670, 970);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
@@ -141,7 +141,7 @@ describe('useTextSelector cross-page instant highlight (corner auto-turn)', () =
test('turns to the next page when the instant-highlight drag dwells in the bottom-right corner', async () => {
const { result } = setup();
engageAndMoveTo(result, 920, 920);
engageAndMoveTo(result, 970, 970);
expect(result.current.isInstantAnnotating.current).toBe(true);
await advance();
@@ -151,7 +151,7 @@ describe('useTextSelector cross-page instant highlight (corner auto-turn)', () =
test('turns to the previous page when the drag dwells in the top-left corner', async () => {
const { result } = setup();
engageAndMoveTo(result, 80, 80);
engageAndMoveTo(result, 30, 30);
await advance();
expect(h.view.prev).toHaveBeenCalledTimes(1);
@@ -170,7 +170,7 @@ describe('useTextSelector cross-page instant highlight (corner auto-turn)', () =
test('does not auto-turn in scrolled mode', async () => {
h.viewSettings = { scrolled: true };
const { result } = setup();
engageAndMoveTo(result, 920, 920);
engageAndMoveTo(result, 970, 970);
await advance();
expect(h.view.next).not.toHaveBeenCalled();
@@ -13,6 +13,11 @@ export const AUTO_TURN_DWELL_MS = 500;
// larger/rectangular zone catches normal selections that end in the lower-right
// of the page and turns the page unexpectedly.
export const AUTO_TURN_CORNER_FRACTION = 0.15;
// Hard ceiling on the corner radius in pixels. The fraction alone grows the zone
// with the reading area, so on a wide screen (desktop, multi-column pages) it
// reaches deep into the text and turns the page when a selection merely ends near
// the edge. Cap each axis so the zone stays a corner regardless of page size.
export const AUTO_TURN_CORNER_MAX_PX = 50;
export type Corner = 'br' | 'tl';
export type Point = { x: number; y: number };
@@ -30,8 +35,8 @@ export interface AutoTurnControls {
// corner. Returns null when the point is in neither.
const cornerOf = (x: number, y: number, w: number, h: number): Corner | null => {
if (w <= 0 || h <= 0) return null;
const rx = w * AUTO_TURN_CORNER_FRACTION;
const ry = h * AUTO_TURN_CORNER_FRACTION;
const rx = Math.min(w * AUTO_TURN_CORNER_FRACTION, AUTO_TURN_CORNER_MAX_PX);
const ry = Math.min(h * AUTO_TURN_CORNER_FRACTION, AUTO_TURN_CORNER_MAX_PX);
const inEllipse = (dx: number, dy: number) => (dx / rx) ** 2 + (dy / ry) ** 2 <= 1;
if (inEllipse(w - x, h - y)) return 'br';
if (inEllipse(x, y)) return 'tl';