aix 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +269 -70
  2. package/ios/EditMenuDefaultActions.swift +70 -0
  3. package/ios/HybridAix.swift +28 -3
  4. package/ios/HybridAixCellView.swift +1 -1
  5. package/ios/HybridAixComposer.swift +82 -0
  6. package/ios/HybridAixDropzone.swift +104 -0
  7. package/ios/HybridAixInputWrapper.swift +447 -0
  8. package/ios/InputType.swift +40 -0
  9. package/ios/PasteFileManager.swift +92 -0
  10. package/nitro.json +8 -0
  11. package/nitrogen/generated/android/Aix+autolinking.cmake +8 -0
  12. package/nitrogen/generated/android/AixOnLoad.cpp +26 -0
  13. package/nitrogen/generated/android/c++/JAixInputWrapperOnPasteEvent.hpp +70 -0
  14. package/nitrogen/generated/android/c++/JFunc_void_std__vector_AixInputWrapperOnPasteEvent_.hpp +98 -0
  15. package/nitrogen/generated/android/c++/JHybridAixComposerSpec.cpp +9 -0
  16. package/nitrogen/generated/android/c++/JHybridAixComposerSpec.hpp +2 -0
  17. package/nitrogen/generated/android/c++/JHybridAixDropzoneSpec.cpp +72 -0
  18. package/nitrogen/generated/android/c++/JHybridAixDropzoneSpec.hpp +66 -0
  19. package/nitrogen/generated/android/c++/JHybridAixInputWrapperSpec.cpp +144 -0
  20. package/nitrogen/generated/android/c++/JHybridAixInputWrapperSpec.hpp +74 -0
  21. package/nitrogen/generated/android/c++/views/JHybridAixComposerStateUpdater.cpp +4 -0
  22. package/nitrogen/generated/android/c++/views/JHybridAixDropzoneStateUpdater.cpp +56 -0
  23. package/nitrogen/generated/android/c++/views/JHybridAixDropzoneStateUpdater.hpp +49 -0
  24. package/nitrogen/generated/android/c++/views/JHybridAixInputWrapperStateUpdater.cpp +72 -0
  25. package/nitrogen/generated/android/c++/views/JHybridAixInputWrapperStateUpdater.hpp +49 -0
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixInputWrapperOnPasteEvent.kt +47 -0
  27. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/Func_void_std__vector_AixInputWrapperOnPasteEvent_.kt +80 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixComposerSpec.kt +6 -0
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixDropzoneSpec.kt +67 -0
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixInputWrapperSpec.kt +91 -0
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixDropzoneManager.kt +50 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixDropzoneStateUpdater.kt +23 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixInputWrapperManager.kt +50 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixInputWrapperStateUpdater.kt +23 -0
  35. package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.cpp +42 -0
  36. package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.hpp +112 -0
  37. package/nitrogen/generated/ios/Aix-Swift-Cxx-Umbrella.hpp +14 -0
  38. package/nitrogen/generated/ios/AixAutolinking.mm +16 -0
  39. package/nitrogen/generated/ios/AixAutolinking.swift +30 -0
  40. package/nitrogen/generated/ios/c++/HybridAixComposerSpecSwift.hpp +7 -0
  41. package/nitrogen/generated/ios/c++/HybridAixDropzoneSpecSwift.cpp +11 -0
  42. package/nitrogen/generated/ios/c++/HybridAixDropzoneSpecSwift.hpp +80 -0
  43. package/nitrogen/generated/ios/c++/HybridAixInputWrapperSpecSwift.cpp +11 -0
  44. package/nitrogen/generated/ios/c++/HybridAixInputWrapperSpecSwift.hpp +108 -0
  45. package/nitrogen/generated/ios/c++/views/HybridAixComposerComponent.mm +5 -0
  46. package/nitrogen/generated/ios/c++/views/HybridAixDropzoneComponent.mm +96 -0
  47. package/nitrogen/generated/ios/c++/views/HybridAixInputWrapperComponent.mm +116 -0
  48. package/nitrogen/generated/ios/swift/AixInputWrapperOnPasteEvent.swift +107 -0
  49. package/nitrogen/generated/ios/swift/Func_void_std__vector_AixInputWrapperOnPasteEvent_.swift +47 -0
  50. package/nitrogen/generated/ios/swift/HybridAixComposerSpec.swift +1 -0
  51. package/nitrogen/generated/ios/swift/HybridAixComposerSpec_cxx.swift +24 -0
  52. package/nitrogen/generated/ios/swift/HybridAixDropzoneSpec.swift +56 -0
  53. package/nitrogen/generated/ios/swift/HybridAixDropzoneSpec_cxx.swift +167 -0
  54. package/nitrogen/generated/ios/swift/HybridAixInputWrapperSpec.swift +60 -0
  55. package/nitrogen/generated/ios/swift/HybridAixInputWrapperSpec_cxx.swift +261 -0
  56. package/nitrogen/generated/shared/c++/AixInputWrapperOnPasteEvent.hpp +88 -0
  57. package/nitrogen/generated/shared/c++/HybridAixComposerSpec.cpp +2 -0
  58. package/nitrogen/generated/shared/c++/HybridAixComposerSpec.hpp +2 -0
  59. package/nitrogen/generated/shared/c++/HybridAixDropzoneSpec.cpp +22 -0
  60. package/nitrogen/generated/shared/c++/HybridAixDropzoneSpec.hpp +67 -0
  61. package/nitrogen/generated/shared/c++/HybridAixInputWrapperSpec.cpp +30 -0
  62. package/nitrogen/generated/shared/c++/HybridAixInputWrapperSpec.hpp +76 -0
  63. package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.cpp +12 -0
  64. package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.hpp +1 -0
  65. package/nitrogen/generated/shared/c++/views/HybridAixDropzoneComponent.cpp +87 -0
  66. package/nitrogen/generated/shared/c++/views/HybridAixDropzoneComponent.hpp +109 -0
  67. package/nitrogen/generated/shared/c++/views/HybridAixInputWrapperComponent.cpp +135 -0
  68. package/nitrogen/generated/shared/c++/views/HybridAixInputWrapperComponent.hpp +114 -0
  69. package/nitrogen/generated/shared/json/AixComposerConfig.json +1 -0
  70. package/nitrogen/generated/shared/json/AixDropzoneConfig.json +10 -0
  71. package/nitrogen/generated/shared/json/AixInputWrapperConfig.json +14 -0
  72. package/package.json +1 -1
  73. package/src/dropzone.ios.tsx +27 -0
  74. package/src/dropzone.tsx +10 -0
  75. package/src/index.ts +3 -0
  76. package/src/input-wrapper.ios.tsx +30 -0
  77. package/src/input-wrapper.tsx +17 -0
  78. package/src/views/aix.nitro.ts +33 -19
@@ -24,6 +24,15 @@ class HybridAixComposer: HybridAixComposerSpec {
24
24
  }
25
25
  }
26
26
 
27
+ var fixInput: Bool? = nil {
28
+ didSet {
29
+ cachedTextInput = nil
30
+ if fixInput == true {
31
+ resolveTextInput()
32
+ }
33
+ }
34
+ }
35
+
27
36
  // MARK: - Inner View
28
37
 
29
38
  /// Custom UIView that notifies owner when layout changes
@@ -56,6 +65,26 @@ class HybridAixComposer: HybridAixComposerSpec {
56
65
  }
57
66
  }
58
67
 
68
+ // MARK: - Gesture Target
69
+
70
+ /// NSObject helper to bridge target-action for gesture recognizers,
71
+ /// since HybridAixComposer does not inherit from NSObject.
72
+ private class GestureTarget: NSObject, UIGestureRecognizerDelegate {
73
+ let handler: (UIPanGestureRecognizer) -> Void
74
+
75
+ init(handler: @escaping (UIPanGestureRecognizer) -> Void) {
76
+ self.handler = handler
77
+ }
78
+
79
+ @objc func handleGesture(_ gesture: UIPanGestureRecognizer) {
80
+ handler(gesture)
81
+ }
82
+
83
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
84
+ return true
85
+ }
86
+ }
87
+
59
88
  // MARK: - Properties
60
89
 
61
90
  /// The UIView for this composer
@@ -67,6 +96,12 @@ class HybridAixComposer: HybridAixComposerSpec {
67
96
  /// Last reported height (to avoid reporting unchanged heights)
68
97
  private var lastReportedHeight: CGFloat = 0
69
98
 
99
+ /// Cached reference to the text input found inside the footer
100
+ private weak var cachedTextInput: UIView?
101
+
102
+ /// Gesture target for pan-to-focus
103
+ private var panGestureTarget: GestureTarget?
104
+
70
105
  // MARK: - Initialization
71
106
 
72
107
  override init() {
@@ -105,6 +140,9 @@ class HybridAixComposer: HybridAixComposerSpec {
105
140
  // Initial state
106
141
  applyKeyboardTransform(height: ctx.keyboardHeight, heightWhenOpen: ctx.keyboardHeightWhenOpen, animated: false)
107
142
  }
143
+
144
+ // Resolve text input once the hierarchy is connected
145
+ resolveTextInput()
108
146
  }
109
147
 
110
148
  /// Called when the view is about to be removed from superview
@@ -128,6 +166,50 @@ class HybridAixComposer: HybridAixComposerSpec {
128
166
  }
129
167
  }
130
168
 
169
+ // MARK: - Text Input
170
+
171
+ /// Find and cache the text input inside the footer's view hierarchy
172
+ var textInput: UIView? {
173
+ if let cached = cachedTextInput {
174
+ return cached
175
+ }
176
+ let searchRoot = view.superview ?? view
177
+ let input = searchRoot.findTextInput()
178
+ cachedTextInput = input
179
+ return input
180
+ }
181
+
182
+ /// Resolve the text input and apply patches
183
+ private func resolveTextInput() {
184
+ print("[Aix] resolveTextInput: fixInput=\(fixInput), textInput=\(textInput)")
185
+ guard fixInput == true, let input = textInput else { return }
186
+ guard let scrollView = input as? UIScrollView else { return }
187
+ print("[Aix] resolveTextInput: scrollView=\(scrollView)")
188
+ scrollView.showsVerticalScrollIndicator = false
189
+ scrollView.showsHorizontalScrollIndicator = false
190
+ scrollView.bounces = false
191
+ scrollView.alwaysBounceVertical = false
192
+ scrollView.alwaysBounceHorizontal = false
193
+ scrollView.keyboardDismissMode = .interactive
194
+ let target = GestureTarget { [weak self] gesture in
195
+ self?.handlePanToFocus(gesture)
196
+ }
197
+ self.panGestureTarget = target
198
+ let panGesture = UIPanGestureRecognizer(target: target, action: #selector(GestureTarget.handleGesture(_:)))
199
+ panGesture.delegate = target
200
+ scrollView.addGestureRecognizer(panGesture)
201
+ }
202
+
203
+ /// Handle pan to focus the text input
204
+ private func handlePanToFocus(_ gesture: UIPanGestureRecognizer) {
205
+ guard gesture.state == .began else { return }
206
+ guard let input = textInput else { return }
207
+ let velocity = gesture.velocity(in: input)
208
+ if velocity.y < -250.0 && !input.isFirstResponder {
209
+ input.becomeFirstResponder()
210
+ }
211
+ }
212
+
131
213
  // MARK: - Keyboard handling
132
214
 
133
215
  /// Apply keyboard transform to move the composer with the keyboard
@@ -0,0 +1,104 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ class HybridAixDropzone: HybridAixDropzoneSpec {
5
+ var onDrop: ((_ events: [AixInputWrapperOnPasteEvent]) -> Void)?
6
+
7
+ private let dropDelegate = DropzoneDelegate()
8
+
9
+ // MARK: - Inner View
10
+ private final class InnerView: UIView {
11
+ weak var owner: HybridAixDropzone?
12
+
13
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
14
+ return false
15
+ }
16
+
17
+ override func didMoveToWindow() {
18
+ super.didMoveToWindow()
19
+ owner?.setupDropInteraction()
20
+ }
21
+ }
22
+
23
+ let view: UIView
24
+
25
+ // MARK: - Init
26
+ override init() {
27
+ let inner = InnerView()
28
+ self.view = inner
29
+ super.init()
30
+ inner.owner = self
31
+ }
32
+
33
+ private var didSetup = false
34
+
35
+ private func setupDropInteraction() {
36
+ guard !didSetup, view.window != nil else { return }
37
+ didSetup = true
38
+
39
+ dropDelegate.onImages = { [weak self] images in
40
+ self?.handleDroppedImages(images)
41
+ }
42
+
43
+ // Add drop interaction to the Fabric container (superview) so it covers the full area
44
+ let target = view.superview ?? view
45
+ target.addInteraction(UIDropInteraction(delegate: dropDelegate))
46
+ }
47
+
48
+ // MARK: - Drop Handling
49
+ private func handleDroppedImages(_ images: [UIImage]) {
50
+ var events: [AixInputWrapperOnPasteEvent] = []
51
+ for image in images {
52
+ if let uri = try? PasteFileManager.save(image: image) {
53
+ let fileURL = URL(fileURLWithPath: uri)
54
+ events.append(AixInputWrapperOnPasteEvent(
55
+ type: "image",
56
+ filePath: uri,
57
+ fileExtension: fileURL.pathExtension,
58
+ fileName: fileURL.lastPathComponent
59
+ ))
60
+ }
61
+ }
62
+ if !events.isEmpty {
63
+ onDrop?(events)
64
+ }
65
+ }
66
+ }
67
+
68
+ // MARK: - Drop Delegate
69
+ private final class DropzoneDelegate: NSObject, UIDropInteractionDelegate {
70
+ var onImages: (([UIImage]) -> Void)?
71
+
72
+ func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool {
73
+ return session.items.contains { $0.itemProvider.canLoadObject(ofClass: UIImage.self) }
74
+ }
75
+
76
+ func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal {
77
+ return UIDropProposal(operation: .copy)
78
+ }
79
+
80
+ func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) {
81
+ let providers = session.items.map { $0.itemProvider }
82
+ var images: [UIImage] = []
83
+ let lock = NSLock()
84
+ let group = DispatchGroup()
85
+
86
+ for provider in providers {
87
+ guard provider.canLoadObject(ofClass: UIImage.self) else { continue }
88
+ group.enter()
89
+ provider.loadObject(ofClass: UIImage.self) { object, _ in
90
+ defer { group.leave() }
91
+ guard let image = object as? UIImage else { return }
92
+ lock.lock()
93
+ images.append(image)
94
+ lock.unlock()
95
+ }
96
+ }
97
+
98
+ group.notify(queue: .main) { [weak self] in
99
+ if !images.isEmpty {
100
+ self?.onImages?(images)
101
+ }
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,447 @@
1
+ import Foundation
2
+ import UIKit
3
+ import UniformTypeIdentifiers
4
+ import ObjectiveC.runtime
5
+
6
+ private let LOG_TAG = "[AixInputWrapper]"
7
+
8
+ fileprivate enum NativeIDKey: String {
9
+ case textInput
10
+ }
11
+
12
+ private var allowedActionsKey: UInt8 = 0
13
+
14
+ class HybridAixInputWrapper: HybridAixInputWrapperSpec {
15
+
16
+ // MARK: - Props
17
+ var pasteConfiguration: [String]?
18
+ var editMenuDefaultActions: [String]? {
19
+ didSet {
20
+ self.parsedEditMenuActions = editMenuDefaultActions?
21
+ .compactMap { EditMenuDefaultActions(rawValue: $0) }
22
+ }
23
+ }
24
+ var maxLines: Double?
25
+ var maxChars: Double?
26
+ var onPaste: ((_ events: [AixInputWrapperOnPasteEvent]) -> Void)?
27
+
28
+ // MARK: - Private State
29
+ private var parsedEditMenuActions: [EditMenuDefaultActions]?
30
+ private var wrappedTextInput: InputType?
31
+ private let dropDelegate = ImageDropDelegate()
32
+ private lazy var pasteDelegate = PasteHandlerDelegate(owner: self)
33
+
34
+ // MARK: - Inner View
35
+ private final class InnerView: UIView {
36
+ weak var owner: HybridAixInputWrapper?
37
+
38
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
39
+ return false
40
+ }
41
+
42
+ override func didMoveToSuperview() {
43
+ super.didMoveToSuperview()
44
+ scheduleAttach()
45
+ }
46
+
47
+ override func didAddSubview(_ subview: UIView) {
48
+ super.didAddSubview(subview)
49
+ scheduleAttach()
50
+ }
51
+
52
+ override func layoutSubviews() {
53
+ super.layoutSubviews()
54
+ scheduleAttach()
55
+ }
56
+
57
+ private func scheduleAttach() {
58
+ guard owner?.wrappedTextInput == nil else { return }
59
+ DispatchQueue.main.async { [weak self] in
60
+ self?.owner?.attachIfPossible()
61
+ }
62
+ }
63
+
64
+ override func willMove(toSuperview newSuperview: UIView?) {
65
+ super.willMove(toSuperview: newSuperview)
66
+ if newSuperview == nil {
67
+ owner?.wrappedTextInput = nil
68
+ }
69
+ }
70
+ }
71
+
72
+ let view: UIView
73
+
74
+ // MARK: - Init
75
+ override init() {
76
+ let inner = InnerView()
77
+ self.view = inner
78
+ super.init()
79
+ inner.owner = self
80
+ }
81
+
82
+ // MARK: - Paste Handling
83
+ func handlePaste() {
84
+ print("\(LOG_TAG) Paste triggered")
85
+ PasteFileManager.cleanupOldFiles()
86
+ let pb = UIPasteboard.general
87
+
88
+ if let images = pb.images, images.count > 0 {
89
+ print("\(LOG_TAG) Pasting \(images.count) image(s)")
90
+ saveImages(images) { [weak self] events in
91
+ if !events.isEmpty {
92
+ self?.onPaste?(events)
93
+ }
94
+ }
95
+ } else if let url = pb.url {
96
+ Task { [weak self] in
97
+ if let image = await self?.fetchImage(from: url) {
98
+ self?.saveImages([image]) { events in
99
+ if !events.isEmpty {
100
+ self?.onPaste?(events)
101
+ }
102
+ }
103
+ }
104
+ }
105
+ } else if !pb.itemProviders.isEmpty {
106
+ checkForFiles(itemProviders: pb.itemProviders) { [weak self] events in
107
+ if !events.isEmpty {
108
+ self?.onPaste?(events)
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ func saveImages(_ images: [UIImage], completion: @escaping ([AixInputWrapperOnPasteEvent]) -> Void) {
115
+ var events: [AixInputWrapperOnPasteEvent] = []
116
+ for image in images {
117
+ if let uri = try? PasteFileManager.save(image: image) {
118
+ let fileURL = URL(fileURLWithPath: uri)
119
+ let ext = fileURL.pathExtension
120
+ print("\(LOG_TAG) Image saved to \(uri)")
121
+ events.append(AixInputWrapperOnPasteEvent(
122
+ type: "image",
123
+ filePath: uri,
124
+ fileExtension: ext,
125
+ fileName: fileURL.lastPathComponent
126
+ ))
127
+ }
128
+ }
129
+ completion(events)
130
+ }
131
+
132
+ // MARK: - Text Input Attachment
133
+ func attachIfPossible() {
134
+ guard wrappedTextInput == nil else { return }
135
+ let searchRoot = view.superview ?? view
136
+ guard let input = findTextInput(in: searchRoot) else {
137
+ print("\(LOG_TAG) No text input found in subview hierarchy")
138
+ return
139
+ }
140
+
141
+ let onImageHandler: (UIImage) -> Void = { [weak self] image in
142
+ self?.saveImages([image]) { events in
143
+ if !events.isEmpty {
144
+ self?.onPaste?(events)
145
+ }
146
+ }
147
+ }
148
+ dropDelegate.onImage = onImageHandler
149
+
150
+ (view as? InnerView).map { $0.addInteraction(UIDropInteraction(delegate: dropDelegate)) }
151
+
152
+ let acceptableTypes = [
153
+ UTType.image.identifier,
154
+ UTType.pdf.identifier,
155
+ UTType.svg.identifier,
156
+ UTType.url.identifier,
157
+ UTType.text.identifier
158
+ ]
159
+
160
+ if let tf = input as? UITextField {
161
+ print("\(LOG_TAG) Found UITextField, attaching paste delegate")
162
+ self.wrappedTextInput = .init(textField: tf)
163
+ tf.pasteConfiguration = .init(acceptableTypeIdentifiers: acceptableTypes)
164
+ tf.pasteDelegate = pasteDelegate
165
+ tf.addInteraction(UIDropInteraction(delegate: dropDelegate))
166
+ applyEditMenuFilter(to: tf)
167
+ } else if let tv = input as? UITextView {
168
+ print("\(LOG_TAG) Found UITextView, attaching paste delegate")
169
+ self.wrappedTextInput = .init(textView: tv)
170
+ tv.pasteConfiguration = .init(acceptableTypeIdentifiers: acceptableTypes)
171
+ tv.pasteDelegate = pasteDelegate
172
+ tv.addInteraction(UIDropInteraction(delegate: dropDelegate))
173
+ applyEditMenuFilter(to: tv)
174
+ }
175
+ }
176
+
177
+ private func findTextInput(in root: UIView) -> UIView? {
178
+ if root is UITextField { return root }
179
+ if root is UITextView { return root }
180
+ for sub in root.subviews {
181
+ if let found = findTextInput(in: sub) { return found }
182
+ }
183
+ return nil
184
+ }
185
+
186
+ private func applyEditMenuFilter(to input: UIView) {
187
+ guard let actions = parsedEditMenuActions else { return }
188
+ let allowedSelectors = actions.map { $0.selector }
189
+ objc_setAssociatedObject(input, &allowedActionsKey, allowedSelectors, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
190
+ swizzleCanPerformAction(on: input)
191
+ print("\(LOG_TAG) Applied edit menu filter: \(actions.map { $0.rawValue })")
192
+ }
193
+
194
+ private func swizzleCanPerformAction(on view: UIView) {
195
+ let originalClass: AnyClass = type(of: view)
196
+ let className = "AixSwizzled_\(NSStringFromClass(originalClass))"
197
+
198
+ if let existingClass = objc_getClass(className) as? AnyClass {
199
+ object_setClass(view, existingClass)
200
+ return
201
+ }
202
+
203
+ guard let subclass = objc_allocateClassPair(originalClass, className, 0) else { return }
204
+
205
+ let swizzledCanPerform: @convention(block) (AnyObject, Selector, Any?) -> Bool = { obj, action, sender in
206
+ guard let allowed = objc_getAssociatedObject(obj, &allowedActionsKey) as? [Selector] else {
207
+ struct Holder { static let sel = #selector(UIResponder.canPerformAction(_:withSender:)) }
208
+ let superImp = class_getMethodImplementation(class_getSuperclass(type(of: obj)), Holder.sel)
209
+ let superFn = unsafeBitCast(superImp, to: (@convention(c) (AnyObject, Selector, Selector, Any?) -> Bool).self)
210
+ return superFn(obj, Holder.sel, action, sender)
211
+ }
212
+ return allowed.contains(action)
213
+ }
214
+
215
+ let imp = imp_implementationWithBlock(swizzledCanPerform)
216
+ let sel = #selector(UIResponder.canPerformAction(_:withSender:))
217
+ let method = class_getInstanceMethod(originalClass, sel)!
218
+ let typeEncoding = method_getTypeEncoding(method)!
219
+ class_addMethod(subclass, sel, imp, typeEncoding)
220
+
221
+ objc_registerClassPair(subclass)
222
+ object_setClass(view, subclass)
223
+ }
224
+
225
+ // MARK: - Helpers
226
+ func fetchImage(from url: URL) async -> UIImage? {
227
+ var req = URLRequest(url: url)
228
+ req.timeoutInterval = 20
229
+ do {
230
+ let (data, resp) = try await URLSession.shared.data(for: req)
231
+ guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return nil }
232
+ return UIImage(data: data)
233
+ } catch {
234
+ return nil
235
+ }
236
+ }
237
+
238
+ func checkForFiles(itemProviders: [NSItemProvider], completion: @escaping ([AixInputWrapperOnPasteEvent]) -> Void) {
239
+ var events: [AixInputWrapperOnPasteEvent] = []
240
+ let lock = NSLock()
241
+ let group = DispatchGroup()
242
+
243
+ for provider in itemProviders {
244
+ if provider.hasItemConformingToTypeIdentifier(UTType.pdf.identifier) {
245
+ group.enter()
246
+ provider.loadDataRepresentation(forTypeIdentifier: UTType.pdf.identifier) { data, _ in
247
+ defer { group.leave() }
248
+ guard let data else { return }
249
+ if let uri = try? PasteFileManager.save(data: data, fileExtension: "pdf") {
250
+ print("\(LOG_TAG) PDF saved to \(uri)")
251
+ let fileURL = URL(fileURLWithPath: uri)
252
+ lock.lock()
253
+ defer { lock.unlock() }
254
+ events.append(AixInputWrapperOnPasteEvent(
255
+ type: "file",
256
+ filePath: uri,
257
+ fileExtension: "pdf",
258
+ fileName: fileURL.lastPathComponent
259
+ ))
260
+ }
261
+ }
262
+ } else if provider.hasItemConformingToTypeIdentifier(UTType.svg.identifier) {
263
+ group.enter()
264
+ provider.loadDataRepresentation(forTypeIdentifier: UTType.svg.identifier) { data, _ in
265
+ defer { group.leave() }
266
+ guard let data else { return }
267
+ if let uri = try? PasteFileManager.save(data: data, fileExtension: "svg") {
268
+ print("\(LOG_TAG) SVG saved to \(uri)")
269
+ let fileURL = URL(fileURLWithPath: uri)
270
+ lock.lock()
271
+ defer { lock.unlock() }
272
+ events.append(AixInputWrapperOnPasteEvent(
273
+ type: "file",
274
+ filePath: uri,
275
+ fileExtension: "svg",
276
+ fileName: fileURL.lastPathComponent
277
+ ))
278
+ }
279
+ }
280
+ } else if provider.canLoadObject(ofClass: UIImage.self) {
281
+ group.enter()
282
+ provider.loadObject(ofClass: UIImage.self) { object, _ in
283
+ defer { group.leave() }
284
+ guard let image = object as? UIImage else { return }
285
+ if let uri = try? PasteFileManager.save(image: image) {
286
+ print("\(LOG_TAG) Image saved to \(uri)")
287
+ let fileURL = URL(fileURLWithPath: uri)
288
+ lock.lock()
289
+ defer { lock.unlock() }
290
+ events.append(AixInputWrapperOnPasteEvent(
291
+ type: "image",
292
+ filePath: uri,
293
+ fileExtension: fileURL.pathExtension,
294
+ fileName: fileURL.lastPathComponent
295
+ ))
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ group.notify(queue: .main) {
302
+ completion(events)
303
+ }
304
+ }
305
+ }
306
+
307
+ // MARK: - Paste Handler Delegate
308
+ final class PasteHandlerDelegate: NSObject, UITextPasteDelegate {
309
+ weak var owner: HybridAixInputWrapper?
310
+
311
+ init(owner: HybridAixInputWrapper) {
312
+ self.owner = owner
313
+ super.init()
314
+ }
315
+
316
+ func textPasteConfigurationSupporting(_ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, transform item: UITextPasteItem) {
317
+ guard let owner else {
318
+ item.setDefaultResult()
319
+ return
320
+ }
321
+
322
+ let pb = UIPasteboard.general
323
+ let provider = item.itemProvider
324
+
325
+ // Multiple images on pasteboard
326
+ if let images = pb.images, images.count > 1 {
327
+ print("\(LOG_TAG) Paste delegate: \(images.count) images")
328
+ owner.saveImages(images) { events in
329
+ if !events.isEmpty {
330
+ owner.onPaste?(events)
331
+ }
332
+ item.setNoResult()
333
+ }
334
+ return
335
+ }
336
+
337
+ // File types (PDF, SVG)
338
+ if provider.hasItemConformingToTypeIdentifier(UTType.pdf.identifier) ||
339
+ provider.hasItemConformingToTypeIdentifier(UTType.svg.identifier) {
340
+ owner.checkForFiles(itemProviders: [provider]) { events in
341
+ if !events.isEmpty {
342
+ owner.onPaste?(events)
343
+ item.setNoResult()
344
+ } else {
345
+ item.setDefaultResult()
346
+ }
347
+ }
348
+ return
349
+ }
350
+
351
+ // Single image
352
+ if let image = pb.image {
353
+ owner.saveImages([image]) { events in
354
+ if !events.isEmpty {
355
+ owner.onPaste?(events)
356
+ }
357
+ item.setNoResult()
358
+ }
359
+ return
360
+ }
361
+
362
+ // URL (try to fetch image)
363
+ if let url = pb.url {
364
+ Task {
365
+ if let image = await owner.fetchImage(from: url) {
366
+ owner.saveImages([image]) { events in
367
+ if !events.isEmpty {
368
+ owner.onPaste?(events)
369
+ }
370
+ item.setNoResult()
371
+ }
372
+ } else {
373
+ item.setDefaultResult()
374
+ }
375
+ }
376
+ return
377
+ }
378
+
379
+ // String URL
380
+ if let string = pb.string, let url = URL(string: string), url.scheme != nil {
381
+ Task {
382
+ if let image = await owner.fetchImage(from: url) {
383
+ owner.saveImages([image]) { events in
384
+ if !events.isEmpty {
385
+ owner.onPaste?(events)
386
+ }
387
+ item.setNoResult()
388
+ }
389
+ } else {
390
+ item.setDefaultResult()
391
+ }
392
+ }
393
+ return
394
+ }
395
+
396
+ // Large text
397
+ if let text = pb.string {
398
+ let lineCount = text.split(separator: "\n", omittingEmptySubsequences: false).count
399
+ let charCount = text.count
400
+ let maxCharsPropInt = owner.maxChars.map { Int($0) }
401
+ let maxLinesPropInt = owner.maxLines.map { Int($0) }
402
+ if let maxCharsVal = maxCharsPropInt, let maxLinesVal = maxLinesPropInt,
403
+ lineCount > maxLinesVal || charCount > maxCharsVal {
404
+ if let uri = try? PasteFileManager.save(text: text) {
405
+ let fileURL = URL(fileURLWithPath: uri)
406
+ owner.onPaste?([AixInputWrapperOnPasteEvent(
407
+ type: "text",
408
+ filePath: uri,
409
+ fileExtension: "txt",
410
+ fileName: fileURL.lastPathComponent
411
+ )])
412
+ item.setNoResult()
413
+ return
414
+ }
415
+ }
416
+ }
417
+
418
+ item.setDefaultResult()
419
+ }
420
+ }
421
+
422
+ // MARK: - Image Drop Delegate
423
+ final class ImageDropDelegate: NSObject, UIDropInteractionDelegate {
424
+ var onImage: ((UIImage) -> Void)?
425
+
426
+ func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool {
427
+ return session.items.contains { $0.itemProvider.canLoadObject(ofClass: UIImage.self) }
428
+ }
429
+
430
+ func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal {
431
+ return UIDropProposal(operation: .copy)
432
+ }
433
+
434
+ func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) {
435
+ for item in session.items {
436
+ let provider = item.itemProvider
437
+ if provider.canLoadObject(ofClass: UIImage.self) {
438
+ provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
439
+ guard let image = object as? UIImage else { return }
440
+ DispatchQueue.main.async {
441
+ self?.onImage?(image)
442
+ }
443
+ }
444
+ }
445
+ }
446
+ }
447
+ }
@@ -0,0 +1,40 @@
1
+ import UIKit
2
+
3
+ final class WeakRef<T> {
4
+ public weak var rawRef: AnyObject?
5
+
6
+ public var ref: T? {
7
+ self.rawRef as? T
8
+ }
9
+
10
+ public init(with ref: T) {
11
+ self.rawRef = ref as AnyObject
12
+ }
13
+
14
+ public init?(with ref: T?) {
15
+ guard let unwrappedRef = ref else { return nil }
16
+ self.rawRef = unwrappedRef as AnyObject
17
+ }
18
+ }
19
+
20
+ enum InputType {
21
+ case textField(WeakRef<UITextField>)
22
+ case textView(WeakRef<UITextView>)
23
+
24
+ var textInput: UITextInput? {
25
+ switch self {
26
+ case let .textField(textField):
27
+ return textField.ref
28
+ case let .textView(textView):
29
+ return textView.ref
30
+ }
31
+ }
32
+
33
+ init(textField: UITextField) {
34
+ self = .textField(.init(with: textField))
35
+ }
36
+
37
+ init(textView: UITextView) {
38
+ self = .textView(.init(with: textView))
39
+ }
40
+ }