diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index 2e6c95986585ea..8ac4b4e97da88e 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -455,6 +455,21 @@ export type NativeProps = $ReadOnly<{| |}>, >, + /** + * Invoked when the user performs the paste action. + */ + onPaste?: ?DirectEventHandler< + $ReadOnly<{| + target: Int32, + items: $ReadOnlyArray< + $ReadOnly<{| + type: string, + data: string, + |}>, + >, + |}>, + >, + /** * The string that will be rendered before text input has been entered. */ @@ -658,6 +673,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { topScroll: { registrationName: 'onScroll', }, + topPaste: { + registrationName: 'onPaste', + }, }, validAttributes: { maxFontSizeMultiplier: true, @@ -712,6 +730,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { textBreakStrategy: true, onScroll: true, onContentSizeChange: true, + onPaste: true, disableFullscreenUI: true, includeFontPadding: true, fontWeight: true, diff --git a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js index 0aa8965bda382d..e51b7991086c8b 100644 --- a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -85,6 +85,9 @@ const RCTTextInputViewConfig = { topContentSizeChange: { registrationName: 'onContentSizeChange', }, + topPaste: { + registrationName: 'onPaste', + }, }, validAttributes: { fontSize: true, @@ -150,6 +153,7 @@ const RCTTextInputViewConfig = { onSelectionChange: true, onContentSizeChange: true, onScroll: true, + onPaste: true, }), }, }; diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts index 75837e341711a7..eeecc3b251fe80 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts @@ -487,6 +487,16 @@ export interface TextInputSubmitEditingEventData { text: string; } +/** + * @see TextInputProps.onPaste + */ +export interface TextInputPasteEventData extends TargetedEvent { + items: Array<{ + type: string; + data: string; + }>; +} + /** * @see https://reactnative.dev/docs/textinput#props */ @@ -831,6 +841,13 @@ export interface TextInputProps | ((e: NativeSyntheticEvent) => void) | undefined; + /** + * Invoked when the user performs the paste action. + */ + onPaste?: + | ((e: NativeSyntheticEvent) => void) + | undefined; + /** * The string that will be rendered before text input has been entered */ diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index 638acd7c7925a2..2f357310223a2f 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -94,6 +94,18 @@ export type EditingEvent = SyntheticEvent< |}>, >; +export type PasteEvent = SyntheticEvent< + $ReadOnly<{| + target: number, + items: $ReadOnlyArray< + $ReadOnly<{| + type: string, + data: string, + |}>, + >, + |}>, +>; + type DataDetectorTypesType = | 'phoneNumber' | 'link' @@ -812,6 +824,11 @@ export type Props = $ReadOnly<{| */ onScroll?: ?(e: ScrollEvent) => mixed, + /** + * Invoked when the user performs the paste action. + */ + onPaste?: ?(e: PasteEvent) => mixed, + /** * The string that will be rendered before text input has been entered. */ diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index b28cc57048395b..5fc3a95c4d6c98 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -134,6 +134,18 @@ export type EditingEvent = SyntheticEvent< |}>, >; +export type PasteEvent = SyntheticEvent< + $ReadOnly<{| + target: number, + items: $ReadOnlyArray< + $ReadOnly<{| + type: string, + data: string, + |}>, + >, + |}>, +>; + type DataDetectorTypesType = | 'phoneNumber' | 'link' @@ -812,6 +824,11 @@ export type Props = $ReadOnly<{| */ onScroll?: ?(e: ScrollEvent) => mixed, + /** + * Invoked when the user performs the paste action. + */ + onPaste?: ?(e: PasteEvent) => mixed, + /** * The string that will be rendered before text input has been entered. */ diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index d5e2e220b1e290..a11679a1153ef0 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -13,6 +13,10 @@ #import #import +#import +#import +#import + @implementation RCTUITextView { UILabel *_placeholderView; UITextView *_detachedTextView; @@ -172,7 +176,32 @@ - (void)scrollRangeToVisible:(NSRange)range - (void)paste:(id)sender { _textWasPasted = YES; - [super paste:sender]; + UIPasteboard *clipboard = [UIPasteboard generalPasteboard]; + if (clipboard.hasImages) { + for (NSItemProvider *itemProvider in clipboard.itemProviders) { + if ([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) { + for (NSString *identifier in itemProvider.registeredTypeIdentifiers) { + if (UTTypeConformsTo((__bridge CFStringRef)identifier, kUTTypeImage)) { + NSString *MIMEType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassMIMEType); + NSString *fileExtension = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassFilenameExtension); + NSString *fileName = [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], fileExtension]; + NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; + NSURL *fileURL = [NSURL fileURLWithPath:filePath]; + NSData *fileData = [clipboard dataForPasteboardType:identifier]; + [fileData writeToFile:filePath atomically:YES]; + [_textInputDelegateAdapter didPaste:MIMEType withData:[fileURL absoluteString]]; + break; + } + } + break; + } + } + } else { + if (clipboard.hasStrings) { + [_textInputDelegateAdapter didPaste:@"text/plain" withData:clipboard.string]; + } + [super paste:sender]; + } } // Turn off scroll animation to fix flaky scrolling. @@ -264,6 +293,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender return NO; } + if (action == @selector(paste:) && [UIPasteboard generalPasteboard].hasImages) { + return YES; + } + return [super canPerformAction:action withSender:sender]; } diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegate.h b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegate.h index 7187177b69d400..748c4cce4589e2 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegate.h +++ b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegate.h @@ -36,6 +36,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)textInputDidChange; - (void)textInputDidChangeSelection; +- (void)textInputDidPaste:(NSString *)type withData:(NSString *)data; @optional diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.h b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.h index f1c32e6e33bb05..0ce9dfe7c8e0a2 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.h +++ b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.h @@ -20,6 +20,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)skipNextTextInputDidChangeSelectionEventWithTextRange:(UITextRange *)textRange; - (void)selectedTextRangeWasSet; +- (void)didPaste:(NSString *)type withData:(NSString *)data; @end @@ -30,6 +31,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithTextView:(UITextView *)backedTextInputView; - (void)skipNextTextInputDidChangeSelectionEventWithTextRange:(UITextRange *)textRange; +- (void)didPaste:(NSString *)type withData:(NSString *)data; @end diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm index ac8e464fc2dce8..2b91802a1238f4 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm @@ -147,6 +147,11 @@ - (void)selectedTextRangeWasSet [self textFieldProbablyDidChangeSelection]; } +- (void)didPaste:(NSString *)type withData:(NSString *)data +{ + [_backedTextInputView.textInputDelegate textInputDidPaste:type withData:data]; +} + #pragma mark - Generalization - (void)textFieldProbablyDidChangeSelection @@ -292,6 +297,11 @@ - (void)skipNextTextInputDidChangeSelectionEventWithTextRange:(UITextRange *)tex _previousSelectedTextRange = textRange; } +- (void)didPaste:(NSString *)type withData:(NSString *)data +{ + [_backedTextInputView.textInputDelegate textInputDidPaste:type withData:data]; +} + #pragma mark - Generalization - (void)textViewProbablyDidChangeSelection diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h index 9a02b8224960f6..06442b4d03b847 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h @@ -37,6 +37,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) RCTDirectEventBlock onChange; @property (nonatomic, copy, nullable) RCTDirectEventBlock onChangeSync; @property (nonatomic, copy, nullable) RCTDirectEventBlock onScroll; +@property (nonatomic, copy, nullable) RCTDirectEventBlock onPaste; @property (nonatomic, assign) NSInteger mostRecentEventCount; @property (nonatomic, assign, readonly) NSInteger nativeEventCount; diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm index 2fc951c836d2f3..93d0456d01ed54 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm @@ -546,6 +546,26 @@ - (void)textInputDidChangeSelection }); } +- (void)textInputDidPaste:(NSString *)type withData:(NSString *)data +{ + if (!_onPaste) { + return; + } + + NSMutableArray *items = [NSMutableArray new]; + [items addObject:@{ + @"type" : type, + @"data" : data, + }]; + + NSDictionary *payload = @{ + @"target" : self.reactTag, + @"items" : items, + }; + + _onPaste(payload); +} + - (void)updateLocalData { [self enforceTextAttributesIfNeeded]; diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm index 20749a602881bf..9782162f56bafd 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm @@ -65,6 +65,7 @@ @implementation RCTBaseTextInputViewManager { RCT_EXPORT_VIEW_PROPERTY(onChangeSync, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onSelectionChange, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onScroll, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onPaste, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(mostRecentEventCount, NSInteger) diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index 03186710893e36..bb165d72d919c8 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -12,6 +12,10 @@ #import #import +#import +#import +#import + @implementation RCTUITextField { RCTBackedTextFieldDelegateAdapter *_textInputDelegateAdapter; NSDictionary *_defaultTextAttributes; @@ -139,6 +143,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender return NO; } + if (action == @selector(paste:) && [UIPasteboard generalPasteboard].hasImages) { + return YES; + } + return [super canPerformAction:action withSender:sender]; } @@ -222,7 +230,32 @@ - (void)scrollRangeToVisible:(NSRange)range - (void)paste:(id)sender { _textWasPasted = YES; - [super paste:sender]; + UIPasteboard *clipboard = [UIPasteboard generalPasteboard]; + if (clipboard.hasImages) { + for (NSItemProvider *itemProvider in clipboard.itemProviders) { + if ([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) { + for (NSString *identifier in itemProvider.registeredTypeIdentifiers) { + if (UTTypeConformsTo((__bridge CFStringRef)identifier, kUTTypeImage)) { + NSString *MIMEType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassMIMEType); + NSString *fileExtension = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassFilenameExtension); + NSString *fileName = [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], fileExtension]; + NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; + NSURL *fileURL = [NSURL fileURLWithPath:filePath]; + NSData *fileData = [clipboard dataForPasteboardType:identifier]; + [fileData writeToFile:filePath atomically:YES]; + [_textInputDelegateAdapter didPaste:MIMEType withData:[fileURL absoluteString]]; + break; + } + } + break; + } + } + } else { + if (clipboard.hasStrings) { + [_textInputDelegateAdapter didPaste:@"text/plain" withData:clipboard.string]; + } + [super paste:sender]; + } } #pragma mark - Layout diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 542cc721c82241..e9ddd3881511c7 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -2718,6 +2718,17 @@ export type NativeProps = $ReadOnly<{| |}>, |}>, >, + onPaste?: ?DirectEventHandler< + $ReadOnly<{| + target: Int32, + items: $ReadOnlyArray< + $ReadOnly<{| + type: string, + data: string, + |}>, + >, + |}>, + >, placeholder?: ?Stringish, placeholderTextColor?: ?ColorValue, secureTextEntry?: ?boolean, @@ -2871,6 +2882,17 @@ export type EditingEvent = SyntheticEvent< target: number, |}>, >; +export type PasteEvent = SyntheticEvent< + $ReadOnly<{| + target: number, + items: $ReadOnlyArray< + $ReadOnly<{| + type: string, + data: string, + |}>, + >, + |}>, +>; type DataDetectorTypesType = | \\"phoneNumber\\" | \\"link\\" @@ -3105,6 +3127,7 @@ export type Props = $ReadOnly<{| onSelectionChange?: ?(e: SelectionChangeEvent) => mixed, onSubmitEditing?: ?(e: EditingEvent) => mixed, onScroll?: ?(e: ScrollEvent) => mixed, + onPaste?: ?(e: PasteEvent) => mixed, placeholder?: ?Stringish, placeholderTextColor?: ?ColorValue, readOnly?: ?boolean, @@ -3216,6 +3239,17 @@ export type EditingEvent = SyntheticEvent< target: number, |}>, >; +export type PasteEvent = SyntheticEvent< + $ReadOnly<{| + target: number, + items: $ReadOnlyArray< + $ReadOnly<{| + type: string, + data: string, + |}>, + >, + |}>, +>; type DataDetectorTypesType = | \\"phoneNumber\\" | \\"link\\" @@ -3444,6 +3478,7 @@ export type Props = $ReadOnly<{| onSelectionChange?: ?(e: SelectionChangeEvent) => mixed, onSubmitEditing?: ?(e: EditingEvent) => mixed, onScroll?: ?(e: ScrollEvent) => mixed, + onPaste?: ?(e: PasteEvent) => mixed, placeholder?: ?Stringish, placeholderTextColor?: ?ColorValue, readOnly?: ?boolean, diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 8c532d85502bc1..b137fb7661bf24 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -432,6 +432,13 @@ - (void)textInputDidChangeSelection } } +- (void)textInputDidPaste:(NSString *)type withData:(NSString *)data +{ + if (_eventEmitter) { + static_cast(*_eventEmitter).onPaste(std::string([type UTF8String]), std::string([data UTF8String])); + } +} + #pragma mark - RCTBackedTextInputDelegate (UIScrollViewDelegate) - (void)scrollViewDidScroll:(UIScrollView *)scrollView diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.java new file mode 100644 index 00000000000000..bfb5819f45a3db --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.textinput; + +/** + * Implement this interface to be informed of paste event in the + * ReactTextEdit This is used by the ReactTextInputManager to forward events + * from the EditText to JS + */ +interface PasteWatcher { + public void onPaste(String type, String data); +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 639914b63cc446..df62f495ab026f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -9,6 +9,10 @@ import static com.facebook.react.uimanager.UIManagerHelper.getReactContext; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ContentResolver; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; @@ -16,6 +20,7 @@ import android.graphics.Rect; import android.graphics.Typeface; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.Editable; @@ -118,6 +123,7 @@ public class ReactEditText extends AppCompatEditText { private @Nullable SelectionWatcher mSelectionWatcher; private @Nullable ContentSizeWatcher mContentSizeWatcher; private @Nullable ScrollWatcher mScrollWatcher; + private @Nullable PasteWatcher mPasteWatcher; private InternalKeyListener mKeyListener; private boolean mDetectScrollMovement = false; private boolean mOnKeyPress = false; @@ -164,6 +170,7 @@ public ReactEditText(Context context) { mKeyListener = new InternalKeyListener(); } mScrollWatcher = null; + mPasteWatcher = null; mTextAttributes = new TextAttributes(); applyTextAttributes(); @@ -331,8 +338,29 @@ public InputConnection onCreateInputConnection(EditorInfo outAttrs) { */ @Override public boolean onTextContextMenuItem(int id) { - if (id == android.R.id.paste) { + if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { id = android.R.id.pasteAsPlainText; + if (mPasteWatcher != null) { + ClipboardManager clipboardManager = + (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboardManager.getPrimaryClip(); + String type = null; + String data = null; + if (clipData.getDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) { + type = ClipDescription.MIMETYPE_TEXT_PLAIN; + data = clipData.getItemAt(0).getText().toString(); + } else { + Uri itemUri = clipData.getItemAt(0).getUri(); + if (itemUri != null) { + ContentResolver cr = getReactContext(this).getContentResolver(); + type = cr.getType(itemUri); + data = itemUri.toString(); + } + } + if (type != null && data != null) { + mPasteWatcher.onPaste(type, data); + } + } } return super.onTextContextMenuItem(id); } @@ -394,6 +422,10 @@ public void setScrollWatcher(@Nullable ScrollWatcher scrollWatcher) { mScrollWatcher = scrollWatcher; } + public void setPasteWatcher(@Nullable PasteWatcher pasteWatcher) { + mPasteWatcher = pasteWatcher; + } + /** * Attempt to set a selection or fail silently. Intentionally meant to handle bad inputs. * EventCounter is the same one used as with text. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java index c65955f303b48f..efb6194d4ec3b3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java @@ -268,6 +268,9 @@ public Map getExportedCustomDirectEventTypeConstants() { .put( ScrollEventType.getJSEventName(ScrollEventType.SCROLL), MapBuilder.of("registrationName", "onScroll")) + .put( + "topPaste", + MapBuilder.of("registrationName", "onPaste")) .build()); return eventTypeConstants; } @@ -484,6 +487,15 @@ public void setOnScroll(final ReactEditText view, boolean onScroll) { } } + @ReactProp(name = "onPaste", defaultBoolean = false) + public void setOnPaste(final ReactEditText view, boolean onPaste) { + if (onPaste) { + view.setPasteWatcher(new ReactPasteWatcher(view)); + } else { + view.setPasteWatcher(null); + } + } + @ReactProp(name = "onKeyPress", defaultBoolean = false) public void setOnKeyPress(final ReactEditText view, boolean onKeyPress) { view.setOnKeyPress(onKeyPress); @@ -1356,6 +1368,25 @@ public void onScrollChanged(int horiz, int vert, int oldHoriz, int oldVert) { } } + private static class ReactPasteWatcher implements PasteWatcher { + private final ReactEditText mReactEditText; + private final EventDispatcher mEventDispatcher; + private final int mSurfaceId; + + public ReactPasteWatcher(ReactEditText editText) { + mReactEditText = editText; + ReactContext reactContext = getReactContext(editText); + mEventDispatcher = getEventDispatcher(reactContext, editText); + mSurfaceId = UIManagerHelper.getSurfaceId(reactContext); + } + + @Override + public void onPaste(String type, String data) { + mEventDispatcher.dispatchEvent( + new ReactTextInputPasteEvent(mSurfaceId, mReactEditText.getId(), type, data)); + } + } + @Override public @Nullable Map getExportedViewConstants() { return MapBuilder.of( diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputPasteEvent.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputPasteEvent.java new file mode 100644 index 00000000000000..78b14b7fbf4179 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputPasteEvent.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.textinput; + +import androidx.annotation.Nullable; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.uimanager.common.ViewUtil; +import com.facebook.react.uimanager.events.Event; + +/** + * Event emitted by EditText native view when clipboard content is pasted + */ +class ReactTextInputPasteEvent extends Event { + + private static final String EVENT_NAME = "topPaste"; + + private String mType; + private String mData; + + @Deprecated + public ReactTextInputPasteEvent(int viewId, String type, String data) { + this(ViewUtil.NO_SURFACE_ID, viewId, type, data); + } + + public ReactTextInputPasteEvent(int surfaceId, int viewId, String type, String data) { + super(surfaceId, viewId); + mType = type; + mData = data; + } + + @Override + public String getEventName() { + return EVENT_NAME; + } + + @Override + public boolean canCoalesce() { + return false; + } + + @Nullable + @Override + protected WritableMap getEventData() { + WritableMap eventData = Arguments.createMap(); + + WritableArray items = Arguments.createArray(); + WritableMap item = Arguments.createMap(); + item.putString("type", mType); + item.putString("data", mData); + items.pushMap(item); + + eventData.putArray("items", items); + + return eventData; + } +} diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/TextInputEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/TextInputEventEmitter.cpp index 6eb58c1681af72..15a85cf40152a7 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/TextInputEventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/TextInputEventEmitter.cpp @@ -172,6 +172,19 @@ void TextInputEventEmitter::onScroll(const Metrics& textInputMetrics) const { }); } +void TextInputEventEmitter::onPaste(const std::string& type, const std::string& data) const { + dispatchEvent("onPaste", [type, data](jsi::Runtime& runtime) { + auto payload = jsi::Object(runtime); + auto items = jsi::Array(runtime, 1); + auto item = jsi::Object(runtime); + item.setProperty(runtime, "type", type); + item.setProperty(runtime, "data", data); + items.setValueAtIndex(runtime, 0, item); + payload.setProperty(runtime, "items", items); + return payload; + }); +} + void TextInputEventEmitter::dispatchTextInputEvent( const std::string& name, const Metrics& textInputMetrics) const { diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/TextInputEventEmitter.h b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/TextInputEventEmitter.h index 9182dd3d2edccb..46700186b1eb79 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/TextInputEventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/TextInputEventEmitter.h @@ -43,6 +43,7 @@ class TextInputEventEmitter : public ViewEventEmitter { void onSubmitEditing(const Metrics& textInputMetrics) const; void onKeyPress(const KeyPressMetrics& keyPressMetrics) const; void onScroll(const Metrics& textInputMetrics) const; + void onPaste(const std::string& type, const std::string& data) const; private: void dispatchTextInputEvent( diff --git a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js index fbf4deb29d2c24..f6669e12202ee0 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js @@ -11,6 +11,7 @@ 'use strict'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import type {PasteEvent} from 'react-native/Libraries/Components/TextInput/TextInput'; import type {TextStyle} from 'react-native/Libraries/StyleSheet/StyleSheet'; import RNTesterButton from '../../components/RNTesterButton'; @@ -20,6 +21,7 @@ import * as React from 'react'; import {useContext, useState} from 'react'; import { Button, + Image, Platform, StyleSheet, Text, @@ -415,6 +417,9 @@ class TextEventsExample extends React.Component<{...}, $FlowFixMeState> { onKeyPress={event => this.updateText('onKeyPress key: ' + event.nativeEvent.key) } + onPaste={event => + this.updateText('onPaste type: ' + event.nativeEvent.items[0].type) + } style={styles.singleLine} /> @@ -846,6 +851,40 @@ function MultilineStyledTextInput({ ); } +function PasteboardTextInput() { + const [pasteEvent, setPasteEvent] = useState(); + const content = pasteEvent?.items[0]; + + return ( + + setPasteEvent(event.nativeEvent)} + placeholder="Paste text or image" + multiline={true} + /> + {content && ( + <> + {'Type: ' + content.type} + {content.type.startsWith('text/') ? ( + {'Data: ' + content.data} + ) : content.type.startsWith('image/') ? ( + + ) : null} + + )} + + ); +} + module.exports = ([ { title: 'Auto-focus & select text on focus', @@ -1149,4 +1188,10 @@ module.exports = ([ ); }, }, + { + title: 'Pasteboard', + render: function (): React.Element { + return ; + }, + }, ]: Array);