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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user