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;
|
||||
|
||||
@@ -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<typeof import('@/utils/misc')>();
|
||||
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<React.SetStateAction<TextSelection | null>>,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 = '<span>Hello</span>';
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <ruby> element here. We synthesize a selection over the base word
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user