diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index dd6e9cf5..4dfb2396 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -9,6 +9,7 @@ import android.graphics.Color import android.graphics.Rect import android.graphics.text.LineBreaker import android.os.Build +import android.text.Editable import android.text.InputType import android.text.Spannable import android.util.AttributeSet @@ -17,12 +18,14 @@ import android.util.Patterns import android.util.TypedValue import android.view.ActionMode import android.view.Gravity +import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager +import android.widget.TextView import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.ViewCompat import com.facebook.react.bridge.ReactContext @@ -43,6 +46,7 @@ import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent import com.swmansion.enriched.textinput.events.OnInputBlurEvent import com.swmansion.enriched.textinput.events.OnInputFocusEvent import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent +import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent import com.swmansion.enriched.textinput.spans.EnrichedInputH1Span import com.swmansion.enriched.textinput.spans.EnrichedInputH2Span import com.swmansion.enriched.textinput.spans.EnrichedInputH3Span @@ -72,7 +76,9 @@ import java.util.regex.Pattern import java.util.regex.PatternSyntaxException import kotlin.math.ceil -class EnrichedTextInputView : AppCompatEditText { +class EnrichedTextInputView : + AppCompatEditText, + TextView.OnEditorActionListener { var stateWrapper: StateWrapper? = null val selection: EnrichedSelection? = EnrichedSelection(this) val spanState: EnrichedSpanState? = EnrichedSpanState(this) @@ -105,6 +111,7 @@ class EnrichedTextInputView : AppCompatEditText { var fontSize: Float? = null private var lineHeight: Float? = null + var submitBehavior: String? = null private var autoFocus = false private var typefaceDirty = false private var didAttachToWindow = false @@ -137,6 +144,18 @@ class EnrichedTextInputView : AppCompatEditText { override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { var inputConnection = super.onCreateInputConnection(outAttrs) + + if (shouldSubmitOnReturn()) { + // Remove the "No Enter Action" flag if it exists + outAttrs.imeOptions = outAttrs.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION.inv() + + // Force the key to be "Done" (or whatever label you set) instead of "Return" + // This ensures onEditorAction gets called instead of just inserting \n + if (outAttrs.imeOptions and EditorInfo.IME_MASK_ACTION == EditorInfo.IME_ACTION_UNSPECIFIED) { + outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_ACTION_DONE + } + } + if (inputConnection != null) { inputConnection = EnrichedTextInputConnectionWrapper( @@ -182,6 +201,53 @@ class EnrichedTextInputView : AppCompatEditText { // Handle checkbox list item clicks this.setCheckboxClickListener() + + setOnEditorActionListener(this) + setReturnKeyLabel(DEFAULT_IME_ACTION_LABEL) + } + + // Similar implementation to: https://github.com/facebook/react-native/blob/c1f5445f4a59d0035389725e47da58eb3d2c267c/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt#L940 + override fun onEditorAction( + v: TextView?, + actionId: Int, + event: KeyEvent?, + ): Boolean { + // Check if it's a valid keyboard action (Done, Next, etc.) or the Enter key (IME_NULL) + val isAction = (actionId and EditorInfo.IME_MASK_ACTION) != 0 || actionId == EditorInfo.IME_NULL + + if (isAction) { + val shouldSubmit = shouldSubmitOnReturn() + val shouldBlur = shouldBlurOnReturn() + + if (shouldSubmit) { + emitSubmitEditing() + } + + if (shouldBlur) { + clearFocus() + } + + if (shouldSubmit || shouldBlur) { + return true + } + } + + // Return false to let the system handle default behavior (like inserting \n) + return false + } + + private fun emitSubmitEditing() { + val context = context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(context) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, id) + dispatcher?.dispatchEvent( + OnSubmitEditingEvent( + surfaceId, + id, + text, + experimentalSynchronousEvents, + ), + ) } // https://github.com/facebook/react-native/blob/36df97f500aa0aa8031098caf7526db358b6ddc1/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt#L295C1-L296C1 @@ -431,6 +497,10 @@ class EnrichedTextInputView : AppCompatEditText { } } + fun setReturnKeyLabel(returnKeyLabel: String?) { + setImeActionLabel(returnKeyLabel, EditorInfo.IME_ACTION_UNSPECIFIED) + } + fun setColor(colorInt: Int?) { if (colorInt == null) { setTextColor(Color.BLACK) @@ -666,6 +736,10 @@ class EnrichedTextInputView : AppCompatEditText { defaultValueDirty = true } + fun shouldBlurOnReturn(): Boolean = submitBehavior == "blurAndSubmit" + + fun shouldSubmitOnReturn(): Boolean = submitBehavior == "submit" || submitBehavior == "blurAndSubmit" + private fun updateDefaultValue() { if (!defaultValueDirty) return @@ -1009,5 +1083,6 @@ class EnrichedTextInputView : AppCompatEditText { const val TAG = "EnrichedTextInputView" const val CLIPBOARD_TAG = "react-native-enriched-clipboard" private const val CONTEXT_MENU_ITEM_ID = 10000 + const val DEFAULT_IME_ACTION_LABEL = "DONE" } } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 1ba1279b..6b24ac27 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -28,6 +28,7 @@ import com.swmansion.enriched.textinput.events.OnMentionDetectedEvent import com.swmansion.enriched.textinput.events.OnMentionEvent import com.swmansion.enriched.textinput.events.OnPasteImagesEvent import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent +import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.styles.HtmlStyle import com.swmansion.enriched.textinput.utils.jsonStringToStringMap @@ -74,6 +75,7 @@ class EnrichedTextInputViewManager : map.put(OnInputKeyPressEvent.EVENT_NAME, mapOf("registrationName" to OnInputKeyPressEvent.EVENT_NAME)) map.put(OnPasteImagesEvent.EVENT_NAME, mapOf("registrationName" to OnPasteImagesEvent.EVENT_NAME)) map.put(OnContextMenuItemPressEvent.EVENT_NAME, mapOf("registrationName" to OnContextMenuItemPressEvent.EVENT_NAME)) + map.put(OnSubmitEditingEvent.EVENT_NAME, mapOf("registrationName" to OnSubmitEditingEvent.EVENT_NAME)) return map } @@ -110,6 +112,30 @@ class EnrichedTextInputViewManager : view?.setCursorColor(color) } + @ReactProp(name = "returnKeyType") + override fun setReturnKeyType( + view: EnrichedTextInputView?, + returnKeyType: String?, + ) { + // Not supported on multiline text input + } + + @ReactProp(name = "submitBehavior") + override fun setSubmitBehavior( + view: EnrichedTextInputView?, + submitBehavior: String?, + ) { + view?.submitBehavior = submitBehavior + } + + @ReactProp(name = "returnKeyLabel") + override fun setReturnKeyLabel( + view: EnrichedTextInputView?, + returnKeyLabel: String?, + ) { + view?.setReturnKeyLabel(returnKeyLabel) + } + @ReactProp(name = "selectionColor", customType = "Color") override fun setSelectionColor( view: EnrichedTextInputView?, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/events/OnSubmitEditingEvent.kt b/android/src/main/java/com/swmansion/enriched/textinput/events/OnSubmitEditingEvent.kt new file mode 100644 index 00000000..80b9e4a0 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/events/OnSubmitEditingEvent.kt @@ -0,0 +1,29 @@ +package com.swmansion.enriched.textinput.events + +import android.text.Editable +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnSubmitEditingEvent( + surfaceId: Int, + viewId: Int, + private val editable: Editable?, + private val experimentalSynchronousEvents: Boolean, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap { + val eventData: WritableMap = Arguments.createMap() + val text = editable.toString() + val normalizedText = text.replace(Regex("\\u200B"), "") + eventData.putString("text", normalizedText) + return eventData + } + + override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents + + companion object { + const val EVENT_NAME: String = "onSubmitEditing" + } +} diff --git a/apps/example/src/hooks/useEditorState.ts b/apps/example/src/hooks/useEditorState.ts index 2791009c..4327f4b6 100644 --- a/apps/example/src/hooks/useEditorState.ts +++ b/apps/example/src/hooks/useEditorState.ts @@ -9,6 +9,7 @@ import { type OnChangeSelectionEvent, type OnKeyPressEvent, type OnPasteImagesEvent, + type OnSubmitEditing, } from 'react-native-enriched'; import { useRef, useState } from 'react'; import { type MentionItem } from '../components/MentionPopup'; @@ -232,6 +233,10 @@ export function useEditorState() { } }; + const handleSubmitEditingEvent = (e: OnSubmitEditing) => { + console.log('Submitted editing:', e.text); + }; + return { ref, stylesState, @@ -269,6 +274,7 @@ export function useEditorState() { handleChangeMention, handleUserMentionSelected, handleChannelMentionSelected, + handleSubmitEditingEvent, submitLink, submitSetValue, selectImage, diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index 7c9b9a5c..7450fe8e 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -54,6 +54,9 @@ export function DevScreen({ onSwitch }: DevScreenProps) { onChangeSelection={(e) => editor.handleSelectionChangeEvent(e.nativeEvent) } + onSubmitEditing={(e) => + editor.handleSubmitEditingEvent(e.nativeEvent) + } onKeyPress={(e) => editor.handleKeyPress(e.nativeEvent)} androidExperimentalSynchronousEvents={ ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS diff --git a/apps/example/src/screens/TestScreen.tsx b/apps/example/src/screens/TestScreen.tsx index ab0820ed..9ad04b0f 100644 --- a/apps/example/src/screens/TestScreen.tsx +++ b/apps/example/src/screens/TestScreen.tsx @@ -84,6 +84,9 @@ export function TestScreen({ onSwitch }: TestScreenProps) { editor.handleSelectionChangeEvent(e.nativeEvent) } onKeyPress={(e) => editor.handleKeyPress(e.nativeEvent)} + onSubmitEditing={(e) => + editor.handleSubmitEditingEvent(e.nativeEvent) + } androidExperimentalSynchronousEvents={ ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS } diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 5e81c9f2..cdd72fab 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,6 +1,7 @@ #import "EnrichedTextInputView.h" #import "CoreText/CoreText.h" #import "ImageAttachment.h" +#import "KeyboardUtils.h" #import "LayoutManagerExtension.h" #import "ParagraphAttributesUtils.h" #import "RCTFabricComponentsPlugins.h" @@ -51,6 +52,7 @@ @implementation EnrichedTextInputView { BOOL _emitTextChange; NSMutableDictionary *_attachmentViews; NSArray *_contextMenuItems; + NSString *_submitBehavior; } // MARK: - Component utils @@ -836,6 +838,17 @@ - (void)updateProps:(Props::Shared const &)props } } + if (newViewProps.returnKeyType != oldViewProps.returnKeyType) { + NSString *str = [NSString fromCppString:newViewProps.returnKeyType]; + + textView.returnKeyType = + [KeyboardUtils getUIReturnKeyTypeFromReturnKeyType:str]; + } + + if (newViewProps.submitBehavior != oldViewProps.submitBehavior) { + _submitBehavior = [NSString fromCppString:newViewProps.submitBehavior]; + } + // autoCapitalize if (newViewProps.autoCapitalize != oldViewProps.autoCapitalize) { NSString *str = [NSString fromCppString:newViewProps.autoCapitalize]; @@ -1192,6 +1205,15 @@ - (bool)isStyle:(StyleType)type activeInMap:(NSDictionary *)styleMap { return false; } +- (bool)textInputShouldReturn { + return [_submitBehavior isEqualToString:@"blurAndSubmit"]; +} + +- (bool)textInputShouldSubmitOnReturn { + return [_submitBehavior isEqualToString:@"blurAndSubmit"] || + [_submitBehavior isEqualToString:@"submit"]; +} + - (void)addStyleBlock:(StyleType)blocking to:(StyleType)blocked { NSMutableArray *blocksArr = [blockingStyles[@(blocked)] mutableCopy]; if (![blocksArr containsObject:@(blocking)]) { @@ -1351,6 +1373,19 @@ - (NSUInteger)getActualIndex:(NSInteger)visibleIndex text:(NSString *)text { return actualIndex; } +- (void)emitOnSubmitEdittingEvent { + auto emitter = [self getEventEmitter]; + if (emitter != nullptr) { + NSString *stringToBeEmitted = [[textView.textStorage.string + stringByReplacingOccurrencesOfString:@"\u200B" + withString:@""] copy]; + + emitter->onSubmitEditing({ + .text = [stringToBeEmitted toCppString], + }); + } +} + - (void)emitOnLinkDetectedEvent:(NSString *)text url:(NSString *)url range:(NSRange)range { @@ -1969,6 +2004,24 @@ - (void)handleKeyPressInRange:(NSString *)text range:(NSRange)range { - (bool)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { + // Check if the user pressed "Enter" + if ([text isEqualToString:@"\n"]) { + const bool shouldSubmit = [self textInputShouldSubmitOnReturn]; + const bool shouldReturn = [self textInputShouldReturn]; + + if (shouldSubmit) { + [self emitOnSubmitEdittingEvent]; + } + + if (shouldReturn) { + [textView endEditing:NO]; + } + + if (shouldSubmit || shouldReturn) { + return NO; + } + } + recentlyChangedRange = NSMakeRange(range.location, text.length); [self handleKeyPressInRange:text range:range]; diff --git a/ios/utils/KeyboardUtils.h b/ios/utils/KeyboardUtils.h new file mode 100644 index 00000000..21e197a8 --- /dev/null +++ b/ios/utils/KeyboardUtils.h @@ -0,0 +1,7 @@ +#import +#pragma once + +@interface KeyboardUtils : NSObject ++ (UIReturnKeyType)getUIReturnKeyTypeFromReturnKeyType: + (NSString *)returnKeyType; +@end diff --git a/ios/utils/KeyboardUtils.mm b/ios/utils/KeyboardUtils.mm new file mode 100644 index 00000000..8f10e108 --- /dev/null +++ b/ios/utils/KeyboardUtils.mm @@ -0,0 +1,31 @@ +#import "KeyboardUtils.h" +#import "RCTTextInputUtils.h" + +@implementation KeyboardUtils ++ (UIReturnKeyType)getUIReturnKeyTypeFromReturnKeyType: + (NSString *)returnKeyType { + if ([returnKeyType isEqualToString:@"done"]) + return UIReturnKeyDone; + if ([returnKeyType isEqualToString:@"go"]) + return UIReturnKeyGo; + if ([returnKeyType isEqualToString:@"next"]) + return UIReturnKeyNext; + if ([returnKeyType isEqualToString:@"search"]) + return UIReturnKeySearch; + if ([returnKeyType isEqualToString:@"send"]) + return UIReturnKeySend; + if ([returnKeyType isEqualToString:@"emergency-call"]) + return UIReturnKeyEmergencyCall; + if ([returnKeyType isEqualToString:@"google"]) + return UIReturnKeyGoogle; + if ([returnKeyType isEqualToString:@"join"]) + return UIReturnKeyJoin; + if ([returnKeyType isEqualToString:@"route"]) + return UIReturnKeyRoute; + if ([returnKeyType isEqualToString:@"yahoo"]) + return UIReturnKeyYahoo; + + return UIReturnKeyDefault; +} + +@end diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index ed6063ea..3e8fc9f6 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -22,6 +22,7 @@ import EnrichedTextInputNativeComponent, { type OnRequestHtmlResultEvent, type OnKeyPressEvent, type OnPasteImagesEvent, + type OnSubmitEditing, } from './spec/EnrichedTextInputNativeComponent'; import type { ColorValue, @@ -31,6 +32,7 @@ import type { MeasureOnSuccessCallback, NativeMethods, NativeSyntheticEvent, + ReturnKeyTypeOptions, TargetedEvent, TextStyle, ViewProps, @@ -114,6 +116,9 @@ export interface EnrichedTextInputProps extends Omit { style?: ViewStyle | TextStyle; scrollEnabled?: boolean; linkRegex?: RegExp | null; + returnKeyType?: ReturnKeyTypeOptions; + returnKeyLabel?: string; + submitBehavior?: 'submit' | 'blurAndSubmit' | 'newline'; onFocus?: (e: FocusEvent) => void; onBlur?: (e: BlurEvent) => void; onChangeText?: (e: NativeSyntheticEvent) => void; @@ -126,6 +131,7 @@ export interface EnrichedTextInputProps extends Omit { onEndMention?: (indicator: string) => void; onChangeSelection?: (e: NativeSyntheticEvent) => void; onKeyPress?: (e: NativeSyntheticEvent) => void; + onSubmitEditing?: (e: NativeSyntheticEvent) => void; onPasteImages?: (e: NativeSyntheticEvent) => void; contextMenuItems?: ContextMenuItem[]; /** @@ -185,6 +191,10 @@ export const EnrichedTextInput = ({ onEndMention, onChangeSelection, onKeyPress, + onSubmitEditing, + returnKeyType, + returnKeyLabel, + submitBehavior, contextMenuItems, androidExperimentalSynchronousEvents = false, useHtmlNormalizer = false, @@ -455,6 +465,10 @@ export const EnrichedTextInput = ({ onInputKeyPress={onKeyPress} contextMenuItems={nativeContextMenuItems} onContextMenuItemPress={handleContextMenuItemPress} + onSubmitEditing={onSubmitEditing} + returnKeyType={returnKeyType} + returnKeyLabel={returnKeyLabel} + submitBehavior={submitBehavior} androidExperimentalSynchronousEvents={ androidExperimentalSynchronousEvents } diff --git a/src/index.tsx b/src/index.tsx index 375534f5..3cfed7e2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,5 +8,6 @@ export type { OnChangeSelectionEvent, OnKeyPressEvent, OnPasteImagesEvent, + OnSubmitEditing, } from './spec/EnrichedTextInputNativeComponent'; export type { HtmlStyle, MentionStyleProperties } from './types'; diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 8bf5186a..83e557d5 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -1,5 +1,6 @@ import { codegenNativeComponent, codegenNativeCommands } from 'react-native'; import type { + BubblingEventHandler, DirectEventHandler, Float, Int32, @@ -159,6 +160,10 @@ export interface OnRequestHtmlResultEvent { html: UnsafeMixed; } +export interface OnSubmitEditing { + text: string; +} + export interface OnKeyPressEvent { key: string; } @@ -360,6 +365,9 @@ export interface NativeProps extends ViewProps { scrollEnabled?: boolean; linkRegex?: LinkNativeRegex; contextMenuItems?: ReadonlyArray>; + returnKeyType?: string; + returnKeyLabel?: string; + submitBehavior?: string; // event callbacks onInputFocus?: DirectEventHandler; @@ -375,6 +383,7 @@ export interface NativeProps extends ViewProps { onInputKeyPress?: DirectEventHandler; onPasteImages?: DirectEventHandler; onContextMenuItemPress?: DirectEventHandler; + onSubmitEditing?: BubblingEventHandler; // Style related props - used for generating proper setters in component's manager // These should not be passed as regular props