feat(reader): extend selections and highlights across pages (#4741) (#4767)

* docs(plan): design for cross-page corner auto-turn (#4741)

Extract useAutoPageTurn so the corner-dwell page turn works for instant
highlight drags and for range-editor handle drags, not just native text
selection. Decouple the dwell liveness from the DOM selection and anchor
each range's non-dragged end to a DOM position so it survives the scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plan): add keyboard turn-on-cross to cross-page design (#4741)

Shift+Arrow selection adjust extends into the off-screen next column
without turning the page. Fold it into the feature with an immediate
turn-on-cross (no dwell) in the keyboard path, reusing the page-edge
geometry from useAutoPageTurn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): extend selections and highlights across pages (#4741)

Extract the corner-dwell auto page-turn (#1354) into useAutoPageTurn,
decoupled from the DOM selection, so every selection gesture can drive
it in paginated mode, not just native text selection:

- Instant Highlight drag: feed the finger corner into the dwell machine
  and DOM-anchor the highlight start so it survives the page scroll.
- SelectionRangeEditor and AnnotationRangeEditor handle drags: feed the
  dragged-handle corner; anchor the non-dragged end to a DOM position so
  the edited range spans pages (the annotation editor previously resolved
  both ends from window coordinates and lost the previous page).
- Shift+Arrow keyboard selection adjust: turn the page immediately when
  the extended focus leaves the visible page, so the growing selection
  stays in view.

An after-turn re-emit rebuilds each gesture's range from the held
position so the selection extends onto the new page without waiting for
the next move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-25 01:23:11 +08:00
committed by GitHub
parent d963b911c8
commit 44a6900da0
16 changed files with 1455 additions and 243 deletions
@@ -0,0 +1,210 @@
# Cross-page selection: corner auto-turn (drag) + keyboard turn-on-cross
Issue: https://github.com/readest/readest/issues/4741
## Problem
In paginated (non-scrolling) mode, four selection gestures cannot extend a
selection/highlight past the edge of the page:
1. **Instant Highlight** drag (the highlighter quick action) — drawing a
highlight by dragging cannot continue onto the next page.
2. **SelectionRangeEditor** handle drag — adjusting a plain (suppressed-native)
selection's range cannot reach text on another page.
3. **AnnotationRangeEditor** handle drag — editing an existing highlight's range
(tap a highlight, drag a handle) cannot reach text on another page.
4. **Keyboard selection adjust** (#4728) — `Shift+←/→` (and `Ctrl/Alt+Shift`)
extends the native selection one char/word into the next, off-screen column
without turning the page; `adjustTextSelection` even returns `true` to
suppress the normal arrow-key page turn, so at the page edge the selection
silently grows off-screen.
Native text selection already auto-turns at the corner (#1354): the selection
caret dwelling in the bottom-right / top-left corner for ~500ms turns the page
and the browser-managed selection extends across the boundary. The gestures
above do not benefit from it.
### Root causes
- The corner-dwell machine lives **inside `useTextSelector`** and its dwell
fire-time guard requires a live DOM `Selection` in the corner
(`armDwell``isValidSelection(doc.getSelection())`). Instant Highlight and
AnnotationRangeEditor have **no** DOM selection (the highlight is a
CFI-based overlay; instant highlight sets `user-select: none`), so the
machine refuses to turn for them even if fed.
- `useTextSelector.handlePointerMove` `return`s early during an instant-highlight
drag, so the corner is never fed at all.
- The range editors drag via overlay `Handle` elements in the **top document**
(pointer-captured), so their pointer stream never reaches the iframe listeners
that feed the corner machine.
- Ranges that must survive a page scroll have to anchor their non-dragged end to
a **DOM `{node, offset}`**, not a window coordinate (a coordinate silently
re-targets to whatever scrolls under it after the turn).
`SelectionRangeEditor` already does this (`fixedAnchorRef` +
`rangeFromAnchorToPoint`); `AnnotationRangeEditor`/`useAnnotationEditor` does
**not** (`buildRangeFromPoints` re-resolves both ends from window points), and
`useInstantAnnotation` does not (recomputes the range from raw
`startPoint``endPoint` coordinates each move).
## Design
### 1. Extract `useAutoPageTurn(bookKey, contentInsets)` (new hook)
Move the corner geometry (`cornerOf` / `cornerAt` / `getReadingAreaRect`) and the
dwell state machine (`engagedCorner`, `autoTurnTimer`, `isAutoTurning`,
`AUTO_TURN_DWELL_MS`, `AUTO_TURN_CORNER_FRACTION`) out of `useTextSelector` into a
standalone hook. Public surface:
- `notePoint(point: { x: number; y: number } | null)` — feed the current
engagement point in **window coordinates**; `null` disengages. Computes the
corner and runs the dwell; the dwell calls `view.next()` (`br`) / `view.prev()`
(`tl`) — logical next/prev so RTL turns the correct way.
- `cancel()` — disengage and clear the pending dwell.
- `isAutoTurning` — ref, read by the Android scroll-pin.
- `onAfterTurn(cb: (corner) => void): () => void` — subscribe; returns an
unsubscribe. Fired after each turn settles. Multiple subscribers allowed
(Set of callbacks).
**Liveness decoupled from the DOM selection.** The dwell fire-time guard becomes
"is the latest fed point still in the corner" (`cornerAt(point) === corner`),
not "is there a valid `Selection`". Consumers that own a selection cancel-on-clear
themselves, so native selection still never turns spuriously. This is the change
that lets the selection-less gestures turn at all.
The hook needs only `bookKey` (for `getReadingAreaRect` / `getView`) and
`contentInsets` (corner inset). It is owned/consumed by `useTextSelector` and
re-exposed to the editors (one shared instance per book view).
### 2. `useTextSelector` consumes the hook
- Replace internal `noteCorner`/`armDwell`/`cancelAutoTurn`/`pointerPos`/
`engagedCorner`/`isAutoTurning`/corner helpers with calls into the hook.
- Feed points:
- `handleSelectionchange`: caret → window point via existing
`focusCaretWindowPos`, `notePoint(caretPoint)` (paginated only) else
`notePoint(null)` on clear.
- `handlePointerMove` / `handleNativeTouchMove`: finger → window point,
`notePoint(point)`, for **both** native selection and instant highlight
(remove the early `return` that skipped corner feeding during instant
highlight).
- Keep the Android scroll-pin here (`handleScroll`, `selectionPosition`):
read `autoTurn.isAutoTurning.current`; re-anchor `selectionPosition` after a
turn via `autoTurn.onAfterTurn(...)`.
- Re-expose `noteAutoTurnPoint` / `cancelAutoTurn` / `onAutoTurn` (subscribe) in
the hook's return so the editors can feed the same instance.
### 3. Instant Highlight (`useInstantAnnotation`)
- Anchor the highlight **start** to a DOM `{node, offset}` at pointer-down
(resolve `findPositionAtPoint(doc, startX, startY)` once; store it). Build each
range from the anchored start → live end-point so the start survives the scroll;
fall back to the current coordinate-resolved start when the down point did not
resolve to a text node.
- Relax the pointer-up "barely moved → cancel" check: do not cancel on
`distance < 10` when a preview highlight was actually drawn
(`previewAnnotationRef.current` set) — post-turn the finger can land near its
start **screen** position while the logical range is large.
- (Re-emit, see §5) provide a way to rebuild the preview from the last pointer
position after a turn.
### 4. Range editors
Both: on handle-drag move, feed the dragged handle's window point to
`noteAutoTurnPoint(point)`; on drag-end (and drag-cancel) call `cancelAutoTurn()`.
- **SelectionRangeEditor** already DOM-anchors the fixed end (`fixedAnchorRef` +
`rangeFromAnchorToPoint`) → works across turns once the corner is fed.
- **AnnotationRangeEditor** + **`useAnnotationEditor`**: anchor the non-dragged
end to a DOM `{node, offset}` captured at drag-start and build the new range
via `rangeFromAnchorToPoint` (same primitive SelectionRangeEditor uses) instead
of `buildRangeFromPoints` re-resolving both ends from window points.
`Annotator` passes the re-exposed `noteAutoTurnPoint` / `cancelAutoTurn` /
`onAutoTurn` to both editor components as props.
### 5. After-turn re-emit (included)
When a turn fires mid-hold and the finger/handle has not moved, the live preview
would not extend until the next move. The active gesture subscribes
`onAutoTurn(() => rebuildFromLastPoint())` on drag-start and unsubscribes on
drag-end, so the range extends onto the new page **immediately**. The final
committed range is correct even without this (drag-end resolves the dragged end
from the live post-turn point), so it is separable, but it is what makes the
hold-then-lift case feel right. Native selection does not subscribe (the browser
extends its own selection).
### 6. Keyboard selection adjust (`Shift+←/→`) — immediate turn-on-cross
Unlike the drag gestures, a keystroke is discrete and deliberate, so the trigger
is **not** dwell-in-corner but an immediate turn the moment the focus leaves the
page. In `useBookShortcuts.adjustTextSelection`, after
`extendSelectionFromContents` has extended the selection (the native parent
path, `isNative`), and only in paginated mode:
- Compute the selection focus caret's window position (promote the existing
`focusCaretWindowPos` from `useTextSelector` to a shared util).
- If the focus is now **outside the visible reading page in the logical extend
direction** (forward → past the trailing edge; backward → past the leading
edge; RTL/vertical aware), call `view.next()` / `view.prev()` so the growing
selection scrolls back into view.
No dwell, no re-emit: `sel.modify('extend', …)` has already moved the DOM
selection, so the turn only needs to scroll it into view. Desktop-only (the
#4728 shortcuts), so the Android scroll-pin is not involved. `adjustTextSelection`
still returns `true` (it owns the turn; the arrow-key page-turn shortcut stays
suppressed). Reuses the pure geometry helpers exported from `useAutoPageTurn`
(`getReadingAreaRect`, a "point beyond page edge" check) rather than the
dwell machine.
## Scope / limits
- Within-section column turns only (the common case). A turn that crosses into a
new chapter (different iframe document) stops extending the range — the
inherent limit of a DOM `Range`/`Selection` spanning two documents; native
selection has the same limit.
- Paginated mode only — every corner feed is gated on `!viewSettings.scrolled`.
- Mouse path for instant highlight is unchanged (immediate engage); the touch
still-hold gate (`INSTANT_HOLD_MS`) from #4745 is unchanged.
## Testing (test-first)
- `useAutoPageTurn` (new unit test): a point dwelling in the `br` corner for
`AUTO_TURN_DWELL_MS` calls `view.next()`; leaving the corner before the dwell
→ no turn; `tl``view.prev()`. No DOM selection involved — proves the
decoupling.
- `useTextSelector` (extend autoTurn/instantHold suites): with instant annotation
engaged, a drag into the corner that dwells → `view.next()` (fails today
because of the early `return` + selection guard). Existing
`useTextSelector-autoTurn.test.ts` must stay green (regression net for the
extraction).
- `useInstantAnnotation`: with `caretPositionFromPoint` mocked to return
different content for the same coords before/after a simulated scroll, the
built range's **start stays the anchored node**; a single tap (no preview)
still cancels.
- `useAnnotationEditor` / AnnotationRangeEditor: the non-dragged end stays the
anchored DOM position across a simulated scroll.
- Keyboard turn-on-cross: with a focus caret mocked beyond the page's trailing
edge after `extendSelectionFromContents`, `view.next()` is called; with the
focus still on the page, it is not. `Shift+←` past the leading edge →
`view.prev()`. Scroll mode → never turns.
## Files touched
- `src/app/reader/hooks/useAutoPageTurn.ts`**new**; exports the dwell hook
plus pure geometry helpers (`getReadingAreaRect`, point-beyond-page check)
reused by the keyboard path.
- `src/app/reader/hooks/useTextSelector.ts` — consume hook; feed instant
highlight; re-expose feed/cancel/subscribe; promote `focusCaretWindowPos` to
a shared util.
- `src/app/reader/hooks/useBookShortcuts.ts` — keyboard turn-on-cross after
extending the selection.
- `src/utils/sel.ts` — home for the promoted `focusCaretWindowPos` (or a sibling
shared util) used by both `useTextSelector` and the keyboard path.
- `src/app/reader/hooks/useInstantAnnotation.ts` — DOM-anchored start; relaxed
cancel; re-emit hook.
- `src/app/reader/hooks/useAnnotationEditor.ts` — DOM-anchored non-dragged end.
- `src/app/reader/components/annotator/SelectionRangeEditor.tsx` — feed/cancel.
- `src/app/reader/components/annotator/AnnotationRangeEditor.tsx` — feed/cancel;
anchored build.
- `src/app/reader/components/annotator/Annotator.tsx` — pass controls to editors.
- `src/__tests__/app/reader/hooks/*` — new + extended tests.
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cleanup, renderHook } from '@testing-library/react';
import type { BookNote } from '@/types/book';
// applyAnnotationRange now takes an already-built (DOM-anchored) range instead of
// resolving both ends from window coordinates, so the edited highlight survives a
// corner auto page-turn. This locks that contract: a commit applies the given
// range's CFI/text; a drag does not persist.
const h = vi.hoisted(() => ({
view: { getCFI: vi.fn(() => 'new-cfi'), addAnnotation: vi.fn() },
updateBooknotes: vi.fn(() => ({})),
saveConfig: vi.fn(),
annotations: [] as BookNote[],
}));
vi.mock('@/context/EnvContext', () => ({ useEnv: () => ({ envConfig: {} }) }));
vi.mock('@/store/settingsStore', () => ({ useSettingsStore: () => ({ settings: {} }) }));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({
getConfig: () => ({ booknotes: h.annotations }),
saveConfig: h.saveConfig,
updateBooknotes: h.updateBooknotes,
}),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getView: () => h.view,
getViewsById: () => [h.view],
getProgress: () => ({ page: 3 }),
}),
}));
vi.mock('@/app/reader/utils/annotatorUtil', () => ({
getHandlePositionsFromRange: () => null,
}));
import { useAnnotationEditor } from '@/app/reader/hooks/useAnnotationEditor';
const annotation = {
id: 'a1',
type: 'annotation',
cfi: 'old-cfi',
style: 'highlight',
color: 'yellow',
text: 'old',
note: '',
} as unknown as BookNote;
const setup = () => {
const setSelection = vi.fn();
const hook = renderHook(() =>
useAnnotationEditor({
bookKey: 'book-1',
annotation,
getAnnotationText: vi.fn(async () => 'edited text'),
setSelection: setSelection as never,
}),
);
return { ...hook, setSelection };
};
const range = {} as Range;
beforeEach(() => {
vi.clearAllMocks();
h.annotations = [{ ...annotation }];
});
afterEach(() => cleanup());
describe('useAnnotationEditor applyAnnotationRange', () => {
test('commit applies the given range CFI/text and persists', async () => {
const { result, setSelection } = setup();
await result.current.applyAnnotationRange(range, 2, false, false);
expect(h.view.getCFI).toHaveBeenCalledWith(2, range);
expect(h.updateBooknotes).toHaveBeenCalledTimes(1);
expect(h.saveConfig).toHaveBeenCalledTimes(1);
expect(setSelection).toHaveBeenCalledWith(
expect.objectContaining({ cfi: 'new-cfi', text: 'edited text', range, annotated: true }),
);
});
test('a drag (isDragging) updates the preview but does not persist', async () => {
const { result, setSelection } = setup();
await result.current.applyAnnotationRange(range, 2, false, true);
expect(h.view.addAnnotation).toHaveBeenCalled();
expect(h.updateBooknotes).not.toHaveBeenCalled();
expect(h.saveConfig).not.toHaveBeenCalled();
expect(setSelection).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,259 @@
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.
const VW = 1000;
const h = vi.hoisted(() => ({
view: {
next: vi.fn(),
prev: vi.fn(),
renderer: { containerPosition: 100 },
},
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getView: () => h.view,
}),
}));
import {
keyboardTurnDirection,
turnForFocusBeyondPage,
useAutoPageTurn,
} from '@/app/reader/hooks/useAutoPageTurn';
const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
const setup = (contentInsets = ZERO_INSETS) =>
renderHook(() => useAutoPageTurn('book-1', contentInsets));
let areaRect = { left: 0, top: 0, right: VW, bottom: VW, width: VW, height: VW };
const advance = () => vi.advanceTimersByTimeAsync(DWELL_MS + 50);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
areaRect = { left: 0, top: 0, right: VW, bottom: VW, width: VW, height: VW };
const cell = document.createElement('div');
cell.id = 'gridcell-book-1';
const fv = document.createElement('foliate-view');
fv.getBoundingClientRect = () => areaRect as DOMRect;
cell.appendChild(fv);
document.body.appendChild(cell);
h.view.renderer.containerPosition = 100;
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
document.getElementById('gridcell-book-1')?.remove();
cleanup();
});
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 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
expect(h.view.prev).not.toHaveBeenCalled();
});
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 });
await advance();
expect(h.view.prev).toHaveBeenCalledTimes(1);
expect(h.view.next).not.toHaveBeenCalled();
});
test('does not turn for a point in the center', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 500, y: 500 });
await advance();
expect(h.view.next).not.toHaveBeenCalled();
expect(h.view.prev).not.toHaveBeenCalled();
});
test('does not turn until the dwell has elapsed', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
await vi.advanceTimersByTimeAsync(DWELL_MS - 100);
expect(h.view.next).not.toHaveBeenCalled();
});
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: 500, y: 500 });
await advance();
expect(h.view.next).not.toHaveBeenCalled();
});
test('cancel() drops a pending turn', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.cancel();
await advance();
expect(h.view.next).not.toHaveBeenCalled();
});
test('turns one page per engagement and does not repeat while held', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
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 });
await advance();
result.current.noteAutoTurnPoint({ x: 500, y: 500 });
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(2);
});
test('null disengages the corner', async () => {
const { result } = setup();
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
result.current.noteAutoTurnPoint(null);
await advance();
expect(h.view.next).not.toHaveBeenCalled();
});
test('measures corners against the content-inset reading area', async () => {
const { result } = setup({ top: 100, right: 100, bottom: 100, left: 100 });
result.current.noteAutoTurnPoint({ x: 960, y: 960 });
await advance();
expect(h.view.next).not.toHaveBeenCalled();
result.current.noteAutoTurnPoint({ x: 860, y: 860 });
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
});
test('onAfterTurn subscribers fire after a turn; unsubscribe stops them', async () => {
const { result } = setup();
const cb = vi.fn();
const unsub = result.current.onAfterTurn(cb);
result.current.noteAutoTurnPoint({ x: 920, y: 920 });
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 });
await advance();
expect(cb).toHaveBeenCalledTimes(1);
});
test('noteCorner honors the injected liveness predicate at fire time', async () => {
const { result } = setup();
let live = true;
// Engage br, but the predicate reports the signal left the corner by fire time.
result.current.noteCorner('br', () => live);
live = false;
await advance();
expect(h.view.next).not.toHaveBeenCalled();
// The signal leaves the corner (disengage), then returns while live.
result.current.noteCorner(null, () => false);
live = true;
result.current.noteCorner('br', () => live);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
});
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: 500, y: 500 })).toBe(null);
expect(result.current.cornerAtPoint(null)).toBe(null);
});
});
const fullArea = { left: 0, top: 0, right: VW, bottom: VW, width: VW, height: VW } as DOMRect;
describe('turnForFocusBeyondPage (keyboard turn-on-cross geometry)', () => {
test('past the right or bottom edge turns to the next page', () => {
expect(turnForFocusBeyondPage({ x: VW + 5, y: 500 }, fullArea)).toBe('next');
expect(turnForFocusBeyondPage({ x: 500, y: VW + 5 }, fullArea)).toBe('next');
});
test('past the left or top edge turns back', () => {
expect(turnForFocusBeyondPage({ x: -5, y: 500 }, fullArea)).toBe('prev');
expect(turnForFocusBeyondPage({ x: 500, y: -5 }, fullArea)).toBe('prev');
});
test('a point still on the page does not turn', () => {
expect(turnForFocusBeyondPage({ x: 500, y: 500 }, fullArea)).toBe(null);
expect(turnForFocusBeyondPage({ x: 999, y: 999 }, fullArea)).toBe(null);
});
test('no reading area means no turn', () => {
expect(turnForFocusBeyondPage({ x: VW + 5, y: 500 }, null)).toBe(null);
});
});
describe('keyboardTurnDirection (extended selection focus)', () => {
// A doc whose selection focus maps to window point (fx, fy) via focusCaretWindowPos.
const makeDoc = (fx: number, fy: number, collapsed = false): { doc: Document } => {
const node = document.createTextNode('text');
const sel = {
focusNode: node,
focusOffset: 0,
isCollapsed: collapsed,
rangeCount: collapsed ? 0 : 1,
} as unknown as Selection;
const doc = {
defaultView: {
getSelection: () => sel,
frameElement: { getBoundingClientRect: () => ({ left: 0, top: 0 }) },
},
createRange: () => ({
setStart: () => {},
collapse: () => {},
getBoundingClientRect: () => ({ left: fx, right: fx, top: fy - 5, bottom: fy + 5 }),
}),
} as unknown as Document;
return { doc };
};
test('focus pushed past the trailing edge -> next', () => {
expect(keyboardTurnDirection([makeDoc(VW + 20, 500)], fullArea)).toBe('next');
});
test('focus pushed past the leading edge -> prev', () => {
expect(keyboardTurnDirection([makeDoc(-20, 500)], fullArea)).toBe('prev');
});
test('focus still on the page -> no turn', () => {
expect(keyboardTurnDirection([makeDoc(500, 500)], fullArea)).toBe(null);
});
test('no live selection -> no turn', () => {
expect(keyboardTurnDirection([makeDoc(VW + 20, 500, true)], fullArea)).toBe(null);
});
});
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cleanup, renderHook } from '@testing-library/react';
// The instant-highlight start is anchored to a DOM position at pointer-down so
// the highlight survives a page scroll: after a corner auto page-turn the same
// screen coordinates resolve to different content, but the anchored start must
// stay put so the range spans both pages.
const h = vi.hoisted(() => ({
view: { getCFI: vi.fn(() => 'cfi'), addAnnotation: vi.fn() },
}));
vi.mock('@/context/EnvContext', () => ({ useEnv: () => ({ envConfig: {} }) }));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: () => ({
settings: {
globalReadSettings: {
highlightStyle: 'highlight',
highlightStyles: { highlight: '#ffff00' },
},
},
}),
}));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({
getConfig: () => ({ booknotes: [] }),
saveConfig: vi.fn(),
updateBooknotes: vi.fn(() => ({})),
}),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getView: () => h.view,
getViewsById: () => [h.view],
getViewSettings: () => ({
enableAnnotationQuickActions: true,
annotationQuickAction: 'highlight',
}),
getProgress: () => ({ page: 1 }),
}),
}));
vi.mock('@/app/reader/utils/annotatorUtil', () => ({
toParentViewportPoint: (_doc: Document, x: number, y: number) => ({ x, y }),
}));
import { useInstantAnnotation } from '@/app/reader/hooks/useInstantAnnotation';
import type { TextSelection } from '@/utils/sel';
let p1: HTMLElement;
let p2: HTMLElement;
let t1: Text;
let t2: Text;
// Whether the page has "turned": after a turn the same screen coords resolve to
// page 2's content.
let scrolled = false;
const setup = () => {
const captured: { selection: TextSelection | null } = { selection: null };
const setSelection = vi.fn((s: TextSelection | null) => {
captured.selection = s;
});
const hook = renderHook(() =>
useInstantAnnotation({
bookKey: 'book-1',
getAnnotationText: vi.fn(async () => 'text'),
setSelection: setSelection as never,
setEditingAnnotation: vi.fn(),
setExternalDragPoint: vi.fn(),
}),
);
return { ...hook, captured, setSelection };
};
const pointer = (x: number, y: number) =>
({ button: 0, clientX: x, clientY: y }) as unknown as PointerEvent;
beforeEach(() => {
vi.clearAllMocks();
scrolled = false;
p1 = document.createElement('p');
p1.textContent = 'first page text here';
p2 = document.createElement('p');
p2.textContent = 'second page text here';
document.body.append(p1, p2);
t1 = p1.firstChild as Text;
t2 = p2.firstChild as Text;
// start zone is x < 40, end zone is x >= 40. After a turn both map to page 2.
(document as unknown as { caretPositionFromPoint: unknown }).caretPositionFromPoint = (
x: number,
) =>
x < 40
? { offsetNode: scrolled ? t2 : t1, offset: 2 }
: { offsetNode: scrolled ? t2 : t1, offset: 8 };
// jsdom has no layout: make every range "cover" the point so the selectable-
// content check passes, and give ranges a measurable box.
Range.prototype.getClientRects = () =>
[{ left: 0, right: 1000, top: 0, bottom: 1000 }] as unknown as DOMRectList;
});
afterEach(() => {
document.body.replaceChildren();
cleanup();
});
describe('useInstantAnnotation DOM-anchored start', () => {
test('the start stays anchored to its DOM node across a simulated page turn', () => {
const { result, captured } = setup();
result.current.handleInstantAnnotationPointerDown(document, 0, pointer(10, 10));
result.current.handleInstantAnnotationPointerMove(document, 0, pointer(60, 10));
expect(captured.selection?.range?.startContainer).toBe(t1);
expect(captured.selection?.range?.endContainer).toBe(t1);
// The page turns: the same coords now resolve to page 2, but the anchored
// start must remain on page 1 so the range spans both pages.
scrolled = true;
result.current.handleInstantAnnotationPointerMove(document, 0, pointer(60, 10));
expect(captured.selection?.range?.startContainer).toBe(t1);
expect(captured.selection?.range?.endContainer).toBe(t2);
});
test('reapplyInstantAnnotation rebuilds across the turn from the held position', () => {
const { result, captured } = setup();
result.current.handleInstantAnnotationPointerDown(document, 0, pointer(10, 10));
result.current.handleInstantAnnotationPointerMove(document, 0, pointer(60, 10));
// Finger held still; page turns; re-emit rebuilds onto page 2.
scrolled = true;
result.current.reapplyInstantAnnotation();
expect(captured.selection?.range?.startContainer).toBe(t1);
expect(captured.selection?.range?.endContainer).toBe(t2);
});
test('a barely-moved tap with no preview cancels (returns false)', async () => {
const { result, setSelection } = setup();
result.current.handleInstantAnnotationPointerDown(document, 0, pointer(10, 10));
const handled = await result.current.handleInstantAnnotationPointerUp(
document,
0,
pointer(12, 11),
);
expect(handled).toBe(false);
expect(setSelection).toHaveBeenLastCalledWith(null);
});
});
@@ -44,6 +44,7 @@ vi.mock('@/app/reader/hooks/useInstantAnnotation', () => ({
handleInstantAnnotationPointerMove: vi.fn(),
handleInstantAnnotationPointerCancel: vi.fn(),
handleInstantAnnotationPointerUp: vi.fn(),
reapplyInstantAnnotation: vi.fn(),
cancelInstantAnnotation: vi.fn(),
}),
}));
@@ -44,6 +44,7 @@ vi.mock('@/app/reader/hooks/useInstantAnnotation', () => ({
handleInstantAnnotationPointerMove: vi.fn(() => true),
handleInstantAnnotationPointerCancel: vi.fn(),
handleInstantAnnotationPointerUp: vi.fn(async () => false),
reapplyInstantAnnotation: vi.fn(),
cancelInstantAnnotation: vi.fn(),
}),
}));
@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cleanup, renderHook } from '@testing-library/react';
// While drawing an instant highlight (the highlighter quick action), dragging
// the finger into the corner must turn the page so the highlight continues
// across the boundary — the same corner-dwell auto-turn native selection uses,
// even though instant highlight carries no DOM selection.
const DWELL_MS = 500;
const VW = 1000;
const h = vi.hoisted(() => ({
view: {
next: vi.fn(),
prev: vi.fn(),
deselect: vi.fn(),
getCFI: vi.fn(() => 'cfi'),
renderer: { containerPosition: 100, scrollLocked: false },
},
appService: { isAndroidApp: false, isMobile: false },
osPlatform: 'macos',
viewSettings: { scrolled: false } as { scrolled: boolean; vertical?: boolean },
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: h.appService }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getView: () => h.view,
getViewSettings: () => h.viewSettings,
getProgress: () => null,
}),
}));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({ getBookData: () => ({}) }),
}));
vi.mock('@/utils/event', () => ({
eventDispatcher: { onSync: vi.fn(), offSync: vi.fn(), on: vi.fn(), off: vi.fn() },
}));
vi.mock('@/app/reader/hooks/useInstantAnnotation', () => ({
useInstantAnnotation: () => ({
isInstantAnnotationEnabled: () => true,
handleInstantAnnotationPointerDown: vi.fn(() => true),
handleInstantAnnotationPointerMove: vi.fn(() => true),
handleInstantAnnotationPointerCancel: vi.fn(),
handleInstantAnnotationPointerUp: vi.fn(async () => false),
reapplyInstantAnnotation: vi.fn(),
cancelInstantAnnotation: vi.fn(),
}),
}));
vi.mock('@/utils/misc', async (importActual) => {
const actual = await importActual<typeof import('@/utils/misc')>();
return { ...actual, getOSPlatform: () => h.osPlatform };
});
import { useTextSelector } from '@/app/reader/hooks/useTextSelector';
const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
const setup = () => {
const noop = vi.fn();
return renderHook(() =>
useTextSelector(
'book-1',
ZERO_INSETS,
noop,
noop,
noop,
vi.fn(async () => ''),
noop,
),
);
};
const doc = {
getSelection: () => null,
createRange: () => ({
setStart: () => {},
collapse: () => {},
getBoundingClientRect: () => ({ left: 0, right: 0, top: 0, bottom: 0 }),
}),
defaultView: { frameElement: { getBoundingClientRect: () => ({ left: 0, top: 0 }) } },
} as unknown as Document;
const mouseDown = (x: number, y: number) => {
const target = document.createElement('span');
return {
pointerType: 'mouse',
button: 0,
clientX: x,
clientY: y,
target,
preventDefault: vi.fn(),
} as unknown as PointerEvent;
};
type Handlers = ReturnType<typeof setup>['result'];
// Engage instant highlight (mouse engages immediately), then move the finger to
// (x, y). The first move is horizontal so the scroll-axis gesture guard lets the
// highlight proceed.
const engageAndMoveTo = (result: Handlers, x: number, y: number) => {
result.current.handlePointerDown(doc, 0, mouseDown(100, y));
result.current.handlePointerMove(doc, 0, {
clientX: x,
clientY: y,
preventDefault: vi.fn(),
} as unknown as PointerEvent);
};
const advance = () => vi.advanceTimersByTimeAsync(DWELL_MS + 50);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
const cell = document.createElement('div');
cell.id = 'gridcell-book-1';
const fv = document.createElement('foliate-view');
fv.getBoundingClientRect = () =>
({ left: 0, top: 0, right: VW, bottom: VW, width: VW, height: VW }) as DOMRect;
cell.appendChild(fv);
document.body.appendChild(cell);
h.appService = { isAndroidApp: false, isMobile: false };
h.osPlatform = 'macos';
h.viewSettings = { scrolled: false };
h.view.renderer.scrollLocked = false;
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
document.getElementById('gridcell-book-1')?.remove();
cleanup();
});
describe('useTextSelector cross-page instant highlight (corner auto-turn)', () => {
test('engages instant annotating on a mouse press', () => {
const { result } = setup();
result.current.handlePointerDown(doc, 0, mouseDown(100, 900));
expect(result.current.isInstantAnnotating.current).toBe(true);
});
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);
expect(result.current.isInstantAnnotating.current).toBe(true);
await advance();
expect(h.view.next).toHaveBeenCalledTimes(1);
expect(h.view.prev).not.toHaveBeenCalled();
});
test('turns to the previous page when the drag dwells in the top-left corner', async () => {
const { result } = setup();
engageAndMoveTo(result, 80, 80);
await advance();
expect(h.view.prev).toHaveBeenCalledTimes(1);
expect(h.view.next).not.toHaveBeenCalled();
});
test('does not turn while the drag stays in the center', async () => {
const { result } = setup();
engageAndMoveTo(result, 500, 500);
await advance();
expect(h.view.next).not.toHaveBeenCalled();
expect(h.view.prev).not.toHaveBeenCalled();
});
test('does not auto-turn in scrolled mode', async () => {
h.viewSettings = { scrolled: true };
const { result } = setup();
engageAndMoveTo(result, 920, 920);
await advance();
expect(h.view.next).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BookNote, HighlightColor } from '@/types/book';
import { Point, TextSelection } from '@/utils/sel';
import { Point, rangeFromAnchorToPoint, TextSelection } from '@/utils/sel';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
@@ -140,6 +140,9 @@ interface AnnotationRangeEditorProps {
getAnnotationText: (range: Range) => Promise<string>;
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>;
onStartEdit: () => void;
noteAutoTurnPoint: (point: Point | null) => void;
cancelAutoTurn: () => void;
onAutoTurn: (cb: () => void) => () => void;
}
const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
@@ -152,6 +155,9 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
getAnnotationText,
setSelection,
onStartEdit,
noteAutoTurnPoint,
cancelAutoTurn,
onAutoTurn,
}) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
@@ -160,7 +166,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
const viewSettings = getViewSettings(bookKey);
const isEink = settings.globalViewSettings.isEink;
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
const { handlePositions, getHandlePositionsFromRange, handleAnnotationRangeChange } =
const { handlePositions, getHandlePositionsFromRange, applyAnnotationRange } =
useAnnotationEditor({ bookKey, annotation, getAnnotationText, setSelection });
const handleColorHex = getHighlightColorHex(settings, handleColor) ?? '#FFFF00';
@@ -168,6 +174,13 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
const dragPointerTypeRef = useRef<string>('');
const startRef = useRef<Point>({ x: 0, y: 0 });
const endRef = useRef<Point>({ x: 0, y: 0 });
// The non-dragged end captured as a DOM position at drag start so the range
// survives a corner auto page-turn (a window coordinate would re-target to
// whatever scrolls under it, losing the previous page's part).
const fixedAnchorRef = useRef<{ node: Node; offset: number } | null>(null);
const lastBuiltRef = useRef<{ range: Range; index: number } | null>(null);
// Unsubscribe for the after-turn re-emit while a handle is being dragged.
const autoTurnUnsubRef = useRef<(() => void) | null>(null);
const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | null>(null);
const [currentStart, setCurrentStart] = useState<Point>({ x: 0, y: 0 });
const [currentEnd, setCurrentEnd] = useState<Point>({ x: 0, y: 0 });
@@ -184,6 +197,11 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
startRef.current = positions.start;
endRef.current = positions.end;
}
// Keep the anchor base in sync with the current annotation range so a fresh
// drag anchors against this annotation, not a previously edited one.
if (!draggingRef.current) {
lastBuiltRef.current = { range: selection.range, index: selection.index };
}
}, [annotation, selection, isVertical, getHandlePositionsFromRange]);
useEffect(() => {
@@ -196,26 +214,68 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
endRef.current = handlePositions.end;
}, [handlePositions]);
// Build the edited range from the DOM-anchored non-dragged end to the dragged
// handle position, apply it, and feed the dragged point into the corner
// auto-turn so the page can turn mid-edit.
const updateFromDraggedPoint = useCallback(
(point: Point) => {
const anchor = fixedAnchorRef.current;
if (!anchor) return;
const doc = anchor.node.ownerDocument;
const win = doc?.defaultView;
if (!doc || !win) return;
const feRect = win.frameElement?.getBoundingClientRect();
const built = rangeFromAnchorToPoint(
doc,
anchor.node,
anchor.offset,
point.x - (feRect?.left ?? 0),
point.y - (feRect?.top ?? 0),
);
if (!built) return;
lastBuiltRef.current = { range: built, index: selection.index };
noteAutoTurnPoint(viewSettings?.scrolled ? null : point);
applyAnnotationRange(built, selection.index, isVertical, true);
},
[selection.index, isVertical, applyAnnotationRange, noteAutoTurnPoint, viewSettings?.scrolled],
);
// Rebuild from the held handle position after an auto page-turn so the edited
// range extends onto the new page without waiting for the next move.
const subscribeAutoTurnReemit = useCallback(() => {
autoTurnUnsubRef.current?.();
autoTurnUnsubRef.current = onAutoTurn(() => {
const point = draggingRef.current === 'start' ? startRef.current : endRef.current;
updateFromDraggedPoint(point);
});
}, [onAutoTurn, updateFromDraggedPoint]);
const handleStartDragStart = useCallback(
(pointerType: string) => {
const base = lastBuiltRef.current?.range ?? selection.range;
fixedAnchorRef.current = { node: base.endContainer, offset: base.endOffset };
draggingRef.current = 'start';
dragPointerTypeRef.current = pointerType;
setDraggingHandle('start');
setLoupePoint({ ...startRef.current });
subscribeAutoTurnReemit();
onStartEdit();
},
[onStartEdit],
[selection, onStartEdit, subscribeAutoTurnReemit],
);
const handleEndDragStart = useCallback(
(pointerType: string) => {
const base = lastBuiltRef.current?.range ?? selection.range;
fixedAnchorRef.current = { node: base.startContainer, offset: base.startOffset };
draggingRef.current = 'end';
dragPointerTypeRef.current = pointerType;
setDraggingHandle('end');
setLoupePoint({ ...endRef.current });
subscribeAutoTurnReemit();
onStartEdit();
},
[onStartEdit],
[selection, onStartEdit, subscribeAutoTurnReemit],
);
const handleStartDrag = useCallback(
@@ -223,9 +283,9 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
setCurrentStart(point);
setLoupePoint(point);
startRef.current = point;
handleAnnotationRangeChange(point, endRef.current, isVertical, true);
updateFromDraggedPoint(point);
},
[isVertical, handleAnnotationRangeChange],
[updateFromDraggedPoint],
);
const handleEndDrag = useCallback(
@@ -233,17 +293,23 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
setCurrentEnd(point);
setLoupePoint(point);
endRef.current = point;
handleAnnotationRangeChange(startRef.current, point, isVertical, true);
updateFromDraggedPoint(point);
},
[isVertical, handleAnnotationRangeChange],
[updateFromDraggedPoint],
);
const handleDragEnd = useCallback(() => {
draggingRef.current = null;
setDraggingHandle(null);
setLoupePoint(null);
handleAnnotationRangeChange(startRef.current, endRef.current, isVertical, false);
}, [isVertical, handleAnnotationRangeChange]);
cancelAutoTurn();
autoTurnUnsubRef.current?.();
autoTurnUnsubRef.current = null;
const last = lastBuiltRef.current;
if (last) {
applyAnnotationRange(last.range, last.index, isVertical, false);
}
}, [isVertical, applyAnnotationRange, cancelAutoTurn]);
if (currentStart.x === 0 && currentStart.y === 0) {
return null;
@@ -327,6 +327,9 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
handleUpToPopup,
handleContextmenu,
applyProgrammaticSelection,
noteAutoTurnPoint,
cancelAutoTurn,
onAutoTurn,
} = useTextSelector(
bookKey,
contentInsets,
@@ -1750,6 +1753,9 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
handleColor={selectedColor}
onRangeChange={applyProgrammaticSelection}
onStartDrag={handleStartEditAnnotation}
noteAutoTurnPoint={noteAutoTurnPoint}
cancelAutoTurn={cancelAutoTurn}
onAutoTurn={onAutoTurn}
/>
)}
{editingAnnotation && editingAnnotation.color && selection && (
@@ -1763,6 +1769,9 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
getAnnotationText={getAnnotationText}
setSelection={setSelection}
onStartEdit={handleStartEditAnnotation}
noteAutoTurnPoint={noteAutoTurnPoint}
cancelAutoTurn={cancelAutoTurn}
onAutoTurn={onAutoTurn}
/>
)}
{showExportDialog && exportData && bookData.book && (
@@ -17,6 +17,9 @@ interface SelectionRangeEditorProps {
handleColor: HighlightColor;
onRangeChange: (range: Range, index: number, commit: boolean) => void;
onStartDrag: () => void;
noteAutoTurnPoint: (point: Point | null) => void;
cancelAutoTurn: () => void;
onAutoTurn: (cb: () => void) => () => void;
}
// Drag handles for a plain (not yet annotated) text selection. Used on
@@ -31,6 +34,9 @@ const SelectionRangeEditor: React.FC<SelectionRangeEditorProps> = ({
handleColor,
onRangeChange,
onStartDrag,
noteAutoTurnPoint,
cancelAutoTurn,
onAutoTurn,
}) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
@@ -45,6 +51,8 @@ const SelectionRangeEditor: React.FC<SelectionRangeEditorProps> = ({
const startRef = useRef<Point>({ x: 0, y: 0 });
const endRef = useRef<Point>({ x: 0, y: 0 });
const lastBuiltRef = useRef<{ range: Range; index: number } | null>(null);
// Unsubscribe for the after-turn re-emit while a handle is being dragged.
const autoTurnUnsubRef = useRef<(() => void) | null>(null);
const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | null>(null);
const [currentStart, setCurrentStart] = useState<Point>({ x: 0, y: 0 });
const [currentEnd, setCurrentEnd] = useState<Point>({ x: 0, y: 0 });
@@ -86,18 +94,32 @@ const SelectionRangeEditor: React.FC<SelectionRangeEditorProps> = ({
if (!built) return;
lastBuiltRef.current = { range: built, index: selection.index };
onRangeChange(built, selection.index, false);
// Drag the handle into the corner to turn the page; the anchored end keeps
// the previous page's part of the selection across the turn.
noteAutoTurnPoint(viewSettings?.scrolled ? null : point);
},
[selection.index, onRangeChange],
[selection.index, onRangeChange, noteAutoTurnPoint, viewSettings?.scrolled],
);
// Rebuild the range from the held handle position after an auto page-turn, so
// the selection extends onto the new page without waiting for the next move.
const subscribeAutoTurnReemit = useCallback(() => {
autoTurnUnsubRef.current?.();
autoTurnUnsubRef.current = onAutoTurn(() => {
const point = draggingRef.current === 'start' ? startRef.current : endRef.current;
updateFromDraggedPoint(point);
});
}, [onAutoTurn, updateFromDraggedPoint]);
const handleStartDragStart = useCallback(() => {
const base = lastBuiltRef.current?.range ?? selection.range;
fixedAnchorRef.current = { node: base.endContainer, offset: base.endOffset };
draggingRef.current = 'start';
setDraggingHandle('start');
setLoupePoint({ ...startRef.current });
subscribeAutoTurnReemit();
onStartDrag();
}, [selection, onStartDrag]);
}, [selection, onStartDrag, subscribeAutoTurnReemit]);
const handleEndDragStart = useCallback(() => {
const base = lastBuiltRef.current?.range ?? selection.range;
@@ -105,8 +127,9 @@ const SelectionRangeEditor: React.FC<SelectionRangeEditorProps> = ({
draggingRef.current = 'end';
setDraggingHandle('end');
setLoupePoint({ ...endRef.current });
subscribeAutoTurnReemit();
onStartDrag();
}, [selection, onStartDrag]);
}, [selection, onStartDrag, subscribeAutoTurnReemit]);
const handleStartDrag = useCallback(
(point: Point) => {
@@ -132,11 +155,14 @@ const SelectionRangeEditor: React.FC<SelectionRangeEditorProps> = ({
draggingRef.current = null;
setDraggingHandle(null);
setLoupePoint(null);
cancelAutoTurn();
autoTurnUnsubRef.current?.();
autoTurnUnsubRef.current = null;
const last = lastBuiltRef.current;
if (last) {
onRangeChange(last.range, last.index, true);
}
}, [onRangeChange]);
}, [onRangeChange, cancelAutoTurn]);
if (currentStart.x === 0 && currentStart.y === 0) {
return null;
@@ -1,12 +1,11 @@
import { useCallback, useRef, useState } from 'react';
import { BookNote } from '@/types/book';
import { Point, TextSelection } from '@/utils/sel';
import { TextSelection } from '@/utils/sel';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import {
buildRangeFromPoints,
getHandlePositionsFromRange as getHandlePositionsForBook,
HandlePositions,
} from '../utils/annotatorUtil';
@@ -39,14 +38,12 @@ export const useAnnotationEditor = ({
[bookKey],
);
const handleAnnotationRangeChange = useCallback(
async (startPoint: Point, endPoint: Point, isVertical: boolean, isDragging: boolean) => {
// Apply an already-built range (anchored at the non-dragged end in the editor
// component so it survives a corner auto page-turn) to the edited annotation.
const applyAnnotationRange = useCallback(
async (newRange: Range, targetIndex: number, isVertical: boolean, isDragging: boolean) => {
if (!editingAnnotationRef.current || !view) return;
const built = buildRangeFromPoints(view, startPoint, endPoint);
if (!built) return;
const { range: newRange, index: targetIndex } = built;
const newPositions = getHandlePositionsFromRange(newRange, isVertical);
if (newPositions) {
setHandlePositions(newPositions);
@@ -105,6 +102,6 @@ export const useAnnotationEditor = ({
handlePositions,
setHandlePositions,
getHandlePositionsFromRange,
handleAnnotationRangeChange,
applyAnnotationRange,
};
};
@@ -0,0 +1,220 @@
import { useEffect, useRef } from 'react';
import { Insets } from '@/types/misc';
import { useReaderStore } from '@/store/readerStore';
import { focusCaretWindowPos } from '@/utils/sel';
const ZERO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
// A signal must rest in a screen corner for this long before the page
// auto-turns, so merely passing a corner mid-drag doesn't flip the page.
export const AUTO_TURN_DWELL_MS = 500;
// The corner zone is a quarter-ellipse within this radius of the actual corner
// (as a fraction of each axis). Kept tight so it is only the corner itself — a
// 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;
export type Corner = 'br' | 'tl';
export type Point = { x: number; y: number };
// The subset of useTextSelector's return that drives the shared corner auto-turn,
// passed to the range editors so their overlay handle drags turn the page too.
export interface AutoTurnControls {
noteAutoTurnPoint: (point: Point | null) => void;
cancelAutoTurn: () => void;
onAutoTurn: (cb: (corner: Corner) => void) => () => void;
}
// Which screen corner a point sits in: bottom-right turns forward, top-left
// turns back. The zone is a quarter-ellipse of radius FRACTION around each
// 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 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';
return null;
};
// Map a window-coordinate point to the corner of the reading area it sits in,
// if any. Corners are measured against `area` (the visible text bounds in window
// coordinates) so they land on the text, not the page margins or a sidebar.
const cornerAt = (xWin: number, yWin: number, area: DOMRect | null): Corner | null => {
if (!area || area.width <= 0 || area.height <= 0) return null;
const x = xWin - area.left;
const y = yWin - area.top;
// Ignore a point outside the visible text (e.g. the selection caret jumping
// into the next, off-screen column while dragging at the edge).
if (x < 0 || x > area.width || y < 0 || y > area.height) return null;
return cornerOf(x, y, area.width, area.height);
};
// The reading frame in window coordinates: the <foliate-view> element's rect
// (a stable element, so it has a sensible page-sized width — unlike the visible
// text range, whose box spans the whole multi-column iframe), inset by the page
// content margins so the corner zone lands on the text area, not the margin.
// Falls back to the reading container (gridcell).
export const getReadingAreaRect = (
bookKey: string,
insets: Insets = ZERO_INSETS,
): DOMRect | null => {
const cell = document.querySelector(`#gridcell-${bookKey}`);
if (!cell) return null;
const frame = cell.querySelector('foliate-view') ?? cell;
const r = frame.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
return new DOMRect(
r.left + insets.left,
r.top + insets.top,
Math.max(0, r.width - insets.left - insets.right),
Math.max(0, r.height - insets.top - insets.bottom),
);
};
// Which way to turn so a focus point that has left the visible reading page
// stays in view: past the trailing (right/bottom) edge turns to the next page,
// past the leading (left/top) edge turns back. Mirrors the corner machine's
// br->next / tl->prev convention. Returns null while the point is on the page.
// Used by the keyboard selection-adjust path: a Shift+Arrow extension that pushes
// the caret into the next, off-screen column turns the page immediately (no
// dwell), unlike the corner-dwell used for drags.
export const turnForFocusBeyondPage = (
point: Point,
area: DOMRect | null,
): 'next' | 'prev' | null => {
if (!area || area.width <= 0 || area.height <= 0) return null;
const x = point.x - area.left;
const y = point.y - area.top;
if (x > area.width || y > area.height) return 'next';
if (x < 0 || y < 0) return 'prev';
return null;
};
// The page to turn to so a keyboard-extended selection's focus stays visible, or
// null. Reads the focus caret of the first content with a live selection and maps
// it through turnForFocusBeyondPage against the reading area.
export const keyboardTurnDirection = (
contents: { doc: Document }[],
area: DOMRect | null,
): 'next' | 'prev' | null => {
for (const { doc } of contents) {
const sel = doc.defaultView?.getSelection?.();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) continue;
const pos = focusCaretWindowPos(doc, sel);
return pos ? turnForFocusBeyondPage(pos, area) : null;
}
return null;
};
// The corner-dwell auto page-turn (#1354), decoupled from the DOM selection so
// any drag/keyboard gesture can drive it: native selection, instant highlight,
// the range editors. A fed signal that rests in the bottom-right / top-left
// corner for AUTO_TURN_DWELL_MS turns one page (logical next/prev so RTL turns
// the correct way). One turn per engagement — a signal must leave the corner and
// return to turn another page.
export const useAutoPageTurn = (bookKey: string, contentInsets: Insets = ZERO_INSETS) => {
const { getView } = useReaderStore();
const autoTurnTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// The corner an input signal is currently engaged in. Stays set after a turn
// so the dwell can't re-arm while held — a signal must leave the corner and
// return to turn another page.
const engagedCorner = useRef<Corner | null>(null);
const isAutoTurning = useRef(false);
// Liveness predicate for the engaged corner, re-checked when the dwell fires.
// Callers inject it so the caret-or-pointer dual signal of native selection is
// preserved while a point-only caller (editor/instant) reports its own point.
const isInCornerRef = useRef<(corner: Corner) => boolean>(() => false);
// Latest point fed through the point-based convenience entry.
const lastPointRef = useRef<Point | null>(null);
const afterTurnSubs = useRef<Set<(corner: Corner) => void>>(new Set());
const readingAreaRect = (): DOMRect | null => getReadingAreaRect(bookKey, contentInsets);
const cornerAtPoint = (point: Point | null): Corner | null =>
point ? cornerAt(point.x, point.y, readingAreaRect()) : null;
const clearTimer = () => {
if (autoTurnTimer.current) {
clearTimeout(autoTurnTimer.current);
autoTurnTimer.current = null;
}
};
const armDwell = (corner: Corner) => {
if (autoTurnTimer.current) return;
autoTurnTimer.current = setTimeout(() => {
autoTurnTimer.current = null;
// Skip if a turn is already running or the signal left the corner.
if (isAutoTurning.current || !isInCornerRef.current(corner)) return;
isAutoTurning.current = true;
const view = getView(bookKey);
// Logical next()/prev() so RTL books turn the correct way.
const turning = corner === 'br' ? view?.next() : view?.prev();
Promise.resolve(turning).finally(() => {
afterTurnSubs.current.forEach((cb) => cb(corner));
isAutoTurning.current = false;
});
}, AUTO_TURN_DWELL_MS);
};
// Feed a corner detected from an input signal into the dwell state machine,
// with a liveness predicate re-checked when the dwell fires. Entering a corner
// arms the dwell; once the signal is no longer in the engaged corner it
// disengages so a re-entry can turn again.
const noteCorner = (corner: Corner | null, isInCorner: (corner: Corner) => boolean) => {
isInCornerRef.current = isInCorner;
if (isAutoTurning.current) return;
if (corner) {
if (engagedCorner.current !== corner) {
engagedCorner.current = corner;
armDwell(corner);
}
} else if (engagedCorner.current && !isInCorner(engagedCorner.current)) {
engagedCorner.current = null;
clearTimer();
}
};
// Point-based convenience for callers whose only signal is a window point
// (instant highlight, range-editor handles, keyboard focus): the liveness
// check is "is the latest fed point still in the engaged corner".
const noteAutoTurnPoint = (point: Point | null) => {
lastPointRef.current = point;
noteCorner(cornerAtPoint(point), (corner) => cornerAtPoint(lastPointRef.current) === corner);
};
// Disengage and drop any pending corner page-turn.
const cancel = () => {
engagedCorner.current = null;
lastPointRef.current = null;
clearTimer();
};
// Subscribe to "a turn settled" (fired with the turned corner). Returns an
// unsubscribe. The active gesture uses it to rebuild its range from the last
// point so the selection extends onto the new page immediately.
const onAfterTurn = (cb: (corner: Corner) => void): (() => void) => {
afterTurnSubs.current.add(cb);
return () => {
afterTurnSubs.current.delete(cb);
};
};
useEffect(() => {
return () => clearTimer();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
isAutoTurning,
readingAreaRect,
cornerAtPoint,
noteCorner,
noteAutoTurnPoint,
cancel,
onAfterTurn,
};
};
@@ -13,6 +13,7 @@ import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants'
import { getParagraphActionForKey } from '@/utils/paragraphPresentation';
import { getScrollGapAttr } from '@/utils/webtoon';
import { extendSelectionFromContents, KeyModifiers } from '@/utils/sel';
import { getReadingAreaRect, keyboardTurnDirection } from './useAutoPageTurn';
import { viewPagination } from './usePagination';
import useShortcuts from '@/hooks/useShortcuts';
import useBooksManager from './useBooksManager';
@@ -81,8 +82,19 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const isNative = event instanceof KeyboardEvent;
const src: KeyModifiers | undefined = isNative ? event : event?.data;
if (!src?.key) return false;
const contents = getView(sideBarBookKey)?.renderer?.getContents?.() ?? [];
return extendSelectionFromContents(contents, src, isNative);
const view = getView(sideBarBookKey ?? '');
const contents = view?.renderer?.getContents?.() ?? [];
const extended = extendSelectionFromContents(contents, src, isNative);
// Keyboard turn-on-cross (#4741): when the extended selection's focus leaves
// the visible page in paginated mode, turn the page so the growing selection
// stays in view. Only for keys we extended ourselves (the native parent
// path); the focused-iframe path lets the browser scroll the focus in.
if (extended && isNative && !getViewSettings(sideBarBookKey ?? '')?.scrolled) {
const dir = keyboardTurnDirection(contents, getReadingAreaRect(sideBarBookKey ?? ''));
if (dir === 'next') view?.next();
else if (dir === 'prev') view?.prev();
}
return extended;
};
const goLeft = () => {
@@ -29,8 +29,16 @@ export const useInstantAnnotation = ({
const { getView, getViewsById, getViewSettings, getProgress } = useReaderStore();
const startPointRef = useRef<Point | null>(null);
// The DOM position the drag started at, captured once at pointer-down. The
// range is rebuilt from this anchor each move so it survives a page scroll —
// the start *coordinates* would otherwise resolve to whatever content scrolls
// under them after a corner auto page-turn, losing the previous page's part.
const startPosRef = useRef<{ node: Node; offset: number } | null>(null);
const startDocRef = useRef<Document | null>(null);
const startIndexRef = useRef<number>(0);
// Latest drag end-point (iframe coords), so the preview can be rebuilt from the
// held position after an auto page-turn without waiting for the next move.
const lastEndPointRef = useRef<Point | null>(null);
const previewAnnotationRef = useRef<BookNote | null>(null);
const annotationIdRef = useRef<string>(uniqueId());
@@ -111,13 +119,12 @@ export const useInstantAnnotation = ({
[findPositionAtPoint],
);
const createRangeFromPoints = useCallback(
(doc: Document, startPoint: Point, endPoint: Point) => {
const startPos = findPositionAtPoint(doc, startPoint.x, startPoint.y);
const endPos = findPositionAtPoint(doc, endPoint.x, endPoint.y);
if (!startPos || !endPos) return null;
const rangeFromPositions = useCallback(
(
doc: Document,
startPos: { node: Node; offset: number },
endPos: { node: Node; offset: number },
) => {
const newRange = doc.createRange();
try {
const positionComparison = startPos.node.compareDocumentPosition(endPos.node);
@@ -144,7 +151,26 @@ export const useInstantAnnotation = ({
return null;
}
},
[findPositionAtPoint],
[],
);
// Build the range from the DOM-anchored start (captured at pointer-down) to the
// current end-point. Anchoring the start to the DOM — not its coordinates — is
// what lets the highlight continue across a corner auto page-turn. Falls back to
// resolving the start coords when the down point did not land on a text node.
const buildRangeFromAnchor = useCallback(
(doc: Document, endPoint: Point) => {
const endPos = findPositionAtPoint(doc, endPoint.x, endPoint.y);
if (!endPos) return null;
const startPos =
startPosRef.current ??
(startPointRef.current
? findPositionAtPoint(doc, startPointRef.current.x, startPointRef.current.y)
: null);
if (!startPos) return null;
return rangeFromPositions(doc, startPos, endPos);
},
[findPositionAtPoint, rangeFromPositions],
);
const createAnnotation = useCallback((cfi: string, text?: string) => {
@@ -165,48 +191,12 @@ export const useInstantAnnotation = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleInstantAnnotationPointerDown = useCallback(
(doc: Document, index: number, ev: PointerEvent) => {
if (!isInstantAnnotationEnabled()) return false;
// Only handle primary button (left click / touch / stylus)
if (ev.button !== 0) return false;
if (!isSelectableContent(doc, ev.clientX, ev.clientY)) return false;
startPointRef.current = { x: ev.clientX, y: ev.clientY };
startDocRef.current = doc;
startIndexRef.current = index;
previewAnnotationRef.current = null;
annotationIdRef.current = uniqueId();
return true;
},
[isInstantAnnotationEnabled, isSelectableContent],
);
const handleInstantAnnotationPointerMove = useCallback(
(doc: Document, index: number, ev: PointerEvent) => {
if (!isInstantAnnotationEnabled()) return false;
// Draw (or redraw) the live preview highlight for a range and reposition the
// loupe. Shared by the pointer-move handler and the after-turn re-emit.
const drawPreview = useCallback(
(doc: Document, index: number, newRange: Range, dragPoint: Point): boolean => {
const view = getView(bookKey);
if (!startPointRef.current || !startDocRef.current || !view) {
return false;
}
const endPoint: Point = { x: ev.clientX, y: ev.clientY };
const startPoint = startPointRef.current;
const deltaX = Math.abs(endPoint.x - startPoint.x);
const deltaY = Math.abs(endPoint.y - startPoint.y);
const distance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
// need a longer horizontal or vertical drag to avoid accidental selections
if (distance < 20 || (deltaX / deltaY < 5 && deltaY / deltaX < 5 && distance < 30)) {
return false;
}
const newRange = createRangeFromPoints(doc, startPoint, endPoint);
if (!newRange) return false;
if (!view) return false;
const cfi = view.getCFI(index, newRange);
if (!cfi) return false;
@@ -229,18 +219,82 @@ export const useInstantAnnotation = ({
});
// Convert iframe pointer coords to parent viewport coords for the loupe
setExternalDragPoint(toParentViewportPoint(doc, ev.clientX, ev.clientY));
setExternalDragPoint(toParentViewportPoint(doc, dragPoint.x, dragPoint.y));
return true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isInstantAnnotationEnabled, createRangeFromPoints],
[bookKey, clearPreviewAnnotation, createAnnotation, getViewsById],
);
const handleInstantAnnotationPointerDown = useCallback(
(doc: Document, index: number, ev: PointerEvent) => {
if (!isInstantAnnotationEnabled()) return false;
// Only handle primary button (left click / touch / stylus)
if (ev.button !== 0) return false;
if (!isSelectableContent(doc, ev.clientX, ev.clientY)) return false;
startPointRef.current = { x: ev.clientX, y: ev.clientY };
// Anchor the start to the DOM so the range survives a page scroll.
startPosRef.current = findPositionAtPoint(doc, ev.clientX, ev.clientY);
lastEndPointRef.current = null;
startDocRef.current = doc;
startIndexRef.current = index;
previewAnnotationRef.current = null;
annotationIdRef.current = uniqueId();
return true;
},
[isInstantAnnotationEnabled, isSelectableContent, findPositionAtPoint],
);
const handleInstantAnnotationPointerMove = useCallback(
(doc: Document, index: number, ev: PointerEvent) => {
if (!isInstantAnnotationEnabled()) return false;
const view = getView(bookKey);
if (!startPointRef.current || !startDocRef.current || !view) {
return false;
}
const endPoint: Point = { x: ev.clientX, y: ev.clientY };
const startPoint = startPointRef.current;
const deltaX = Math.abs(endPoint.x - startPoint.x);
const deltaY = Math.abs(endPoint.y - startPoint.y);
const distance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
// need a longer horizontal or vertical drag to avoid accidental selections
if (distance < 20 || (deltaX / deltaY < 5 && deltaY / deltaX < 5 && distance < 30)) {
return false;
}
const newRange = buildRangeFromAnchor(doc, endPoint);
if (!newRange) return false;
lastEndPointRef.current = endPoint;
return drawPreview(doc, index, newRange, endPoint);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isInstantAnnotationEnabled, buildRangeFromAnchor, drawPreview],
);
// Rebuild the preview from the held position after a corner auto page-turn: the
// start stays DOM-anchored on the previous page while the end-point coordinates
// now resolve to content on the page just turned to.
const reapplyInstantAnnotation = useCallback(() => {
const doc = startDocRef.current;
const endPoint = lastEndPointRef.current;
if (!doc || !endPoint) return;
const newRange = buildRangeFromAnchor(doc, endPoint);
if (newRange) drawPreview(doc, startIndexRef.current, newRange, endPoint);
}, [buildRangeFromAnchor, drawPreview]);
const handleInstantAnnotationPointerCancel = useCallback(() => {
if (!isInstantAnnotationEnabled()) return false;
startPointRef.current = null;
startPosRef.current = null;
lastEndPointRef.current = null;
startDocRef.current = null;
clearInstantAnnotationState();
return true;
@@ -253,6 +307,8 @@ export const useInstantAnnotation = ({
const view = getView(bookKey);
if (!startPointRef.current || !view) {
startPointRef.current = null;
startPosRef.current = null;
lastEndPointRef.current = null;
startDocRef.current = null;
clearInstantAnnotationState();
return false;
@@ -260,19 +316,27 @@ export const useInstantAnnotation = ({
const endPoint: Point = { x: ev.clientX, y: ev.clientY };
const startPoint = startPointRef.current;
const hadPreview = !!previewAnnotationRef.current;
// Build before clearing the anchor (it reads startPosRef).
const newRange = buildRangeFromAnchor(doc, endPoint);
startPointRef.current = null;
startPosRef.current = null;
lastEndPointRef.current = null;
startDocRef.current = null;
const distance = Math.sqrt(
Math.pow(endPoint.x - startPoint.x, 2) + Math.pow(endPoint.y - startPoint.y, 2),
);
if (distance < 10) {
// A barely-moved gesture is a tap, not a highlight — unless a preview was
// actually drawn during the drag (after a cross-page turn the finger can
// land near its start screen-position while the range spans pages).
if (distance < 10 && !hadPreview) {
clearInstantAnnotationState();
return false;
}
const newRange = createRangeFromPoints(doc, startPoint, endPoint);
if (!newRange) {
clearInstantAnnotationState();
return false;
@@ -319,7 +383,7 @@ export const useInstantAnnotation = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
[
isInstantAnnotationEnabled,
createRangeFromPoints,
buildRangeFromAnchor,
getAnnotationText,
clearInstantAnnotationState,
],
@@ -327,6 +391,8 @@ export const useInstantAnnotation = ({
const cancelInstantAnnotation = useCallback(() => {
startPointRef.current = null;
startPosRef.current = null;
lastEndPointRef.current = null;
startDocRef.current = null;
clearInstantAnnotationState();
}, [clearInstantAnnotationState]);
@@ -337,6 +403,7 @@ export const useInstantAnnotation = ({
handleInstantAnnotationPointerMove,
handleInstantAnnotationPointerCancel,
handleInstantAnnotationPointerUp,
reapplyInstantAnnotation,
cancelInstantAnnotation,
};
};
@@ -7,6 +7,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { getOSPlatform } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import {
focusCaretWindowPos,
isHyphenHandleBugProneRange,
isPointerInsideSelection,
Point,
@@ -14,19 +15,9 @@ import {
repairJumpedSelectionRange,
TextSelection,
} from '@/utils/sel';
import { Corner, useAutoPageTurn } from './useAutoPageTurn';
import { useInstantAnnotation } from './useInstantAnnotation';
const ZERO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
// The selection focus must rest in a screen corner for this long before the
// page auto-turns, so merely passing a corner mid-drag doesn't flip the page.
const AUTO_TURN_DWELL_MS = 500;
// The corner zone is a quarter-ellipse within this radius of the actual corner
// (as a fraction of each axis). Kept tight so it is only the corner itself — a
// larger/rectangular zone catches normal selections that end in the lower-right
// of the page and turns the page unexpectedly.
const AUTO_TURN_CORNER_FRACTION = 0.15;
// Instant-highlight quick action: on touch a plain tap and a swipe are both
// page-turn gestures, so the highlight must not engage on pointer-down or it
// swallows the tap/swipe (an Android tap-to-paginate regression). It only engages
@@ -38,83 +29,6 @@ const INSTANT_HOLD_MS = 300;
// settling in to highlight, so the pending engagement is cancelled.
const INSTANT_HOLD_MOVE_PX = 10;
type Corner = 'br' | 'tl';
// Which screen corner a point sits in: bottom-right turns forward, top-left
// turns back. The zone is a quarter-ellipse of radius FRACTION around each
// 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 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';
return null;
};
// Map a window-coordinate point to the corner of the reading area it sits in,
// if any. Corners are measured against `area` (the visible text bounds in window
// coordinates) so they land on the text, not the page margins or a sidebar.
const cornerAt = (xWin: number, yWin: number, area: DOMRect | null): Corner | null => {
if (!area || area.width <= 0 || area.height <= 0) return null;
const x = xWin - area.left;
const y = yWin - area.top;
// Ignore a point outside the visible text (e.g. the selection caret jumping
// into the next, off-screen column while dragging at the edge).
if (x < 0 || x > area.width || y < 0 || y > area.height) return null;
return cornerOf(x, y, area.width, area.height);
};
// Window-coordinate position of the selection focus (caret), or null. The book
// content lives in a (possibly very wide, multi-column) iframe translated by the
// pagination offset, so map the caret from iframe space via the iframe element's
// on-screen rect.
const focusCaretWindowPos = (doc: Document, sel: Selection): { x: number; y: number } | null => {
const focusNode = sel.focusNode;
const win = doc.defaultView;
if (!focusNode || !win) return null;
let rect: DOMRect;
try {
const range = doc.createRange();
const offset =
focusNode.nodeType === Node.TEXT_NODE
? Math.min(sel.focusOffset, (focusNode.textContent ?? '').length)
: sel.focusOffset;
range.setStart(focusNode, offset);
range.collapse(true);
rect = range.getBoundingClientRect();
} catch {
return null;
}
// An unmeasurable range (e.g. focus on an empty element) collapses to 0,0,0,0.
if (rect.top === 0 && rect.bottom === 0 && rect.left === 0 && rect.right === 0) return null;
const feRect = win.frameElement?.getBoundingClientRect();
return {
x: (rect.left + rect.right) / 2 + (feRect?.left ?? 0),
y: (rect.top + rect.bottom) / 2 + (feRect?.top ?? 0),
};
};
// The reading frame in window coordinates: the <foliate-view> element's rect
// (a stable element, so it has a sensible page-sized width — unlike the visible
// text range, whose box spans the whole multi-column iframe), inset by the page
// content margins so the corner zone lands on the text area, not the margin.
// Falls back to the reading container (gridcell).
const getReadingAreaRect = (bookKey: string, insets: Insets = ZERO_INSETS): DOMRect | null => {
const cell = document.querySelector(`#gridcell-${bookKey}`);
if (!cell) return null;
const frame = cell.querySelector('foliate-view') ?? cell;
const r = frame.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
return new DOMRect(
r.left + insets.left,
r.top + insets.top,
Math.max(0, r.width - insets.left - insets.right),
Math.max(0, r.height - insets.top - insets.bottom),
);
};
export const useTextSelector = (
bookKey: string,
contentInsets: Insets,
@@ -131,9 +45,16 @@ export const useTextSelector = (
const bookData = getBookData(bookKey);
const osPlatform = getOSPlatform();
// The reading frame inset by the page content margins, used to measure the
// auto-turn corners so they land on the text area, not the margin.
const readingAreaRect = (): DOMRect | null => getReadingAreaRect(bookKey, contentInsets);
// Corner-dwell auto page-turn (#1354), now driven by every selection gesture
// through a shared engagement point — see useAutoPageTurn.
const {
isAutoTurning,
cornerAtPoint,
noteCorner,
noteAutoTurnPoint,
cancel: cancelAutoTurn,
onAfterTurn,
} = useAutoPageTurn(bookKey, contentInsets);
const isPopuped = useRef(false);
const isUpToPopup = useRef(false);
@@ -153,6 +74,9 @@ export const useTextSelector = (
// element is restored on release (the pointerup target may differ once the
// finger has moved across nodes).
const instantAnnotationTarget = useRef<HTMLElement | null>(null);
// Unsubscribe for the after-turn re-emit: while instant annotating, a corner
// auto-turn rebuilds the preview from the held position onto the new page.
const instantReemitUnsub = useRef<(() => void) | null>(null);
// Pending instant-highlight still-hold (touch/pen). While a hold is in flight
// these remember the press so the timer can engage at the same spot; the gate
// is armed in handlePointerDown and dropped by a release or a swipe.
@@ -160,14 +84,9 @@ export const useTextSelector = (
const instantHoldTarget = useRef<HTMLElement | null>(null);
const instantHoldStartClient = useRef<Point | null>(null);
const instantHoldStartWindow = useRef<{ x: number; y: number } | null>(null);
const autoTurnTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// The corner an input signal is currently engaged in. Stays set after a turn so
// the dwell can't re-arm while held — a signal must leave the corner and return
// to turn another page.
const engagedCorner = useRef<Corner | null>(null);
const isAutoTurning = useRef(false);
// Latest pointer position in window coords (from pointermove or, on Android,
// native touchmove). One of the engagement signals alongside the caret.
// native touchmove): an auto-turn engagement signal alongside the caret, and
// the finger position the Android hyphen repair rebuilds from.
const pointerPos = useRef<{ x: number; y: number } | null>(null);
// Android hyphen selection-bounds bug (#1553): the selection anchor captured
@@ -200,6 +119,7 @@ export const useTextSelector = (
handleInstantAnnotationPointerMove,
handleInstantAnnotationPointerCancel,
handleInstantAnnotationPointerUp,
reapplyInstantAnnotation,
} = useInstantAnnotation({
bookKey,
getAnnotationText,
@@ -243,6 +163,8 @@ export const useTextSelector = (
instantAnnotationTarget.current = target;
if (view) view.renderer.scrollLocked = true;
target.style.userSelect = 'none';
instantReemitUnsub.current?.();
instantReemitUnsub.current = onAfterTurn(() => reapplyInstantAnnotation());
};
const stopInstantAnnotating = () => {
@@ -250,6 +172,8 @@ export const useTextSelector = (
isInstantAnnotated.current = false;
annotationStartPoint.current = null;
if (view) view.renderer.scrollLocked = false;
instantReemitUnsub.current?.();
instantReemitUnsub.current = null;
if (instantAnnotationTarget.current) {
instantAnnotationTarget.current.style.userSelect = '';
instantAnnotationTarget.current = null;
@@ -357,6 +281,11 @@ export const useTextSelector = (
}
ev.preventDefault();
isInstantAnnotated.current = handleInstantAnnotationPointerMove(doc, index, ev);
// Cross-page instant highlight: feed the finger corner into the same dwell
// machine native selection uses, so the page turns and the highlight
// continues across the boundary (the start is DOM-anchored in
// useInstantAnnotation so it survives the scroll).
noteAutoTurnPoint(getViewSettings(bookKey)?.scrolled ? null : pointerPos.current);
return;
}
@@ -371,7 +300,7 @@ export const useTextSelector = (
const sel = doc.getSelection();
const valid = !!sel && isValidSelection(sel);
const corner = !viewSettings?.scrolled && valid ? pointerCornerNow() : null;
noteCorner(corner, doc);
noteCorner(corner, (c) => inCorner(c, doc));
};
// Android native touchmove — the pointer engagement signal during a native
@@ -382,20 +311,16 @@ export const useTextSelector = (
pointerPos.current = { x: x / dpr, y: y / dpr };
maybeCancelInstantHoldOnMove();
const viewSettings = getViewSettings(bookKey);
// Instant highlight has no DOM selection (user-select is off); feed the
// finger corner directly so the drag can turn the page across boundaries.
if (isInstantAnnotating.current) {
noteAutoTurnPoint(viewSettings?.scrolled ? null : pointerPos.current);
return;
}
const sel = doc.getSelection();
const valid = !!sel && isValidSelection(sel);
const corner = !viewSettings?.scrolled && valid ? pointerCornerNow() : null;
noteCorner(corner, doc);
};
// Disengage and drop any pending corner page-turn (e.g. when the selection is
// cleared).
const cancelAutoTurn = () => {
engagedCorner.current = null;
if (autoTurnTimer.current) {
clearTimeout(autoTurnTimer.current);
autoTurnTimer.current = null;
}
noteCorner(corner, (c) => inCorner(c, doc));
};
const handlePointerCancel = (_doc: Document, _index: number, _ev: PointerEvent) => {
@@ -560,66 +485,19 @@ export const useTextSelector = (
};
// The corner the latest pointer (pointermove / native touchmove) position is in.
const pointerCornerNow = (): Corner | null => {
const p = pointerPos.current;
return p ? cornerAt(p.x, p.y, readingAreaRect()) : null;
};
const pointerCornerNow = (): Corner | null => cornerAtPoint(pointerPos.current);
// The corner the selection caret (focus) is in.
const caretCornerNow = (doc: Document): Corner | null => {
const sel = doc.getSelection();
if (!sel || !isValidSelection(sel)) return null;
const pos = focusCaretWindowPos(doc, sel);
return pos ? cornerAt(pos.x, pos.y, readingAreaRect()) : null;
return cornerAtPoint(focusCaretWindowPos(doc, sel));
};
// Whether any input signal (pointer/touch or caret) is currently in corner `c`.
// Injected into the dwell machine as the native-selection liveness predicate so
// the page only turns while the caret OR the finger is still in the corner.
const inCorner = (c: Corner, doc: Document): boolean =>
pointerCornerNow() === c || caretCornerNow(doc) === c;
// Once a signal has stayed inside a corner for AUTO_TURN_DWELL_MS, turn one page
// (#1354). One turn per engagement — a signal must leave the corner and return
// to turn again (engagedCorner stays set after a turn so the dwell can't re-arm
// while held).
const armDwell = (corner: Corner, doc: Document) => {
if (autoTurnTimer.current) return;
autoTurnTimer.current = setTimeout(() => {
autoTurnTimer.current = null;
const sel = doc.getSelection();
// Skip if the selection ended or every signal left the corner during the dwell.
if (isAutoTurning.current || !sel || !isValidSelection(sel) || !inCorner(corner, doc)) return;
// On Android an active selection pins the container scroll (issue #873 in
// handleScroll). A deliberate page-turn IS a container scroll, so it gets
// snapped straight back unless we suspend the pin for the turn and then
// re-anchor it to the page we land on.
isAutoTurning.current = true;
// Logical next()/prev() so RTL books turn the correct way.
const turning = corner === 'br' ? view?.next() : view?.prev();
Promise.resolve(turning).finally(() => {
selectionPosition.current = view?.renderer?.containerPosition ?? selectionPosition.current;
isAutoTurning.current = false;
});
}, AUTO_TURN_DWELL_MS);
};
// Feed a corner detected from an input signal (pointer/touch/caret) into the
// dwell state machine. Entering a corner arms the dwell; once no signal is in
// the engaged corner any more it disengages so a re-entry can turn again.
const noteCorner = (corner: Corner | null, doc: Document) => {
if (isAutoTurning.current) return;
if (corner) {
if (engagedCorner.current !== corner) {
engagedCorner.current = corner;
armDwell(corner, doc);
}
} else if (engagedCorner.current && !inCorner(engagedCorner.current, doc)) {
engagedCorner.current = null;
if (autoTurnTimer.current) {
clearTimeout(autoTurnTimer.current);
autoTurnTimer.current = null;
}
}
};
const handleSelectionchange = (doc: Document, index: number) => {
// Echo of our own programmatic selection writes (handle suppression or a
// custom-handle drag) — not user input.
@@ -651,7 +529,7 @@ export const useTextSelector = (
// selection drag, where pointer/touch-move don't fire). Feed it into the same
// dwell machine the pointer uses.
if (isValidSelection(sel)) {
noteCorner(!viewSettings?.scrolled ? caretCornerNow(doc) : null, doc);
noteCorner(!viewSettings?.scrolled ? caretCornerNow(doc) : null, (c) => inCorner(c, doc));
} else {
cancelAutoTurn();
}
@@ -743,9 +621,16 @@ export const useTextSelector = (
};
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
// After any auto page-turn, re-anchor the Android selection scroll-pin to the
// page we landed on (the #873 pin in handleScroll). Harmless when nothing is
// pinned (instant highlight / range editors carry no DOM selection).
const unsubAfterTurn = onAfterTurn(() => {
selectionPosition.current =
getView(bookKey)?.renderer?.containerPosition ?? selectionPosition.current;
});
return () => {
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
if (autoTurnTimer.current) clearTimeout(autoTurnTimer.current);
unsubAfterTurn();
if (instantHoldTimer.current) clearTimeout(instantHoldTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -768,5 +653,10 @@ export const useTextSelector = (
handleUpToPopup,
handleContextmenu,
applyProgrammaticSelection,
// The shared corner auto-turn feed/cancel/subscribe, re-exposed so the range
// editors can drive the same machine from their overlay handle drags.
noteAutoTurnPoint,
cancelAutoTurn,
onAutoTurn: onAfterTurn,
};
};
+31
View File
@@ -593,6 +593,37 @@ export const isHyphenHandleBugProneRange = (range: Range, vertical = false): boo
return blockHasGeneratedHyphens(block, vertical);
};
// Window-coordinate position of the selection focus (caret), or null. The book
// content lives in a (possibly very wide, multi-column) iframe translated by the
// pagination offset, so map the caret from iframe space via the iframe element's
// on-screen rect. Used by the corner auto page-turn (the caret is an engagement
// signal) and the keyboard turn-on-cross check.
export const focusCaretWindowPos = (doc: Document, sel: Selection): Point | null => {
const focusNode = sel.focusNode;
const win = doc.defaultView;
if (!focusNode || !win) return null;
let rect: DOMRect;
try {
const range = doc.createRange();
const offset =
focusNode.nodeType === Node.TEXT_NODE
? Math.min(sel.focusOffset, (focusNode.textContent ?? '').length)
: sel.focusOffset;
range.setStart(focusNode, offset);
range.collapse(true);
rect = range.getBoundingClientRect();
} catch {
return null;
}
// An unmeasurable range (e.g. focus on an empty element) collapses to 0,0,0,0.
if (rect.top === 0 && rect.bottom === 0 && rect.left === 0 && rect.right === 0) return null;
const feRect = win.frameElement?.getBoundingClientRect();
return {
x: (rect.left + rect.right) / 2 + (feRect?.left ?? 0),
y: (rect.top + rect.bottom) / 2 + (feRect?.top ?? 0),
};
};
// Rebuild a selection range between a known-good anchor and the caret at a
// point (in `doc` viewport coordinates) — used to restore the range a
// corrupted long-press drag was meant to produce: anchored at the