expo-paste-input 0.1.11 → 0.1.12
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.
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
break
|
|
410
|
-
}
|
|
834
|
+
extractedPayload = .imageData(imageData)
|
|
835
|
+
break
|
|
411
836
|
}
|
|
412
837
|
|
|
413
838
|
// Method 2: Use pasteboard API
|
|
414
|
-
if
|
|
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
|
-
|
|
419
|
-
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
|
458
|
-
|
|
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
|
|
464
|
-
|
|
884
|
+
if staticImagePayloads.isEmpty && gifDataItems.isEmpty, let image = pasteboard.image {
|
|
885
|
+
staticImagePayloads.append(.image(image))
|
|
465
886
|
}
|
|
466
887
|
|
|
467
|
-
|
|
468
|
-
|
|
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