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:
+162
@@ -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))
|
||||
}
|
||||
}
|
||||
+4
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user