From b4f7cb80d92863a2ef1ba288292fcbbd5d3a5bfe Mon Sep 17 00:00:00 2001 From: Islam Rustamov Date: Sun, 18 Jan 2026 16:28:05 +0300 Subject: [PATCH 01/10] feat: add returnKeyType submitBehavior and onSubmitEditing on iOS --- apps/example/src/App.tsx | 7 +++ ios/EnrichedTextInputView.mm | 49 ++++++++++++++++++++ ios/utils/KeyboardUtils.h | 7 +++ ios/utils/KeyboardUtils.mm | 34 ++++++++++++++ src/EnrichedTextInput.tsx | 9 ++++ src/spec/EnrichedTextInputNativeComponent.ts | 8 ++++ 6 files changed, 114 insertions(+) create mode 100644 ios/utils/KeyboardUtils.h create mode 100644 ios/utils/KeyboardUtils.mm diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 6edefc174..430b2f9f0 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -27,6 +27,7 @@ import { DEFAULT_IMAGE_WIDTH, prepareImageDimensions, } from './utils/prepareImageDimensions'; +import type { OnSubmitEditing } from '../../../src/EnrichedTextInputNativeComponent'; type StylesState = OnChangeStateEvent; @@ -289,6 +290,10 @@ export default function App() { console.log('Key pressed:', e.key); }; + const handleSubmitEditing = ({ text }: OnSubmitEditing) => { + console.log('Text submitted with: ', text); + }; + const handleLinkDetected = (state: CurrentLinkState) => { console.log(state); setCurrentLink(state); @@ -308,6 +313,7 @@ export default function App() { handleChangeText(e.nativeEvent)} onChangeHtml={(e) => handleChangeHtml(e.nativeEvent)} onChangeState={(e) => handleChangeState(e.nativeEvent)} + onSubmitEditing={(e) => handleSubmitEditing(e.nativeEvent)} onLinkDetected={handleLinkDetected} onMentionDetected={console.log} onStartMention={handleStartMention} diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index b4d608b6f..66238c764 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,5 +1,6 @@ #import "EnrichedTextInputView.h" #import "CoreText/CoreText.h" +#import "KeyboardUtils.h" #import "LayoutManagerExtension.h" #import "ParagraphAttributesUtils.h" #import "RCTFabricComponentsPlugins.h" @@ -46,6 +47,7 @@ @implementation EnrichedTextInputView { UIColor *_placeholderColor; BOOL _emitFocusBlur; BOOL _emitTextChange; + NSString *_submitBehavior; } // MARK: - Component utils @@ -770,6 +772,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]; @@ -1213,6 +1226,31 @@ - (void)focus { [textView reactFocus]; } +- (BOOL)textInputShouldReturn { + return [_submitBehavior isEqualToString:@"blurAndSubmit"]; +} + +- (BOOL)textInputShouldSubmitOnReturn { + const BOOL shouldSubmit = + [_submitBehavior isEqualToString:@"blurAndSubmit"] || + [_submitBehavior isEqualToString:@"submit"]; + + auto emitter = [self getEventEmitter]; + if (emitter != nullptr) { + if (shouldSubmit) { + NSString *stringToBeEmitted = [[textView.textStorage.string + stringByReplacingOccurrencesOfString:@"\u200B" + withString:@""] copy]; + + emitter->onSubmitEditing({ + .text = [stringToBeEmitted toCppString], + }); + } + } + + return shouldSubmit; +} + - (void)setValue:(NSString *)value { NSString *initiallyProcessedHtml = [parser initiallyProcessHtml:value]; if (initiallyProcessedHtml == nullptr) { @@ -1763,6 +1801,17 @@ - (void)handleKeyPressInRange:(NSString *)text range:(NSRange)range { - (bool)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { + if ([text isEqualToString:@"\n"]) { + const BOOL shouldSubmit = [self textInputShouldSubmitOnReturn]; + const BOOL shouldReturn = [self textInputShouldReturn]; + if (shouldReturn) { + [textView endEditing:NO]; + return NO; + } else if (shouldSubmit) { + 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 000000000..21e197a89 --- /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 000000000..6e72c2d4b --- /dev/null +++ b/ios/utils/KeyboardUtils.mm @@ -0,0 +1,34 @@ +#import "KeyboardUtils.h" +#import "RCTTextInputUtils.h" + +@implementation KeyboardUtils ++ (UIReturnKeyType)getUIReturnKeyTypeFromReturnKeyType: + (NSString *)returnKeyType { + NSMutableDictionary *uiReturnKeyTypes = [NSMutableDictionary dictionary]; + + uiReturnKeyTypes[@"done"] = @(UIReturnKeyDone); + uiReturnKeyTypes[@"go"] = @(UIReturnKeyGo); + uiReturnKeyTypes[@"next"] = @(UIReturnKeyNext); + uiReturnKeyTypes[@"search"] = @(UIReturnKeySearch); + uiReturnKeyTypes[@"send"] = @(UIReturnKeySend); + uiReturnKeyTypes[@"default"] = @(UIReturnKeyDefault); + uiReturnKeyTypes[@"none"] = @(UIReturnKeyDefault); + uiReturnKeyTypes[@"previous"] = @(UIReturnKeyDefault); + uiReturnKeyTypes[@"emergency-call"] = @(UIReturnKeyEmergencyCall); + uiReturnKeyTypes[@"google"] = @(UIReturnKeyGoogle); + uiReturnKeyTypes[@"join"] = @(UIReturnKeyJoin); + uiReturnKeyTypes[@"route"] = @(UIReturnKeyRoute); + uiReturnKeyTypes[@"yahoo"] = @(UIReturnKeyYahoo); + + id value = uiReturnKeyTypes[returnKeyType]; + + if (value) { + UIReturnKeyType returnKey = (UIReturnKeyType)[value integerValue]; + + return returnKey; + } + + return UIReturnKeyDefault; +} + +@end diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index 018e8adab..0224f2838 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -21,6 +21,7 @@ import EnrichedTextInputNativeComponent, { type MentionStyleProperties, type OnChangeStateDeprecatedEvent, type OnKeyPressEvent, + type OnSubmitEditing, } from './spec/EnrichedTextInputNativeComponent'; import type { ColorValue, @@ -30,6 +31,7 @@ import type { MeasureOnSuccessCallback, NativeMethods, NativeSyntheticEvent, + ReturnKeyTypeOptions, TextStyle, ViewProps, ViewStyle, @@ -137,6 +139,8 @@ export interface EnrichedTextInputProps extends Omit { style?: ViewStyle | TextStyle; scrollEnabled?: boolean; linkRegex?: RegExp | null; + returnKeyType?: ReturnKeyTypeOptions; + submitBehavior?: 'submit' | 'blurAndSubmit' | 'newline'; onFocus?: () => void; onBlur?: () => void; onChangeText?: (e: NativeSyntheticEvent) => void; @@ -155,6 +159,7 @@ export interface EnrichedTextInputProps extends Omit { onEndMention?: (indicator: string) => void; onChangeSelection?: (e: NativeSyntheticEvent) => void; onKeyPress?: (e: NativeSyntheticEvent) => void; + onSubmitEditing?: (e: NativeSyntheticEvent) => void; /** * If true, Android will use experimental synchronous events. * This will prevent from input flickering when updating component size. @@ -213,8 +218,10 @@ export const EnrichedTextInput = ({ onEndMention, onChangeSelection, onKeyPress, + onSubmitEditing, androidExperimentalSynchronousEvents = false, scrollEnabled = true, + returnKeyType, ...rest }: EnrichedTextInputProps) => { const nativeRef = useRef(null); @@ -430,10 +437,12 @@ export const EnrichedTextInput = ({ onChangeSelection={onChangeSelection} onRequestHtmlResult={handleRequestHtmlResult} onInputKeyPress={onKeyPress} + onSubmitEditing={onSubmitEditing} androidExperimentalSynchronousEvents={ androidExperimentalSynchronousEvents } scrollEnabled={scrollEnabled} + returnKeyType={returnKeyType} {...rest} /> ); diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index d03141f79..0bcb3d7c5 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, @@ -175,6 +176,10 @@ export interface OnRequestHtmlResultEvent { html: UnsafeMixed; } +export interface OnSubmitEditing { + text: string; +} + export interface MentionStyleProperties { color?: ColorValue; backgroundColor?: ColorValue; @@ -247,6 +252,8 @@ export interface NativeProps extends ViewProps { htmlStyle?: HtmlStyleInternal; scrollEnabled?: boolean; linkRegex?: LinkNativeRegex; + returnKeyType?: string; + submitBehavior?: string; // event callbacks onInputFocus?: DirectEventHandler; @@ -261,6 +268,7 @@ export interface NativeProps extends ViewProps { onChangeSelection?: DirectEventHandler; onRequestHtmlResult?: DirectEventHandler; onInputKeyPress?: DirectEventHandler; + onSubmitEditing?: BubblingEventHandler; // Style related props - used for generating proper setters in component's manager // These should not be passed as regular props From ed7c6f1a0f0e9371c4d8b96213b28d4cf71dc5b1 Mon Sep 17 00:00:00 2001 From: Islam Rustamov Date: Sun, 18 Jan 2026 22:19:39 +0300 Subject: [PATCH 02/10] feat: add returnKeyType returnKeyLabel submitBehavior and onSubmitEditing on Android --- .../textinput/EnrichedTextInputView.kt | 25 +++++++++++++ .../textinput/EnrichedTextInputViewManager.kt | 26 ++++++++++++++ .../textinput/events/OnSubmitEditingEvent.kt | 29 +++++++++++++++ .../textinput/watchers/EnrichedTextWatcher.kt | 36 +++++++++++++++++++ src/EnrichedTextInput.tsx | 3 +- src/spec/EnrichedTextInputNativeComponent.ts | 1 + 6 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/events/OnSubmitEditingEvent.kt 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 9ea30753b..e525a5d1a 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -91,6 +91,7 @@ class EnrichedTextInputView : AppCompatEditText { var experimentalSynchronousEvents: Boolean = false var fontSize: Float? = null + var submitBehavior: String? = null private var autoFocus = false private var typefaceDirty = false private var didAttachToWindow = false @@ -399,6 +400,25 @@ class EnrichedTextInputView : AppCompatEditText { } } + fun setReturnKeyType(returnKeyType: String?) { + var returnKeyFlag = EditorInfo.IME_ACTION_DONE + when (returnKeyType) { + "go" -> returnKeyFlag = EditorInfo.IME_ACTION_GO + "next" -> returnKeyFlag = EditorInfo.IME_ACTION_NEXT + "none" -> returnKeyFlag = EditorInfo.IME_ACTION_NONE + "previous" -> returnKeyFlag = EditorInfo.IME_ACTION_PREVIOUS + "search" -> returnKeyFlag = EditorInfo.IME_ACTION_SEARCH + "send" -> returnKeyFlag = EditorInfo.IME_ACTION_SEND + "done" -> returnKeyFlag = EditorInfo.IME_ACTION_DONE + } + + imeOptions = returnKeyFlag + } + + fun setReturnKeyLabel(returnKeyLabel: String?) { + setImeActionLabel(returnKeyLabel, imeOptions) + } + fun setColor(colorInt: Int?) { if (colorInt == null) { setTextColor(Color.BLACK) @@ -511,6 +531,10 @@ class EnrichedTextInputView : AppCompatEditText { defaultValueDirty = true } + fun shouldBlurOnReturn(): Boolean = submitBehavior == "blurAndSubmit" + + fun shouldSubmitOnReturn(): Boolean = submitBehavior == "submit" || submitBehavior == "blurAndSubmit" + private fun updateDefaultValue() { if (!defaultValueDirty) return @@ -834,5 +858,6 @@ class EnrichedTextInputView : AppCompatEditText { companion object { const val CLIPBOARD_TAG = "react-native-enriched-clipboard" + const val IME_ACTION_ID = 0x670 } } 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 d6012bef1..832a1c357 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -27,6 +27,7 @@ import com.swmansion.enriched.textinput.events.OnLinkDetectedEvent import com.swmansion.enriched.textinput.events.OnMentionDetectedEvent import com.swmansion.enriched.textinput.events.OnMentionEvent 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 @@ -72,6 +73,7 @@ class EnrichedTextInputViewManager : map.put(OnChangeSelectionEvent.EVENT_NAME, mapOf("registrationName" to OnChangeSelectionEvent.EVENT_NAME)) map.put(OnRequestHtmlResultEvent.EVENT_NAME, mapOf("registrationName" to OnRequestHtmlResultEvent.EVENT_NAME)) map.put(OnInputKeyPressEvent.EVENT_NAME, mapOf("registrationName" to OnInputKeyPressEvent.EVENT_NAME)) + map.put(OnSubmitEditingEvent.EVENT_NAME, mapOf("registrationName" to OnSubmitEditingEvent.EVENT_NAME)) return map } @@ -108,6 +110,30 @@ class EnrichedTextInputViewManager : view?.setCursorColor(color) } + @ReactProp(name = "returnKeyType") + override fun setReturnKeyType( + view: EnrichedTextInputView?, + returnKeyType: String?, + ) { + view?.setReturnKeyType(returnKeyType) + } + + @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 000000000..80b9e4a09 --- /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/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index 028b41c15..994b4cb95 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt @@ -6,6 +6,7 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.events.OnChangeTextEvent +import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent class EnrichedTextWatcher( private val view: EnrichedTextInputView, @@ -32,6 +33,18 @@ class EnrichedTextWatcher( startCursorPosition = start endCursorPosition = start + count view.layoutManager.invalidateLayout() + + if (count == 1) { + val currentChar = s?.get(start + count - 1) + if (currentChar == '\n') { + view.text?.delete(start, start + count) + + emitSubmitEditing(view.text) + } + + return + } + view.isRemovingMany = !view.isDuringTransaction && before > count + 1 } @@ -50,6 +63,29 @@ class EnrichedTextWatcher( view.parametrizedStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) } + private fun emitSubmitEditing(editable: Editable?) { + val shouldSubmit = view.shouldSubmitOnReturn() + val shouldBlur = view.shouldBlurOnReturn() + + if (shouldSubmit) { + val context = view.context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(context) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + dispatcher?.dispatchEvent( + OnSubmitEditingEvent( + surfaceId, + view.id, + editable, + view.experimentalSynchronousEvents, + ), + ) + } + + if (shouldBlur) { + view.clearFocus() + } + } + private fun emitChangeText(editable: Editable) { if (!view.shouldEmitOnChangeText) { return diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index 0224f2838..e558fbc0a 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -140,6 +140,7 @@ export interface EnrichedTextInputProps extends Omit { scrollEnabled?: boolean; linkRegex?: RegExp | null; returnKeyType?: ReturnKeyTypeOptions; + returnKeyLabel?: string; submitBehavior?: 'submit' | 'blurAndSubmit' | 'newline'; onFocus?: () => void; onBlur?: () => void; @@ -221,7 +222,6 @@ export const EnrichedTextInput = ({ onSubmitEditing, androidExperimentalSynchronousEvents = false, scrollEnabled = true, - returnKeyType, ...rest }: EnrichedTextInputProps) => { const nativeRef = useRef(null); @@ -442,7 +442,6 @@ export const EnrichedTextInput = ({ androidExperimentalSynchronousEvents } scrollEnabled={scrollEnabled} - returnKeyType={returnKeyType} {...rest} /> ); diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 0bcb3d7c5..e9aee9364 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -253,6 +253,7 @@ export interface NativeProps extends ViewProps { scrollEnabled?: boolean; linkRegex?: LinkNativeRegex; returnKeyType?: string; + returnKeyLabel?: string; submitBehavior?: string; // event callbacks From cdea712b0fa85881ca62611cbbcb3c44e0771a93 Mon Sep 17 00:00:00 2001 From: Islam Rustamov Date: Sun, 18 Jan 2026 22:39:29 +0300 Subject: [PATCH 03/10] chore: cleanup --- .../com/swmansion/enriched/textinput/EnrichedTextInputView.kt | 1 - apps/example/src/App.tsx | 1 - 2 files changed, 2 deletions(-) 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 e525a5d1a..d939113ec 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -858,6 +858,5 @@ class EnrichedTextInputView : AppCompatEditText { companion object { const val CLIPBOARD_TAG = "react-native-enriched-clipboard" - const val IME_ACTION_ID = 0x670 } } diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 430b2f9f0..863594c82 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -313,7 +313,6 @@ export default function App() { Date: Tue, 20 Jan 2026 22:37:53 +0300 Subject: [PATCH 04/10] chore: refactoring --- .../textinput/EnrichedTextInputView.kt | 23 +++---- .../textinput/EnrichedTextInputViewManager.kt | 2 +- .../textinput/watchers/EnrichedTextWatcher.kt | 46 +++++++------- apps/example/src/App.tsx | 2 +- ios/EnrichedTextInputView.mm | 62 ++++++++++--------- ios/utils/KeyboardUtils.mm | 43 ++++++------- 6 files changed, 85 insertions(+), 93 deletions(-) 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 d939113ec..69f68fdd5 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -400,21 +400,6 @@ class EnrichedTextInputView : AppCompatEditText { } } - fun setReturnKeyType(returnKeyType: String?) { - var returnKeyFlag = EditorInfo.IME_ACTION_DONE - when (returnKeyType) { - "go" -> returnKeyFlag = EditorInfo.IME_ACTION_GO - "next" -> returnKeyFlag = EditorInfo.IME_ACTION_NEXT - "none" -> returnKeyFlag = EditorInfo.IME_ACTION_NONE - "previous" -> returnKeyFlag = EditorInfo.IME_ACTION_PREVIOUS - "search" -> returnKeyFlag = EditorInfo.IME_ACTION_SEARCH - "send" -> returnKeyFlag = EditorInfo.IME_ACTION_SEND - "done" -> returnKeyFlag = EditorInfo.IME_ACTION_DONE - } - - imeOptions = returnKeyFlag - } - fun setReturnKeyLabel(returnKeyLabel: String?) { setImeActionLabel(returnKeyLabel, imeOptions) } @@ -535,6 +520,14 @@ class EnrichedTextInputView : AppCompatEditText { fun shouldSubmitOnReturn(): Boolean = submitBehavior == "submit" || submitBehavior == "blurAndSubmit" + fun blurOnReturn() { + val shouldBlur = shouldBlurOnReturn() + + if (shouldBlur) { + clearFocus() + } + } + private fun updateDefaultValue() { if (!defaultValueDirty) return 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 832a1c357..e7a66e46b 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -115,7 +115,7 @@ class EnrichedTextInputViewManager : view: EnrichedTextInputView?, returnKeyType: String?, ) { - view?.setReturnKeyType(returnKeyType) + // Not supported on multiline text input } @ReactProp(name = "submitBehavior") diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index 994b4cb95..6d669c442 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt @@ -34,15 +34,22 @@ class EnrichedTextWatcher( endCursorPosition = start + count view.layoutManager.invalidateLayout() + // Check if the user pressed "Enter" if (count == 1) { val currentChar = s?.get(start + count - 1) + if (currentChar == '\n') { - view.text?.delete(start, start + count) + val shouldSubmit = view.shouldSubmitOnReturn() - emitSubmitEditing(view.text) - } + if (shouldSubmit) { + view.text?.delete(start, start + count) - return + emitSubmitEditing(view.text) + view.blurOnReturn() + + return + } + } } view.isRemovingMany = !view.isDuringTransaction && before > count + 1 @@ -64,26 +71,17 @@ class EnrichedTextWatcher( } private fun emitSubmitEditing(editable: Editable?) { - val shouldSubmit = view.shouldSubmitOnReturn() - val shouldBlur = view.shouldBlurOnReturn() - - if (shouldSubmit) { - val context = view.context as ReactContext - val surfaceId = UIManagerHelper.getSurfaceId(context) - val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) - dispatcher?.dispatchEvent( - OnSubmitEditingEvent( - surfaceId, - view.id, - editable, - view.experimentalSynchronousEvents, - ), - ) - } - - if (shouldBlur) { - view.clearFocus() - } + val context = view.context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(context) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + dispatcher?.dispatchEvent( + OnSubmitEditingEvent( + surfaceId, + view.id, + editable, + view.experimentalSynchronousEvents, + ), + ) } private fun emitChangeText(editable: Editable) { diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 863594c82..9d6212c21 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -291,7 +291,7 @@ export default function App() { }; const handleSubmitEditing = ({ text }: OnSubmitEditing) => { - console.log('Text submitted with: ', text); + console.log('Text submitted with:', text); }; const handleLinkDetected = (state: CurrentLinkState) => { diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 66238c764..ceea38e44 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1122,6 +1122,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)]) { @@ -1226,31 +1235,6 @@ - (void)focus { [textView reactFocus]; } -- (BOOL)textInputShouldReturn { - return [_submitBehavior isEqualToString:@"blurAndSubmit"]; -} - -- (BOOL)textInputShouldSubmitOnReturn { - const BOOL shouldSubmit = - [_submitBehavior isEqualToString:@"blurAndSubmit"] || - [_submitBehavior isEqualToString:@"submit"]; - - auto emitter = [self getEventEmitter]; - if (emitter != nullptr) { - if (shouldSubmit) { - NSString *stringToBeEmitted = [[textView.textStorage.string - stringByReplacingOccurrencesOfString:@"\u200B" - withString:@""] copy]; - - emitter->onSubmitEditing({ - .text = [stringToBeEmitted toCppString], - }); - } - } - - return shouldSubmit; -} - - (void)setValue:(NSString *)value { NSString *initiallyProcessedHtml = [parser initiallyProcessHtml:value]; if (initiallyProcessedHtml == nullptr) { @@ -1299,6 +1283,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 { @@ -1801,13 +1798,20 @@ - (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]; + const bool shouldSubmit = [self textInputShouldSubmitOnReturn]; + const bool shouldReturn = [self textInputShouldReturn]; + + if (shouldSubmit) { + [self emitOnSubmitEdittingEvent]; + } + if (shouldReturn) { [textView endEditing:NO]; - return NO; - } else if (shouldSubmit) { + } + + if (shouldSubmit || shouldReturn) { return NO; } } diff --git a/ios/utils/KeyboardUtils.mm b/ios/utils/KeyboardUtils.mm index 6e72c2d4b..8f10e1086 100644 --- a/ios/utils/KeyboardUtils.mm +++ b/ios/utils/KeyboardUtils.mm @@ -4,29 +4,26 @@ @implementation KeyboardUtils + (UIReturnKeyType)getUIReturnKeyTypeFromReturnKeyType: (NSString *)returnKeyType { - NSMutableDictionary *uiReturnKeyTypes = [NSMutableDictionary dictionary]; - - uiReturnKeyTypes[@"done"] = @(UIReturnKeyDone); - uiReturnKeyTypes[@"go"] = @(UIReturnKeyGo); - uiReturnKeyTypes[@"next"] = @(UIReturnKeyNext); - uiReturnKeyTypes[@"search"] = @(UIReturnKeySearch); - uiReturnKeyTypes[@"send"] = @(UIReturnKeySend); - uiReturnKeyTypes[@"default"] = @(UIReturnKeyDefault); - uiReturnKeyTypes[@"none"] = @(UIReturnKeyDefault); - uiReturnKeyTypes[@"previous"] = @(UIReturnKeyDefault); - uiReturnKeyTypes[@"emergency-call"] = @(UIReturnKeyEmergencyCall); - uiReturnKeyTypes[@"google"] = @(UIReturnKeyGoogle); - uiReturnKeyTypes[@"join"] = @(UIReturnKeyJoin); - uiReturnKeyTypes[@"route"] = @(UIReturnKeyRoute); - uiReturnKeyTypes[@"yahoo"] = @(UIReturnKeyYahoo); - - id value = uiReturnKeyTypes[returnKeyType]; - - if (value) { - UIReturnKeyType returnKey = (UIReturnKeyType)[value integerValue]; - - return returnKey; - } + 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; } From f4feec20af0d90db52a44edeea353b3e0e0516bc Mon Sep 17 00:00:00 2001 From: Islam Rustamov Date: Tue, 20 Jan 2026 23:44:48 +0300 Subject: [PATCH 05/10] fix: android crash on ime action label press --- .../swmansion/enriched/textinput/EnrichedTextInputView.kt | 5 ++++- apps/example/src/App.tsx | 2 +- src/index.tsx | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) 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 69f68fdd5..34963c488 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -159,6 +159,8 @@ class EnrichedTextInputView : AppCompatEditText { setEditableFactory(EnrichedEditableFactory(spanWatcher)) addTextChangedListener(EnrichedTextWatcher(this)) + + setReturnKeyLabel(DEFAULT_IME_ACTION_LABEL) } // https://github.com/facebook/react-native/blob/36df97f500aa0aa8031098caf7526db358b6ddc1/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt#L295C1-L296C1 @@ -401,7 +403,7 @@ class EnrichedTextInputView : AppCompatEditText { } fun setReturnKeyLabel(returnKeyLabel: String?) { - setImeActionLabel(returnKeyLabel, imeOptions) + setImeActionLabel(returnKeyLabel, EditorInfo.IME_ACTION_UNSPECIFIED) } fun setColor(colorInt: Int?) { @@ -851,5 +853,6 @@ class EnrichedTextInputView : AppCompatEditText { companion object { const val CLIPBOARD_TAG = "react-native-enriched-clipboard" + const val DEFAULT_IME_ACTION_LABEL = "DONE" } } diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 9d6212c21..921272934 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -10,6 +10,7 @@ import { type OnChangeSelectionEvent, type HtmlStyle, type OnKeyPressEvent, + type OnSubmitEditing, } from 'react-native-enriched'; import { useRef, useState } from 'react'; import { Button } from './components/Button'; @@ -27,7 +28,6 @@ import { DEFAULT_IMAGE_WIDTH, prepareImageDimensions, } from './utils/prepareImageDimensions'; -import type { OnSubmitEditing } from '../../../src/EnrichedTextInputNativeComponent'; type StylesState = OnChangeStateEvent; diff --git a/src/index.tsx b/src/index.tsx index c28530ee4..9a26d1750 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,4 +8,5 @@ export type { OnMentionDetected, OnChangeSelectionEvent, OnKeyPressEvent, + OnSubmitEditing, } from './spec/EnrichedTextInputNativeComponent'; From 63ed83e9952bf50561058671d87fd5fbcea250af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 21 Jan 2026 10:04:36 +0100 Subject: [PATCH 06/10] fix: bluring input --- .../textinput/EnrichedTextInputView.kt | 72 ++++++++++++++++--- .../textinput/watchers/EnrichedTextWatcher.kt | 34 --------- 2 files changed, 63 insertions(+), 43 deletions(-) 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 34963c488..8c5e3d71d 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 @@ -16,10 +17,12 @@ import android.util.Log import android.util.Patterns import android.util.TypedValue import android.view.Gravity +import android.view.KeyEvent 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 com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap @@ -34,6 +37,7 @@ import com.swmansion.enriched.textinput.events.MentionHandler 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.EnrichedH1Span import com.swmansion.enriched.textinput.spans.EnrichedH2Span import com.swmansion.enriched.textinput.spans.EnrichedH3Span @@ -60,7 +64,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) @@ -122,6 +128,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( @@ -160,9 +178,53 @@ class EnrichedTextInputView : AppCompatEditText { addTextChangedListener(EnrichedTextWatcher(this)) + setOnEditorActionListener(this) setReturnKeyLabel(DEFAULT_IME_ACTION_LABEL) } + 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 override fun onTouchEvent(ev: MotionEvent): Boolean { when (ev.action) { @@ -522,14 +584,6 @@ class EnrichedTextInputView : AppCompatEditText { fun shouldSubmitOnReturn(): Boolean = submitBehavior == "submit" || submitBehavior == "blurAndSubmit" - fun blurOnReturn() { - val shouldBlur = shouldBlurOnReturn() - - if (shouldBlur) { - clearFocus() - } - } - private fun updateDefaultValue() { if (!defaultValueDirty) return diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index 6d669c442..028b41c15 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt @@ -6,7 +6,6 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.events.OnChangeTextEvent -import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent class EnrichedTextWatcher( private val view: EnrichedTextInputView, @@ -33,25 +32,6 @@ class EnrichedTextWatcher( startCursorPosition = start endCursorPosition = start + count view.layoutManager.invalidateLayout() - - // Check if the user pressed "Enter" - if (count == 1) { - val currentChar = s?.get(start + count - 1) - - if (currentChar == '\n') { - val shouldSubmit = view.shouldSubmitOnReturn() - - if (shouldSubmit) { - view.text?.delete(start, start + count) - - emitSubmitEditing(view.text) - view.blurOnReturn() - - return - } - } - } - view.isRemovingMany = !view.isDuringTransaction && before > count + 1 } @@ -70,20 +50,6 @@ class EnrichedTextWatcher( view.parametrizedStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) } - private fun emitSubmitEditing(editable: Editable?) { - val context = view.context as ReactContext - val surfaceId = UIManagerHelper.getSurfaceId(context) - val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) - dispatcher?.dispatchEvent( - OnSubmitEditingEvent( - surfaceId, - view.id, - editable, - view.experimentalSynchronousEvents, - ), - ) - } - private fun emitChangeText(editable: Editable) { if (!view.shouldEmitOnChangeText) { return From 388bc525db7e23dc9c638a600306bb81ba24a634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 21 Jan 2026 10:33:15 +0100 Subject: [PATCH 07/10] fix: add comment to onEditorAction --- .../com/swmansion/enriched/textinput/EnrichedTextInputView.kt | 1 + 1 file changed, 1 insertion(+) 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 8c5e3d71d..c48863a62 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -182,6 +182,7 @@ class EnrichedTextInputView : 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, From 75e2afd224c5dea6032d259642c38ae7a5057050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Furga=C5=82a?= <74370735+exploIF@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:08:43 +0100 Subject: [PATCH 08/10] feat: checkbox list - android (#363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handles: #248 This PR introduces checkbox list style on Android. Ensure that checklist style on Android works fine https://github.com/user-attachments/assets/a3c77384-19a8-4752-ad28-e838ae838037 | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ✅ | --- README.md | 1 + .../textinput/EnrichedTextInputView.kt | 16 +++- .../textinput/EnrichedTextInputViewManager.kt | 7 ++ .../spans/EnrichedCheckboxListSpan.kt | 94 +++++++++++++++++++ .../enriched/textinput/spans/EnrichedSpans.kt | 29 ++++-- .../enriched/textinput/styles/HtmlStyle.kt | 21 +++++ .../enriched/textinput/styles/ListStyles.kt | 64 ++++++++++--- .../textinput/utils/CheckboxDrawable.kt | 81 ++++++++++++++++ .../textinput/utils/EnrichedParser.java | 56 +++++++++-- .../textinput/utils/EnrichedSpanState.kt | 12 +++ .../enriched/textinput/utils/Utils.kt | 85 +++++++++++++++++ apps/example/src/App.tsx | 7 ++ apps/example/src/components/Toolbar.tsx | 12 +++ docs/API_REFERENCE.md | 32 +++++++ src/EnrichedTextInput.tsx | 10 ++ src/spec/EnrichedTextInputNativeComponent.ts | 17 ++++ src/utils/normalizeHtmlStyle.ts | 6 ++ 17 files changed, 518 insertions(+), 32 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedCheckboxListSpan.kt create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/utils/CheckboxDrawable.kt diff --git a/README.md b/README.md index de5aff1a2..5ac5e7025 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ Supported styles: - blockquote - ordered list - unordered list +- checkbox list Each of the styles can be toggled the same way as in the example from [usage section](#usage); call a proper `toggle` function on the component ref. 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 c48863a62..08544b414 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -58,6 +58,7 @@ import com.swmansion.enriched.textinput.utils.EnrichedParser import com.swmansion.enriched.textinput.utils.EnrichedSelection import com.swmansion.enriched.textinput.utils.EnrichedSpanState import com.swmansion.enriched.textinput.utils.mergeSpannables +import com.swmansion.enriched.textinput.utils.setCheckboxClickListener import com.swmansion.enriched.textinput.watchers.EnrichedSpanWatcher import com.swmansion.enriched.textinput.watchers.EnrichedTextWatcher import java.util.regex.Pattern @@ -174,10 +175,13 @@ class EnrichedTextInputView : // Ensure that every time new editable is created, it has EnrichedSpanWatcher attached val spanWatcher = EnrichedSpanWatcher(this) this.spanWatcher = spanWatcher - setEditableFactory(EnrichedEditableFactory(spanWatcher)) + setEditableFactory(EnrichedEditableFactory(spanWatcher)) addTextChangedListener(EnrichedTextWatcher(this)) + // Handle checkbox list item clicks + this.setCheckboxClickListener() + setOnEditorActionListener(this) setReturnKeyLabel(DEFAULT_IME_ACTION_LABEL) } @@ -620,6 +624,7 @@ class EnrichedTextInputView : EnrichedSpans.BLOCK_QUOTE -> paragraphStyles?.toggleStyle(EnrichedSpans.BLOCK_QUOTE) EnrichedSpans.ORDERED_LIST -> listStyles?.toggleStyle(EnrichedSpans.ORDERED_LIST) EnrichedSpans.UNORDERED_LIST -> listStyles?.toggleStyle(EnrichedSpans.UNORDERED_LIST) + EnrichedSpans.CHECKBOX_LIST -> listStyles?.toggleStyle(EnrichedSpans.CHECKBOX_LIST) else -> Log.w("EnrichedTextInputView", "Unknown style: $name") } @@ -648,6 +653,7 @@ class EnrichedTextInputView : EnrichedSpans.BLOCK_QUOTE -> paragraphStyles?.removeStyle(EnrichedSpans.BLOCK_QUOTE, start, end) EnrichedSpans.ORDERED_LIST -> listStyles?.removeStyle(EnrichedSpans.ORDERED_LIST, start, end) EnrichedSpans.UNORDERED_LIST -> listStyles?.removeStyle(EnrichedSpans.UNORDERED_LIST, start, end) + EnrichedSpans.CHECKBOX_LIST -> listStyles?.removeStyle(EnrichedSpans.CHECKBOX_LIST, start, end) EnrichedSpans.LINK -> parametrizedStyles?.removeStyle(EnrichedSpans.LINK, start, end) EnrichedSpans.IMAGE -> parametrizedStyles?.removeStyle(EnrichedSpans.IMAGE, start, end) EnrichedSpans.MENTION -> parametrizedStyles?.removeStyle(EnrichedSpans.MENTION, start, end) @@ -675,6 +681,7 @@ class EnrichedTextInputView : EnrichedSpans.BLOCK_QUOTE -> paragraphStyles?.getStyleRange() EnrichedSpans.ORDERED_LIST -> listStyles?.getStyleRange() EnrichedSpans.UNORDERED_LIST -> listStyles?.getStyleRange() + EnrichedSpans.CHECKBOX_LIST -> listStyles?.getStyleRange() EnrichedSpans.LINK -> parametrizedStyles?.getStyleRange() EnrichedSpans.IMAGE -> parametrizedStyles?.getStyleRange() EnrichedSpans.MENTION -> parametrizedStyles?.getStyleRange() @@ -734,6 +741,13 @@ class EnrichedTextInputView : toggleStyle(name) } + fun toggleCheckboxListItem(checked: Boolean) { + val isValid = verifyStyle(EnrichedSpans.CHECKBOX_LIST) + if (!isValid) return + + listStyles?.toggleCheckboxListStyle(checked) + } + fun addLink( start: Int, end: Int, 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 e7a66e46b..039660dc9 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -366,6 +366,13 @@ class EnrichedTextInputViewManager : view?.verifyAndToggleStyle(EnrichedSpans.UNORDERED_LIST) } + override fun toggleCheckboxList( + view: EnrichedTextInputView?, + isChecked: Boolean, + ) { + view?.toggleCheckboxListItem(isChecked) + } + override fun addLink( view: EnrichedTextInputView?, start: Int, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedCheckboxListSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedCheckboxListSpan.kt new file mode 100644 index 000000000..c3fef6948 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedCheckboxListSpan.kt @@ -0,0 +1,94 @@ +package com.swmansion.enriched.textinput.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spanned +import android.text.TextPaint +import android.text.style.LeadingMarginSpan +import android.text.style.LineHeightSpan +import android.text.style.MetricAffectingSpan +import androidx.core.graphics.withTranslation +import com.swmansion.enriched.textinput.spans.interfaces.EnrichedParagraphSpan +import com.swmansion.enriched.textinput.styles.HtmlStyle +import com.swmansion.enriched.utils.CheckboxDrawable + +class EnrichedCheckboxListSpan( + var isChecked: Boolean, + private val htmlStyle: HtmlStyle, +) : MetricAffectingSpan(), + LineHeightSpan, + LeadingMarginSpan, + EnrichedParagraphSpan { + override val dependsOnHtmlStyle: Boolean = true + + private val checkboxDrawable = + CheckboxDrawable(htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxColor, isChecked).apply { + setBounds(0, 0, htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxSize) + } + + override fun updateMeasureState(tp: TextPaint) { + // Do nothing, but inform layout that this span affects text metrics + } + + override fun updateDrawState(tp: TextPaint) { + // Do nothing, but inform layout that this span affects text metrics + } + + // Include checkbox size in text measurements to avoid clipping + override fun chooseHeight( + text: CharSequence, + start: Int, + end: Int, + spanstartv: Int, + v: Int, + fm: Paint.FontMetricsInt, + ) { + val checkboxSize = htmlStyle.ulCheckboxBoxSize + val currentLineHeight = fm.descent - fm.ascent + + if (checkboxSize > currentLineHeight) { + val extraSpace = checkboxSize - currentLineHeight + val halfExtra = extraSpace / 2 + + fm.ascent -= halfExtra + fm.descent += (extraSpace - halfExtra) + + fm.top -= halfExtra + fm.bottom += (extraSpace - halfExtra) + } + } + + override fun getLeadingMargin(first: Boolean): Int = + htmlStyle.ulCheckboxBoxSize + htmlStyle.ulCheckboxMarginLeft + htmlStyle.ulCheckboxGapWidth + + override fun drawLeadingMargin( + canvas: Canvas, + paint: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence, + start: Int, + end: Int, + first: Boolean, + layout: Layout?, + ) { + val spannedText = text as Spanned + + if (spannedText.getSpanStart(this) == start) { + checkboxDrawable.update(isChecked) + + val lineCenter = (top + bottom) / 2f + val drawableTop = lineCenter - (htmlStyle.ulCheckboxBoxSize / 2f) + + canvas.withTranslation(x.toFloat() + htmlStyle.ulCheckboxMarginLeft, drawableTop) { + checkboxDrawable.draw(this) + } + } + } + + override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedCheckboxListSpan = EnrichedCheckboxListSpan(isChecked, htmlStyle) +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt index 56edb2bae..a23a7449d 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt @@ -17,7 +17,7 @@ data class ParagraphSpanConfig( data class ListSpanConfig( override val clazz: Class<*>, - val shortcut: String, + val shortcut: String?, ) : ISpanConfig data class StylesMergingConfig( @@ -48,6 +48,7 @@ object EnrichedSpans { // list styles const val UNORDERED_LIST = "unordered_list" const val ORDERED_LIST = "ordered_list" + const val CHECKBOX_LIST = "checkbox_list" // parametrized styles const val LINK = "link" @@ -79,6 +80,7 @@ object EnrichedSpans { mapOf( UNORDERED_LIST to ListSpanConfig(EnrichedUnorderedListSpan::class.java, "- "), ORDERED_LIST to ListSpanConfig(EnrichedOrderedListSpan::class.java, "1. "), + CHECKBOX_LIST to ListSpanConfig(EnrichedCheckboxListSpan::class.java, null), ) val parametrizedStyles: Map = @@ -132,44 +134,44 @@ object EnrichedSpans { } H1 -> { - val conflictingStyles = mutableListOf(H2, H3, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H2, H3, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h1Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H2 -> { - val conflictingStyles = mutableListOf(H1, H3, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H3, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h2Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H3 -> { - val conflictingStyles = mutableListOf(H1, H2, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H2, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h3Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H4 -> { - val conflictingStyles = mutableListOf(H1, H2, H3, H5, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H2, H3, H5, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h4Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H5 -> { - val conflictingStyles = mutableListOf(H1, H2, H3, H4, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H2, H3, H4, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h5Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H6 -> { - val conflictingStyles = mutableListOf(H1, H2, H3, H4, H5, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H2, H3, H4, H5, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h6Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } BLOCK_QUOTE -> { StylesMergingConfig( - conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, CODE_BLOCK, ORDERED_LIST, UNORDERED_LIST), + conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, CODE_BLOCK, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST), ) } @@ -189,6 +191,7 @@ object EnrichedSpans { STRIKETHROUGH, UNORDERED_LIST, ORDERED_LIST, + CHECKBOX_LIST, BLOCK_QUOTE, INLINE_CODE, ), @@ -197,13 +200,19 @@ object EnrichedSpans { UNORDERED_LIST -> { StylesMergingConfig( - conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, ORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE), + conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, ORDERED_LIST, CHECKBOX_LIST, CODE_BLOCK, BLOCK_QUOTE), ) } ORDERED_LIST -> { StylesMergingConfig( - conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, UNORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE), + conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, UNORDERED_LIST, CHECKBOX_LIST, CODE_BLOCK, BLOCK_QUOTE), + ) + } + + CHECKBOX_LIST -> { + StylesMergingConfig( + conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, UNORDERED_LIST, ORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE), ) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt index 34dce9139..70ea06956 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt @@ -52,6 +52,11 @@ class HtmlStyle { var ulBulletSize: Int = 8 var ulBulletColor: Int = Color.BLACK + var ulCheckboxBoxSize: Int = 50 + var ulCheckboxGapWidth: Int = 16 + var ulCheckboxMarginLeft: Int = 24 + var ulCheckboxBoxColor: Int = Color.BLACK + var aColor: Int = Color.BLACK var aUnderline: Boolean = true @@ -118,6 +123,12 @@ class HtmlStyle { ulMarginLeft = parseFloat(ulStyle, "marginLeft").toInt() ulBulletSize = parseFloat(ulStyle, "bulletSize").toInt() + val ulCheckboxStyle = style.getMap("ulCheckbox") + ulCheckboxBoxSize = parseFloat(ulCheckboxStyle, "boxSize").toInt() + ulCheckboxGapWidth = parseFloat(ulCheckboxStyle, "gapWidth").toInt() + ulCheckboxMarginLeft = parseFloat(ulCheckboxStyle, "marginLeft").toInt() + ulCheckboxBoxColor = parseColor(ulCheckboxStyle, "boxColor") + val aStyle = style.getMap("a") aColor = parseColor(aStyle, "color") aUnderline = parseIsUnderline(aStyle) @@ -290,6 +301,11 @@ class HtmlStyle { ulBulletSize == other.ulBulletSize && ulBulletColor == other.ulBulletColor && + ulCheckboxBoxSize == other.ulCheckboxBoxSize && + ulCheckboxGapWidth == other.ulCheckboxGapWidth && + ulCheckboxMarginLeft == other.ulCheckboxMarginLeft && + ulCheckboxBoxColor == other.ulCheckboxBoxColor && + aColor == other.aColor && aUnderline == other.aUnderline && @@ -332,6 +348,11 @@ class HtmlStyle { result = 31 * result + ulBulletSize.hashCode() result = 31 * result + ulBulletColor.hashCode() + result = 31 * result + ulCheckboxBoxSize.hashCode() + result = 31 * result + ulCheckboxGapWidth.hashCode() + result = 31 * result + ulCheckboxMarginLeft.hashCode() + result = 31 * result + ulCheckboxBoxColor.hashCode() + result = 31 * result + aColor.hashCode() result = 31 * result + aUnderline.hashCode() diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt index 36cc24f11..606b6b901 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt @@ -5,6 +5,7 @@ import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import com.swmansion.enriched.textinput.EnrichedTextInputView +import com.swmansion.enriched.textinput.spans.EnrichedCheckboxListSpan import com.swmansion.enriched.textinput.spans.EnrichedOrderedListSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.EnrichedUnorderedListSpan @@ -57,19 +58,29 @@ class ListStyles( name: String, start: Int, end: Int, + isChecked: Boolean? = false, ) { val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) - if (name == EnrichedSpans.UNORDERED_LIST) { - val span = EnrichedUnorderedListSpan(view.htmlStyle) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - return - } + when (name) { + EnrichedSpans.UNORDERED_LIST -> { + val span = EnrichedUnorderedListSpan(view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + EnrichedSpans.ORDERED_LIST -> { + val index = getOrderedListIndex(spannable, safeStart) + val span = EnrichedOrderedListSpan(index, view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + EnrichedSpans.CHECKBOX_LIST -> { + val span = EnrichedCheckboxListSpan(isChecked ?: false, view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - if (name == EnrichedSpans.ORDERED_LIST) { - val index = getOrderedListIndex(spannable, safeStart) - val span = EnrichedOrderedListSpan(index, view.htmlStyle) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + // Invalidate layout to update checkbox drawing in case checkbox is bigger than line height + view.layoutManager.invalidateLayout() + } } } @@ -104,7 +115,10 @@ class ListStyles( } } - fun toggleStyle(name: String) { + private fun toggleStyle( + name: String, + checkboxState: Boolean?, + ) { if (view.selection == null) return val config = EnrichedSpans.listSpans[name] ?: return val spannable = view.text as SpannableStringBuilder @@ -123,7 +137,7 @@ class ListStyles( spannable.insert(start, EnrichedConstants.ZWS_STRING) view.spanState?.setStart(name, start + 1) removeSpansForRange(spannable, start, end, config.clazz) - setSpan(spannable, name, start, end + 1) + setSpan(spannable, name, start, end + 1, checkboxState) return } @@ -135,7 +149,7 @@ class ListStyles( for (paragraph in paragraphs) { spannable.insert(currentStart, EnrichedConstants.ZWS_STRING) val currentEnd = currentStart + paragraph.length + 1 - setSpan(spannable, name, currentStart, currentEnd) + setSpan(spannable, name, currentStart, currentEnd, checkboxState) currentStart = currentEnd + 1 } @@ -143,6 +157,14 @@ class ListStyles( view.spanState?.setStart(name, currentStart) } + fun toggleStyle(name: String) { + toggleStyle(name, false) + } + + fun toggleCheckboxListStyle(checked: Boolean) { + toggleStyle(EnrichedSpans.CHECKBOX_LIST, checked) + } + private fun handleAfterTextChanged( s: Editable, name: String, @@ -155,7 +177,7 @@ class ListStyles( val isBackspace = previousTextLength > s.length val isNewLine = cursorPosition > 0 && s[cursorPosition - 1] == '\n' - val isShortcut = s.substring(start, end).startsWith(config.shortcut) + val isShortcut = config.shortcut?.let { s.substring(start, end).startsWith(it) } ?: false val spans = s.getSpans(start, end, config.clazz) // Remove spans if cursor is at the start of the paragraph and spans exist @@ -180,6 +202,21 @@ class ListStyles( return } + if (name === EnrichedSpans.CHECKBOX_LIST) { + if (spans.isNotEmpty()) { + val previousSpan = spans[0] as EnrichedCheckboxListSpan + val isChecked = previousSpan.isChecked + + for (span in spans) { + s.removeSpan(span) + } + + setSpan(s, EnrichedSpans.CHECKBOX_LIST, start, end, isChecked) + } + + return + } + if (spans.isNotEmpty()) { for (span in spans) { s.removeSpan(span) @@ -196,6 +233,7 @@ class ListStyles( ) { handleAfterTextChanged(s, EnrichedSpans.ORDERED_LIST, endCursorPosition, previousTextLength) handleAfterTextChanged(s, EnrichedSpans.UNORDERED_LIST, endCursorPosition, previousTextLength) + handleAfterTextChanged(s, EnrichedSpans.CHECKBOX_LIST, endCursorPosition, previousTextLength) } fun getStyleRange(): Pair = view.selection?.getParagraphSelection() ?: Pair(0, 0) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/CheckboxDrawable.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/CheckboxDrawable.kt new file mode 100644 index 000000000..5b7ff3869 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/CheckboxDrawable.kt @@ -0,0 +1,81 @@ +package com.swmansion.enriched.utils + +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.drawable.Drawable + +class CheckboxDrawable( + private val size: Int, + private var color: Int, + private var isChecked: Boolean, +) : Drawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val path = Path() + + fun update(checked: Boolean) { + this.isChecked = checked + invalidateSelf() + } + + override fun draw(canvas: Canvas) { + val saveCount = canvas.saveLayer(0f, 0f, size.toFloat(), size.toFloat(), null) + + paint.color = color + paint.style = Paint.Style.FILL + + // Full square background with transparent checkmark + if (isChecked) { + val cornerRadius = size * 0.15f + canvas.drawRoundRect(0f, 0f, size.toFloat(), size.toFloat(), cornerRadius, cornerRadius, paint) + + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.XOR) + paint.strokeWidth = size * 0.15f + paint.style = Paint.Style.STROKE + paint.strokeCap = Paint.Cap.ROUND + paint.strokeJoin = Paint.Join.ROUND + + path.reset() + path.moveTo(size * 0.25f, size * 0.5f) + path.lineTo(size * 0.45f, size * 0.7f) + path.lineTo(size * 0.75f, size * 0.3f) + canvas.drawPath(path, paint) + + paint.xfermode = null + canvas.restoreToCount(saveCount) + return + } + + // Border only square for unchecked state + paint.style = Paint.Style.STROKE + paint.strokeWidth = size * 0.1f + val margin = paint.strokeWidth / 2f + val cornerRadius = size * 0.15f + canvas.drawRoundRect( + margin, + margin, + size - margin, + size - margin, + cornerRadius, + cornerRadius, + paint, + ) + + canvas.restoreToCount(saveCount) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedParser.java index 6fceff93d..cdce99718 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedParser.java @@ -11,6 +11,7 @@ import android.text.style.ParagraphStyle; import com.swmansion.enriched.textinput.spans.EnrichedBlockQuoteSpan; import com.swmansion.enriched.textinput.spans.EnrichedBoldSpan; +import com.swmansion.enriched.textinput.spans.EnrichedCheckboxListSpan; import com.swmansion.enriched.textinput.spans.EnrichedCodeBlockSpan; import com.swmansion.enriched.textinput.spans.EnrichedH1Span; import com.swmansion.enriched.textinput.spans.EnrichedH2Span; @@ -156,6 +157,8 @@ private static String getBlockTag(EnrichedParagraphSpan[] spans) { return "ul"; } else if (span instanceof EnrichedOrderedListSpan) { return "ol"; + } else if (span instanceof EnrichedCheckboxListSpan) { + return "ul data-type=\"checkbox\""; } else if (span instanceof EnrichedH1Span) { return "h1"; } else if (span instanceof EnrichedH2Span) { @@ -177,6 +180,8 @@ private static String getBlockTag(EnrichedParagraphSpan[] spans) { private static void withinBlock(StringBuilder out, Spanned text, int start, int end) { boolean isInUlList = false; boolean isInOlList = false; + boolean isInCheckboxList = false; + int next; for (int i = start; i <= end; i = next) { next = TextUtils.indexOf(text, '\n', i, end); @@ -192,6 +197,10 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int // Current paragraph is no longer a list item; close the previously opened list isInOlList = false; out.append("\n"); + } else if (isInCheckboxList) { + // Current paragraph is no longer a list item; close the previously opened list + isInCheckboxList = false; + out.append("\n"); } out.append("
\n"); } else { @@ -200,6 +209,7 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int String tag = getBlockTag(paragraphStyles); boolean isUlListItem = tag.equals("ul"); boolean isOlListItem = tag.equals("ol"); + boolean isCheckboxListItem = tag.equals("ul data-type=\"checkbox\""); if (isInUlList && !isUlListItem) { // Current paragraph is no longer a list item; close the previously opened list @@ -209,6 +219,10 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int // Current paragraph is no longer a list item; close the previously opened list isInOlList = false; out.append("\n"); + } else if (isInCheckboxList && !isCheckboxListItem) { + // Current paragraph is no longer a list item; close the previously opened list + isInCheckboxList = false; + out.append("\n"); } if (isUlListItem && !isInUlList) { @@ -219,14 +233,27 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int // Current paragraph is the first item in a list isInOlList = true; out.append("\n"); + } else if (isCheckboxListItem && !isInCheckboxList) { + // Current paragraph is the first item in a list + isInCheckboxList = true; + out.append("
    \n"); } - boolean isList = isUlListItem || isOlListItem; + boolean isList = isUlListItem || isOlListItem || isCheckboxListItem; String tagType = isList ? "li" : tag; - out.append("<"); + out.append("<"); out.append(tagType); + if (isCheckboxListItem) { + EnrichedCheckboxListSpan[] checkboxSpans = + text.getSpans(i, next, EnrichedCheckboxListSpan.class); + if (checkboxSpans.length > 0) { + boolean isChecked = checkboxSpans[0].isChecked(); + if (isChecked) out.append(" checked"); + } + } + out.append(">"); withinParagraph(out, text, i, next); out.append("\n"); + } else if (next == end && isInCheckboxList) { + isInCheckboxList = false; + out.append("
\n"); } } next++; @@ -378,6 +408,7 @@ class HtmlToSpannedConverter implements ContentHandler { private final EnrichedParser.ImageGetter mImageGetter; private static Integer currentOrderedListItemIndex = 0; private static Boolean isInOrderedList = false; + private static Boolean isInCheckboxList = false; private static Boolean isEmptyTag = false; public HtmlToSpannedConverter( @@ -454,6 +485,8 @@ private void handleStartTag(String tag, Attributes attributes) { startBlockElement(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("ul")) { isInOrderedList = false; + String dataType = attributes.getValue("", "data-type"); + isInCheckboxList = "checkbox".equals(dataType); startBlockElement(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("ol")) { isInOrderedList = true; @@ -461,7 +494,7 @@ private void handleStartTag(String tag, Attributes attributes) { startBlockElement(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("li")) { isEmptyTag = true; - startLi(mSpannableStringBuilder); + startLi(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("b")) { start(mSpannableStringBuilder, new Bold()); } else if (tag.equalsIgnoreCase("i")) { @@ -578,14 +611,17 @@ private static void handleBr(Editable text) { text.append('\n'); } - private void startLi(Editable text) { + private void startLi(Editable text, Attributes attributes) { startBlockElement(text); if (isInOrderedList) { currentOrderedListItemIndex++; - start(text, new List("ol", currentOrderedListItemIndex)); + start(text, new List("ordered", currentOrderedListItemIndex, false)); + } else if (isInCheckboxList) { + String isChecked = attributes.getValue("", "checked"); + start(text, new List("checked", 0, "checked".equals(isChecked))); } else { - start(text, new List("ul", 0)); + start(text, new List("unordered", 0, false)); } } @@ -594,8 +630,10 @@ private static void endLi(Editable text, HtmlStyle style) { List l = getLast(text, List.class); if (l != null) { - if (l.mType.equals("ol")) { + if (l.mType.equals("ordered")) { setParagraphSpanFromMark(text, l, new EnrichedOrderedListSpan(l.mIndex, style)); + } else if (l.mType.equals("checked")) { + setParagraphSpanFromMark(text, l, new EnrichedCheckboxListSpan(l.mChecked, style)); } else { setParagraphSpanFromMark(text, l, new EnrichedUnorderedListSpan(style)); } @@ -884,10 +922,12 @@ private static class Blockquote {} private static class List { public int mIndex; public String mType; + public boolean mChecked; - public List(String type, int index) { + public List(String type, int index, boolean checked) { mType = type; mIndex = index; + mChecked = checked; } } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt index a13151f94..9b6a639a6 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt @@ -46,6 +46,8 @@ class EnrichedSpanState( private set var unorderedListStart: Int? = null private set + var checkboxListStart: Int? = null + private set var linkStart: Int? = null private set var imageStart: Int? = null @@ -128,6 +130,11 @@ class EnrichedSpanState( emitStateChangeEvent() } + fun setCheckboxListStart(start: Int?) { + this.checkboxListStart = start + emitStateChangeEvent() + } + fun setLinkStart(start: Int?) { this.linkStart = start emitStateChangeEvent() @@ -161,6 +168,7 @@ class EnrichedSpanState( EnrichedSpans.BLOCK_QUOTE -> blockQuoteStart EnrichedSpans.ORDERED_LIST -> orderedListStart EnrichedSpans.UNORDERED_LIST -> unorderedListStart + EnrichedSpans.CHECKBOX_LIST -> checkboxListStart EnrichedSpans.LINK -> linkStart EnrichedSpans.IMAGE -> imageStart EnrichedSpans.MENTION -> mentionStart @@ -190,6 +198,7 @@ class EnrichedSpanState( EnrichedSpans.BLOCK_QUOTE -> setBlockQuoteStart(start) EnrichedSpans.ORDERED_LIST -> setOrderedListStart(start) EnrichedSpans.UNORDERED_LIST -> setUnorderedListStart(start) + EnrichedSpans.CHECKBOX_LIST -> setCheckboxListStart(start) EnrichedSpans.LINK -> setLinkStart(start) EnrichedSpans.IMAGE -> setImageStart(start) EnrichedSpans.MENTION -> setMentionStart(start) @@ -225,6 +234,7 @@ class EnrichedSpanState( deprecatedPayload.putBoolean("isBlockQuote", blockQuoteStart != null) deprecatedPayload.putBoolean("isOrderedList", orderedListStart != null) deprecatedPayload.putBoolean("isUnorderedList", unorderedListStart != null) + deprecatedPayload.putBoolean("isCheckboxList", checkboxListStart != null) deprecatedPayload.putBoolean("isLink", linkStart != null) deprecatedPayload.putBoolean("isImage", imageStart != null) deprecatedPayload.putBoolean("isMention", mentionStart != null) @@ -269,6 +279,7 @@ class EnrichedSpanState( if (blockQuoteStart != null) EnrichedSpans.BLOCK_QUOTE else null, if (orderedListStart != null) EnrichedSpans.ORDERED_LIST else null, if (unorderedListStart != null) EnrichedSpans.UNORDERED_LIST else null, + if (checkboxListStart != null) EnrichedSpans.CHECKBOX_LIST else null, if (linkStart != null) EnrichedSpans.LINK else null, if (imageStart != null) EnrichedSpans.IMAGE else null, if (mentionStart != null) EnrichedSpans.MENTION else null, @@ -292,6 +303,7 @@ class EnrichedSpanState( payload.putMap("link", getStyleState(activeStyles, EnrichedSpans.LINK)) payload.putMap("image", getStyleState(activeStyles, EnrichedSpans.IMAGE)) payload.putMap("mention", getStyleState(activeStyles, EnrichedSpans.MENTION)) + payload.putMap("checkboxList", getStyleState(activeStyles, EnrichedSpans.CHECKBOX_LIST)) // Do not emit event if payload is the same if (previousPayload == payload) { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt index 192448d38..a922959ba 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt @@ -1,6 +1,13 @@ package com.swmansion.enriched.textinput.utils +import android.annotation.SuppressLint +import android.text.Selection +import android.text.Spannable +import android.text.Spanned import android.util.Log +import android.view.MotionEvent +import android.widget.TextView +import com.swmansion.enriched.textinput.spans.EnrichedCheckboxListSpan import org.json.JSONObject fun jsonStringToStringMap(json: String): Map { @@ -19,3 +26,81 @@ fun jsonStringToStringMap(json: String): Map { return result } + +// Sets a touch listener on TextView which is responsible for detecting touches on checkbox icons +// We don't use ClickableSpan because it works fine only when LinkMovementMethod is set on TextView +// Which breaks text selection and other features +@SuppressLint("ClickableViewAccessibility") +fun TextView.setCheckboxClickListener() { + var isDownOnCheckbox = false + + setOnTouchListener { v, event -> + val tv = v as TextView + val layout = tv.layout ?: return@setOnTouchListener false + val spannable = tv.text as? Spanned ?: return@setOnTouchListener false + + // Get touch coordinates relative to the text content + val x = event.x.toInt() - tv.totalPaddingLeft + tv.scrollX + val y = event.y.toInt() - tv.totalPaddingTop + tv.scrollY + + // Identify the line and whether it's the first line of the span + val line = layout.getLineForVertical(y) + val lineStart = layout.getLineStart(line) + + // Find spans for specific line + val spans = spannable.getSpans(lineStart, lineStart, EnrichedCheckboxListSpan::class.java) + if (spans.isEmpty()) return@setOnTouchListener false + + // There should be only one span per line as we don't support nested lists + val span = spans[0] + val isFirstLine = spannable.getSpanStart(span) == lineStart + val marginWidth = span.getLeadingMargin(true) + + // Check if touch is on checkbox icon area (which is in the leading margin on the first line) + val isInHotZone = isFirstLine && x in 0..marginWidth + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (isInHotZone) { + isDownOnCheckbox = true + return@setOnTouchListener true + } + } + + MotionEvent.ACTION_UP -> { + if (isDownOnCheckbox && isInHotZone) { + val spannable = tv.text as? Spannable + if (spannable != null) { + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + val flags = spannable.getSpanFlags(span) + span.isChecked = !span.isChecked + + // Reapply span so changes are visible without need to redraw entire TextView + spannable.removeSpan(span) + spannable.setSpan(span, start, end, flags) + + // For focused input, ensure cursor is active for affected paragraph + if (tv.isFocused) { + val currentCursor = Selection.getSelectionEnd(spannable) + if (currentCursor < start || currentCursor > end) { + Selection.setSelection(spannable, end) + } + } + } + + isDownOnCheckbox = false + return@setOnTouchListener true + } + isDownOnCheckbox = false + } + + MotionEvent.ACTION_CANCEL -> { + isDownOnCheckbox = false + } + } + + // Let TextView handle other touches (e.g., for selection) + false + } +} diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 921272934..9e5969f2e 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -64,6 +64,7 @@ const DEFAULT_STYLES: StylesState = { link: DEFAULT_STYLE_STATE, image: DEFAULT_STYLE_STATE, mention: DEFAULT_STYLE_STATE, + checkboxList: DEFAULT_STYLE_STATE, }; const DEFAULT_LINK_STATE = { @@ -461,6 +462,12 @@ const htmlStyle: HtmlStyle = { marginLeft: 24, gapWidth: 16, }, + ulCheckbox: { + boxSize: 24, + gapWidth: 16, + marginLeft: 24, + boxColor: 'rgb(0, 26, 114)', + }, }; const styles = StyleSheet.create({ diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index 78335499b..9f46944d2 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -79,6 +79,10 @@ const STYLE_ITEMS = [ name: 'ordered-list', icon: 'list-ol', }, + { + name: 'checkbox-list', + icon: 'check-square-o', + }, ] as const; type Item = (typeof STYLE_ITEMS)[number]; @@ -147,6 +151,10 @@ export const Toolbar: FC = ({ case 'ordered-list': editorRef.current?.toggleOrderedList(); break; + case 'checkbox-list': + // Make checkbox checked by default + editorRef.current?.toggleCheckboxList(true); + break; case 'link': onOpenLinkModal(); break; @@ -197,6 +205,8 @@ export const Toolbar: FC = ({ return stylesState.image.isBlocking; case 'mention': return stylesState.mention.isBlocking; + case 'checkbox-list': + return stylesState.checkboxList.isBlocking; default: return false; } @@ -240,6 +250,8 @@ export const Toolbar: FC = ({ return stylesState.image.isActive; case 'mention': return stylesState.mention.isActive; + case 'checkbox-list': + return stylesState.checkboxList.isActive; default: return false; } diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 35e1c68aa..25f8a1705 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -251,6 +251,11 @@ interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + checkboxList: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; } ``` @@ -288,6 +293,7 @@ interface OnChangeStateDeprecatedEvent { isBlockQuote: boolean; isOrderedList: boolean; isUnorderedList: boolean; + isCheckboxList: boolean; isLink: boolean; isImage: boolean; isMention: boolean; @@ -697,6 +703,17 @@ toggleUnorderedList: () => void; Converts current selection into an unordered list. +### `.toggleCheckboxList()` + +```ts +toggleCheckboxList: (checked: boolean) => void; +``` + +Converts current selection into an unordered list with checkboxes as items. Each checkbox can be either checked or unchecked. +User can later toggle each checkbox individually by tapping on it. + +- `checked: boolean` - defines whether the checkboxes should be checked or unchecked by default. + ## HtmlStyle type Allows customizing HTML styles. @@ -759,6 +776,12 @@ interface HtmlStyle { marginLeft?: number; gapWidth?: number; }; + ulCheckbox?: { + boxColor?: ColorValue; + boxSize?: number; + marginLeft?: number; + gapWidth?: number; + }; } interface MentionStyleProperties { @@ -824,3 +847,12 @@ By bullet, we mean the dot that begins each line of the list. - `bulletSize` sets both the height and the width of the bullet, defaults to `8`. - `marginLeft` is the margin to the left of the bullet (between the bullet and input's left edge), defaults to `16`. - `gapWidth` sets the gap between the bullet and the list item's text, defaults to `16`. + +### ulCheckbox (checkbox list) + +Allows using unordered list with checkboxes instead of bullets. + +- `boxColor` defines the color of the checkbox, takes [color](https://reactnative.dev/docs/colors) value and defaults to `blue`. +- `boxSize` sets both the height and the width of the checkbox, defaults to `24`. +- `marginLeft` is the margin to the left of the checkbox (between the checkbox and input's left edge), defaults to `16`. +- `gapWidth` sets the gap between the checkbox and the list item's text, defaults to `16`. diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index e558fbc0a..f7ea72145 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -63,6 +63,7 @@ export interface EnrichedTextInputInstance extends NativeMethods { toggleBlockQuote: () => void; toggleOrderedList: () => void; toggleUnorderedList: () => void; + toggleCheckboxList: (checked: boolean) => void; setLink: (start: number, end: number, text: string, url: string) => void; setImage: (src: string, width: number, height: number) => void; startMention: (indicator: string) => void; @@ -122,6 +123,12 @@ export interface HtmlStyle { marginLeft?: number; gapWidth?: number; }; + ulCheckbox?: { + boxSize?: number; + gapWidth?: number; + marginLeft?: number; + boxColor?: ColorValue; + }; } export interface EnrichedTextInputProps extends Omit { @@ -331,6 +338,9 @@ export const EnrichedTextInput = ({ toggleUnorderedList: () => { Commands.toggleUnorderedList(nullthrows(nativeRef.current)); }, + toggleCheckboxList: (checked: boolean) => { + Commands.toggleCheckboxList(nullthrows(nativeRef.current), checked); + }, setLink: (start: number, end: number, text: string, url: string) => { Commands.addLink(nullthrows(nativeRef.current), start, end, text, url); }, diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index e9aee9364..79a8653d7 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -118,6 +118,11 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + checkboxList: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; } export interface OnChangeStateDeprecatedEvent { @@ -136,6 +141,7 @@ export interface OnChangeStateDeprecatedEvent { isBlockQuote: boolean; isOrderedList: boolean; isUnorderedList: boolean; + isCheckboxList: boolean; isLink: boolean; isImage: boolean; isMention: boolean; @@ -236,6 +242,12 @@ export interface HtmlStyleInternal { marginLeft?: Float; gapWidth?: Float; }; + ulCheckbox?: { + gapWidth?: Float; + boxSize?: Float; + marginLeft?: Float; + boxColor?: ColorValue; + }; } export interface NativeProps extends ViewProps { @@ -317,6 +329,10 @@ interface NativeCommands { toggleBlockQuote: (viewRef: React.ElementRef) => void; toggleOrderedList: (viewRef: React.ElementRef) => void; toggleUnorderedList: (viewRef: React.ElementRef) => void; + toggleCheckboxList: ( + viewRef: React.ElementRef, + checked: boolean + ) => void; addLink: ( viewRef: React.ElementRef, start: Int32, @@ -370,6 +386,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'toggleBlockQuote', 'toggleOrderedList', 'toggleUnorderedList', + 'toggleCheckboxList', 'addLink', 'addImage', 'startMention', diff --git a/src/utils/normalizeHtmlStyle.ts b/src/utils/normalizeHtmlStyle.ts index 4a35d4dc3..6c3b2d61c 100644 --- a/src/utils/normalizeHtmlStyle.ts +++ b/src/utils/normalizeHtmlStyle.ts @@ -66,6 +66,12 @@ const defaultStyle: Required = { marginLeft: 16, gapWidth: 16, }, + ulCheckbox: { + boxSize: 24, + gapWidth: 16, + marginLeft: 16, + boxColor: 'blue', + }, }; const isMentionStyleRecord = ( From 4e50c950a8f8363379c4ee39972501f8b3fb4319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Mon, 9 Mar 2026 13:58:02 +0100 Subject: [PATCH 09/10] fix: add submit edditing event to Dev and Test screens --- apps/example/src/hooks/useEditorState.ts | 6 ++++++ apps/example/src/screens/DevScreen.tsx | 3 +++ apps/example/src/screens/TestScreen.tsx | 3 +++ 3 files changed, 12 insertions(+) diff --git a/apps/example/src/hooks/useEditorState.ts b/apps/example/src/hooks/useEditorState.ts index 2791009cb..4327f4b60 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 7c9b9a5c5..7450fe8e3 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 4fb5b6077..bdda1d4d9 100644 --- a/apps/example/src/screens/TestScreen.tsx +++ b/apps/example/src/screens/TestScreen.tsx @@ -72,6 +72,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 } From 3c8eadd9bf97a8b5c449480a12f62087f37ba957 Mon Sep 17 00:00:00 2001 From: Piotr Karamon Date: Fri, 20 Mar 2026 16:00:02 +0100 Subject: [PATCH 10/10] refactor: destructure props explicitly --- src/EnrichedTextInput.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index 03316996b..3e8fc9f67 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -192,6 +192,9 @@ export const EnrichedTextInput = ({ onChangeSelection, onKeyPress, onSubmitEditing, + returnKeyType, + returnKeyLabel, + submitBehavior, contextMenuItems, androidExperimentalSynchronousEvents = false, useHtmlNormalizer = false, @@ -463,6 +466,9 @@ export const EnrichedTextInput = ({ contextMenuItems={nativeContextMenuItems} onContextMenuItemPress={handleContextMenuItemPress} onSubmitEditing={onSubmitEditing} + returnKeyType={returnKeyType} + returnKeyLabel={returnKeyLabel} + submitBehavior={submitBehavior} androidExperimentalSynchronousEvents={ androidExperimentalSynchronousEvents }