expo-paste-input 0.1.11 → 0.1.13

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 (52) hide show
  1. package/android/build/.transforms/04cfbef6d6438014a0f56f30321e9943/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/pasteinput/ExpoPasteInputView$1.dex +0 -0
  2. package/android/build/.transforms/04cfbef6d6438014a0f56f30321e9943/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/pasteinput/ExpoPasteInputView$ClipboardPayload.dex +0 -0
  3. package/android/build/.transforms/04cfbef6d6438014a0f56f30321e9943/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/pasteinput/ExpoPasteInputView$createActionModeCallback$1.dex +0 -0
  4. package/android/build/.transforms/04cfbef6d6438014a0f56f30321e9943/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/pasteinput/ExpoPasteInputView.dex +0 -0
  5. package/android/build/.transforms/61052c0b101cc72f1be04cd5637e14e3/transformed/classes/classes_dex/classes.dex +0 -0
  6. package/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar +0 -0
  7. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/pasteinput/ExpoPasteInputView$1.class +0 -0
  8. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/pasteinput/ExpoPasteInputView$ClipboardPayload.class +0 -0
  9. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/pasteinput/ExpoPasteInputView$createActionModeCallback$1.class +0 -0
  10. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/pasteinput/ExpoPasteInputView.class +0 -0
  11. package/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar +0 -0
  12. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab +0 -0
  13. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at +0 -0
  14. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab +0 -0
  15. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at +0 -0
  16. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab +0 -0
  17. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at +0 -0
  18. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab +0 -0
  19. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at +0 -0
  20. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab +0 -0
  21. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at +0 -0
  22. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab +0 -0
  23. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at +0 -0
  24. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab +0 -0
  25. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at +0 -0
  26. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab +0 -0
  27. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at +0 -0
  28. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab +1 -1
  29. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab +0 -0
  30. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at +0 -0
  31. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab +0 -0
  32. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream +0 -0
  33. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len +0 -0
  34. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len +0 -0
  35. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at +0 -0
  36. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i +0 -0
  37. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab +0 -0
  38. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream +0 -0
  39. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len +0 -0
  40. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len +0 -0
  41. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at +0 -0
  42. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i +0 -0
  43. package/android/build/kotlin/compileDebugKotlin/cacheable/last-build.bin +0 -0
  44. package/android/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin +0 -0
  45. package/android/build/kotlin/compileDebugKotlin/local-state/build-history.bin +0 -0
  46. package/android/build/tmp/kotlin-classes/debug/expo/modules/pasteinput/ExpoPasteInputView$1.class +0 -0
  47. package/android/build/tmp/kotlin-classes/debug/expo/modules/pasteinput/ExpoPasteInputView$ClipboardPayload.class +0 -0
  48. package/android/build/tmp/kotlin-classes/debug/expo/modules/pasteinput/ExpoPasteInputView$createActionModeCallback$1.class +0 -0
  49. package/android/build/tmp/kotlin-classes/debug/expo/modules/pasteinput/ExpoPasteInputView.class +0 -0
  50. package/android/src/main/java/expo/modules/pasteinput/ExpoPasteInputView.kt +72 -1
  51. package/ios/ExpoPasteInputView.swift +544 -97
  52. package/package.json +1 -1
@@ -11,6 +11,7 @@ import android.view.View
11
11
  import android.view.ViewGroup
12
12
  import android.view.ActionMode
13
13
  import android.widget.EditText
14
+ import kotlin.math.max
14
15
  import androidx.core.view.ContentInfoCompat
15
16
  import androidx.core.view.OnReceiveContentListener
16
17
  import androidx.core.view.ViewCompat
@@ -59,6 +60,76 @@ class ExpoPasteInputView(context: Context, appContext: AppContext) : ExpoView(co
59
60
  }
60
61
  })
61
62
  }
63
+
64
+ /**
65
+ * Ensure this wrapper behaves like a normal container in React Native layouts.
66
+ *
67
+ * Without an explicit measure/layout pass-through, some parent layouts can end up
68
+ * treating this view as having near-zero height, collapsing the wrapped EditText.
69
+ */
70
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
71
+ var maxChildWidth = 0
72
+ var maxChildHeight = 0
73
+ var childState = 0
74
+
75
+ for (i in 0 until childCount) {
76
+ val child = getChildAt(i)
77
+ if (child.visibility == View.GONE) continue
78
+
79
+ val lp = child.layoutParams
80
+ if (lp is MarginLayoutParams) {
81
+ measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0)
82
+ maxChildWidth = max(maxChildWidth, child.measuredWidth + lp.leftMargin + lp.rightMargin)
83
+ maxChildHeight = max(maxChildHeight, child.measuredHeight + lp.topMargin + lp.bottomMargin)
84
+ } else {
85
+ measureChild(child, widthMeasureSpec, heightMeasureSpec)
86
+ maxChildWidth = max(maxChildWidth, child.measuredWidth)
87
+ maxChildHeight = max(maxChildHeight, child.measuredHeight)
88
+ }
89
+
90
+ childState = View.combineMeasuredStates(childState, child.measuredState)
91
+ }
92
+
93
+ maxChildWidth += paddingLeft + paddingRight
94
+ maxChildHeight += paddingTop + paddingBottom
95
+
96
+ val measuredWidth = View.resolveSizeAndState(maxChildWidth, widthMeasureSpec, childState)
97
+ val measuredHeight =
98
+ View.resolveSizeAndState(maxChildHeight, heightMeasureSpec, childState shl View.MEASURED_HEIGHT_STATE_SHIFT)
99
+
100
+ setMeasuredDimension(measuredWidth, measuredHeight)
101
+ }
102
+
103
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
104
+ val parentLeft = paddingLeft
105
+ val parentTop = paddingTop
106
+ val parentRight = r - l - paddingRight
107
+
108
+ for (i in 0 until childCount) {
109
+ val child = getChildAt(i)
110
+ if (child.visibility == View.GONE) continue
111
+
112
+ val lp = child.layoutParams
113
+ val left: Int
114
+ val top: Int
115
+ val right: Int
116
+ val bottom: Int
117
+
118
+ if (lp is MarginLayoutParams) {
119
+ left = parentLeft + lp.leftMargin
120
+ top = parentTop + lp.topMargin
121
+ right = (parentRight - lp.rightMargin).coerceAtLeast(left)
122
+ bottom = (top + child.measuredHeight).coerceAtMost(b - t - paddingBottom - lp.bottomMargin)
123
+ } else {
124
+ left = parentLeft
125
+ top = parentTop
126
+ right = parentRight.coerceAtLeast(left)
127
+ bottom = (top + child.measuredHeight).coerceAtMost(b - t - paddingBottom)
128
+ }
129
+
130
+ child.layout(left, top, right, bottom)
131
+ }
132
+ }
62
133
 
63
134
  // Pass through all touch events to children - never intercept
64
135
  override fun onInterceptTouchEvent(ev: android.view.MotionEvent?): Boolean {
@@ -484,4 +555,4 @@ class ExpoPasteInputView(context: Context, appContext: AppContext) : ExpoView(co
484
555
  "type" to "unsupported"
485
556
  ))
486
557
  }
487
- }
558
+ }
@@ -23,10 +23,21 @@ private protocol TextInputEnhanceable: UIView {
23
23
  extension UITextField: TextInputEnhanceable {}
24
24
  extension UITextView: TextInputEnhanceable {}
25
25
 
26
+ private enum MediaPayload {
27
+ case gif(Data)
28
+ case imageData(Data)
29
+ case image(UIImage)
30
+ }
31
+
26
32
  class ExpoPasteInputView: ExpoView {
27
33
  private let onPaste = EventDispatcher()
34
+ private let mediaProcessingQueue = DispatchQueue(label: "expo.modules.pasteinput.media-processing", qos: .userInitiated)
28
35
  private var textInputView: UIView?
29
36
  private var isMonitoring: Bool = false
37
+ private var textDidChangeObserver: NSObjectProtocol?
38
+ private weak var observedTextView: UITextView?
39
+ private var isSanitizingAttachments: Bool = false
40
+ private var originalAdaptiveImageGlyphSupport: Bool?
30
41
  private let gifTypes: Set<String> = ["com.compuserve.gif", "public.gif", "image/gif"]
31
42
  private let webpTypes: Set<String> = ["org.webmproject.webp", "public.webp", "image/webp"]
32
43
  // Track which classes have been swizzled (once per class, never unswizzle)
@@ -88,15 +99,28 @@ class ExpoPasteInputView: ExpoView {
88
99
  }
89
100
 
90
101
  private func startMonitoring() {
91
- guard !isMonitoring else { return }
92
-
93
102
  // Find TextInput in view hierarchy
94
- textInputView = findTextInputInView(self)
95
-
96
- if let textInput = textInputView {
97
- isMonitoring = true
98
- enhanceTextInput(textInput)
103
+ guard let textInput = findTextInputInView(self) else {
104
+ return
105
+ }
106
+
107
+ if let currentTextInput = textInputView,
108
+ isMonitoring,
109
+ currentTextInput === textInput {
110
+ if let textView = textInput as? UITextView {
111
+ observeTextViewChanges(for: textView)
112
+ }
113
+ return
114
+ }
115
+
116
+ if let currentTextInput = textInputView,
117
+ currentTextInput !== textInput {
118
+ restoreTextInput(currentTextInput)
99
119
  }
120
+
121
+ textInputView = textInput
122
+ isMonitoring = true
123
+ enhanceTextInput(textInput)
100
124
  }
101
125
 
102
126
  private func stopMonitoring() {
@@ -113,12 +137,36 @@ class ExpoPasteInputView: ExpoView {
113
137
  private func enhanceTextInput(_ view: UIView) {
114
138
  // Store weak reference to this wrapper on the text input view to avoid retain cycles
115
139
  objc_setAssociatedObject(view, &textInputWrapperKey, WeakWrapper(self), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
140
+
141
+ if #available(iOS 18.0, *) {
142
+ originalAdaptiveImageGlyphSupport = currentAdaptiveImageGlyphSupport(for: view)
143
+ setAdaptiveImageGlyphSupport(true, for: view)
144
+ } else {
145
+ originalAdaptiveImageGlyphSupport = nil
146
+ }
147
+
148
+ if let textView = view as? UITextView {
149
+ observeTextViewChanges(for: textView)
150
+ } else {
151
+ stopObservingTextView()
152
+ }
116
153
 
117
154
  // Swizzle canPerformAction and paste methods (once per class, never unswizzle)
118
155
  swizzleTextInputMethods(view)
119
156
  }
120
157
 
121
158
  private func restoreTextInput(_ view: UIView) {
159
+ if #available(iOS 18.0, *),
160
+ let originalAdaptiveImageGlyphSupport {
161
+ setAdaptiveImageGlyphSupport(originalAdaptiveImageGlyphSupport, for: view)
162
+ }
163
+ originalAdaptiveImageGlyphSupport = nil
164
+
165
+ if let textView = view as? UITextView,
166
+ observedTextView === textView {
167
+ stopObservingTextView()
168
+ }
169
+
122
170
  // Only clear the association; swizzling stays global and is guarded
123
171
  objc_setAssociatedObject(view, &textInputWrapperKey, nil, .OBJC_ASSOCIATION_ASSIGN)
124
172
  }
@@ -156,7 +204,7 @@ class ExpoPasteInputView: ExpoView {
156
204
  // "App would like to paste" privacy prompt on menu-open checks.
157
205
  // We allow paste action visibility and read the pasteboard only
158
206
  // when the user explicitly taps Paste in `paste(_:)`.
159
- return true
207
+ return wrapper.shouldExposePasteAction(for: object)
160
208
  }
161
209
  }
162
210
 
@@ -304,6 +352,50 @@ class ExpoPasteInputView: ExpoView {
304
352
  }
305
353
  }
306
354
  }
355
+
356
+ if #available(iOS 18.0, *),
357
+ let originalMethod = class_getInstanceMethod(viewClass, NSSelectorFromString("insertAdaptiveImageGlyph:replacementRange:")) {
358
+ let adaptiveGlyphSelector = NSSelectorFromString("insertAdaptiveImageGlyph:replacementRange:")
359
+ let swizzledAdaptiveGlyphSelector = NSSelectorFromString("_expoPasteInput_insertAdaptiveImageGlyph:replacementRange:")
360
+ let originalAdaptiveGlyphIMP = method_getImplementation(originalMethod)
361
+
362
+ if class_getInstanceMethod(viewClass, swizzledAdaptiveGlyphSelector) == nil {
363
+ let swizzledImplementation: @convention(block) (AnyObject, AnyObject, UITextRange?) -> Void = { object, glyphObject, replacementRange in
364
+ guard let weakWrapper = objc_getAssociatedObject(object, &textInputWrapperKey) as? WeakWrapper,
365
+ let wrapper = weakWrapper.value else {
366
+ typealias OriginalIMP = @convention(c) (AnyObject, Selector, AnyObject, UITextRange?) -> Void
367
+ let originalFunction = unsafeBitCast(originalAdaptiveGlyphIMP, to: OriginalIMP.self)
368
+ originalFunction(object, adaptiveGlyphSelector, glyphObject, replacementRange)
369
+ return
370
+ }
371
+
372
+ guard let adaptiveGlyph = glyphObject as? NSAdaptiveImageGlyph else {
373
+ typealias OriginalIMP = @convention(c) (AnyObject, Selector, AnyObject, UITextRange?) -> Void
374
+ let originalFunction = unsafeBitCast(originalAdaptiveGlyphIMP, to: OriginalIMP.self)
375
+ originalFunction(object, adaptiveGlyphSelector, glyphObject, replacementRange)
376
+ return
377
+ }
378
+
379
+ if wrapper.handleAdaptiveImageGlyphInsertion(adaptiveGlyph) {
380
+ return
381
+ }
382
+
383
+ typealias OriginalIMP = @convention(c) (AnyObject, Selector, AnyObject, UITextRange?) -> Void
384
+ let originalFunction = unsafeBitCast(originalAdaptiveGlyphIMP, to: OriginalIMP.self)
385
+ originalFunction(object, adaptiveGlyphSelector, glyphObject, replacementRange)
386
+ }
387
+
388
+ let blockIMP = imp_implementationWithBlock(unsafeBitCast(swizzledImplementation, to: AnyObject.self))
389
+ let types = method_getTypeEncoding(originalMethod)
390
+
391
+ if class_addMethod(viewClass, swizzledAdaptiveGlyphSelector, blockIMP, types) {
392
+ if let swizzledMethod = class_getInstanceMethod(viewClass, swizzledAdaptiveGlyphSelector) {
393
+ method_exchangeImplementations(originalMethod, swizzledMethod)
394
+ didSwizzle = true
395
+ }
396
+ }
397
+ }
398
+ }
307
399
 
308
400
  // Mark this class as swizzled only if we successfully swizzled at least one method
309
401
  // (once per class, never unswizzle)
@@ -325,6 +417,341 @@ class ExpoPasteInputView: ExpoView {
325
417
 
326
418
  return nil
327
419
  }
420
+
421
+ @available(iOS 18.0, *)
422
+ private func currentAdaptiveImageGlyphSupport(for view: UIView) -> Bool? {
423
+ if let textView = view as? UITextView {
424
+ return textView.supportsAdaptiveImageGlyph
425
+ }
426
+
427
+ if let textField = view as? UITextField {
428
+ return textField.supportsAdaptiveImageGlyph
429
+ }
430
+
431
+ return nil
432
+ }
433
+
434
+ @available(iOS 18.0, *)
435
+ private func setAdaptiveImageGlyphSupport(_ isEnabled: Bool, for view: UIView) {
436
+ if let textView = view as? UITextView {
437
+ textView.supportsAdaptiveImageGlyph = isEnabled
438
+ return
439
+ }
440
+
441
+ if let textField = view as? UITextField {
442
+ textField.supportsAdaptiveImageGlyph = isEnabled
443
+ }
444
+ }
445
+
446
+ private func shouldExposePasteAction(for object: AnyObject) -> Bool {
447
+ if let textView = object as? UITextView {
448
+ return textView.isEditable &&
449
+ textView.isSelectable &&
450
+ textView.isUserInteractionEnabled &&
451
+ !textView.isHidden &&
452
+ textView.alpha > 0.01
453
+ }
454
+
455
+ if let textField = object as? UITextField {
456
+ return textField.isEnabled &&
457
+ textField.isUserInteractionEnabled &&
458
+ !textField.isHidden &&
459
+ textField.alpha > 0.01
460
+ }
461
+
462
+ if let view = object as? UIView {
463
+ return view.isUserInteractionEnabled &&
464
+ !view.isHidden &&
465
+ view.alpha > 0.01
466
+ }
467
+
468
+ return false
469
+ }
470
+
471
+ private func observeTextViewChanges(for textView: UITextView) {
472
+ if observedTextView === textView, textDidChangeObserver != nil {
473
+ return
474
+ }
475
+
476
+ stopObservingTextView()
477
+ observedTextView = textView
478
+
479
+ textDidChangeObserver = NotificationCenter.default.addObserver(
480
+ forName: UITextView.textDidChangeNotification,
481
+ object: textView,
482
+ queue: .main
483
+ ) { [weak self, weak textView] _ in
484
+ guard let self, let textView else {
485
+ return
486
+ }
487
+
488
+ self.handleTextViewDidChange(textView)
489
+ }
490
+ }
491
+
492
+ private func stopObservingTextView() {
493
+ if let observer = textDidChangeObserver {
494
+ NotificationCenter.default.removeObserver(observer)
495
+ textDidChangeObserver = nil
496
+ }
497
+
498
+ observedTextView = nil
499
+ }
500
+
501
+ private func handleTextViewDidChange(_ textView: UITextView) {
502
+ guard observedTextView === textView, !isSanitizingAttachments else {
503
+ return
504
+ }
505
+
506
+ let attributedText = textView.attributedText ?? NSAttributedString(string: textView.text ?? "")
507
+ guard attributedText.length > 0 else {
508
+ return
509
+ }
510
+
511
+ var attachmentRanges: [NSRange] = []
512
+ var mediaPayloads: [MediaPayload] = []
513
+
514
+ attributedText.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in
515
+ guard let attachment = value as? NSTextAttachment else {
516
+ return
517
+ }
518
+
519
+ attachmentRanges.append(range)
520
+
521
+ if let payload = self.extractMediaPayload(from: attachment, textView: textView, range: range) {
522
+ mediaPayloads.append(payload)
523
+ }
524
+ }
525
+
526
+ if #available(iOS 18.0, *) {
527
+ attributedText.enumerateAttribute(.adaptiveImageGlyph, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in
528
+ guard let adaptiveGlyph = value as? NSAdaptiveImageGlyph else {
529
+ return
530
+ }
531
+
532
+ attachmentRanges.append(range)
533
+
534
+ if let payload = self.extractMediaPayload(from: adaptiveGlyph) {
535
+ mediaPayloads.append(payload)
536
+ }
537
+ }
538
+ }
539
+
540
+ attachmentRanges = uniqueRanges(attachmentRanges)
541
+
542
+ guard !attachmentRanges.isEmpty else {
543
+ return
544
+ }
545
+
546
+ sanitizeAttachments(in: textView, ranges: attachmentRanges)
547
+
548
+ guard !mediaPayloads.isEmpty else {
549
+ handleUnsupportedPaste()
550
+ return
551
+ }
552
+
553
+ emitImagesAsync(for: mediaPayloads)
554
+ }
555
+
556
+ private func emitImagesAsync(for payloads: [MediaPayload]) {
557
+ mediaProcessingQueue.async { [weak self] in
558
+ guard let self else {
559
+ return
560
+ }
561
+
562
+ let uris = self.temporaryFileURIs(for: payloads)
563
+
564
+ DispatchQueue.main.async { [weak self] in
565
+ guard let self else {
566
+ return
567
+ }
568
+
569
+ if uris.isEmpty {
570
+ self.handleUnsupportedPaste()
571
+ return
572
+ }
573
+
574
+ self.emitImages(uris: uris)
575
+ }
576
+ }
577
+ }
578
+
579
+ private func sanitizeAttachments(in textView: UITextView, ranges: [NSRange]) {
580
+ let sanitizedText = NSMutableAttributedString(attributedString: textView.attributedText ?? NSAttributedString(string: textView.text ?? ""))
581
+ let originalSelectedRange = textView.selectedRange
582
+
583
+ for range in ranges.reversed() {
584
+ sanitizedText.deleteCharacters(in: range)
585
+ }
586
+
587
+ isSanitizingAttachments = true
588
+ defer {
589
+ isSanitizingAttachments = false
590
+ }
591
+
592
+ textView.attributedText = sanitizedText
593
+
594
+ guard originalSelectedRange.location != NSNotFound else {
595
+ return
596
+ }
597
+
598
+ textView.selectedRange = adjustedSelectedRange(
599
+ from: originalSelectedRange,
600
+ removing: ranges,
601
+ finalLength: sanitizedText.length
602
+ )
603
+ }
604
+
605
+ private func adjustedSelectedRange(from selectedRange: NSRange, removing ranges: [NSRange], finalLength: Int) -> NSRange {
606
+ let start = adjustedLocation(selectedRange.location, removing: ranges)
607
+ let end = adjustedLocation(selectedRange.location + selectedRange.length, removing: ranges)
608
+ let clampedStart = min(max(0, start), finalLength)
609
+ let clampedEnd = min(max(clampedStart, end), finalLength)
610
+
611
+ return NSRange(location: clampedStart, length: clampedEnd - clampedStart)
612
+ }
613
+
614
+ private func adjustedLocation(_ location: Int, removing ranges: [NSRange]) -> Int {
615
+ var adjustedLocation = location
616
+
617
+ for range in ranges {
618
+ let rangeEnd = NSMaxRange(range)
619
+
620
+ if location >= rangeEnd {
621
+ adjustedLocation -= range.length
622
+ continue
623
+ }
624
+
625
+ if location >= range.location {
626
+ adjustedLocation = min(adjustedLocation, range.location)
627
+ break
628
+ }
629
+ }
630
+
631
+ return max(0, adjustedLocation)
632
+ }
633
+
634
+ private func uniqueRanges(_ ranges: [NSRange]) -> [NSRange] {
635
+ var seen = Set<String>()
636
+ var uniqueRanges: [NSRange] = []
637
+
638
+ for range in ranges.sorted(by: { lhs, rhs in
639
+ if lhs.location == rhs.location {
640
+ return lhs.length < rhs.length
641
+ }
642
+ return lhs.location < rhs.location
643
+ }) {
644
+ let key = "\(range.location):\(range.length)"
645
+ if seen.insert(key).inserted {
646
+ uniqueRanges.append(range)
647
+ }
648
+ }
649
+
650
+ return uniqueRanges
651
+ }
652
+
653
+ private func extractMediaPayload(from attachment: NSTextAttachment, textView: UITextView, range: NSRange) -> MediaPayload? {
654
+ if let fileWrapperData = attachment.fileWrapper?.regularFileContents,
655
+ let payload = extractMediaPayload(fromData: fileWrapperData) {
656
+ return payload
657
+ }
658
+
659
+ if let contents = attachment.contents,
660
+ let payload = extractMediaPayload(fromData: contents) {
661
+ return payload
662
+ }
663
+
664
+ if let image = attachment.image,
665
+ image.size.width > 0,
666
+ image.size.height > 0 {
667
+ return .image(image)
668
+ }
669
+
670
+ let attachmentBounds = attachment.bounds.size.width > 0 && attachment.bounds.size.height > 0
671
+ ? attachment.bounds
672
+ : CGRect(origin: .zero, size: CGSize(width: 128, height: 128))
673
+
674
+ if let image = attachment.image(forBounds: attachmentBounds, textContainer: textView.textContainer, characterIndex: range.location),
675
+ image.size.width > 0,
676
+ image.size.height > 0 {
677
+ return .image(image)
678
+ }
679
+
680
+ if let renderedImage = renderTextAttachment(in: textView, range: range) {
681
+ return .image(renderedImage)
682
+ }
683
+
684
+ return nil
685
+ }
686
+
687
+ @available(iOS 18.0, *)
688
+ private func extractMediaPayload(from adaptiveGlyph: NSAdaptiveImageGlyph) -> MediaPayload? {
689
+ extractMediaPayload(fromData: adaptiveGlyph.imageContent)
690
+ }
691
+
692
+ private func extractMediaPayload(fromData data: Data) -> MediaPayload? {
693
+ guard !data.isEmpty else {
694
+ return nil
695
+ }
696
+
697
+ if isGIFData(data) {
698
+ return .gif(data)
699
+ }
700
+
701
+ return .imageData(data)
702
+ }
703
+
704
+ private func renderTextAttachment(in textView: UITextView, range: NSRange) -> UIImage? {
705
+ let glyphRange = textView.layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
706
+ var rect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer)
707
+
708
+ rect.origin.x += textView.textContainerInset.left - textView.contentOffset.x
709
+ rect.origin.y += textView.textContainerInset.top - textView.contentOffset.y
710
+ rect = rect.integral
711
+
712
+ guard rect.width > 1, rect.height > 1 else {
713
+ return nil
714
+ }
715
+
716
+ let format = UIGraphicsImageRendererFormat.default()
717
+ format.scale = textView.window?.screen.scale ?? UIScreen.main.scale
718
+ format.opaque = false
719
+
720
+ let image = UIGraphicsImageRenderer(size: rect.size, format: format).image { _ in
721
+ let drawRect = CGRect(
722
+ origin: CGPoint(x: -rect.origin.x, y: -rect.origin.y),
723
+ size: textView.bounds.size
724
+ )
725
+
726
+ if textView.window != nil {
727
+ textView.drawHierarchy(in: drawRect, afterScreenUpdates: false)
728
+ } else {
729
+ guard let context = UIGraphicsGetCurrentContext() else {
730
+ return
731
+ }
732
+
733
+ context.translateBy(x: -rect.origin.x, y: -rect.origin.y)
734
+ textView.layer.render(in: context)
735
+ }
736
+ }
737
+
738
+ guard image.size.width > 0, image.size.height > 0 else {
739
+ return nil
740
+ }
741
+
742
+ return image
743
+ }
744
+
745
+ @available(iOS 18.0, *)
746
+ private func handleAdaptiveImageGlyphInsertion(_ adaptiveGlyph: NSAdaptiveImageGlyph) -> Bool {
747
+ guard let payload = extractMediaPayload(from: adaptiveGlyph) else {
748
+ handleUnsupportedPaste()
749
+ return true
750
+ }
751
+
752
+ emitImagesAsync(for: [payload])
753
+ return true
754
+ }
328
755
 
329
756
  private func processPasteboardContent() {
330
757
  // This method is only called for image pastes
@@ -343,7 +770,7 @@ class ExpoPasteInputView: ExpoView {
343
770
  ]
344
771
 
345
772
  var gifDataItems: [Data] = []
346
- var staticImages: [UIImage] = []
773
+ var staticImagePayloads: [MediaPayload] = []
347
774
  var processedGifHashes = Set<Int>()
348
775
 
349
776
  // Get all items once to ensure consistent access
@@ -396,7 +823,7 @@ class ExpoPasteInputView: ExpoView {
396
823
  }
397
824
 
398
825
  // ===== STEP 2: This item is NOT a GIF - extract static image =====
399
- var extractedImage: UIImage? = nil
826
+ var extractedPayload: MediaPayload? = nil
400
827
 
401
828
  // Try each static image type in order of preference (only if this item has that type)
402
829
  for imageType in staticImageTypes {
@@ -404,26 +831,22 @@ class ExpoPasteInputView: ExpoView {
404
831
 
405
832
  // Method 1: Try item dictionary directly
406
833
  if let imageData = item[imageType] as? Data, !imageData.isEmpty, !isGIFData(imageData) {
407
- if let image = safeCreateImage(from: imageData) {
408
- extractedImage = image
409
- break
410
- }
834
+ extractedPayload = .imageData(imageData)
835
+ break
411
836
  }
412
837
 
413
838
  // Method 2: Use pasteboard API
414
- if extractedImage == nil,
839
+ if extractedPayload == nil,
415
840
  let dataArray = pasteboard.data(forPasteboardType: imageType, inItemSet: singleItemSet),
416
841
  let imageData = dataArray.first,
417
842
  !imageData.isEmpty, !isGIFData(imageData) {
418
- if let image = safeCreateImage(from: imageData) {
419
- extractedImage = image
420
- break
421
- }
843
+ extractedPayload = .imageData(imageData)
844
+ break
422
845
  }
423
846
  }
424
847
 
425
848
  // Fallback: Try any non-GIF image data from the item dictionary
426
- if extractedImage == nil {
849
+ if extractedPayload == nil {
427
850
  // Sort keys to have consistent ordering (prefer png, jpeg, then others)
428
851
  let sortedKeys = itemKeys.sorted { k1, k2 in
429
852
  let priority1 = k1.contains("png") ? 0 : (k1.contains("jpeg") || k1.contains("jpg") ? 1 : 2)
@@ -439,102 +862,38 @@ class ExpoPasteInputView: ExpoView {
439
862
 
440
863
  // Try Data
441
864
  if let imageData = item[key] as? Data, imageData.count >= 6, !isGIFData(imageData) {
442
- if let image = safeCreateImage(from: imageData) {
443
- extractedImage = image
444
- break
445
- }
865
+ extractedPayload = .imageData(imageData)
866
+ break
446
867
  }
447
868
 
448
869
  // Try UIImage
449
870
  if let image = item[key] as? UIImage, image.size.width > 0, image.size.height > 0 {
450
- extractedImage = image
871
+ extractedPayload = .image(image)
451
872
  break
452
873
  }
453
874
  }
454
875
  }
455
876
 
456
877
  // Add the extracted static image
457
- if let image = extractedImage {
458
- staticImages.append(image)
878
+ if let payload = extractedPayload {
879
+ staticImagePayloads.append(payload)
459
880
  }
460
881
  }
461
882
 
462
883
  // Final fallback: If nothing was extracted at all, try pasteboard.image
463
- if staticImages.isEmpty && gifDataItems.isEmpty, let image = pasteboard.image {
464
- staticImages.append(image)
884
+ if staticImagePayloads.isEmpty && gifDataItems.isEmpty, let image = pasteboard.image {
885
+ staticImagePayloads.append(.image(image))
465
886
  }
466
887
 
467
- // Use the collected data
468
- let images = staticImages
469
-
470
- // Handle both GIFs and static images together
471
- if !gifDataItems.isEmpty || !images.isEmpty {
472
- // Combine GIFs and static images into one paste event
473
- var allFilePaths: [String] = []
474
-
475
- // First, add GIF file paths
476
- if !gifDataItems.isEmpty {
477
- let tempDir = FileManager.default.temporaryDirectory
478
- for gifData in gifDataItems {
479
- let fileName = UUID().uuidString + ".gif"
480
- let fileURL = tempDir.appendingPathComponent(fileName)
481
-
482
- do {
483
- try gifData.write(to: fileURL)
484
- let filePath = "file://" + fileURL.path
485
- allFilePaths.append(filePath)
486
- } catch {
487
- continue // Skip this GIF if we can't save it
488
- }
489
- }
490
- }
491
-
492
- // Then, add static image file paths
493
- if !images.isEmpty {
494
- for image in images {
495
- let normalizedImage = image.normalizedOrientation()
496
- let hasAlpha = normalizedImage.hasAlpha
497
-
498
- // Preserve transparency for images with alpha channel
499
- let imageData: Data?
500
- if hasAlpha {
501
- imageData = normalizedImage.pngData()
502
- } else {
503
- imageData = normalizedImage.jpegData(compressionQuality: 0.8)
504
- }
505
-
506
- guard let imageData = imageData else {
507
- continue // Skip this image if we can't compress it
508
- }
509
-
510
- let tempDir = FileManager.default.temporaryDirectory
511
- let fileExtension = hasAlpha ? ".png" : ".jpg"
512
- let fileName = UUID().uuidString + fileExtension
513
- let fileURL = tempDir.appendingPathComponent(fileName)
514
-
515
- do {
516
- try imageData.write(to: fileURL)
517
- let filePath = "file://" + fileURL.path
518
- allFilePaths.append(filePath)
519
- } catch {
520
- continue // Skip this image if we can't save it
521
- }
522
- }
523
- }
524
-
525
- if !allFilePaths.isEmpty {
526
- // Send all images (GIFs and static) in one event
527
- onPaste([
528
- "type": "images",
529
- "uris": allFilePaths
530
- ])
531
- } else {
532
- handleUnsupportedPaste()
533
- }
534
- } else {
888
+ let mediaPayloads = gifDataItems.map { MediaPayload.gif($0) } + staticImagePayloads
889
+
890
+ if mediaPayloads.isEmpty {
535
891
  // If we have neither GIFs nor images, treat as unsupported
536
892
  handleUnsupportedPaste()
893
+ return
537
894
  }
895
+
896
+ emitImagesAsync(for: mediaPayloads)
538
897
  }
539
898
 
540
899
  /// Detects if the given data is a GIF by checking for GIF87a or GIF89a header
@@ -604,9 +963,97 @@ class ExpoPasteInputView: ExpoView {
604
963
  "type": "unsupported"
605
964
  ])
606
965
  }
966
+
967
+ private func emitImages(uris: [String]) {
968
+ guard !uris.isEmpty else {
969
+ return
970
+ }
971
+
972
+ onPaste([
973
+ "type": "images",
974
+ "uris": uris
975
+ ])
976
+ }
977
+
978
+ private func temporaryFileURIs(for payloads: [MediaPayload]) -> [String] {
979
+ var uris: [String] = []
980
+
981
+ for payload in payloads {
982
+ switch payload {
983
+ case .gif(let data):
984
+ if let uri = writeTemporaryGIF(data) {
985
+ uris.append(uri)
986
+ }
987
+ case .imageData(let data):
988
+ if let uri = writeTemporaryImageData(data) {
989
+ uris.append(uri)
990
+ }
991
+ case .image(let image):
992
+ if let uri = writeTemporaryImage(image) {
993
+ uris.append(uri)
994
+ }
995
+ }
996
+ }
997
+
998
+ return uris
999
+ }
1000
+
1001
+ private func writeTemporaryGIF(_ data: Data) -> String? {
1002
+ guard !data.isEmpty else {
1003
+ return nil
1004
+ }
1005
+
1006
+ let fileURL = FileManager.default.temporaryDirectory
1007
+ .appendingPathComponent(UUID().uuidString)
1008
+ .appendingPathExtension("gif")
1009
+
1010
+ do {
1011
+ try data.write(to: fileURL)
1012
+ return fileURL.absoluteString
1013
+ } catch {
1014
+ return nil
1015
+ }
1016
+ }
1017
+
1018
+ private func writeTemporaryImageData(_ data: Data) -> String? {
1019
+ guard let image = safeCreateImage(from: data) else {
1020
+ return nil
1021
+ }
1022
+
1023
+ return writeTemporaryImage(image)
1024
+ }
1025
+
1026
+ private func writeTemporaryImage(_ image: UIImage) -> String? {
1027
+ let normalizedImage = image.normalizedOrientation()
1028
+ let hasAlpha = normalizedImage.hasAlpha
1029
+
1030
+ let imageData: Data?
1031
+ if hasAlpha {
1032
+ imageData = normalizedImage.pngData()
1033
+ } else {
1034
+ imageData = normalizedImage.jpegData(compressionQuality: 0.8)
1035
+ }
1036
+
1037
+ guard let imageData else {
1038
+ return nil
1039
+ }
1040
+
1041
+ let fileExtension = hasAlpha ? "png" : "jpg"
1042
+ let fileURL = FileManager.default.temporaryDirectory
1043
+ .appendingPathComponent(UUID().uuidString)
1044
+ .appendingPathExtension(fileExtension)
1045
+
1046
+ do {
1047
+ try imageData.write(to: fileURL)
1048
+ return fileURL.absoluteString
1049
+ } catch {
1050
+ return nil
1051
+ }
1052
+ }
607
1053
 
608
1054
  deinit {
609
1055
  stopMonitoring()
1056
+ stopObservingTextView()
610
1057
  }
611
1058
 
612
1059
  private func hasPasteboardData(forAnyTypeIn types: Set<String>, pasteboard: UIPasteboard) -> Bool {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-paste-input",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "A wrapper around React Native TextInput to paste images and GIFs from the clipboard (iOS, Android, Web)",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",