expo-paste-input 0.1.0
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/.eslintrc.js +5 -0
- package/README.md +123 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/pasteinput/ExpoPasteInputModule.kt +14 -0
- package/android/src/main/java/expo/modules/pasteinput/ExpoPasteInputView.kt +418 -0
- package/build/TextInputWrapper.types.d.ts +22 -0
- package/build/TextInputWrapper.types.d.ts.map +1 -0
- package/build/TextInputWrapper.types.js +2 -0
- package/build/TextInputWrapper.types.js.map +1 -0
- package/build/TextInputWrapperView.d.ts +5 -0
- package/build/TextInputWrapperView.d.ts.map +1 -0
- package/build/TextInputWrapperView.js +17 -0
- package/build/TextInputWrapperView.js.map +1 -0
- package/build/TextInputWrapperView.web.d.ts +5 -0
- package/build/TextInputWrapperView.web.d.ts.map +1 -0
- package/build/TextInputWrapperView.web.js +10 -0
- package/build/TextInputWrapperView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoPasteInput.podspec +23 -0
- package/ios/ExpoPasteInputModule.swift +11 -0
- package/ios/ExpoPasteInputView.swift +601 -0
- package/package.json +43 -0
- package/src/TextInputWrapper.types.ts +19 -0
- package/src/TextInputWrapperView.tsx +34 -0
- package/src/TextInputWrapperView.web.tsx +17 -0
- package/src/index.ts +5 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
import ObjectiveC
|
|
4
|
+
import ImageIO
|
|
5
|
+
|
|
6
|
+
// Association key for storing the wrapper view reference on text input views
|
|
7
|
+
private var textInputWrapperKey: UInt8 = 0
|
|
8
|
+
|
|
9
|
+
// Weak wrapper to avoid retain cycles
|
|
10
|
+
private class WeakWrapper {
|
|
11
|
+
weak var value: ExpoPasteInputView?
|
|
12
|
+
init(_ value: ExpoPasteInputView) {
|
|
13
|
+
self.value = value
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Protocol to identify text input views that can be enhanced
|
|
18
|
+
private protocol TextInputEnhanceable: UIView {
|
|
19
|
+
func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool
|
|
20
|
+
func paste(_ sender: Any?)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
extension UITextField: TextInputEnhanceable {}
|
|
24
|
+
extension UITextView: TextInputEnhanceable {}
|
|
25
|
+
|
|
26
|
+
class ExpoPasteInputView: ExpoView {
|
|
27
|
+
private let onPaste = EventDispatcher()
|
|
28
|
+
private var textInputView: UIView?
|
|
29
|
+
private var isMonitoring: Bool = false
|
|
30
|
+
// Track which classes have been swizzled (once per class, never unswizzle)
|
|
31
|
+
private static var swizzledClasses: Set<String> = []
|
|
32
|
+
|
|
33
|
+
required init(appContext: AppContext? = nil) {
|
|
34
|
+
super.init(appContext: appContext)
|
|
35
|
+
clipsToBounds = false
|
|
36
|
+
backgroundColor = .clear
|
|
37
|
+
// Keep user interaction enabled so we can monitor, but pass through touches
|
|
38
|
+
isUserInteractionEnabled = true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Pass through all touch events to children - never intercept
|
|
42
|
+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
43
|
+
// Always delegate to super first to check children
|
|
44
|
+
let hitView = super.hitTest(point, with: event)
|
|
45
|
+
|
|
46
|
+
// If we hit ourselves or nothing, return nil to pass through
|
|
47
|
+
if hitView == self || hitView == nil {
|
|
48
|
+
return nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Return the child view that was hit
|
|
52
|
+
return hitView
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
56
|
+
// Only return true if a child contains the point
|
|
57
|
+
for subview in subviews.reversed() {
|
|
58
|
+
let convertedPoint = subview.convert(point, from: self)
|
|
59
|
+
if subview.point(inside: convertedPoint, with: event) {
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Never claim the point for ourselves
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
override func didMoveToSuperview() {
|
|
68
|
+
super.didMoveToSuperview()
|
|
69
|
+
if superview != nil {
|
|
70
|
+
startMonitoring()
|
|
71
|
+
} else {
|
|
72
|
+
stopMonitoring()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override func didAddSubview(_ subview: UIView) {
|
|
77
|
+
super.didAddSubview(subview)
|
|
78
|
+
startMonitoring()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func startMonitoring() {
|
|
82
|
+
guard !isMonitoring else { return }
|
|
83
|
+
|
|
84
|
+
// Find TextInput in view hierarchy
|
|
85
|
+
textInputView = findTextInputInView(self)
|
|
86
|
+
|
|
87
|
+
if let textInput = textInputView {
|
|
88
|
+
isMonitoring = true
|
|
89
|
+
enhanceTextInput(textInput)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private func stopMonitoring() {
|
|
94
|
+
guard isMonitoring else { return }
|
|
95
|
+
isMonitoring = false
|
|
96
|
+
|
|
97
|
+
// Only clear the association; swizzling stays global and is guarded
|
|
98
|
+
if let textInput = textInputView {
|
|
99
|
+
restoreTextInput(textInput)
|
|
100
|
+
}
|
|
101
|
+
textInputView = nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func enhanceTextInput(_ view: UIView) {
|
|
105
|
+
// Store weak reference to this wrapper on the text input view to avoid retain cycles
|
|
106
|
+
objc_setAssociatedObject(view, &textInputWrapperKey, WeakWrapper(self), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
107
|
+
|
|
108
|
+
// Swizzle canPerformAction and paste methods (once per class, never unswizzle)
|
|
109
|
+
swizzleTextInputMethods(view)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func restoreTextInput(_ view: UIView) {
|
|
113
|
+
// Only clear the association; swizzling stays global and is guarded
|
|
114
|
+
objc_setAssociatedObject(view, &textInputWrapperKey, nil, .OBJC_ASSOCIATION_ASSIGN)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private func swizzleTextInputMethods(_ view: UIView) {
|
|
118
|
+
let viewClass: AnyClass = type(of: view)
|
|
119
|
+
let className = String(describing: viewClass)
|
|
120
|
+
|
|
121
|
+
// Swizzle once per class, never unswizzle
|
|
122
|
+
guard !ExpoPasteInputView.swizzledClasses.contains(className) else {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var originalCanPerformIMP: IMP? = nil
|
|
127
|
+
var originalPasteIMP: IMP? = nil
|
|
128
|
+
var didSwizzle = false
|
|
129
|
+
|
|
130
|
+
// Swizzle canPerformAction (once per class)
|
|
131
|
+
let canPerformSelector = #selector(UIResponder.canPerformAction(_:withSender:))
|
|
132
|
+
let swizzledCanPerformSelector = NSSelectorFromString("_expoPasteInput_canPerformAction:withSender:")
|
|
133
|
+
|
|
134
|
+
if let originalMethod = class_getInstanceMethod(viewClass, canPerformSelector) {
|
|
135
|
+
originalCanPerformIMP = method_getImplementation(originalMethod)
|
|
136
|
+
|
|
137
|
+
// Only add swizzled method if it doesn't exist
|
|
138
|
+
if class_getInstanceMethod(viewClass, swizzledCanPerformSelector) == nil {
|
|
139
|
+
let swizzledImplementation: @convention(block) (AnyObject, Selector, Any?) -> Bool = { object, action, sender in
|
|
140
|
+
// Check if this text input is associated with a wrapper
|
|
141
|
+
if let weakWrapper = objc_getAssociatedObject(object, &textInputWrapperKey) as? WeakWrapper,
|
|
142
|
+
weakWrapper.value != nil {
|
|
143
|
+
// Only process if this is our wrapped text input
|
|
144
|
+
if action == #selector(UIResponderStandardEditActions.paste(_:)) {
|
|
145
|
+
let pasteboard = UIPasteboard.general
|
|
146
|
+
if pasteboard.hasImages || pasteboard.hasStrings {
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Call original implementation
|
|
153
|
+
if let originalIMP = originalCanPerformIMP {
|
|
154
|
+
typealias OriginalIMP = @convention(c) (AnyObject, Selector, Selector, Any?) -> Bool
|
|
155
|
+
let originalFunction = unsafeBitCast(originalIMP, to: OriginalIMP.self)
|
|
156
|
+
return originalFunction(object, canPerformSelector, action, sender)
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let blockIMP = imp_implementationWithBlock(unsafeBitCast(swizzledImplementation, to: AnyObject.self))
|
|
162
|
+
let types = method_getTypeEncoding(originalMethod)
|
|
163
|
+
|
|
164
|
+
if class_addMethod(viewClass, swizzledCanPerformSelector, blockIMP, types) {
|
|
165
|
+
if let swizzledMethod = class_getInstanceMethod(viewClass, swizzledCanPerformSelector) {
|
|
166
|
+
method_exchangeImplementations(originalMethod, swizzledMethod)
|
|
167
|
+
didSwizzle = true
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Swizzle paste method (once per class)
|
|
174
|
+
let pasteSelector = #selector(UIResponderStandardEditActions.paste(_:))
|
|
175
|
+
let swizzledPasteSelector = NSSelectorFromString("_expoPasteInput_paste:")
|
|
176
|
+
|
|
177
|
+
if let originalMethod = class_getInstanceMethod(viewClass, pasteSelector) {
|
|
178
|
+
originalPasteIMP = method_getImplementation(originalMethod)
|
|
179
|
+
|
|
180
|
+
// Only add swizzled method if it doesn't exist
|
|
181
|
+
if class_getInstanceMethod(viewClass, swizzledPasteSelector) == nil {
|
|
182
|
+
let swizzledImplementation: @convention(block) (AnyObject, Any?) -> Void = { object, sender in
|
|
183
|
+
// Check if this text input is associated with a wrapper
|
|
184
|
+
guard let weakWrapper = objc_getAssociatedObject(object, &textInputWrapperKey) as? WeakWrapper,
|
|
185
|
+
let wrapper = weakWrapper.value else {
|
|
186
|
+
// Not our text input, call original and return
|
|
187
|
+
if let originalIMP = originalPasteIMP {
|
|
188
|
+
typealias OriginalIMP = @convention(c) (AnyObject, Selector, Any?) -> Void
|
|
189
|
+
let originalFunction = unsafeBitCast(originalIMP, to: OriginalIMP.self)
|
|
190
|
+
originalFunction(object, pasteSelector, sender)
|
|
191
|
+
}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let pasteboard = UIPasteboard.general
|
|
196
|
+
|
|
197
|
+
// CRITICAL: Check for GIFs FIRST using explicit type queries
|
|
198
|
+
// This gets raw data without triggering UIImage conversion
|
|
199
|
+
let gifTypes = ["com.compuserve.gif", "public.gif", "image/gif"]
|
|
200
|
+
var hasGIF = false
|
|
201
|
+
for gifType in gifTypes {
|
|
202
|
+
if let gifData = pasteboard.data(forPasteboardType: gifType), !gifData.isEmpty {
|
|
203
|
+
hasGIF = true
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Also check items for GIF data (but be careful not to trigger conversion)
|
|
209
|
+
if !hasGIF {
|
|
210
|
+
for item in pasteboard.items {
|
|
211
|
+
for (key, _) in item {
|
|
212
|
+
if gifTypes.contains(key) || key.lowercased().contains("gif") {
|
|
213
|
+
hasGIF = true
|
|
214
|
+
break
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if hasGIF { break }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// If we have a GIF, process it immediately without touching hasImages
|
|
222
|
+
if hasGIF {
|
|
223
|
+
DispatchQueue.main.async {
|
|
224
|
+
wrapper.processPasteboardContent()
|
|
225
|
+
}
|
|
226
|
+
return // Don't call original paste for GIFs
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check for other image data (but not GIFs, already handled)
|
|
230
|
+
var hasImageData = false
|
|
231
|
+
for item in pasteboard.items {
|
|
232
|
+
for (key, value) in item {
|
|
233
|
+
// Skip GIF-related keys
|
|
234
|
+
if key.lowercased().contains("gif") {
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check if this looks like image data
|
|
239
|
+
let isImageKey = key.contains("image") || key.contains("png") || key.contains("jpeg") ||
|
|
240
|
+
key.contains("jpg") || key.contains("tiff")
|
|
241
|
+
|
|
242
|
+
if isImageKey && (value is Data || value is UIImage) {
|
|
243
|
+
hasImageData = true
|
|
244
|
+
break
|
|
245
|
+
} else if value is UIImage {
|
|
246
|
+
hasImageData = true
|
|
247
|
+
break
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if hasImageData { break }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// If we found potential image data, process it
|
|
254
|
+
if hasImageData {
|
|
255
|
+
DispatchQueue.main.async {
|
|
256
|
+
wrapper.processPasteboardContent()
|
|
257
|
+
}
|
|
258
|
+
return // Don't call original paste for images
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Fallback: check hasImages only if no image data found in items
|
|
262
|
+
// This is safer as we've already checked for GIFs above
|
|
263
|
+
if pasteboard.hasImages {
|
|
264
|
+
DispatchQueue.main.async {
|
|
265
|
+
wrapper.processPasteboardContent()
|
|
266
|
+
}
|
|
267
|
+
return // Don't call original paste for images
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Handle text - call original paste first, then notify
|
|
271
|
+
if let originalIMP = originalPasteIMP {
|
|
272
|
+
typealias OriginalIMP = @convention(c) (AnyObject, Selector, Any?) -> Void
|
|
273
|
+
let originalFunction = unsafeBitCast(originalIMP, to: OriginalIMP.self)
|
|
274
|
+
originalFunction(object, pasteSelector, sender)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Notify about text paste
|
|
278
|
+
if pasteboard.hasStrings {
|
|
279
|
+
DispatchQueue.main.async {
|
|
280
|
+
wrapper.processTextPaste()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let blockIMP = imp_implementationWithBlock(unsafeBitCast(swizzledImplementation, to: AnyObject.self))
|
|
286
|
+
let types = method_getTypeEncoding(originalMethod)
|
|
287
|
+
|
|
288
|
+
if class_addMethod(viewClass, swizzledPasteSelector, blockIMP, types) {
|
|
289
|
+
if let swizzledMethod = class_getInstanceMethod(viewClass, swizzledPasteSelector) {
|
|
290
|
+
method_exchangeImplementations(originalMethod, swizzledMethod)
|
|
291
|
+
didSwizzle = true
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Mark this class as swizzled only if we successfully swizzled at least one method
|
|
298
|
+
// (once per class, never unswizzle)
|
|
299
|
+
if didSwizzle {
|
|
300
|
+
ExpoPasteInputView.swizzledClasses.insert(className)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private func findTextInputInView(_ view: UIView) -> UIView? {
|
|
305
|
+
let className = String(describing: type(of: view))
|
|
306
|
+
if className.contains("RCTUITextField") || className.contains("RCTUITextView") ||
|
|
307
|
+
className.contains("UITextField") || className.contains("UITextView") {
|
|
308
|
+
return view
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for subview in view.subviews {
|
|
312
|
+
if let found = findTextInputInView(subview) {
|
|
313
|
+
return found
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return nil
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private func processPasteboardContent() {
|
|
321
|
+
// This method is only called for image pastes
|
|
322
|
+
let pasteboard = UIPasteboard.general
|
|
323
|
+
|
|
324
|
+
let gifTypes: Set<String> = ["com.compuserve.gif", "public.gif", "image/gif"]
|
|
325
|
+
let staticImageTypes = ["public.png", "public.jpeg", "public.tiff", "public.heic", "public.image"]
|
|
326
|
+
|
|
327
|
+
var gifDataItems: [Data] = []
|
|
328
|
+
var staticImages: [UIImage] = []
|
|
329
|
+
var processedGifHashes = Set<Int>()
|
|
330
|
+
|
|
331
|
+
// Get all items once to ensure consistent access
|
|
332
|
+
let items = pasteboard.items
|
|
333
|
+
let itemCount = items.count
|
|
334
|
+
|
|
335
|
+
// Process each pasteboard item individually
|
|
336
|
+
// This ensures correct handling of mixed GIF and static image pastes
|
|
337
|
+
for itemIndex in 0..<itemCount {
|
|
338
|
+
let item = items[itemIndex]
|
|
339
|
+
let itemKeys = Set(item.keys) // Types available for THIS specific item
|
|
340
|
+
let singleItemSet = IndexSet(integer: itemIndex)
|
|
341
|
+
|
|
342
|
+
var itemIsGif = false
|
|
343
|
+
var gifDataForItem: Data? = nil
|
|
344
|
+
|
|
345
|
+
// ===== STEP 1: Check if this item is a GIF =====
|
|
346
|
+
// Check if any of this item's keys indicate it's a GIF
|
|
347
|
+
let itemGifKeys = itemKeys.filter { key in
|
|
348
|
+
gifTypes.contains(key) || key.lowercased().contains("gif")
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Try to extract GIF data from this item
|
|
352
|
+
for gifKey in itemGifKeys {
|
|
353
|
+
// Method 1: Try to get data from the item dictionary directly
|
|
354
|
+
if let gifData = item[gifKey] as? Data, !gifData.isEmpty, isGIFData(gifData) {
|
|
355
|
+
gifDataForItem = gifData
|
|
356
|
+
itemIsGif = true
|
|
357
|
+
break
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Method 2: Use pasteboard API for this specific item
|
|
361
|
+
if let dataArray = pasteboard.data(forPasteboardType: gifKey, inItemSet: singleItemSet),
|
|
362
|
+
let gifData = dataArray.first,
|
|
363
|
+
!gifData.isEmpty, isGIFData(gifData) {
|
|
364
|
+
gifDataForItem = gifData
|
|
365
|
+
itemIsGif = true
|
|
366
|
+
break
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// If found a GIF, add it and continue to next item
|
|
371
|
+
if itemIsGif, let gifData = gifDataForItem {
|
|
372
|
+
let hash = gifData.hashValue
|
|
373
|
+
if !processedGifHashes.contains(hash) {
|
|
374
|
+
gifDataItems.append(gifData)
|
|
375
|
+
processedGifHashes.insert(hash)
|
|
376
|
+
}
|
|
377
|
+
continue // Skip static image extraction for this item
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ===== STEP 2: This item is NOT a GIF - extract static image =====
|
|
381
|
+
var extractedImage: UIImage? = nil
|
|
382
|
+
|
|
383
|
+
// Try each static image type in order of preference (only if this item has that type)
|
|
384
|
+
for imageType in staticImageTypes {
|
|
385
|
+
guard itemKeys.contains(imageType) else { continue }
|
|
386
|
+
|
|
387
|
+
// Method 1: Try item dictionary directly
|
|
388
|
+
if let imageData = item[imageType] as? Data, !imageData.isEmpty, !isGIFData(imageData) {
|
|
389
|
+
if let image = safeCreateImage(from: imageData) {
|
|
390
|
+
extractedImage = image
|
|
391
|
+
break
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Method 2: Use pasteboard API
|
|
396
|
+
if extractedImage == nil,
|
|
397
|
+
let dataArray = pasteboard.data(forPasteboardType: imageType, inItemSet: singleItemSet),
|
|
398
|
+
let imageData = dataArray.first,
|
|
399
|
+
!imageData.isEmpty, !isGIFData(imageData) {
|
|
400
|
+
if let image = safeCreateImage(from: imageData) {
|
|
401
|
+
extractedImage = image
|
|
402
|
+
break
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Fallback: Try any non-GIF image data from the item dictionary
|
|
408
|
+
if extractedImage == nil {
|
|
409
|
+
// Sort keys to have consistent ordering (prefer png, jpeg, then others)
|
|
410
|
+
let sortedKeys = itemKeys.sorted { k1, k2 in
|
|
411
|
+
let priority1 = k1.contains("png") ? 0 : (k1.contains("jpeg") || k1.contains("jpg") ? 1 : 2)
|
|
412
|
+
let priority2 = k2.contains("png") ? 0 : (k2.contains("jpeg") || k2.contains("jpg") ? 1 : 2)
|
|
413
|
+
return priority1 < priority2
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for key in sortedKeys {
|
|
417
|
+
// Skip GIF-related keys
|
|
418
|
+
if key.lowercased().contains("gif") {
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Try Data
|
|
423
|
+
if let imageData = item[key] as? Data, imageData.count >= 6, !isGIFData(imageData) {
|
|
424
|
+
if let image = safeCreateImage(from: imageData) {
|
|
425
|
+
extractedImage = image
|
|
426
|
+
break
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Try UIImage
|
|
431
|
+
if let image = item[key] as? UIImage, image.size.width > 0, image.size.height > 0 {
|
|
432
|
+
extractedImage = image
|
|
433
|
+
break
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Add the extracted static image
|
|
439
|
+
if let image = extractedImage {
|
|
440
|
+
staticImages.append(image)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Final fallback: If nothing was extracted at all, try pasteboard.image
|
|
445
|
+
if staticImages.isEmpty && gifDataItems.isEmpty, let image = pasteboard.image {
|
|
446
|
+
staticImages.append(image)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Use the collected data
|
|
450
|
+
let images = staticImages
|
|
451
|
+
|
|
452
|
+
// Handle both GIFs and static images together
|
|
453
|
+
if !gifDataItems.isEmpty || !images.isEmpty {
|
|
454
|
+
// Combine GIFs and static images into one paste event
|
|
455
|
+
var allFilePaths: [String] = []
|
|
456
|
+
|
|
457
|
+
// First, add GIF file paths
|
|
458
|
+
if !gifDataItems.isEmpty {
|
|
459
|
+
let tempDir = FileManager.default.temporaryDirectory
|
|
460
|
+
for gifData in gifDataItems {
|
|
461
|
+
let fileName = UUID().uuidString + ".gif"
|
|
462
|
+
let fileURL = tempDir.appendingPathComponent(fileName)
|
|
463
|
+
|
|
464
|
+
do {
|
|
465
|
+
try gifData.write(to: fileURL)
|
|
466
|
+
let filePath = "file://" + fileURL.path
|
|
467
|
+
allFilePaths.append(filePath)
|
|
468
|
+
} catch {
|
|
469
|
+
continue // Skip this GIF if we can't save it
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Then, add static image file paths
|
|
475
|
+
if !images.isEmpty {
|
|
476
|
+
for image in images {
|
|
477
|
+
// Preserve transparency for images with alpha channel
|
|
478
|
+
let imageData: Data?
|
|
479
|
+
if image.hasAlpha {
|
|
480
|
+
imageData = image.pngData()
|
|
481
|
+
} else {
|
|
482
|
+
imageData = image.jpegData(compressionQuality: 0.8)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
guard let imageData = imageData else {
|
|
486
|
+
continue // Skip this image if we can't compress it
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
let tempDir = FileManager.default.temporaryDirectory
|
|
490
|
+
let fileExtension = image.hasAlpha ? ".png" : ".jpg"
|
|
491
|
+
let fileName = UUID().uuidString + fileExtension
|
|
492
|
+
let fileURL = tempDir.appendingPathComponent(fileName)
|
|
493
|
+
|
|
494
|
+
do {
|
|
495
|
+
try imageData.write(to: fileURL)
|
|
496
|
+
let filePath = "file://" + fileURL.path
|
|
497
|
+
allFilePaths.append(filePath)
|
|
498
|
+
} catch {
|
|
499
|
+
continue // Skip this image if we can't save it
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if !allFilePaths.isEmpty {
|
|
505
|
+
// Send all images (GIFs and static) in one event
|
|
506
|
+
onPaste([
|
|
507
|
+
"type": "images",
|
|
508
|
+
"uris": allFilePaths
|
|
509
|
+
])
|
|
510
|
+
} else {
|
|
511
|
+
handleUnsupportedPaste()
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
// If we have neither GIFs nor images, treat as unsupported
|
|
515
|
+
handleUnsupportedPaste()
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/// Detects if the given data is a GIF by checking for GIF87a or GIF89a header
|
|
520
|
+
private func isGIFData(_ data: Data) -> Bool {
|
|
521
|
+
guard data.count >= 6 else { return false }
|
|
522
|
+
|
|
523
|
+
// Check for GIF signature: "GIF87a" or "GIF89a"
|
|
524
|
+
let gif87aSignature: [UInt8] = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] // "GIF87a"
|
|
525
|
+
let gif89aSignature: [UInt8] = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] // "GIF89a"
|
|
526
|
+
|
|
527
|
+
let header = data.prefix(6)
|
|
528
|
+
let headerBytes = [UInt8](header)
|
|
529
|
+
|
|
530
|
+
return headerBytes == gif87aSignature || headerBytes == gif89aSignature
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/// Safely creates a UIImage from data, validating it first to prevent ImageIO errors
|
|
534
|
+
private func safeCreateImage(from data: Data) -> UIImage? {
|
|
535
|
+
guard data.count > 0 else { return nil }
|
|
536
|
+
|
|
537
|
+
// Use ImageIO to validate the data before creating UIImage
|
|
538
|
+
// This prevents ImageIO errors from corrupted or invalid image data
|
|
539
|
+
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
|
|
540
|
+
return nil
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Check if the image source has at least one image
|
|
544
|
+
guard CGImageSourceGetCount(imageSource) > 0 else {
|
|
545
|
+
return nil
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Get the first image from the source
|
|
549
|
+
guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
|
|
550
|
+
return nil
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Create UIImage from CGImage (this is safer than UIImage(data:))
|
|
554
|
+
let image = UIImage(cgImage: cgImage)
|
|
555
|
+
|
|
556
|
+
// Validate the image has valid dimensions
|
|
557
|
+
guard image.size.width > 0 && image.size.height > 0 else {
|
|
558
|
+
return nil
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return image
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private func processTextPaste() {
|
|
565
|
+
// This method is only called for text pastes
|
|
566
|
+
let pasteboard = UIPasteboard.general
|
|
567
|
+
|
|
568
|
+
// Check for text using pasteboard.string
|
|
569
|
+
if let text = pasteboard.string, !text.isEmpty {
|
|
570
|
+
handleTextPaste(text)
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// No text found - don't trigger unsupported, just ignore
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private func handleTextPaste(_ text: String) {
|
|
578
|
+
onPaste([
|
|
579
|
+
"type": "text",
|
|
580
|
+
"value": text
|
|
581
|
+
])
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private func handleUnsupportedPaste() {
|
|
585
|
+
onPaste([
|
|
586
|
+
"type": "unsupported"
|
|
587
|
+
])
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
deinit {
|
|
591
|
+
stopMonitoring()
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
extension UIImage {
|
|
596
|
+
var hasAlpha: Bool {
|
|
597
|
+
guard let cgImage = self.cgImage else { return false }
|
|
598
|
+
let alphaInfo = cgImage.alphaInfo
|
|
599
|
+
return alphaInfo != .none && alphaInfo != .noneSkipFirst && alphaInfo != .noneSkipLast
|
|
600
|
+
}
|
|
601
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-paste-input",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A wrapper around React Native TextInput to paste images and GIFs from the clipboard (iOS, Android, Web)",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "expo-module build",
|
|
9
|
+
"clean": "expo-module clean",
|
|
10
|
+
"lint": "expo-module lint",
|
|
11
|
+
"test": "expo-module test",
|
|
12
|
+
"prepare": "expo-module prepare",
|
|
13
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
14
|
+
"expo-module": "expo-module",
|
|
15
|
+
"open:ios": "xed example/ios",
|
|
16
|
+
"open:android": "open -a \"Android Studio\" example/android"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"react-native",
|
|
20
|
+
"expo",
|
|
21
|
+
"expo-paste-input",
|
|
22
|
+
"ExpoPasteInput"
|
|
23
|
+
],
|
|
24
|
+
"repository": "https://github.com/arunabhverma/expo-paste-input",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/arunabhverma/expo-paste-input/issues"
|
|
27
|
+
},
|
|
28
|
+
"author": "Arunabh Verma <arunabhverma01@gmail.com> (https://github.com/arunabhverma)",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/arunabhverma/expo-paste-input#readme",
|
|
31
|
+
"dependencies": {},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/react": "~19.1.0",
|
|
34
|
+
"expo-module-scripts": "^5.0.8",
|
|
35
|
+
"expo": "^54.0.27",
|
|
36
|
+
"react-native": "0.81.5"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"expo": "*",
|
|
40
|
+
"react": "*",
|
|
41
|
+
"react-native": "*"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ViewProps } from "react-native";
|
|
2
|
+
|
|
3
|
+
export type PasteEventPayload =
|
|
4
|
+
| { type: "text"; value: string }
|
|
5
|
+
| { type: "images"; uris: string[] }
|
|
6
|
+
| { type: "unsupported" };
|
|
7
|
+
|
|
8
|
+
export interface TextInputWrapperViewProps extends ViewProps {
|
|
9
|
+
/**
|
|
10
|
+
* Callback fired when a paste event is detected.
|
|
11
|
+
* @param payload - The paste event payload containing type and content
|
|
12
|
+
*/
|
|
13
|
+
onPaste?: (payload: PasteEventPayload) => void;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Child components to wrap. Typically a TextInput component.
|
|
17
|
+
*/
|
|
18
|
+
children?: React.ReactNode;
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { requireNativeView } from "expo";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import type { View } from "react-native";
|
|
4
|
+
import type {
|
|
5
|
+
PasteEventPayload,
|
|
6
|
+
TextInputWrapperViewProps,
|
|
7
|
+
} from "./TextInputWrapper.types";
|
|
8
|
+
|
|
9
|
+
const NativeTextInputWrapper = requireNativeView("ExpoPasteInput");
|
|
10
|
+
|
|
11
|
+
export const TextInputWrapperView = React.forwardRef<
|
|
12
|
+
View,
|
|
13
|
+
TextInputWrapperViewProps
|
|
14
|
+
>((props, ref) => {
|
|
15
|
+
const { onPaste, children, ...viewProps } = props;
|
|
16
|
+
|
|
17
|
+
const handlePaste = React.useCallback(
|
|
18
|
+
(event: { nativeEvent: PasteEventPayload }) => {
|
|
19
|
+
if (onPaste) {
|
|
20
|
+
// Expo View events wrap the payload in nativeEvent
|
|
21
|
+
onPaste(event.nativeEvent);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
[onPaste],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<NativeTextInputWrapper ref={ref} onPaste={handlePaste} {...viewProps}>
|
|
29
|
+
{children}
|
|
30
|
+
</NativeTextInputWrapper>
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
TextInputWrapperView.displayName = "TextInputWrapperView";
|