From b5b6f5091fa38fc989b501c74fdb37afb910580e Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 26 Jan 2026 17:33:19 -0800 Subject: [PATCH] [Super Clipboard][iOS] - Swizzle Flutter method to say that we can paste even when binary data is on the clipboard --- .../Classes/SuperEditorClipboardPlugin.swift | 110 ++++++++++++++---- 1 file changed, 88 insertions(+), 22 deletions(-) diff --git a/super_editor_clipboard/ios/Classes/SuperEditorClipboardPlugin.swift b/super_editor_clipboard/ios/Classes/SuperEditorClipboardPlugin.swift index c93e037c4..b07adf63a 100644 --- a/super_editor_clipboard/ios/Classes/SuperEditorClipboardPlugin.swift +++ b/super_editor_clipboard/ios/Classes/SuperEditorClipboardPlugin.swift @@ -16,7 +16,9 @@ public class SuperEditorClipboardPlugin: NSObject, FlutterPlugin { let instance = SuperEditorClipboardPlugin() registrar.addMethodCallDelegate(instance, channel: channel) + // Swizzle both the action execution (paste) and the validation (canPerformAction) swizzleFlutterPaste() + swizzleCanPerformAction() } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -33,57 +35,122 @@ public class SuperEditorClipboardPlugin: NSObject, FlutterPlugin { } } + // MARK: - Swizzling Logic + private static func swizzleFlutterPaste() { - // 1. Locate the private Flutter engine class - guard let flutterClass = NSClassFromString("FlutterTextInputView") else { - log("RichPastePlugin: Could not find FlutterTextInputView") + swizzle( + clsName: "FlutterTextInputView", + originalSelector: #selector(UIResponder.paste(_:)), + customSelector: #selector(customPaste(_:)) + ) + } + + private static func swizzleCanPerformAction() { + swizzle( + clsName: "FlutterTextInputView", + originalSelector: #selector(UIResponder.canPerformAction(_:withSender:)), + customSelector: #selector(customCanPerformAction(_:withSender:)) + ) + } + + private static func swizzle(clsName: String, originalSelector: Selector, customSelector: Selector) { + guard let flutterClass = NSClassFromString(clsName) else { + log("Could not find \(clsName)") return } - let originalSelector = #selector(UIResponder.paste(_:)) - let swizzledSelector = #selector(customPaste(_:)) - - // 2. Get the methods guard let originalMethod = class_getInstanceMethod(flutterClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(SuperEditorClipboardPlugin.self, swizzledSelector) else { + let swizzledMethod = class_getInstanceMethod(SuperEditorClipboardPlugin.self, customSelector) else { + log("Could not find methods to swizzle for \(clsName)") return } - // 3. Inject our custom method into the Flutter engine class + // Add the custom method to the Flutter class let didAddMethod = class_addMethod( flutterClass, - swizzledSelector, + customSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod) ) if didAddMethod { - // 4. Swap the pointers - let newMethod = class_getInstanceMethod(flutterClass, swizzledSelector)! + // Exchange implementations so 'originalSelector' calls our custom code, + // and 'customSelector' calls the original code. + let newMethod = class_getInstanceMethod(flutterClass, customSelector)! method_exchangeImplementations(originalMethod, newMethod) + log("Successfully swizzled \(originalSelector) in \(clsName)") + } else { + log("Failed to add method \(customSelector) to \(clsName)") } } - // This method is "moved" into FlutterTextInputView at runtime. - // 'self' inside this method will actually be the FlutterTextInputView instance. + // MARK: - Custom Implementations + + /// This method replaces `paste(_:)` at runtime. @objc func customPaste(_ sender: Any?) { if (!SuperEditorClipboardPlugin.doCustomPaste) { SuperEditorClipboardPlugin.log("Running regular Flutter paste") - // FALLBACK: - // This calls the ORIGINAL paste logic. - // Because we swapped the methods, calling 'customPaste' on 'self' - // now triggers the engine's original 'insertText' flow. + // FALLBACK: Call original implementation (which is now mapped to customPaste) if self.responds(to: #selector(customPaste(_:))) { self.perform(#selector(customPaste(_:)), with: sender) } - - return; + return } SuperEditorClipboardPlugin.log("Running custom paste") SuperEditorClipboardPlugin.channel?.invokeMethod("paste", arguments: nil) } + /// This method replaces `canPerformAction(_:withSender:)` at runtime. + @objc func customCanPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + let isPasteAction = action == #selector(UIResponderStandardEditActions.paste(_:)) + + // 1. If it is the PASTE action AND we are in custom mode, check our broader conditions. + if isPasteAction && SuperEditorClipboardPlugin.doCustomPaste { + // Check for ANY pasteable content (Images, Colors, URLs, Strings) + // Note: Flutter only checks `hasStrings`. + if UIPasteboard.general.hasStrings || + UIPasteboard.general.hasImages || + UIPasteboard.general.hasURLs || + UIPasteboard.general.hasColors { + return true + } + } + + // 2. Otherwise (or if the custom check failed), fall back to the ORIGINAL logic. + // Because we exchanged implementations, calling 'customCanPerformAction' here + // actually invokes the original Flutter engine logic. + + // We cannot use 'perform' for Bool return types, so we use IMP casting. + return SuperEditorClipboardPlugin.callOriginalCanPerformAction( + instance: self, + selector: #selector(customCanPerformAction(_:withSender:)), + action: action, + sender: sender + ) + } + + // MARK: - Helpers + + /// Safely invokes the original implementation of `canPerformAction` (which is now swapped). + private static func callOriginalCanPerformAction(instance: Any, selector: Selector, action: Selector, sender: Any?) -> Bool { + guard let method = class_getInstanceMethod(object_getClass(instance), selector) else { + return false + } + + let imp = method_getImplementation(method) + + // Define the C function signature for (BOOL)objc_msgSend(id, SEL, SEL, id) + typealias CanPerformActionFunction = @convention(c) (AnyObject, Selector, Selector, Any?) -> Bool + + let originalFunction = unsafeBitCast(imp, to: CanPerformActionFunction.self) + + // 'instance' is 'self' (FlutterTextInputView) + // 'selector' is the selector triggering this IMP (customCanPerformAction) + // 'action' is the argument (e.g., paste:) + return originalFunction(instance as AnyObject, selector, action, sender) + } + public static let isLoggingEnabled = false internal static func log(_ message: String) { @@ -91,5 +158,4 @@ public class SuperEditorClipboardPlugin: NSObject, FlutterPlugin { print("[SuperEditorClipboardPlugin] \(message)") } } -} - +} \ No newline at end of file