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
@@ -0,0 +1,162 @@
import ObjectiveC
import UIKit
import WebKit
import os
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!, category: "ContextMenuSuppressor")
/// Suppresses the iOS system text-selection menu (Copy / Look Up / Translate
/// / Share) for non-editable web content, so it never covers Readest's own
/// annotation toolbar.
///
/// Three hooks are installed; together they cover every code path iOS uses to
/// present that menu:
///
/// * `WKContentView.editMenuInteraction(_:menuForConfiguration:suggestedActions:)`
/// the `UIEditMenuInteraction` delegate method WebKit uses to *build* the
/// menu on iOS 16+. This is the menu users actually see on modern iOS.
/// Returning an empty `UIMenu` means nothing is presented.
/// * `UIEditMenuInteraction.presentEditMenu(with:)` the present-time hook,
/// a backstop in case WebKit presents without re-querying the delegate.
/// * `WKContentView.canPerformAction(_:withSender:)` the legacy
/// `UIMenuController` gate used on iOS 15 and earlier.
///
/// All three keep the native menu intact for editable HTML fields (so Paste /
/// Select All still work) via an editable-context probe: an editable
/// selection reports `cut:` true, an editable caret with clipboard content
/// reports `paste:` true, and non-editable web content reports both false.
enum ContextMenuSuppressor {
private static var installed = false
/// Installs the hooks once per process. Safe to call repeatedly.
static func installIfNeeded() {
guard !installed else { return }
installed = true
// `WKContentView` is the private first-responder subview that hosts the
// web content and the selection menu. A future Apple rename makes this
// lookup fail; we log and no-op rather than crash.
guard let contentViewClass = NSClassFromString("WKContentView") else {
logger.warning("WKContentView class not found; menu suppression disabled")
return
}
installCanPerformActionSwizzle(on: contentViewClass)
if #available(iOS 16.0, *) {
installEditMenuSwizzle(on: contentViewClass)
installPresentEditMenuSwizzle()
}
}
// MARK: - Editable-context probe
private static let cutSelector = #selector(UIResponderStandardEditActions.cut(_:))
private static let pasteSelector = #selector(UIResponderStandardEditActions.paste(_:))
/// Whether `responder`'s current selection is inside an editable field.
/// `canPerformAction` is a side-effect-free query, so this is safe to call
/// while a menu is being built.
private static func isEditableContext(_ responder: UIResponder) -> Bool {
responder.canPerformAction(cutSelector, withSender: nil)
|| responder.canPerformAction(pasteSelector, withSender: nil)
}
// MARK: - iOS 16+ edit menu (the menu users see on modern iOS)
@available(iOS 16.0, *)
private static func installEditMenuSwizzle(on cls: AnyClass) {
let selector = NSSelectorFromString(
"editMenuInteraction:menuForConfiguration:suggestedActions:")
guard let method = class_getInstanceMethod(cls, selector) else {
logger.warning(
"editMenuInteraction delegate method not found; iOS 16+ menu suppression disabled")
return
}
// Capture the original IMP before replacing it; calls through this
// pointer bypass the swizzle, so there is no recursion.
typealias OriginalIMP = @convention(c) (
AnyObject, Selector, UIEditMenuInteraction, UIEditMenuConfiguration, [UIMenuElement]
) -> UIMenu?
let originalIMP = unsafeBitCast(
method_getImplementation(method), to: OriginalIMP.self)
// IMP-from-block receives (self, args...) no `_cmd`.
let block:
@convention(block) (
AnyObject, UIEditMenuInteraction, UIEditMenuConfiguration, [UIMenuElement]
) -> UIMenu? = { receiver, interaction, configuration, suggestedActions in
let editable = (receiver as? UIResponder).map(isEditableContext) ?? false
if editable {
return originalIMP(receiver, selector, interaction, configuration, suggestedActions)
}
// Non-editable selection: an empty menu presents nothing. Text
// selection and drag handles are unaffected.
return UIMenu(title: "", children: [])
}
method_setImplementation(method, imp_implementationWithBlock(block))
}
// MARK: - iOS 16+ present-time backstop
@available(iOS 16.0, *)
private static func installPresentEditMenuSwizzle() {
let selector = #selector(UIEditMenuInteraction.presentEditMenu(with:))
guard let method = class_getInstanceMethod(UIEditMenuInteraction.self, selector) else {
logger.warning(
"presentEditMenu(with:) not found; present-time suppression disabled")
return
}
typealias OriginalIMP = @convention(c) (
AnyObject, Selector, UIEditMenuConfiguration
) -> Void
let originalIMP = unsafeBitCast(
method_getImplementation(method), to: OriginalIMP.self)
let block: @convention(block) (AnyObject, UIEditMenuConfiguration) -> Void = {
receiver, configuration in
let view = (receiver as? UIEditMenuInteraction)?.view
let editable = view.map(isEditableContext) ?? false
if editable {
originalIMP(receiver, selector, configuration)
}
// Non-editable selection: skip presentation entirely.
}
method_setImplementation(method, imp_implementationWithBlock(block))
}
// MARK: - Legacy UIMenuController (iOS 15 and earlier)
private static func installCanPerformActionSwizzle(on cls: AnyClass) {
let selector = #selector(UIResponder.canPerformAction(_:withSender:))
guard let method = class_getInstanceMethod(cls, selector) else {
logger.warning(
"canPerformAction(_:withSender:) not found; legacy menu suppression disabled")
return
}
typealias OriginalIMP =
@convention(c) (AnyObject, Selector, Selector, Any?) -> Bool
let originalIMP = unsafeBitCast(
method_getImplementation(method), to: OriginalIMP.self)
let block: @convention(block) (AnyObject, Selector, Any?) -> Bool = {
receiver, action, sender in
let editable =
originalIMP(receiver, selector, cutSelector, sender)
|| originalIMP(receiver, selector, pasteSelector, sender)
if editable {
return originalIMP(receiver, selector, action, sender)
}
// Non-editable selection: report false for every action so the system
// builds an empty edit menu and never presents it.
return false
}
method_setImplementation(method, imp_implementationWithBlock(block))
}
}
@@ -469,6 +469,10 @@ class NativeBridgePlugin: Plugin {
self.webView = webview
logger.log("NativeBridgePlugin loaded")
// Suppress the iOS system text-selection edit menu so it never
// covers Readest's annotation toolbar. See ContextMenuSuppressor.
ContextMenuSuppressor.installIfNeeded()
webViewLifecycleManager = WebViewLifecycleManager()
webViewLifecycleManager?.startMonitoring(webView: webview)
logger.log("NativeBridgePlugin: WebView lifecycle monitoring activated")
@@ -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) {