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