diff --git a/apps/readest-app/.claude/plans/2026-06-24-cross-page-highlight-autoturn-design.md b/apps/readest-app/.claude/plans/2026-06-24-cross-page-highlight-autoturn-design.md new file mode 100644 index 00000000..166fbe6b --- /dev/null +++ b/apps/readest-app/.claude/plans/2026-06-24-cross-page-highlight-autoturn-design.md @@ -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. diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/useAnnotationEditor.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/useAnnotationEditor.test.ts new file mode 100644 index 00000000..a356809b --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/hooks/useAnnotationEditor.test.ts @@ -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(); + }); +}); diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/useAutoPageTurn.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/useAutoPageTurn.test.ts new file mode 100644 index 00000000..d6835c47 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/hooks/useAutoPageTurn.test.ts @@ -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); + }); +}); diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/useInstantAnnotation.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/useInstantAnnotation.test.ts new file mode 100644 index 00000000..2d89d725 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/hooks/useInstantAnnotation.test.ts @@ -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); + }); +}); diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-autoTurn.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-autoTurn.test.ts index d4ec0e20..031327de 100644 --- a/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-autoTurn.test.ts +++ b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-autoTurn.test.ts @@ -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(), }), })); diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantHold.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantHold.test.ts index 580d2ca9..e9073891 100644 --- a/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantHold.test.ts +++ b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantHold.test.ts @@ -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(), }), })); diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantTurn.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantTurn.test.ts new file mode 100644 index 00000000..f5494d38 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantTurn.test.ts @@ -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(); + 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['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(); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx index ce904353..57f17ed3 100644 --- a/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx @@ -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; setSelection: React.Dispatch>; onStartEdit: () => void; + noteAutoTurnPoint: (point: Point | null) => void; + cancelAutoTurn: () => void; + onAutoTurn: (cb: () => void) => () => void; } const AnnotationRangeEditor: React.FC = ({ @@ -152,6 +155,9 @@ const AnnotationRangeEditor: React.FC = ({ getAnnotationText, setSelection, onStartEdit, + noteAutoTurnPoint, + cancelAutoTurn, + onAutoTurn, }) => { const { appService } = useEnv(); const { settings } = useSettingsStore(); @@ -160,7 +166,7 @@ const AnnotationRangeEditor: React.FC = ({ 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 = ({ const dragPointerTypeRef = useRef(''); const startRef = useRef({ x: 0, y: 0 }); const endRef = useRef({ 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({ x: 0, y: 0 }); const [currentEnd, setCurrentEnd] = useState({ x: 0, y: 0 }); @@ -184,6 +197,11 @@ const AnnotationRangeEditor: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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; diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 33cb8b88..69e700f1 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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 && ( diff --git a/apps/readest-app/src/app/reader/components/annotator/SelectionRangeEditor.tsx b/apps/readest-app/src/app/reader/components/annotator/SelectionRangeEditor.tsx index 517f4483..c1feed01 100644 --- a/apps/readest-app/src/app/reader/components/annotator/SelectionRangeEditor.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/SelectionRangeEditor.tsx @@ -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 = ({ handleColor, onRangeChange, onStartDrag, + noteAutoTurnPoint, + cancelAutoTurn, + onAutoTurn, }) => { const { appService } = useEnv(); const { settings } = useSettingsStore(); @@ -45,6 +51,8 @@ const SelectionRangeEditor: React.FC = ({ const startRef = useRef({ x: 0, y: 0 }); const endRef = useRef({ 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({ x: 0, y: 0 }); const [currentEnd, setCurrentEnd] = useState({ x: 0, y: 0 }); @@ -86,18 +94,32 @@ const SelectionRangeEditor: React.FC = ({ 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 = ({ 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 = ({ 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; diff --git a/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts b/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts index 810259a8..ab975d70 100644 --- a/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts +++ b/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts @@ -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, }; }; diff --git a/apps/readest-app/src/app/reader/hooks/useAutoPageTurn.ts b/apps/readest-app/src/app/reader/hooks/useAutoPageTurn.ts new file mode 100644 index 00000000..4008514b --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useAutoPageTurn.ts @@ -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 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 | 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(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(null); + const afterTurnSubs = useRef 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, + }; +}; diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index 47da5c78..746b23a0 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -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 = () => { diff --git a/apps/readest-app/src/app/reader/hooks/useInstantAnnotation.ts b/apps/readest-app/src/app/reader/hooks/useInstantAnnotation.ts index 4e58ab36..881a0566 100644 --- a/apps/readest-app/src/app/reader/hooks/useInstantAnnotation.ts +++ b/apps/readest-app/src/app/reader/hooks/useInstantAnnotation.ts @@ -29,8 +29,16 @@ export const useInstantAnnotation = ({ const { getView, getViewsById, getViewSettings, getProgress } = useReaderStore(); const startPointRef = useRef(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(null); const startIndexRef = useRef(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(null); const previewAnnotationRef = useRef(null); const annotationIdRef = useRef(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, }; }; diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts index 940395a8..11f6df47 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -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 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(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(null); const instantHoldStartClient = useRef(null); const instantHoldStartWindow = useRef<{ x: number; y: number } | null>(null); - const autoTurnTimer = useRef | 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(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, }; }; diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts index df166b7a..08c960bf 100644 --- a/apps/readest-app/src/utils/sel.ts +++ b/apps/readest-app/src/utils/sel.ts @@ -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