fix(ios): suppress native text-selection menu over annotation tools (#4231)

On iOS the system text-selection menu (Copy / Look Up / Translate /
Share) appeared on top of Readest's annotation toolbar. The previous
workaround removed and re-added the selection range on a timer
(makeSelectionOnIOS) to shake the menu off — flaky on iOS 16 and on the
first long-press of a word.

Suppress the menu natively instead, in the native-bridge iOS plugin.
ContextMenuSuppressor swizzles WKContentView so non-editable web
selections produce an empty menu that is never presented:

  * editMenuInteraction(_:menuForConfiguration:suggestedActions:) — the
    UIEditMenuInteraction delegate WebKit uses to build the menu on
    iOS 16+ (the menu users actually see on modern iOS).
  * presentEditMenu(with:) — a present-time backstop.
  * canPerformAction(_:withSender:) — the legacy UIMenuController gate
    for iOS 15 and earlier.

Editable HTML fields keep their native menu (Paste / Select All still
work) via a cut:/paste: probe. Text selection and drag handles are
unaffected, so the annotation toolbar still triggers.

With suppression handled natively, makeSelectionOnIOS is removed and iOS
selections take the same path as desktop.

Closes #4218

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-20 01:49:58 +08:00
committed by GitHub
parent 088f690c35
commit 97221b8d23
3 changed files with 170 additions and 25 deletions
@@ -68,27 +68,6 @@ export const useTextSelector = (
index,
});
};
// FIXME: extremely hacky way to dismiss system selection tools on iOS
const makeSelectionOnIOS = async (sel: Selection, index: number) => {
isTextSelected.current = true;
const range = sel.getRangeAt(0);
setTimeout(() => {
sel.removeAllRanges();
setTimeout(async () => {
if (!isTextSelected.current) return;
sel.addRange(range);
const progress = getProgress(bookKey);
setSelection({
key: bookKey,
text: await getAnnotationText(range),
cfi: view?.getCFI(index, range),
page: bookData?.isFixedLayout ? index + 1 : progress?.page || 0,
range,
index,
});
}, 30);
}, 30);
};
const startInstantAnnotating = (ev: PointerEvent) => {
isInstantAnnotating.current = true;
@@ -175,11 +154,11 @@ export const useTextSelector = (
const sel = doc.getSelection() as Selection;
if (isValidSelection(sel)) {
const isPointerInside = ev && isPointerInsideSelection(sel, ev);
const isIOS = osPlatform === 'ios' || appService?.isIOSApp;
if (isPointerInside && isIOS) {
makeSelectionOnIOS(sel, index);
} else if (isPointerInside) {
// iOS no longer needs a special path: the native plugin
// (ContextMenuSuppressor) suppresses the system selection menu, so
// iOS selections go through the same path as desktop.
if (isPointerInside) {
isUpToPopup.current = true;
makeSelection(sel, index, true);
} else if (appService?.isAndroidApp) {