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:
Huang Xin
2026-06-29 01:47:09 +08:00
committed by GitHub
parent eaf307e71e
commit 70bad93ebf
7 changed files with 475 additions and 0 deletions
+54
View File
@@ -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