forked from akai/readest
* 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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user