expo-paste-input 0.1.15 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -24
- package/ios/ExpoPasteInputView.swift +49 -91
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# expo-paste-input
|
|
2
2
|
|
|
3
|
-
`expo-paste-input` is a lightweight wrapper around React Native `TextInput` that lets users paste images and GIFs directly from the system clipboard on **iOS and Android**.
|
|
3
|
+
`expo-paste-input` is a lightweight wrapper around React Native `TextInput` that lets users paste images, stickers, and GIFs directly from the system clipboard on **iOS and Android**.
|
|
4
4
|
|
|
5
5
|
It works at the native level to intercept paste events before React Native handles them, giving you access to pasted media as local file URIs while keeping full control over your own `TextInput` component.
|
|
6
|
+
On iOS, it also uses native text-input hooks to capture keyboard stickers that are not always exposed like normal clipboard image data.
|
|
6
7
|
|
|
7
8
|
See the original demo on [Twitter](https://x.com/iamarunabh/status/1997738168247062774)
|
|
8
9
|
|
|
9
|
-
| iOS
|
|
10
|
-
|
|
|
10
|
+
| iOS | Android |
|
|
11
|
+
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
11
12
|
| <img src="https://github.com/user-attachments/assets/c7d9baac-b2f8-4942-9a7f-52932e65ae7e" /> | <img src="https://github.com/user-attachments/assets/6057745c-ccfc-4ca0-935d-9aa4668f22e3" /> |
|
|
12
13
|
|
|
13
|
-
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
18
|
-
- Paste **text, images, and multiple GIFs**
|
|
18
|
+
- Paste **text, images, stickers, and multiple GIFs**
|
|
19
19
|
- Works on **iOS and Android**
|
|
20
20
|
- True wrapper around `TextInput` (bring your own input)
|
|
21
21
|
- No custom UI, no opinionated styles
|
|
@@ -31,7 +31,7 @@ See the original demo on [Twitter](https://x.com/iamarunabh/status/1997738168247
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
npx expo install expo-paste-input
|
|
34
|
-
|
|
34
|
+
```
|
|
35
35
|
|
|
36
36
|
or
|
|
37
37
|
|
|
@@ -93,9 +93,9 @@ type PasteEventPayload =
|
|
|
93
93
|
| { type: "unsupported" };
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
- `text` → pasted text
|
|
97
|
+
- `images` → local file URIs (`file://...`)
|
|
98
|
+
- `unsupported` → anything else
|
|
99
99
|
|
|
100
100
|
---
|
|
101
101
|
|
|
@@ -113,10 +113,10 @@ Instead:
|
|
|
113
113
|
|
|
114
114
|
This means:
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
- you keep full control of your input
|
|
117
|
+
- works with any custom TextInput
|
|
118
|
+
- no prop mirroring
|
|
119
|
+
- future-proof with RN updates
|
|
120
120
|
|
|
121
121
|
---
|
|
122
122
|
|
|
@@ -124,25 +124,27 @@ This means:
|
|
|
124
124
|
|
|
125
125
|
### iOS
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
- Intercepts native `paste(_:)`
|
|
128
|
+
- Extracts images from `UIPasteboard`
|
|
129
|
+
- Saves to temp files
|
|
130
|
+
- Preserves GIFs and stickers
|
|
131
|
+
- Adds native sticker handling for iOS keyboard media using text-input hooks (including adaptive image glyph insertion), because stickers are not always exposed like normal clipboard image data
|
|
131
132
|
|
|
132
133
|
### Android
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
- Uses `OnReceiveContentListener` + `ActionMode`
|
|
136
|
+
- Prevents Android "Can't paste images" toast
|
|
137
|
+
- Saves pasted media to cache
|
|
138
|
+
- Stickers and images are handled through regular clipboard/content APIs
|
|
137
139
|
|
|
138
140
|
---
|
|
139
141
|
|
|
140
142
|
## Notes
|
|
141
143
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
- Image URIs are temporary files, move them if you need persistence.
|
|
145
|
+
- Text paste events fire after the text is inserted.
|
|
146
|
+
- Image paste events prevent default paste (since TextInput can't render images).
|
|
147
|
+
- Web is currently a no-op implementation.
|
|
146
148
|
|
|
147
149
|
---
|
|
148
150
|
|
|
@@ -511,14 +511,17 @@ class ExpoPasteInputView: ExpoView {
|
|
|
511
511
|
var attachmentRanges: [NSRange] = []
|
|
512
512
|
var mediaPayloads: [MediaPayload] = []
|
|
513
513
|
|
|
514
|
+
// Only track ranges for attachments we successfully extract a real payload
|
|
515
|
+
// from. Attachments without a payload (e.g. iOS dictation placeholders)
|
|
516
|
+
// are left alone — sanitizing them would delete characters the system
|
|
517
|
+
// manages itself, and emitting "unsupported" would raise a spurious error.
|
|
514
518
|
attributedText.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in
|
|
515
519
|
guard let attachment = value as? NSTextAttachment else {
|
|
516
520
|
return
|
|
517
521
|
}
|
|
518
522
|
|
|
519
|
-
attachmentRanges.append(range)
|
|
520
|
-
|
|
521
523
|
if let payload = self.extractMediaPayload(from: attachment, textView: textView, range: range) {
|
|
524
|
+
attachmentRanges.append(range)
|
|
522
525
|
mediaPayloads.append(payload)
|
|
523
526
|
}
|
|
524
527
|
}
|
|
@@ -529,9 +532,8 @@ class ExpoPasteInputView: ExpoView {
|
|
|
529
532
|
return
|
|
530
533
|
}
|
|
531
534
|
|
|
532
|
-
attachmentRanges.append(range)
|
|
533
|
-
|
|
534
535
|
if let payload = self.extractMediaPayload(from: adaptiveGlyph) {
|
|
536
|
+
attachmentRanges.append(range)
|
|
535
537
|
mediaPayloads.append(payload)
|
|
536
538
|
}
|
|
537
539
|
}
|
|
@@ -539,17 +541,12 @@ class ExpoPasteInputView: ExpoView {
|
|
|
539
541
|
|
|
540
542
|
attachmentRanges = uniqueRanges(attachmentRanges)
|
|
541
543
|
|
|
542
|
-
guard !
|
|
544
|
+
guard !mediaPayloads.isEmpty else {
|
|
543
545
|
return
|
|
544
546
|
}
|
|
545
547
|
|
|
546
548
|
sanitizeAttachments(in: textView, ranges: attachmentRanges)
|
|
547
549
|
|
|
548
|
-
guard !mediaPayloads.isEmpty else {
|
|
549
|
-
handleUnsupportedPaste()
|
|
550
|
-
return
|
|
551
|
-
}
|
|
552
|
-
|
|
553
550
|
emitImagesAsync(for: mediaPayloads)
|
|
554
551
|
}
|
|
555
552
|
|
|
@@ -651,6 +648,11 @@ class ExpoPasteInputView: ExpoView {
|
|
|
651
648
|
}
|
|
652
649
|
|
|
653
650
|
private func extractMediaPayload(from attachment: NSTextAttachment, textView: UITextView, range: NSRange) -> MediaPayload? {
|
|
651
|
+
// Only accept attachments that carry real image payloads. We intentionally
|
|
652
|
+
// do not fall back to `image(forBounds:)` or rendering the text view's
|
|
653
|
+
// hierarchy, because system-inserted attachments (e.g. the iOS dictation
|
|
654
|
+
// placeholder) draw themselves via those paths and would cause us to
|
|
655
|
+
// emit a screenshot of the composer as a "pasted image".
|
|
654
656
|
if let fileWrapperData = attachment.fileWrapper?.regularFileContents,
|
|
655
657
|
let payload = extractMediaPayload(fromData: fileWrapperData) {
|
|
656
658
|
return payload
|
|
@@ -667,20 +669,6 @@ class ExpoPasteInputView: ExpoView {
|
|
|
667
669
|
return .image(image)
|
|
668
670
|
}
|
|
669
671
|
|
|
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
672
|
return nil
|
|
685
673
|
}
|
|
686
674
|
|
|
@@ -701,47 +689,6 @@ class ExpoPasteInputView: ExpoView {
|
|
|
701
689
|
return .imageData(data)
|
|
702
690
|
}
|
|
703
691
|
|
|
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
692
|
@available(iOS 18.0, *)
|
|
746
693
|
private func handleAdaptiveImageGlyphInsertion(_ adaptiveGlyph: NSAdaptiveImageGlyph) -> Bool {
|
|
747
694
|
guard let payload = extractMediaPayload(from: adaptiveGlyph) else {
|
|
@@ -910,32 +857,32 @@ class ExpoPasteInputView: ExpoView {
|
|
|
910
857
|
return headerBytes == gif87aSignature || headerBytes == gif89aSignature
|
|
911
858
|
}
|
|
912
859
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
860
|
+
private func inferredImageFileExtension(from imageSource: CGImageSource, fallbackData data: Data) -> String {
|
|
861
|
+
if let type = CGImageSourceGetType(imageSource) as String? {
|
|
862
|
+
switch type {
|
|
863
|
+
case "public.png":
|
|
864
|
+
return "png"
|
|
865
|
+
case "public.jpeg":
|
|
866
|
+
return "jpg"
|
|
867
|
+
case "public.gif":
|
|
868
|
+
return "gif"
|
|
869
|
+
case "public.webp", "org.webmproject.webp":
|
|
870
|
+
return "webp"
|
|
871
|
+
case "public.heic":
|
|
872
|
+
return "heic"
|
|
873
|
+
case "public.heif":
|
|
874
|
+
return "heif"
|
|
875
|
+
case "public.tiff":
|
|
876
|
+
return "tiff"
|
|
877
|
+
default:
|
|
878
|
+
break
|
|
879
|
+
}
|
|
931
880
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
return nil
|
|
881
|
+
|
|
882
|
+
if isGIFData(data) {
|
|
883
|
+
return "gif"
|
|
936
884
|
}
|
|
937
|
-
|
|
938
|
-
return image
|
|
885
|
+
return "png"
|
|
939
886
|
}
|
|
940
887
|
|
|
941
888
|
private func processTextPaste() {
|
|
@@ -1016,11 +963,22 @@ class ExpoPasteInputView: ExpoView {
|
|
|
1016
963
|
}
|
|
1017
964
|
|
|
1018
965
|
private func writeTemporaryImageData(_ data: Data) -> String? {
|
|
1019
|
-
guard let
|
|
966
|
+
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
|
967
|
+
CGImageSourceGetCount(imageSource) > 0 else {
|
|
1020
968
|
return nil
|
|
1021
969
|
}
|
|
1022
970
|
|
|
1023
|
-
|
|
971
|
+
let fileExtension = inferredImageFileExtension(from: imageSource, fallbackData: data)
|
|
972
|
+
let fileURL = FileManager.default.temporaryDirectory
|
|
973
|
+
.appendingPathComponent(UUID().uuidString)
|
|
974
|
+
.appendingPathExtension(fileExtension)
|
|
975
|
+
|
|
976
|
+
do {
|
|
977
|
+
try data.write(to: fileURL)
|
|
978
|
+
return fileURL.absoluteString
|
|
979
|
+
} catch {
|
|
980
|
+
return nil
|
|
981
|
+
}
|
|
1024
982
|
}
|
|
1025
983
|
|
|
1026
984
|
private func writeTemporaryImage(_ image: UIImage) -> String? {
|
package/package.json
CHANGED