Files
Huang Xin 97221b8d23 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>
2026-05-19 19:49:58 +02:00

163 lines
6.5 KiB
Swift

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))
}
}