From 8d98046fa96d8e3998b8130e55f162cba6ade2c0 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Thu, 13 Nov 2025 02:06:27 +0100 Subject: [PATCH] feat: add onKeyPress callback --- .../EnrichedTextInputConnectionWrapper.kt | 120 ++++++++++++++++++ .../enriched/EnrichedTextInputView.kt | 17 +++ .../enriched/EnrichedTextInputViewManager.kt | 2 + .../enriched/events/OnInputKeyPressEvent.kt | 27 ++++ example/src/App.tsx | 6 + ios/EnrichedTextInputView.h | 1 + ios/EnrichedTextInputView.mm | 23 ++++ ios/inputTextView/InputTextView.mm | 15 +++ src/EnrichedTextInput.tsx | 4 + src/EnrichedTextInputNativeComponent.ts | 5 + src/index.tsx | 1 + 11 files changed, 221 insertions(+) create mode 100644 android/src/main/java/com/swmansion/enriched/EnrichedTextInputConnectionWrapper.kt create mode 100644 android/src/main/java/com/swmansion/enriched/events/OnInputKeyPressEvent.kt diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputConnectionWrapper.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputConnectionWrapper.kt new file mode 100644 index 00000000..7f07d253 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputConnectionWrapper.kt @@ -0,0 +1,120 @@ +package com.swmansion.enriched + +import android.view.KeyEvent +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.swmansion.enriched.events.OnInputKeyPressEvent + +class EnrichedTextInputConnectionWrapper( + target: InputConnection, + private val reactContext: ReactContext, + private val editText: EnrichedTextInputView, + private val experimentalSynchronousEvents: Boolean +) : InputConnectionWrapper(target, false) { + + private var isBatchEdit = false + private var key: String? = null + + override fun beginBatchEdit(): Boolean { + isBatchEdit = true + return super.beginBatchEdit() + } + + override fun endBatchEdit(): Boolean { + isBatchEdit = false + key?.let { k -> + dispatchKeyEvent(k) + key = null + } + return super.endBatchEdit() + } + + override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean { + val previousSelectionStart = editText.selectionStart + val previousSelectionEnd = editText.selectionEnd + + val consumed = super.setComposingText(text, newCursorPosition) + + val currentSelectionStart = editText.selectionStart + val noPreviousSelection = previousSelectionStart == previousSelectionEnd + val cursorDidNotMove = currentSelectionStart == previousSelectionStart + val cursorMovedBackwardsOrAtBeginningOfInput = + currentSelectionStart < previousSelectionStart || currentSelectionStart <= 0 + + val inputKey = + if ( + cursorMovedBackwardsOrAtBeginningOfInput || (!noPreviousSelection && cursorDidNotMove) + ) { + BACKSPACE_KEY_VALUE + } else { + editText.text?.get(currentSelectionStart - 1).toString() + } + + dispatchKeyEventOrEnqueue(inputKey) + return consumed + } + + override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean { + var inputKey = text.toString() + // Assume not a keyPress if length > 1 (or 2 if unicode) + if (inputKey.length <= 2) { + if (inputKey.isEmpty()) { + inputKey = BACKSPACE_KEY_VALUE + } + dispatchKeyEventOrEnqueue(inputKey) + } + return super.commitText(text, newCursorPosition) + } + + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + dispatchKeyEvent(BACKSPACE_KEY_VALUE) + return super.deleteSurroundingText(beforeLength, afterLength) + } + + // Called by SwiftKey when cursor at beginning of input when there is a delete + // or when enter is pressed anywhere in the text. Whereas stock Android Keyboard calls + // [InputConnection.deleteSurroundingText] & [InputConnection.commitText] + // in each case, respectively. + override fun sendKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + val isNumberKey = event.unicodeChar in 48..57 + when (event.keyCode) { + KeyEvent.KEYCODE_DEL -> dispatchKeyEvent(BACKSPACE_KEY_VALUE) + KeyEvent.KEYCODE_ENTER -> dispatchKeyEvent(ENTER_KEY_VALUE) + else -> + if (isNumberKey) { + dispatchKeyEvent(event.number.toString()) + } + } + } + return super.sendKeyEvent(event) + } + + private fun dispatchKeyEventOrEnqueue(inputKey: String) { + if (isBatchEdit) { + key = inputKey + } else { + dispatchKeyEvent(inputKey) + } + } + + private fun dispatchKeyEvent(inputKey: String) { + val resolvedKey = if (inputKey == NEWLINE_RAW_VALUE) ENTER_KEY_VALUE else inputKey + val surfaceId = UIManagerHelper.getSurfaceId(editText) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, editText.id) + eventDispatcher?.dispatchEvent(OnInputKeyPressEvent( + surfaceId = surfaceId, + viewId = editText.id, + key = resolvedKey, + experimentalSynchronousEvents = experimentalSynchronousEvents + )) + } + + companion object { + const val NEWLINE_RAW_VALUE: String = "\n" + const val BACKSPACE_KEY_VALUE: String = "Backspace" + const val ENTER_KEY_VALUE: String = "Enter" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt index bae67e50..8d5910ab 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt @@ -16,6 +16,8 @@ import android.util.Log import android.util.TypedValue import android.view.Gravity import android.view.MotionEvent +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import androidx.appcompat.widget.AppCompatEditText import com.facebook.react.bridge.ReactContext @@ -89,6 +91,21 @@ class EnrichedTextInputView : AppCompatEditText { prepareComponent() } + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + var inputConnection = super.onCreateInputConnection(outAttrs) + if (inputConnection != null) { + inputConnection = + EnrichedTextInputConnectionWrapper( + inputConnection, + context as ReactContext, + this, + experimentalSynchronousEvents + ) + } + + return inputConnection + } + init { inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager } diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt index 418c3fb2..52bedbd1 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt @@ -22,6 +22,7 @@ import com.swmansion.enriched.events.OnChangeSelectionEvent import com.swmansion.enriched.events.OnChangeStateEvent import com.swmansion.enriched.events.OnChangeTextEvent import com.swmansion.enriched.events.OnInputFocusEvent +import com.swmansion.enriched.events.OnInputKeyPressEvent import com.swmansion.enriched.events.OnLinkDetectedEvent import com.swmansion.enriched.events.OnMentionDetectedEvent import com.swmansion.enriched.events.OnMentionEvent @@ -72,6 +73,7 @@ class EnrichedTextInputViewManager : SimpleViewManager(), map.put(OnMentionDetectedEvent.EVENT_NAME, mapOf("registrationName" to OnMentionDetectedEvent.EVENT_NAME)) map.put(OnMentionEvent.EVENT_NAME, mapOf("registrationName" to OnMentionEvent.EVENT_NAME)) map.put(OnChangeSelectionEvent.EVENT_NAME, mapOf("registrationName" to OnChangeSelectionEvent.EVENT_NAME)) + map.put(OnInputKeyPressEvent.EVENT_NAME, mapOf("registrationName" to OnInputKeyPressEvent.EVENT_NAME)) return map } diff --git a/android/src/main/java/com/swmansion/enriched/events/OnInputKeyPressEvent.kt b/android/src/main/java/com/swmansion/enriched/events/OnInputKeyPressEvent.kt new file mode 100644 index 00000000..e5fe9142 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/events/OnInputKeyPressEvent.kt @@ -0,0 +1,27 @@ +package com.swmansion.enriched.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnInputKeyPressEvent(surfaceId: Int, viewId: Int, private val key: String, private val experimentalSynchronousEvents: Boolean) : + Event(surfaceId, viewId) { + override fun getEventName(): String { + return EVENT_NAME + } + + override fun getEventData(): WritableMap { + val eventData: WritableMap = Arguments.createMap() + eventData.putString("key", key) + + return eventData + } + + override fun experimental_isSynchronous(): Boolean { + return experimentalSynchronousEvents + } + + companion object { + const val EVENT_NAME: String = "onInputKeyPress" + } +} diff --git a/example/src/App.tsx b/example/src/App.tsx index b5dac0c9..767d0182 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -15,6 +15,7 @@ import { type OnChangeStateEvent, type OnChangeSelectionEvent, type HtmlStyle, + type OnKeyPressEvent, } from 'react-native-enriched'; import { useRef, useState } from 'react'; import { Button } from './components/Button'; @@ -243,6 +244,10 @@ export default function App() { console.log('Input blurred'); }; + const handleKeyPress = (e: NativeSyntheticEvent) => { + console.log('Key pressed:', e.nativeEvent.key); + }; + const handleLinkDetected = (state: CurrentLinkState) => { console.log(state); setCurrentLink(state); @@ -283,6 +288,7 @@ export default function App() { onEndMention={handleEndMention} onFocus={handleFocusEvent} onBlur={handleBlurEvent} + onKeyPress={handleKeyPress} onChangeSelection={handleSelectionChangeEvent} androidExperimentalSynchronousEvents={ ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index ec4670b7..f5febce9 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -26,6 +26,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)emitOnMentionEvent:(NSString *)indicator text:(nullable NSString *)text; - (void)anyTextMayHaveBeenModified; - (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range; +- (void)emitOnKeyPressEvent:(NSString *)key; @end NS_ASSUME_NONNULL_END diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c4882e1c..98ccc629 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1151,8 +1151,31 @@ - (void)textViewDidEndEditing:(UITextView *)textView { } } +- (void)emitOnKeyPressEvent:(NSString *)key { + auto emitter = [self getEventEmitter]; + if (emitter != nullptr) { + emitter->onInputKeyPress({ + .key = [key toCppString] + }); + } +} + - (bool)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { recentlyChangedRange = NSMakeRange(range.location, text.length); + NSString *key = nil; + if (text.length == 0 && range.length > 0) { + key = @"Backspace"; + } else if ([text isEqualToString:@"\n"]) { + key = @"Enter"; + } else if ([text isEqualToString:@"\t"]) { + key = @"Tab"; + } else if (text.length == 1) { + key = text; + } + + if(key != nil) { + [self emitOnKeyPressEvent: key]; + } UnorderedListStyle *uStyle = stylesDict[@([UnorderedListStyle getStyleType])]; OrderedListStyle *oStyle = stylesDict[@([OrderedListStyle getStyleType])]; diff --git a/ios/inputTextView/InputTextView.mm b/ios/inputTextView/InputTextView.mm index 33767c8d..219bc44c 100644 --- a/ios/inputTextView/InputTextView.mm +++ b/ios/inputTextView/InputTextView.mm @@ -37,6 +37,8 @@ - (void)paste:(id)sender { EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input; if(typedInput == nullptr) { return; } + _textWasPasted = YES; + UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; NSArray *pasteboardTypes = pasteboard.pasteboardTypes; NSRange currentRange = typedInput->textView.selectedRange; @@ -114,4 +116,17 @@ - (void)cut:(id)sender { [typedInput anyTextMayHaveBeenModified]; } +- (void)deleteBackward { + EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input; + if (typedInput != nullptr) { + [typedInput emitOnKeyPressEvent:@"Backspace"]; + } + + [super deleteBackward]; + + if (typedInput != nullptr) { + [typedInput anyTextMayHaveBeenModified]; + } +} + @end diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index 8ee4e242..568a5acd 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -17,6 +17,7 @@ import EnrichedTextInputNativeComponent, { type OnMentionDetected, type OnMentionDetectedInternal, type MentionStyleProperties, + type OnKeyPressEvent, } from './EnrichedTextInputNativeComponent'; import type { ColorValue, @@ -141,6 +142,7 @@ export interface EnrichedTextInputProps extends Omit { onChangeMention?: (e: OnChangeMentionEvent) => void; onEndMention?: (indicator: string) => void; onChangeSelection?: (e: NativeSyntheticEvent) => void; + onKeyPress?: (e: NativeSyntheticEvent) => void; /** * If true, Android will use experimental synchronous events. * This will prevent from input flickering when updating component size. @@ -191,6 +193,7 @@ export const EnrichedTextInput = ({ onChangeMention, onEndMention, onChangeSelection, + onKeyPress, androidExperimentalSynchronousEvents = false, ...rest }: EnrichedTextInputProps) => { @@ -349,6 +352,7 @@ export const EnrichedTextInput = ({ onMentionDetected={handleMentionDetected} onMention={handleMentionEvent} onChangeSelection={onChangeSelection} + onInputKeyPress={onKeyPress} androidExperimentalSynchronousEvents={ androidExperimentalSynchronousEvents } diff --git a/src/EnrichedTextInputNativeComponent.ts b/src/EnrichedTextInputNativeComponent.ts index 34a9c5e3..6c461402 100644 --- a/src/EnrichedTextInputNativeComponent.ts +++ b/src/EnrichedTextInputNativeComponent.ts @@ -70,6 +70,10 @@ export interface MentionStyleProperties { textDecorationLine?: 'underline' | 'none'; } +export interface OnKeyPressEvent { + key: string; +} + export interface HtmlStyleInternal { h1?: { fontSize?: Float; @@ -146,6 +150,7 @@ export interface NativeProps extends ViewProps { onMentionDetected?: DirectEventHandler; onMention?: DirectEventHandler; onChangeSelection?: DirectEventHandler; + onInputKeyPress?: DirectEventHandler; // Style related props - used for generating proper setters in component's manager // These should not be passed as regular props diff --git a/src/index.tsx b/src/index.tsx index 82b37a07..4750fe81 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,4 +6,5 @@ export type { OnLinkDetected, OnMentionDetected, OnChangeSelectionEvent, + OnKeyPressEvent, } from './EnrichedTextInputNativeComponent';