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:
@@ -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