fix(annotator): defer Android quick action until touchend, closes #3935 (#3979)

On Android, long-press selects text via selectionchange while the finger
is still on the screen. The quick action handler was gated by
androidTouchEndRef and silently returned, so no popup ever opened. After
the user lifted, nothing re-ran the gated action.

Track the gated action in a small DeferredActionState ref and flush it
from the native touchend handler, so instant copy/dictionary/wikipedia/
search/translate/tts now fire on the first long-press release.
This commit is contained in:
Huang Xin
2026-04-27 23:37:22 +08:00
committed by GitHub
parent 4f55920b71
commit 94ede10f6e
3 changed files with 176 additions and 26 deletions
@@ -26,6 +26,12 @@ import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/services/nav';
import { throttle } from '@/utils/throttle';
import {
cancelDeferredAction,
createDeferredActionState,
flushDeferredAction,
runOrDeferAction,
} from '../../utils/deferredAction';
import { runSimpleCC } from '@/utils/simplecc';
import { getWordCount } from '@/utils/word';
import { getIndexFromCfi, isCfiInLocation } from '@/utils/cfi';
@@ -95,6 +101,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalReadSettings.highlightStyles[selectedStyle],
);
const androidTouchEndRef = useRef(false);
// Holds a quick action that fired while the user is still touching the screen
// (Android long-press selects text via selectionchange before touchend). The
// pending action runs on touchend so popups don't open under an active touch.
const deferredQuickActionRef = useRef(createDeferredActionState());
const showingPopup =
showAnnotPopup ||
@@ -256,11 +266,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const ev = event.detail as NativeTouchEventType;
if (ev.type === 'touchstart') {
androidTouchEndRef.current = false;
cancelDeferredAction(deferredQuickActionRef.current);
handleTouchStart();
} else if (ev.type === 'touchend') {
androidTouchEndRef.current = true;
handleTouchEnd();
handlePointerUp(doc, index);
flushDeferredAction(deferredQuickActionRef.current);
}
};
@@ -489,32 +501,41 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleQuickAction = () => {
const action = viewSettings.annotationQuickAction;
if (appService?.isAndroidApp && !androidTouchEndRef.current) return;
switch (action) {
case 'copy':
handleCopy(false);
handleDismissPopupAndSelection();
break;
case 'highlight':
// highlight is already applied in instant annotating
handleDismissPopupAndSelection();
break;
case 'search':
handleSearch();
break;
case 'dictionary':
handleDictionary();
break;
case 'wikipedia':
handleWikipedia();
break;
case 'translate':
handleTranslation();
break;
case 'tts':
handleSpeakText(true);
break;
}
const runAction = () => {
switch (action) {
case 'copy':
handleCopy(false);
handleDismissPopupAndSelection();
break;
case 'highlight':
// highlight is already applied in instant annotating
handleDismissPopupAndSelection();
break;
case 'search':
handleSearch();
break;
case 'dictionary':
handleDictionary();
break;
case 'wikipedia':
handleWikipedia();
break;
case 'translate':
handleTranslation();
break;
case 'tts':
handleSpeakText(true);
break;
}
};
// On Android, a long-press fires selectionchange (and this handler) while
// the finger is still down. Defer until touchend so popups aren't dismissed
// by the in-progress touch (closes #3935).
runOrDeferAction(
deferredQuickActionRef.current,
!!appService?.isAndroidApp && !androidTouchEndRef.current,
runAction,
);
};
useEffect(() => {