forked from akai/readest
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user