forked from akai/readest
feat(reader): select word on double-click and run instant action or toolbar (#4846)
Double-click (mouse) or touch double-tap on a word now selects that word, like a long-press selection, then runs the configured instant quick action or raises the annotation toolbar when none is set. The iframe posted iframe-double-click but nothing consumed it, so a touch double-tap did nothing (Android has no native double-tap word-select; on desktop the browser already selects the word natively via the pointerup path). - sel.ts: getWordRangeAt expands a caret to its word-like segment via Intl.Segmenter (CJK and Latin); getWordRangeFromPoint resolves the caret at a point and delegates. - useTextSelector: handleDoubleClick selects the word and routes through the existing makeSelection flow (guarded so the programmatic selectionchange echo is ignored). It no-ops when a native selection already exists, so the desktop double-click path is not double-fired. - Annotator: consume iframe-double-click, resolve the visible section doc/index, and set pointerDownTimeRef to 0 so the deliberate double-tap bypasses the touch long-press hold gate before the instant action fires. Tests: unit coverage for the word-range helpers and the selection routing (plus the desktop guard), and an Android CDP e2e for the double-tap gesture on a real device. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<WordHit | null>(`
|
||||
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<boolean>(`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);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,14 @@ export const tap = (x: number, y: number): Promise<string> =>
|
||||
export const longPress = (x: number, y: number, ms = 700): Promise<string> =>
|
||||
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<string> => {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user