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