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.
Files changed (40) hide show
  1. package/.eslintrc.js +5 -0
  2. package/README.md +123 -0
  3. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  4. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  5. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  6. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  7. package/android/.gradle/8.9/gc.properties +0 -0
  8. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  9. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  10. package/android/.gradle/vcs-1/gc.properties +0 -0
  11. package/android/build.gradle +43 -0
  12. package/android/src/main/AndroidManifest.xml +2 -0
  13. package/android/src/main/java/expo/modules/pasteinput/ExpoPasteInputModule.kt +14 -0
  14. package/android/src/main/java/expo/modules/pasteinput/ExpoPasteInputView.kt +418 -0
  15. package/build/TextInputWrapper.types.d.ts +22 -0
  16. package/build/TextInputWrapper.types.d.ts.map +1 -0
  17. package/build/TextInputWrapper.types.js +2 -0
  18. package/build/TextInputWrapper.types.js.map +1 -0
  19. package/build/TextInputWrapperView.d.ts +5 -0
  20. package/build/TextInputWrapperView.d.ts.map +1 -0
  21. package/build/TextInputWrapperView.js +17 -0
  22. package/build/TextInputWrapperView.js.map +1 -0
  23. package/build/TextInputWrapperView.web.d.ts +5 -0
  24. package/build/TextInputWrapperView.web.d.ts.map +1 -0
  25. package/build/TextInputWrapperView.web.js +10 -0
  26. package/build/TextInputWrapperView.web.js.map +1 -0
  27. package/build/index.d.ts +4 -0
  28. package/build/index.d.ts.map +1 -0
  29. package/build/index.js +4 -0
  30. package/build/index.js.map +1 -0
  31. package/expo-module.config.json +9 -0
  32. package/ios/ExpoPasteInput.podspec +23 -0
  33. package/ios/ExpoPasteInputModule.swift +11 -0
  34. package/ios/ExpoPasteInputView.swift +601 -0
  35. package/package.json +43 -0
  36. package/src/TextInputWrapper.types.ts +19 -0
  37. package/src/TextInputWrapperView.tsx +34 -0
  38. package/src/TextInputWrapperView.web.tsx +17 -0
  39. package/src/index.ts +5 -0
  40. 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";