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.
- package/README.md +269 -70
- package/ios/EditMenuDefaultActions.swift +70 -0
- package/ios/HybridAix.swift +28 -3
- package/ios/HybridAixCellView.swift +1 -1
- package/ios/HybridAixComposer.swift +82 -0
- package/ios/HybridAixDropzone.swift +104 -0
- package/ios/HybridAixInputWrapper.swift +447 -0
- package/ios/InputType.swift +40 -0
- package/ios/PasteFileManager.swift +92 -0
- package/nitro.json +8 -0
- package/nitrogen/generated/android/Aix+autolinking.cmake +8 -0
- package/nitrogen/generated/android/AixOnLoad.cpp +26 -0
- package/nitrogen/generated/android/c++/JAixInputWrapperOnPasteEvent.hpp +70 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_AixInputWrapperOnPasteEvent_.hpp +98 -0
- package/nitrogen/generated/android/c++/JHybridAixComposerSpec.cpp +9 -0
- package/nitrogen/generated/android/c++/JHybridAixComposerSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/JHybridAixDropzoneSpec.cpp +72 -0
- package/nitrogen/generated/android/c++/JHybridAixDropzoneSpec.hpp +66 -0
- package/nitrogen/generated/android/c++/JHybridAixInputWrapperSpec.cpp +144 -0
- package/nitrogen/generated/android/c++/JHybridAixInputWrapperSpec.hpp +74 -0
- package/nitrogen/generated/android/c++/views/JHybridAixComposerStateUpdater.cpp +4 -0
- package/nitrogen/generated/android/c++/views/JHybridAixDropzoneStateUpdater.cpp +56 -0
- package/nitrogen/generated/android/c++/views/JHybridAixDropzoneStateUpdater.hpp +49 -0
- package/nitrogen/generated/android/c++/views/JHybridAixInputWrapperStateUpdater.cpp +72 -0
- package/nitrogen/generated/android/c++/views/JHybridAixInputWrapperStateUpdater.hpp +49 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixInputWrapperOnPasteEvent.kt +47 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/Func_void_std__vector_AixInputWrapperOnPasteEvent_.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixComposerSpec.kt +6 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixDropzoneSpec.kt +67 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixInputWrapperSpec.kt +91 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixDropzoneManager.kt +50 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixDropzoneStateUpdater.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixInputWrapperManager.kt +50 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixInputWrapperStateUpdater.kt +23 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.cpp +42 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.hpp +112 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Umbrella.hpp +14 -0
- package/nitrogen/generated/ios/AixAutolinking.mm +16 -0
- package/nitrogen/generated/ios/AixAutolinking.swift +30 -0
- package/nitrogen/generated/ios/c++/HybridAixComposerSpecSwift.hpp +7 -0
- package/nitrogen/generated/ios/c++/HybridAixDropzoneSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridAixDropzoneSpecSwift.hpp +80 -0
- package/nitrogen/generated/ios/c++/HybridAixInputWrapperSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridAixInputWrapperSpecSwift.hpp +108 -0
- package/nitrogen/generated/ios/c++/views/HybridAixComposerComponent.mm +5 -0
- package/nitrogen/generated/ios/c++/views/HybridAixDropzoneComponent.mm +96 -0
- package/nitrogen/generated/ios/c++/views/HybridAixInputWrapperComponent.mm +116 -0
- package/nitrogen/generated/ios/swift/AixInputWrapperOnPasteEvent.swift +107 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_AixInputWrapperOnPasteEvent_.swift +47 -0
- package/nitrogen/generated/ios/swift/HybridAixComposerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridAixComposerSpec_cxx.swift +24 -0
- package/nitrogen/generated/ios/swift/HybridAixDropzoneSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridAixDropzoneSpec_cxx.swift +167 -0
- package/nitrogen/generated/ios/swift/HybridAixInputWrapperSpec.swift +60 -0
- package/nitrogen/generated/ios/swift/HybridAixInputWrapperSpec_cxx.swift +261 -0
- package/nitrogen/generated/shared/c++/AixInputWrapperOnPasteEvent.hpp +88 -0
- package/nitrogen/generated/shared/c++/HybridAixComposerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridAixComposerSpec.hpp +2 -0
- package/nitrogen/generated/shared/c++/HybridAixDropzoneSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridAixDropzoneSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridAixInputWrapperSpec.cpp +30 -0
- package/nitrogen/generated/shared/c++/HybridAixInputWrapperSpec.hpp +76 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.cpp +12 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.hpp +1 -0
- package/nitrogen/generated/shared/c++/views/HybridAixDropzoneComponent.cpp +87 -0
- package/nitrogen/generated/shared/c++/views/HybridAixDropzoneComponent.hpp +109 -0
- package/nitrogen/generated/shared/c++/views/HybridAixInputWrapperComponent.cpp +135 -0
- package/nitrogen/generated/shared/c++/views/HybridAixInputWrapperComponent.hpp +114 -0
- package/nitrogen/generated/shared/json/AixComposerConfig.json +1 -0
- package/nitrogen/generated/shared/json/AixDropzoneConfig.json +10 -0
- package/nitrogen/generated/shared/json/AixInputWrapperConfig.json +14 -0
- package/package.json +1 -1
- package/src/dropzone.ios.tsx +27 -0
- package/src/dropzone.tsx +10 -0
- package/src/index.ts +3 -0
- package/src/input-wrapper.ios.tsx +30 -0
- package/src/input-wrapper.tsx +17 -0
- 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
|
+
}
|