expo-paste-input 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +5 -0
- package/README.md +123 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/pasteinput/ExpoPasteInputModule.kt +14 -0
- package/android/src/main/java/expo/modules/pasteinput/ExpoPasteInputView.kt +418 -0
- package/build/TextInputWrapper.types.d.ts +22 -0
- package/build/TextInputWrapper.types.d.ts.map +1 -0
- package/build/TextInputWrapper.types.js +2 -0
- package/build/TextInputWrapper.types.js.map +1 -0
- package/build/TextInputWrapperView.d.ts +5 -0
- package/build/TextInputWrapperView.d.ts.map +1 -0
- package/build/TextInputWrapperView.js +17 -0
- package/build/TextInputWrapperView.js.map +1 -0
- package/build/TextInputWrapperView.web.d.ts +5 -0
- package/build/TextInputWrapperView.web.d.ts.map +1 -0
- package/build/TextInputWrapperView.web.js +10 -0
- package/build/TextInputWrapperView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoPasteInput.podspec +23 -0
- package/ios/ExpoPasteInputModule.swift +11 -0
- package/ios/ExpoPasteInputView.swift +601 -0
- package/package.json +43 -0
- package/src/TextInputWrapper.types.ts +19 -0
- package/src/TextInputWrapperView.tsx +34 -0
- package/src/TextInputWrapperView.web.tsx +17 -0
- package/src/index.ts +5 -0
- package/tsconfig.json +9 -0
package/.eslintrc.js
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# TextInputWrapper
|
|
2
|
+
|
|
3
|
+
A native Expo module for cross-platform paste event handling in React Native TextInput components.
|
|
4
|
+
|
|
5
|
+
## Demo
|
|
6
|
+
|
|
7
|
+
| iOS | Android |
|
|
8
|
+
| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
9
|
+
| <video src="https://github.com/user-attachments/assets/b54b15ac-5b98-4dc7-84d7-2e7d48e53e24" /> | <video src="https://github.com/user-attachments/assets/4d709a2c-2dca-431d-8972-05f01b7e5276" /> |
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
This is **not a published npm package**. This is the exact production-ready module used inside a real app. If you want to use it, copy the `modules/text-input-wrapper` folder into your Expo project and run it.
|
|
14
|
+
|
|
15
|
+
### What it does
|
|
16
|
+
|
|
17
|
+
- Intercepts paste events on TextInput components
|
|
18
|
+
- Handles pasted text and images consistently across iOS and Android
|
|
19
|
+
- Supports pasting multiple images, including GIFs
|
|
20
|
+
- Returns file URIs for pasted images that you can use directly
|
|
21
|
+
|
|
22
|
+
### Paste event payload
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
type PasteEventPayload =
|
|
26
|
+
| { type: "text"; value: string }
|
|
27
|
+
| { type: "images"; uris: string[] }
|
|
28
|
+
| { type: "unsupported" };
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
The module wraps a TextInput and intercepts paste events at the native level before they reach the default handler.
|
|
34
|
+
|
|
35
|
+
### iOS
|
|
36
|
+
|
|
37
|
+
On iOS, the module uses method swizzling to intercept `paste(_:)` on the underlying `UITextField`/`UITextView`. When a paste is detected:
|
|
38
|
+
|
|
39
|
+
- For **images**: The paste is intercepted, images are extracted from `UIPasteboard`, saved to temp files, and URIs are sent to JavaScript. GIFs are preserved as-is.
|
|
40
|
+
- For **text**: The original paste proceeds normally, and the pasted text is forwarded to JavaScript.
|
|
41
|
+
|
|
42
|
+
The wrapper view is transparent and passes through all touch events to children.
|
|
43
|
+
|
|
44
|
+
### Android
|
|
45
|
+
|
|
46
|
+
On Android, the module uses `OnReceiveContentListener` (API 31+) and a custom `ActionMode.Callback` to intercept paste events from both keyboard and context menu:
|
|
47
|
+
|
|
48
|
+
- For **images**: The paste is consumed before Android shows the "Can't add images" toast. Images are decoded, saved to cache, and URIs are sent to JavaScript.
|
|
49
|
+
- For **text**: The original paste proceeds normally, and the pasted text is forwarded to JavaScript.
|
|
50
|
+
|
|
51
|
+
The wrapper view is non-interactive and delegates all touch events to children.
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
### 1. Copy the module
|
|
56
|
+
|
|
57
|
+
Copy the `modules/text-input-wrapper` folder into your Expo project's `modules` directory.
|
|
58
|
+
|
|
59
|
+
### 2. Run prebuild / pod install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# If using Expo prebuild
|
|
63
|
+
npx expo prebuild
|
|
64
|
+
|
|
65
|
+
# Or if you already have native projects
|
|
66
|
+
cd ios && pod install
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 3. Import and use
|
|
70
|
+
|
|
71
|
+
Wrap your TextInput with `TextInputWrapper` and handle the `onPaste` callback:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import {
|
|
75
|
+
TextInputWrapper,
|
|
76
|
+
PasteEventPayload,
|
|
77
|
+
} from "@/modules/text-input-wrapper";
|
|
78
|
+
import { TextInput } from "react-native";
|
|
79
|
+
|
|
80
|
+
function MyInput() {
|
|
81
|
+
const handlePaste = (payload: PasteEventPayload) => {
|
|
82
|
+
if (payload.type === "images") {
|
|
83
|
+
// payload.uris contains file:// URIs for each pasted image
|
|
84
|
+
console.log("Pasted images:", payload.uris);
|
|
85
|
+
} else if (payload.type === "text") {
|
|
86
|
+
// payload.value contains the pasted text
|
|
87
|
+
console.log("Pasted text:", payload.value);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<TextInputWrapper onPaste={handlePaste}>
|
|
93
|
+
<TextInput placeholder="Paste here..." />
|
|
94
|
+
</TextInputWrapper>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Notes
|
|
100
|
+
|
|
101
|
+
- This is **intentionally not packaged as a library**
|
|
102
|
+
- It's meant to be copied, modified, and extended for your specific needs
|
|
103
|
+
- The image URIs point to temporary files — move or copy them if you need persistence
|
|
104
|
+
- Text paste events fire _after_ the text is inserted into the input
|
|
105
|
+
- Image paste events _prevent_ the default paste (since TextInput can't display images)
|
|
106
|
+
|
|
107
|
+
If you want to build a library on top of this, feel free. Please credit **Arunabh Verma** as inspiration.
|
|
108
|
+
|
|
109
|
+
## Inspiration
|
|
110
|
+
|
|
111
|
+
This project exists because of inspiration from:
|
|
112
|
+
|
|
113
|
+
- **[Fernando Rojo](https://x.com/fernandorojo)** — Inspired by his blog post how paste input is implemented in the v0 app
|
|
114
|
+
- Blog + context: how native paste handling works in v0
|
|
115
|
+
- 𝕏: **[How we built the v0 iOS app](https://x.com/fernandorojo/status/1993098916456452464)**
|
|
116
|
+
- **[v0](https://v0.dev)** — The real-world product discussed in Fernando Rojo’s writing
|
|
117
|
+
|
|
118
|
+
Their work on pushing React Native closer to native platform conventions was the catalyst for building this.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
Built by **Arunabh Verma**
|
|
123
|
+
Demo: [X post ↗](https://x.com/iamarunabh/status/1997738168247062774)
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
group = 'expo.modules.pasteinput'
|
|
4
|
+
version = '0.7.6'
|
|
5
|
+
|
|
6
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
7
|
+
apply from: expoModulesCorePlugin
|
|
8
|
+
applyKotlinExpoModulesCorePlugin()
|
|
9
|
+
useCoreDependencies()
|
|
10
|
+
useExpoPublishing()
|
|
11
|
+
|
|
12
|
+
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
|
13
|
+
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
|
14
|
+
// Most of the time, you may like to manage the Android SDK versions yourself.
|
|
15
|
+
def useManagedAndroidSdkVersions = false
|
|
16
|
+
if (useManagedAndroidSdkVersions) {
|
|
17
|
+
useDefaultAndroidSdkVersions()
|
|
18
|
+
} else {
|
|
19
|
+
buildscript {
|
|
20
|
+
// Simple helper that allows the root project to override versions declared by this library.
|
|
21
|
+
ext.safeExtGet = { prop, fallback ->
|
|
22
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
project.android {
|
|
26
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
|
27
|
+
defaultConfig {
|
|
28
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
29
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
android {
|
|
35
|
+
namespace "expo.modules.pasteinput"
|
|
36
|
+
defaultConfig {
|
|
37
|
+
versionCode 1
|
|
38
|
+
versionName "0.7.6"
|
|
39
|
+
}
|
|
40
|
+
lintOptions {
|
|
41
|
+
abortOnError false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package expo.modules.pasteinput
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
class ExpoPasteInputModule : Module() {
|
|
7
|
+
override fun definition() = ModuleDefinition {
|
|
8
|
+
Name("ExpoPasteInput")
|
|
9
|
+
|
|
10
|
+
View(ExpoPasteInputView::class) {
|
|
11
|
+
Events("onPaste")
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
package expo.modules.pasteinput
|
|
2
|
+
|
|
3
|
+
import android.content.ClipboardManager
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Bitmap
|
|
6
|
+
import android.graphics.BitmapFactory
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import android.view.View
|
|
10
|
+
import android.view.ViewGroup
|
|
11
|
+
import android.view.ActionMode
|
|
12
|
+
import android.widget.EditText
|
|
13
|
+
import androidx.core.view.ContentInfoCompat
|
|
14
|
+
import androidx.core.view.OnReceiveContentListener
|
|
15
|
+
import androidx.core.view.ViewCompat
|
|
16
|
+
import expo.modules.kotlin.AppContext
|
|
17
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
18
|
+
import expo.modules.kotlin.views.ExpoView
|
|
19
|
+
import java.io.File
|
|
20
|
+
import java.io.FileOutputStream
|
|
21
|
+
import java.io.InputStream
|
|
22
|
+
import java.io.IOException
|
|
23
|
+
|
|
24
|
+
class ExpoPasteInputView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
25
|
+
private val onPaste by EventDispatcher()
|
|
26
|
+
private var textInputView: EditText? = null
|
|
27
|
+
private var isMonitoring: Boolean = false
|
|
28
|
+
private var contentListener: OnReceiveContentListener? = null
|
|
29
|
+
private var originalActionModeCallback: ActionMode.Callback? = null
|
|
30
|
+
private var customActionModeCallback: ActionMode.Callback? = null
|
|
31
|
+
|
|
32
|
+
init {
|
|
33
|
+
// Make view completely transparent and non-interactive - only monitor paste events
|
|
34
|
+
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
|
35
|
+
isClickable = false
|
|
36
|
+
isFocusable = false
|
|
37
|
+
isFocusableInTouchMode = false
|
|
38
|
+
isEnabled = true // Keep enabled so children can receive events
|
|
39
|
+
|
|
40
|
+
// Enable monitoring when view is attached
|
|
41
|
+
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
|
42
|
+
override fun onViewAttachedToWindow(v: View) {
|
|
43
|
+
startMonitoring()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override fun onViewDetachedFromWindow(v: View) {
|
|
47
|
+
stopMonitoring()
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Pass through all touch events to children - never intercept
|
|
53
|
+
override fun onInterceptTouchEvent(ev: android.view.MotionEvent?): Boolean {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
override fun onTouchEvent(event: android.view.MotionEvent?): Boolean {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override fun dispatchTouchEvent(ev: android.view.MotionEvent?): Boolean {
|
|
62
|
+
return super.dispatchTouchEvent(ev)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override fun onViewAdded(child: View?) {
|
|
66
|
+
super.onViewAdded(child)
|
|
67
|
+
// Re-scan for text input when a new child is added
|
|
68
|
+
if (!isMonitoring) {
|
|
69
|
+
startMonitoring()
|
|
70
|
+
} else {
|
|
71
|
+
// If already monitoring, check if the new child is a text input
|
|
72
|
+
val newTextInput = findTextInputInView(child)
|
|
73
|
+
if (newTextInput != null && newTextInput != textInputView) {
|
|
74
|
+
// Found a different text input, switch to it
|
|
75
|
+
stopMonitoring()
|
|
76
|
+
startMonitoring()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun startMonitoring() {
|
|
82
|
+
if (isMonitoring) return
|
|
83
|
+
|
|
84
|
+
// Find TextInput (EditText) in view hierarchy
|
|
85
|
+
val foundTextInput = findTextInputInView(this) as? EditText
|
|
86
|
+
|
|
87
|
+
if (foundTextInput != null) {
|
|
88
|
+
textInputView = foundTextInput
|
|
89
|
+
isMonitoring = true
|
|
90
|
+
setupPasteHandling(foundTextInput)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun stopMonitoring() {
|
|
95
|
+
if (!isMonitoring) return
|
|
96
|
+
|
|
97
|
+
val editText = textInputView
|
|
98
|
+
if (editText != null) {
|
|
99
|
+
cleanupPasteHandling(editText)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
isMonitoring = false
|
|
103
|
+
textInputView = null
|
|
104
|
+
contentListener = null
|
|
105
|
+
originalActionModeCallback = null
|
|
106
|
+
customActionModeCallback = null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fun setupPasteHandling(editText: EditText) {
|
|
110
|
+
// Set up OnReceiveContentListener for Android 12+ (API 31+)
|
|
111
|
+
// This is the primary mechanism for handling image pastes
|
|
112
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
113
|
+
contentListener = createContentListener()
|
|
114
|
+
ViewCompat.setOnReceiveContentListener(
|
|
115
|
+
editText,
|
|
116
|
+
arrayOf("image/*", "text/plain"),
|
|
117
|
+
contentListener!!
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Intercept paste from context menu to prevent toast
|
|
122
|
+
enhanceOnTextContextMenuItem(editText)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private fun cleanupPasteHandling(editText: EditText) {
|
|
126
|
+
// Remove OnReceiveContentListener
|
|
127
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && contentListener != null) {
|
|
128
|
+
ViewCompat.setOnReceiveContentListener(editText, null, null)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Restore original ActionMode.Callback
|
|
132
|
+
if (customActionModeCallback != null) {
|
|
133
|
+
editText.customSelectionActionModeCallback = originalActionModeCallback
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private fun createContentListener(): OnReceiveContentListener {
|
|
138
|
+
return OnReceiveContentListener { view, payload ->
|
|
139
|
+
val clip = payload.clip
|
|
140
|
+
val itemCount = clip.itemCount
|
|
141
|
+
|
|
142
|
+
if (itemCount == 0) {
|
|
143
|
+
return@OnReceiveContentListener payload
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Collect images and GIFs separately
|
|
147
|
+
val imageUris = mutableListOf<Uri>()
|
|
148
|
+
val gifUris = mutableListOf<Uri>()
|
|
149
|
+
var textContent: String? = null
|
|
150
|
+
|
|
151
|
+
// Process each item in the clip
|
|
152
|
+
for (i in 0 until itemCount) {
|
|
153
|
+
val item = clip.getItemAt(i)
|
|
154
|
+
|
|
155
|
+
// Check for image URI
|
|
156
|
+
val uri = item.uri
|
|
157
|
+
if (uri != null) {
|
|
158
|
+
val mimeType = context.contentResolver.getType(uri)
|
|
159
|
+
if (mimeType != null && mimeType.startsWith("image/")) {
|
|
160
|
+
// Separate GIFs from regular images
|
|
161
|
+
if (mimeType == "image/gif") {
|
|
162
|
+
gifUris.add(uri)
|
|
163
|
+
} else {
|
|
164
|
+
imageUris.add(uri)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check for text
|
|
170
|
+
val text = item.text
|
|
171
|
+
if (!text.isNullOrEmpty() && textContent == null) {
|
|
172
|
+
textContent = text.toString()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Handle GIFs and images (always as array, even for single item)
|
|
177
|
+
if (gifUris.isNotEmpty() || imageUris.isNotEmpty()) {
|
|
178
|
+
processMultipleImagePaste(imageUris, gifUris)
|
|
179
|
+
// Return null to completely consume the content and prevent default paste
|
|
180
|
+
// This prevents Android from showing the "Can't add images" toast
|
|
181
|
+
return@OnReceiveContentListener null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Handle text
|
|
185
|
+
if (textContent != null) {
|
|
186
|
+
handleTextPaste(textContent)
|
|
187
|
+
// Allow default text paste behavior
|
|
188
|
+
return@OnReceiveContentListener payload
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Unsupported content type
|
|
192
|
+
handleUnsupportedPaste()
|
|
193
|
+
return@OnReceiveContentListener payload
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fun enhanceOnTextContextMenuItem(editText: EditText) {
|
|
198
|
+
// Intercept paste from context menu to prevent toast
|
|
199
|
+
// This must happen BEFORE Android tries to paste, otherwise the toast appears
|
|
200
|
+
try {
|
|
201
|
+
// Store original callback if it exists
|
|
202
|
+
originalActionModeCallback = editText.customSelectionActionModeCallback
|
|
203
|
+
|
|
204
|
+
// Set up a custom ActionMode.Callback to intercept paste from context menu
|
|
205
|
+
// This is the most reliable way to intercept paste before the toast appears
|
|
206
|
+
customActionModeCallback = object : ActionMode.Callback {
|
|
207
|
+
override fun onCreateActionMode(mode: ActionMode?, menu: android.view.Menu?): Boolean {
|
|
208
|
+
// Delegate to original callback if it exists
|
|
209
|
+
return originalActionModeCallback?.onCreateActionMode(mode, menu) ?: true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
override fun onPrepareActionMode(mode: ActionMode?, menu: android.view.Menu?): Boolean {
|
|
213
|
+
// Delegate to original callback if it exists
|
|
214
|
+
return originalActionModeCallback?.onPrepareActionMode(mode, menu) ?: false
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
override fun onActionItemClicked(mode: ActionMode?, item: android.view.MenuItem?): Boolean {
|
|
218
|
+
if (item?.itemId == android.R.id.paste) {
|
|
219
|
+
// Intercept paste action
|
|
220
|
+
val clipboard = editText.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
221
|
+
val clipData = clipboard.primaryClip
|
|
222
|
+
|
|
223
|
+
if (clipData != null && clipData.itemCount > 0) {
|
|
224
|
+
// Collect images and GIFs separately
|
|
225
|
+
val imageUris = mutableListOf<Uri>()
|
|
226
|
+
val gifUris = mutableListOf<Uri>()
|
|
227
|
+
var textContent: String? = null
|
|
228
|
+
|
|
229
|
+
// Process all items in the clipboard
|
|
230
|
+
for (i in 0 until clipData.itemCount) {
|
|
231
|
+
val clipItem = clipData.getItemAt(i)
|
|
232
|
+
val uri = clipItem.uri
|
|
233
|
+
|
|
234
|
+
// Check if it's an image
|
|
235
|
+
if (uri != null) {
|
|
236
|
+
val mimeType = editText.context.contentResolver.getType(uri)
|
|
237
|
+
if (mimeType != null && mimeType.startsWith("image/")) {
|
|
238
|
+
// Separate GIFs from regular images
|
|
239
|
+
if (mimeType == "image/gif") {
|
|
240
|
+
gifUris.add(uri)
|
|
241
|
+
} else {
|
|
242
|
+
imageUris.add(uri)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check for text (only take first text item)
|
|
248
|
+
if (textContent == null) {
|
|
249
|
+
val text = clipItem.text
|
|
250
|
+
if (!text.isNullOrEmpty()) {
|
|
251
|
+
textContent = text.toString()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Handle GIFs and images (always as array, even for single item)
|
|
257
|
+
if (gifUris.isNotEmpty() || imageUris.isNotEmpty()) {
|
|
258
|
+
processMultipleImagePaste(imageUris, gifUris)
|
|
259
|
+
mode?.finish()
|
|
260
|
+
return true // We handled it, prevent default paste
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Check for text
|
|
264
|
+
if (textContent != null) {
|
|
265
|
+
// For text, let the normal paste logic run, then notify JS
|
|
266
|
+
var handled = false
|
|
267
|
+
|
|
268
|
+
// 1) Let any existing callback handle it
|
|
269
|
+
if (originalActionModeCallback != null) {
|
|
270
|
+
handled = originalActionModeCallback!!.onActionItemClicked(mode, item)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2) If nothing handled it, fall back to EditText's default handler
|
|
274
|
+
if (!handled && item != null) {
|
|
275
|
+
handled = editText.onTextContextMenuItem(item.itemId)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (handled) {
|
|
279
|
+
handleTextPaste(textContent)
|
|
280
|
+
}
|
|
281
|
+
mode?.finish()
|
|
282
|
+
return handled
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// For other actions, delegate to original callback or return false
|
|
288
|
+
return originalActionModeCallback?.onActionItemClicked(mode, item) ?: false
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
override fun onDestroyActionMode(mode: ActionMode?) {
|
|
292
|
+
// Delegate to original callback if it exists
|
|
293
|
+
originalActionModeCallback?.onDestroyActionMode(mode)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
editText.customSelectionActionModeCallback = customActionModeCallback
|
|
298
|
+
|
|
299
|
+
} catch (e: Exception) {
|
|
300
|
+
// If ActionMode.Callback approach fails, we'll rely on OnReceiveContentListener
|
|
301
|
+
// which should still work but may show the toast briefly
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private fun findTextInputInView(view: View?): View? {
|
|
306
|
+
if (view == null) return null
|
|
307
|
+
|
|
308
|
+
val className = view.javaClass.simpleName
|
|
309
|
+
if (className.contains("ReactTextInput") ||
|
|
310
|
+
className.contains("EditText") ||
|
|
311
|
+
view is EditText) {
|
|
312
|
+
return view
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (view is ViewGroup) {
|
|
316
|
+
for (i in 0 until view.childCount) {
|
|
317
|
+
val child = view.getChildAt(i)
|
|
318
|
+
val found = findTextInputInView(child)
|
|
319
|
+
if (found != null) {
|
|
320
|
+
return found
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
internal fun processMultipleImagePaste(imageUris: List<Uri>, gifUris: List<Uri> = emptyList()) {
|
|
329
|
+
try {
|
|
330
|
+
val filePaths = mutableListOf<String>()
|
|
331
|
+
|
|
332
|
+
// Process GIFs first - copy them directly without decoding
|
|
333
|
+
for (gifUri in gifUris) {
|
|
334
|
+
val gifPath = copyGifFile(gifUri)
|
|
335
|
+
if (gifPath != null) {
|
|
336
|
+
filePaths.add(gifPath)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Process regular images - decode and compress
|
|
341
|
+
for (uri in imageUris) {
|
|
342
|
+
val inputStream = context.contentResolver.openInputStream(uri) ?: continue
|
|
343
|
+
|
|
344
|
+
// Use try-with-resources equivalent (use block) to ensure stream is closed
|
|
345
|
+
inputStream.use { stream ->
|
|
346
|
+
val bitmap = BitmapFactory.decodeStream(stream)
|
|
347
|
+
|
|
348
|
+
if (bitmap == null) {
|
|
349
|
+
return@use // Skip this image if we can't decode it
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Save to cache directory
|
|
353
|
+
val cacheDir = context.cacheDir
|
|
354
|
+
// Use UUID-like approach for better uniqueness: timestamp + counter
|
|
355
|
+
val fileName = "${System.currentTimeMillis()}_${filePaths.size}.jpg"
|
|
356
|
+
val file = File(cacheDir, fileName)
|
|
357
|
+
|
|
358
|
+
FileOutputStream(file).use { outputStream ->
|
|
359
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
|
|
360
|
+
outputStream.flush()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
val filePath = "file://${file.absolutePath}"
|
|
364
|
+
filePaths.add(filePath)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (filePaths.isEmpty()) {
|
|
369
|
+
handleUnsupportedPaste()
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Always use images format with array, even for single image
|
|
374
|
+
onPaste(mapOf(
|
|
375
|
+
"type" to "images",
|
|
376
|
+
"uris" to filePaths
|
|
377
|
+
))
|
|
378
|
+
} catch (e: Exception) {
|
|
379
|
+
handleUnsupportedPaste()
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private fun copyGifFile(uri: Uri): String? {
|
|
384
|
+
return try {
|
|
385
|
+
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
|
|
386
|
+
|
|
387
|
+
// Save to cache directory
|
|
388
|
+
val cacheDir = context.cacheDir
|
|
389
|
+
// Use timestamp + counter for better uniqueness instead of random
|
|
390
|
+
val fileName = "${System.currentTimeMillis()}_${System.nanoTime()}.gif"
|
|
391
|
+
val file = File(cacheDir, fileName)
|
|
392
|
+
|
|
393
|
+
inputStream.use { input ->
|
|
394
|
+
FileOutputStream(file).use { output ->
|
|
395
|
+
input.copyTo(output)
|
|
396
|
+
output.flush()
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
"file://${file.absolutePath}"
|
|
401
|
+
} catch (e: Exception) {
|
|
402
|
+
null
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private fun handleTextPaste(text: String) {
|
|
407
|
+
onPaste(mapOf(
|
|
408
|
+
"type" to "text",
|
|
409
|
+
"value" to text
|
|
410
|
+
))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private fun handleUnsupportedPaste() {
|
|
414
|
+
onPaste(mapOf(
|
|
415
|
+
"type" to "unsupported"
|
|
416
|
+
))
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ViewProps } from "react-native";
|
|
2
|
+
export type PasteEventPayload = {
|
|
3
|
+
type: "text";
|
|
4
|
+
value: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: "images";
|
|
7
|
+
uris: string[];
|
|
8
|
+
} | {
|
|
9
|
+
type: "unsupported";
|
|
10
|
+
};
|
|
11
|
+
export interface TextInputWrapperViewProps extends ViewProps {
|
|
12
|
+
/**
|
|
13
|
+
* Callback fired when a paste event is detected.
|
|
14
|
+
* @param payload - The paste event payload containing type and content
|
|
15
|
+
*/
|
|
16
|
+
onPaste?: (payload: PasteEventPayload) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Child components to wrap. Typically a TextInput component.
|
|
19
|
+
*/
|
|
20
|
+
children?: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=TextInputWrapper.types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TextInputWrapper.types.d.ts","sourceRoot":"","sources":["../src/TextInputWrapper.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,CAAC;AAE5B,MAAM,WAAW,yBAA0B,SAAQ,SAAS;IAC1D;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAE/C;;OAEG;IACH,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TextInputWrapper.types.js","sourceRoot":"","sources":["../src/TextInputWrapper.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { ViewProps } from \"react-native\";\n\nexport type PasteEventPayload =\n | { type: \"text\"; value: string }\n | { type: \"images\"; uris: string[] }\n | { type: \"unsupported\" };\n\nexport interface TextInputWrapperViewProps extends ViewProps {\n /**\n * Callback fired when a paste event is detected.\n * @param payload - The paste event payload containing type and content\n */\n onPaste?: (payload: PasteEventPayload) => void;\n\n /**\n * Child components to wrap. Typically a TextInput component.\n */\n children?: React.ReactNode;\n}\n"]}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { View } from "react-native";
|
|
3
|
+
import type { TextInputWrapperViewProps } from "./TextInputWrapper.types";
|
|
4
|
+
export declare const TextInputWrapperView: React.ForwardRefExoticComponent<TextInputWrapperViewProps & React.RefAttributes<View>>;
|
|
5
|
+
//# sourceMappingURL=TextInputWrapperView.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TextInputWrapperView.d.ts","sourceRoot":"","sources":["../src/TextInputWrapperView.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,KAAK,EAEV,yBAAyB,EAC1B,MAAM,0BAA0B,CAAC;AAIlC,eAAO,MAAM,oBAAoB,wFAqB/B,CAAC"}
|