diff --git a/apps/readest-app/src/__tests__/android/double-click.android.test.ts b/apps/readest-app/src/__tests__/android/double-click.android.test.ts new file mode 100644 index 00000000..14c33748 --- /dev/null +++ b/apps/readest-app/src/__tests__/android/double-click.android.test.ts @@ -0,0 +1,112 @@ +import path from 'node:path'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { doubleTap } from './helpers/adb'; +import { CdpPage } from './helpers/cdp'; +import { + detectAndroidEnv, + dismissSelection, + getSelectionState, + openFixtureBook, + waitFor, +} from './helpers/reader'; + +// End-to-end coverage for the double-click / touch double-tap gesture: tapping a +// word twice quickly selects that word — as if long-press-selecting it — and +// raises the annotation toolbar. With the default config no quick action is set +// (annotationQuickAction === null), so the toolbar (.selection-popup) appears. +// +// Runs against any adb device/emulator with Readest installed; soft-skips +// otherwise. + +const FIXTURE = path.resolve(__dirname, '../fixtures/data/sample-alice.epub'); + +interface WordHit { + word: string; + deviceX: number; + deviceY: number; +} + +// Find a comfortably on-screen word (>= 4 latin letters, no adjacent apostrophe +// so the native word matches the segmenter word), away from the page edges so +// the tap lands on text rather than a margin. +const locateAnyWord = (page: CdpPage) => + page.evaluate(` + const view = document.querySelector('foliate-view'); + const dpr = window.devicePixelRatio || 1; + const W = window.innerWidth, H = window.innerHeight; + for (const c of view.renderer.getContents()) { + if (!c.doc) continue; + const walker = c.doc.createTreeWalker(c.doc.body, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + const re = /[A-Za-z]{4,}/g; + let m; + while ((m = re.exec(node.data))) { + const before = node.data[m.index - 1] || ' '; + const after = node.data[m.index + m[0].length] || ' '; + if (/['\\u2019]/.test(before) || /['\\u2019]/.test(after)) continue; + const range = c.doc.createRange(); + range.setStart(node, m.index); + range.setEnd(node, m.index + m[0].length); + const rect = range.getBoundingClientRect(); + if (!rect.width || !rect.height) continue; + const frame = c.doc.defaultView.frameElement.getBoundingClientRect(); + const cssX = frame.left + rect.left + rect.width / 2; + const cssY = frame.top + rect.top + rect.height / 2; + if (cssX < W * 0.15 || cssX > W * 0.85) continue; + if (cssY < H * 0.2 || cssY > H * 0.8) continue; + return { word: m[0], deviceX: Math.round(cssX * dpr), deviceY: Math.round(cssY * dpr) }; + } + } + } + return null; + `); + +const hasAnnotPopup = (page: CdpPage) => + page.evaluate(`return !!document.querySelector('.selection-popup');`); + +const env = await detectAndroidEnv(); +if (!env) { + console.warn('[test:android] no adb device with Readest installed — skipping the Android lane'); +} + +describe.runIf(env)('Android double-tap word selection + toolbar', () => { + let page: CdpPage; + + beforeAll(async () => { + page = await openFixtureBook(FIXTURE); + }, 120_000); + + afterAll(() => { + page?.close(); + }); + + beforeEach(async () => { + const sel = await getSelectionState(page); + if (sel.exists && !sel.collapsed) await dismissSelection(page); + }, 60_000); + + it('selects the double-tapped word and shows the annotation toolbar', async () => { + const hit = await waitFor(() => locateAnyWord(page), { label: 'on-screen word' }); + + await doubleTap(hit.deviceX, hit.deviceY); + + const sel = await waitFor( + async () => { + const s = await getSelectionState(page); + return s.exists && !s.collapsed ? s : null; + }, + { label: `selection of "${hit.word}"` }, + ); + // The whole word is selected, like a long-press word selection. + expect(sel.text).toBe(hit.word); + + // No quick action configured by default, so the annotation toolbar appears. + const shown = await waitFor(async () => (await hasAnnotPopup(page)) || null, { + label: 'annotation toolbar', + }); + expect(shown).toBe(true); + + await dismissSelection(page); + }); +}); diff --git a/apps/readest-app/src/__tests__/android/helpers/adb.ts b/apps/readest-app/src/__tests__/android/helpers/adb.ts index 258a577a..90e8cc66 100644 --- a/apps/readest-app/src/__tests__/android/helpers/adb.ts +++ b/apps/readest-app/src/__tests__/android/helpers/adb.ts @@ -53,6 +53,14 @@ export const tap = (x: number, y: number): Promise => export const longPress = (x: number, y: number, ms = 700): Promise => adbShell(`input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${ms}`); +// Two taps in a single shell invocation so both `click` events reach the WebView +// inside the double-click window (DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250ms). +export const doubleTap = (x: number, y: number): Promise => { + const rx = Math.round(x); + const ry = Math.round(y); + return adbShell(`input tap ${rx} ${ry} && input tap ${rx} ${ry}`); +}; + export interface MotionStep { x: number; y: number; diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-doubleClick.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-doubleClick.test.ts new file mode 100644 index 00000000..65cd8d9b --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-doubleClick.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, renderHook } from '@testing-library/react'; + +// A double-click (or touch double-tap) on a word should select that word and +// route it through the same selection state that drives the quick action / +// annotation toolbar — mirroring a long-press selection. + +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: () => false, + handleInstantAnnotationPointerDown: vi.fn(() => true), + handleInstantAnnotationPointerMove: vi.fn(() => true), + handleInstantAnnotationPointerCancel: vi.fn(), + handleInstantAnnotationPointerUp: vi.fn(async () => false), + reapplyInstantAnnotation: vi.fn(), + cancelInstantAnnotation: vi.fn(), + }), +})); +vi.mock('@/utils/misc', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOSPlatform: () => h.osPlatform }; +}); + +import { useTextSelector } from '@/app/reader/hooks/useTextSelector'; +import type { TextSelection } from '@/utils/sel'; + +const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 }; + +const setup = (setSelection: (s: TextSelection | null) => void) => { + const noop = vi.fn(); + return renderHook(() => + useTextSelector( + 'book-1', + ZERO_INSETS, + setSelection as React.Dispatch>, + noop, + noop, + // getAnnotationText: return the range text so we can assert the selected word + vi.fn(async (range: Range) => range.toString()), + noop, + ), + ); +}; + +// A jsdom document carrying a real text node and a caretRangeFromPoint that +// resolves to a caret inside "world". +const makeDoc = (caretOffset: number | null) => { + const container = document.createElement('div'); + const node = document.createTextNode('Hello world test'); + container.appendChild(node); + document.body.appendChild(container); + Object.assign(document, { + caretRangeFromPoint: () => { + if (caretOffset === null) return null; + const r = document.createRange(); + r.setStart(node, caretOffset); + r.collapse(true); + return r; + }, + }); + return { container, node, doc: document as Document }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + h.appService = { isAndroidApp: false, isMobile: false }; + h.osPlatform = 'macos'; + h.viewSettings = { scrolled: false }; + document.getSelection()?.removeAllRanges(); +}); + +afterEach(() => { + cleanup(); + delete (document as { caretRangeFromPoint?: unknown }).caretRangeFromPoint; + document.body.innerHTML = ''; +}); + +describe('useTextSelector double-click word selection', () => { + test('selects the word at the point and reports it as a selection', async () => { + const setSelection = vi.fn(); + const { result } = setup(setSelection); + const { container, doc } = makeDoc(8); // caret inside "world" + + await result.current.handleDoubleClick(doc, 0, 50, 50); + + expect(setSelection).toHaveBeenCalledTimes(1); + const arg = setSelection.mock.calls[0]![0] as TextSelection; + expect(arg.text).toBe('world'); + expect(arg.index).toBe(0); + // The DOM selection now holds the word, like a long-press selection. + expect(document.getSelection()?.toString()).toBe('world'); + expect(result.current.isTextSelected.current).toBe(true); + + document.body.removeChild(container); + }); + + test('does nothing when the point is not on a word', async () => { + const setSelection = vi.fn(); + const { result } = setup(setSelection); + const { container, doc } = makeDoc(null); // caretRangeFromPoint resolves nothing + + await result.current.handleDoubleClick(doc, 0, 0, 0); + + expect(setSelection).not.toHaveBeenCalled(); + document.body.removeChild(container); + }); + + test('skips when a native selection already exists (desktop double-click path)', async () => { + const setSelection = vi.fn(); + const { result } = setup(setSelection); + const { container, node, doc } = makeDoc(8); + + // Simulate the browser having already selected the word natively. + const pre = document.createRange(); + pre.setStart(node, 6); + pre.setEnd(node, 11); + const sel = document.getSelection()!; + sel.removeAllRanges(); + sel.addRange(pre); + + await result.current.handleDoubleClick(doc, 0, 50, 50); + + // The existing pointerup path owns this; the double-click handler must not + // double-fire the selection. + expect(setSelection).not.toHaveBeenCalled(); + document.body.removeChild(container); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/sel.test.ts b/apps/readest-app/src/__tests__/utils/sel.test.ts index 7e8b0661..1b90777c 100644 --- a/apps/readest-app/src/__tests__/utils/sel.test.ts +++ b/apps/readest-app/src/__tests__/utils/sel.test.ts @@ -531,4 +531,95 @@ describe('sel utilities', () => { document.body.removeChild(container); }); }); + + describe('getWordRangeAt', () => { + const withTextNode = (text: string) => { + const container = document.createElement('div'); + const node = document.createTextNode(text); + container.appendChild(node); + document.body.appendChild(container); + return { container, node }; + }; + + it('expands a caret in the middle of a word to the whole word', async () => { + const { container, node } = withTextNode('Hello world test'); + const { getWordRangeAt } = await import('@/utils/sel'); + // offset 8 = the "r" inside "world" + const range = getWordRangeAt(node, 8); + expect(range?.toString()).toBe('world'); + document.body.removeChild(container); + }); + + it('selects the word when the caret sits at the word start boundary', async () => { + const { container, node } = withTextNode('Hello world test'); + const { getWordRangeAt } = await import('@/utils/sel'); + const range = getWordRangeAt(node, 6); // start of "world" + expect(range?.toString()).toBe('world'); + document.body.removeChild(container); + }); + + it('selects the word when the caret sits at the word end boundary', async () => { + const { container, node } = withTextNode('Hello world test'); + const { getWordRangeAt } = await import('@/utils/sel'); + const range = getWordRangeAt(node, 11); // end of "world" + expect(range?.toString()).toBe('world'); + document.body.removeChild(container); + }); + + it('selects a CJK word at the caret', async () => { + const { container, node } = withTextNode('阅读测试内容'); + const { getWordRangeAt } = await import('@/utils/sel'); + const range = getWordRangeAt(node, 1); + expect(range && range.toString().length > 0).toBe(true); + document.body.removeChild(container); + }); + + it('returns null on whitespace / non-word positions', async () => { + const { container, node } = withTextNode(' '); + const { getWordRangeAt } = await import('@/utils/sel'); + expect(getWordRangeAt(node, 1)).toBeNull(); + document.body.removeChild(container); + }); + + it('returns null when the node is not a text node', async () => { + const container = document.createElement('div'); + container.innerHTML = 'Hello'; + document.body.appendChild(container); + const { getWordRangeAt } = await import('@/utils/sel'); + expect(getWordRangeAt(container, 0)).toBeNull(); + document.body.removeChild(container); + }); + }); + + describe('getWordRangeFromPoint', () => { + it('resolves the word under the point via caretRangeFromPoint', async () => { + const container = document.createElement('div'); + const node = document.createTextNode('Hello world test'); + container.appendChild(node); + document.body.appendChild(container); + + const caretRange = document.createRange(); + caretRange.setStart(node, 8); + caretRange.collapse(true); + const doc = Object.assign(document, { + caretRangeFromPoint: () => caretRange, + }) as Document & { caretRangeFromPoint: (x: number, y: number) => Range | null }; + + const { getWordRangeFromPoint } = await import('@/utils/sel'); + const range = getWordRangeFromPoint(doc, 50, 50); + expect(range?.toString()).toBe('world'); + + delete (doc as { caretRangeFromPoint?: unknown }).caretRangeFromPoint; + document.body.removeChild(container); + }); + + it('returns null when no caret resolves at the point', async () => { + const doc = Object.assign(document, { + caretRangeFromPoint: () => null, + }) as Document & { caretRangeFromPoint: (x: number, y: number) => Range | null }; + const { getWordRangeFromPoint } = await import('@/utils/sel'); + expect(getWordRangeFromPoint(doc, 0, 0)).toBeNull(); + delete (doc as { caretRangeFromPoint?: unknown }).caretRangeFromPoint; + }); + }); }); diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 42443681..24d54abf 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -322,6 +322,7 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ handleNativeTouchMove, handlePointerCancel, handlePointerUp, + handleDoubleClick, handleSelectionchange, handleShowPopup, handleUpToPopup, @@ -612,6 +613,33 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ listenToNativeTouchEvents, }); + // A double-click / touch double-tap on a word selects that word and raises the + // quick action (if one is configured) or the annotation toolbar — like a + // long-press selection. The iframe posts `iframe-double-click` (gated by the + // user's double-click setting) with coordinates in the originating section's + // viewport; resolve the visible section's doc/index the way the native-touch + // bridge does, then select the word under the point. + useEffect(() => { + const handleDoubleClickMessage = (msg: MessageEvent) => { + const data = msg.data; + if (!data || data.bookKey !== bookKey || data.type !== 'iframe-double-click') return; + const renderer = view?.renderer; + const contents = renderer?.getContents?.() ?? []; + const content = contents.find((c) => c.index === renderer?.primaryIndex) ?? contents[0]; + const doc = content?.doc; + const index = content?.index; + if (!doc || index === undefined) return; + // A double-click is a deliberate act-on-word gesture, so let the quick + // action fire without the touch long-press hold gate (matching a mouse + // selection, which sets this to 0 on pointerdown). + pointerDownTimeRef.current = 0; + void handleDoubleClick(doc, index, data.clientX, data.clientY); + }; + window.addEventListener('message', handleDoubleClickMessage); + return () => window.removeEventListener('message', handleDoubleClickMessage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey, view]); + // Word Lens: open the dictionary popup for a tapped glossed word. The tap is // detected in the iframe click handler (iframeEventHandlers.ts), which sends // the gloss element here. We synthesize a selection over the base word diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts index 11f6df47..0ae1eee8 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -8,6 +8,7 @@ import { getOSPlatform } from '@/utils/misc'; import { eventDispatcher } from '@/utils/event'; import { focusCaretWindowPos, + getWordRangeFromPoint, isHyphenHandleBugProneRange, isPointerInsideSelection, Point, @@ -419,6 +420,29 @@ export const useTextSelector = ( } }; + // A double-click / touch double-tap on a word: select the word (like a + // long-press selection) and route it through the same selection state that + // drives the quick action / annotation toolbar. On desktop the browser already + // selects the word natively on a real double-click, and that selection flows + // through handlePointerUp; so we only synthesize the selection when nothing is + // selected yet — the touch double-tap case (Android has no native word-select + // gesture), where the dblclick is detected from two quick taps. + const handleDoubleClick = async (doc: Document, index: number, x: number, y: number) => { + if (isInstantAnnotating.current) return; + const sel = doc.getSelection(); + if (!sel || isValidSelection(sel)) return; + const range = getWordRangeFromPoint(doc, x, y); + if (!range) return; + guardProgrammaticSelection(); + sel.removeAllRanges(); + sel.addRange(range); + releaseProgrammaticSelection(); + // No isUpToPopup latch here: a double-tap is two taps both consumed by the + // double-click detection, so no trailing single-click follows that would + // dismiss the popup — the next deliberate tap should dismiss it normally. + await makeSelection(sel, index, false); + }; + const handlePointerUp = async (doc: Document, index: number, ev?: PointerEvent) => { isPointerDown.current = false; // A tap (or a long-press shorter than the hold) that never engaged: drop the @@ -648,6 +672,7 @@ export const useTextSelector = ( handleNativeTouchMove, handlePointerCancel, handlePointerUp, + handleDoubleClick, handleSelectionchange, handleShowPopup, handleUpToPopup, diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts index 08c960bf..43d3439e 100644 --- a/apps/readest-app/src/utils/sel.ts +++ b/apps/readest-app/src/utils/sel.ts @@ -475,6 +475,60 @@ export const snapRangeToWords = (range: Range): void => { snapEndToWordBoundary(); }; +// Expand a caret position (a text node + offset) to the word-like segment that +// contains it — the same word a native double-click would select. Returns null +// when the position isn't inside word-like text (whitespace, punctuation, a +// non-text node). CJK is segmented via Intl.Segmenter, matching snapRangeToWords. +export const getWordRangeAt = (node: Node, offset: number): Range | null => { + if (node.nodeType !== Node.TEXT_NODE) return null; + if (typeof Intl === 'undefined' || !Intl.Segmenter) return null; + const text = node.textContent ?? ''; + if (!text) return null; + const doc = node.ownerDocument; + if (!doc) return null; + const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }); + for (const seg of segmenter.segment(text)) { + if (!seg.isWordLike) continue; + const start = seg.index; + const end = seg.index + seg.segment.length; + // The caret falls inside this word, or sits exactly on either edge (a + // caret-from-point at a word boundary should still select the adjacent word). + if (offset >= start && offset <= end) { + const range = doc.createRange(); + try { + range.setStart(node, start); + range.setEnd(node, end); + } catch { + return null; + } + return range.collapsed ? null : range; + } + } + return null; +}; + +// The word range under a point (in `doc` viewport coordinates), like a native +// double-click. Returns null when the point isn't on word-like text. +export const getWordRangeFromPoint = (doc: Document, x: number, y: number): Range | null => { + let node: Node | null = null; + let offset = 0; + if (doc.caretPositionFromPoint) { + const pos = doc.caretPositionFromPoint(x, y); + if (pos) { + node = pos.offsetNode; + offset = pos.offset; + } + } else if (doc.caretRangeFromPoint) { + const range = doc.caretRangeFromPoint(x, y); + if (range) { + node = range.startContainer; + offset = range.startOffset; + } + } + if (!node) return null; + return getWordRangeAt(node, offset); +}; + // --- Android hyphenation selection-bounds bug (issue #1553) ----------------- // // Blink's `LayoutSelection::ComputePaintingSelectionStateForCursor` compares