custom-ios-selection-menu-capacitorjs.md

Custom native text-selection menus for WKWebView in Capacitor (iOS ≤15 → 26+)

How to replace the system "Copy · Look Up · Translate…" callout with your own native items — e.g. Copy · Edit or Delete · Share · Select all — inside an Ionic/Capacitor app, while leaving the default menu untouched in editable text fields.

The problem

Inside a WKWebView, the menu that appears after a text selection is not built from DOM events. It is composed natively by WKContentView (a private WebKit class), which means none of the usual web tricks reach it:

  • -webkit-touch-callout: none — no effect on the selection callout in iOS 16+
  • contextmenu + preventDefault() — the menu is not event-driven
  • Removing/re-adding the selection via JS — the menu anchors to the selection itself

The only reliable lever is at the native layer: the methods WKContentView uses to compose the menu.

The approach: runtime swizzling

WKContentView is private, so you can't subclass it. But it does implement the menu-composition methods itself, so you can safely swap their implementations with method_setImplementation and keep a reference to the original.

Three hooks cover every iOS version:

iOS version Method on WKContentView
≤ 15 canPerformAction(_:withSender:) (UIMenuController path)
16 – 25 editMenuInteraction:menuForConfiguration:suggestedActions:
26+ buildMenuForWebViewWithBuilder: (UIMenuBuilder path)
Tip: to find the right selector on a new iOS release, dump the class's methods with class_copyMethodList and grep for "menu".

1. The swizzles

import UIKit
import WebKit

enum SelectionCalloutSuppression {
    static var suppressed = false          // set by the plugin while our page is mounted
    private static var installed = false

    static func install() {
        guard !installed,
              let cls = NSClassFromString("WKContentView")
        else { return }
        installed = true

        // iOS ≤ 15 — UIMenuController path
        if let method = class_getInstanceMethod(cls, #selector(UIResponder.canPerformAction(_:withSender:))) {
            let sel = #selector(UIResponder.canPerformAction(_:withSender:))
            typealias IMP = @convention(c) (AnyObject, Selector, Selector, AnyObject?) -> Bool
            let original = unsafeBitCast(method_getImplementation(method), to: IMP.self)
            let block: @convention(block) (AnyObject, Selector, AnyObject?) -> Bool = { obj, action, sender in
                if suppressed { return false }   // or return a subset for custom items
                return original(obj, sel, action, sender)
            }
            method_setImplementation(method, imp_implementationWithBlock(block))
        }

        // iOS 16–25 — UIEditMenuInteraction delegate path
        if #available(iOS 16.0, *) {
            let menuSel = NSSelectorFromString("editMenuInteraction:menuForConfiguration:suggestedActions:")
            if let method = class_getInstanceMethod(cls, menuSel) {
                typealias IMP = @convention(c) (
                    AnyObject, Selector, UIEditMenuInteraction, UIEditMenuConfiguration, [UIMenuElement]
                ) -> UIMenu?
                let original = unsafeBitCast(method_getImplementation(method), to: IMP.self)
                let block: @convention(block) (
                    AnyObject, UIEditMenuInteraction, UIEditMenuConfiguration, [UIMenuElement]
                ) -> UIMenu? = { obj, interaction, configuration, suggested in
                    if suppressed { return customMenu(for: obj) }
                    return original(obj, menuSel, interaction, configuration, suggested)
                }
                method_setImplementation(method, imp_implementationWithBlock(block))
            }
        }

        // iOS 26+ — the callout is composed via a UIMenuBuilder
        let buildSel = NSSelectorFromString("buildMenuForWebViewWithBuilder:")
        if let method = class_getInstanceMethod(cls, buildSel) {
            typealias IMP = @convention(c) (AnyObject, Selector, AnyObject) -> Void
            let original = unsafeBitCast(method_getImplementation(method), to: IMP.self)
            let block: @convention(block) (AnyObject, AnyObject) -> Void = { obj, builder in
                if suppressed, let menuBuilder = builder as? UIMenuBuilder {
                    // Skip the original entirely and insert only our items.
                    let inline = UIMenu(options: [.displayInline],
                                        children: customMenu(for: obj).children)
                    menuBuilder.insertChild(inline, atStartOfMenu: .root)
                } else {
                    original(obj, buildSel, builder)
                }
            }
            method_setImplementation(method, imp_implementationWithBlock(block))
        }
    }
}

2. Building your custom menu

@MainActor
private static func customMenu(for obj: AnyObject) -> UIMenu {
    let copy = UIAction(title: "Copy") { _ in
        selectedText(from: obj) { UIPasteboard.general.string = $0 }
    }
    let editOrDelete = UIAction(title: "Edit or Delete") { _ in
        selectedText(from: obj) { text in
            // Bridge back to JS — see section 4
            SelectionMenuPlugin.current?.notifyListeners(
                "selectionAction",
                data: ["action": "editOrDelete", "text": text ?? ""]
            )
        }
    }
    let share = UIAction(title: "Share") { _ in
        selectedText(from: obj) { text in
            guard let text else { return }
            let vc = UIActivityViewController(activityItems: [text], applicationActivities: nil)
            webView(for: obj)?.window?.rootViewController?.present(vc, animated: true)
        }
    }
    let selectAll = UIAction(title: "Select all") { _ in
        webView(for: obj)?.evaluateJavaScript(
            "(() => { const el = document.querySelector('.markdown-body');"
            + "if (!el) return; const r = document.createRange();"
            + "r.selectNodeContents(el); const s = window.getSelection();"
            + "s.removeAllRanges(); s.addRange(r); })()"
        )
    }
    return UIMenu(children: [copy, editOrDelete, share, selectAll])
}

// WKContentView → WKWebView via KVC on the private property
private static func webView(for responder: AnyObject) -> WKWebView? {
    (responder as? NSObject)?.value(forKey: "webView") as? WKWebView
}

private static func selectedText(from obj: AnyObject,
                                 completion: @escaping (String?) -> Void) {
    webView(for: obj)?.evaluateJavaScript("window.getSelection().toString()") { result, _ in
        completion(result as? String)
    }
}

3. A Capacitor plugin to toggle it

The suppressed flag scopes the custom menu to a single page — here, any non-editable content screen. Editable inputs everywhere else keep the traditional menu.

import Capacitor

@objc(SelectionMenuPlugin)
public class SelectionMenuPlugin: CAPPlugin, CAPBridgedPlugin {
    static weak var current: SelectionMenuPlugin?
    public let identifier = "SelectionMenuPlugin"
    public let jsName = "SelectionMenu"
    public let pluginMethods: [CAPPluginMethod] = [
        CAPPluginMethod(name: "enable", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "disable", returnType: CAPPluginReturnPromise),
    ]

    public override func load() {
        SelectionMenuPlugin.current = self
        SelectionCalloutSuppression.install()
    }

    @objc func enable(_ call: CAPPluginCall) {
        SelectionCalloutSuppression.suppressed = true
        call.resolve()
    }

    @objc func disable(_ call: CAPPluginCall) {
        SelectionCalloutSuppression.suppressed = false
        call.resolve()
    }
}

// Register it without touching the .pbxproj: subclass the bridge VC
class MainViewController: CAPBridgeViewController {
    override func capacitorDidLoad() {
        super.capacitorDidLoad()
        bridge?.registerPluginInstance(SelectionMenuPlugin())
    }
}

Then use MainViewController() as the rootViewController in your SceneDelegate / AppDelegate.

4. The JS side

import { Capacitor, registerPlugin } from '@capacitor/core';

const SelectionMenu = registerPlugin('SelectionMenu');

// On mount of the page that needs the custom menu:
await SelectionMenu.enable();
SelectionMenu.addListener('selectionAction', ({ action, text }) => {
  if (action === 'editOrDelete') {
    // e.g. route to your composer with `text` seeded
  }
});

// On unmount:
await SelectionMenu.disable();

Caveats

  • Private API surface: WKContentView and buildMenuForWebViewWithBuilder: are private WebKit internals. It works on iOS 26/27 today, but validate on each new major release and consider the App Store review implications of touching private classes.
  • Probe before you swizzle: when a new iOS version breaks the hook, dump selectors again — the composition path moved twice already (16 → 26).
  • Gate by flag, not by content sniffing: probing "is this selection editable?" via canPerformAction cut:/paste: is clipboard-dependent and unreliable; scoping with an explicit enable/disable flag per page is deterministic.
  • UIMenuBuilder only accepts UIMenu children/siblings — wrap individual UIActions in UIMenu(options: [.displayInline], children:) to render them as flat items.

*Tested on iOS 26.5 and 27.0 simulators (Ionic Vue 3 + Capacitor 8).*
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论