From 97221b8d23b0a4eafec19ea9895af7570081305e Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 20 May 2026 01:49:58 +0800 Subject: [PATCH] fix(ios): suppress native text-selection menu over annotation tools (#4231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ios/Sources/ContextMenuSuppressor.swift | 162 ++++++++++++++++++ .../ios/Sources/NativeBridgePlugin.swift | 4 + .../src/app/reader/hooks/useTextSelector.ts | 29 +--- 3 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ContextMenuSuppressor.swift diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ContextMenuSuppressor.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ContextMenuSuppressor.swift new file mode 100644 index 00000000..2ee94fa3 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ContextMenuSuppressor.swift @@ -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)) + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 66919d22..4c2978bc 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -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") diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts index 254d5774..d80d6506 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -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) {