From 1ca2bae0540bdd6be382f0b7fad3a6778d483a3b Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 7 Aug 2026 10:05:17 +0200 Subject: [PATCH 01/13] feat(ios, android): maxLength --- .../textinput/EnrichedTextInputView.kt | 39 +++++- .../textinput/EnrichedTextInputViewManager.kt | 9 ++ .../textinput/styles/ParametrizedStyles.kt | 66 +++++++---- .../textinput/utils/EnrichedSpannable.kt | 20 ++++ .../textinput/utils/MaxLengthFilter.kt | 112 ++++++++++++++++++ docs/INPUT_API_REFERENCE.md | 13 ++ ios/EnrichedTextInputView.mm | 76 +++++++++++- ios/config/EnrichedConfig.h | 5 + ios/config/EnrichedConfig.mm | 11 ++ .../EnrichedInputTextView.mm | 10 ++ ios/inputHtmlParser/InputHtmlParser.mm | 104 ++++++++++++++-- ios/styles/MentionStyle.mm | 13 +- ios/utils/MaxLengthUtils.h | 29 +++++ ios/utils/MaxLengthUtils.mm | 75 ++++++++++++ src/native/EnrichedTextInput.tsx | 2 + src/spec/EnrichedTextInputNativeComponent.ts | 3 + src/types.ts | 15 +++ src/utils/EnrichedTextInputDefaultProps.ts | 2 + 18 files changed, 570 insertions(+), 34 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt create mode 100644 ios/utils/MaxLengthUtils.h create mode 100644 ios/utils/MaxLengthUtils.mm 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 023cc20fc..cf5ab4253 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -68,6 +68,8 @@ import com.swmansion.enriched.textinput.styles.ParametrizedStyles import com.swmansion.enriched.textinput.utils.EnrichedEditableFactory import com.swmansion.enriched.textinput.utils.EnrichedSelection import com.swmansion.enriched.textinput.utils.EnrichedSpanState +import com.swmansion.enriched.textinput.utils.MaxLength +import com.swmansion.enriched.textinput.utils.MaxLengthFilter import com.swmansion.enriched.textinput.utils.RichContentReceiver import com.swmansion.enriched.textinput.utils.ShortcutsHandler import com.swmansion.enriched.textinput.utils.mergeSpannables @@ -123,6 +125,9 @@ class EnrichedTextInputView : var spanWatcher: EnrichedSpanWatcher? = null var layoutManager: EnrichedTextInputViewLayoutManager = EnrichedTextInputViewLayoutManager(this) + // -1 means no limit, see MaxLengthFilter + var maxLength: Int = MaxLength.UNLIMITED + var shouldEmitHtml: Boolean = false var shouldEmitOnChangeText: Boolean = false var experimentalSynchronousEvents: Boolean = false @@ -232,6 +237,10 @@ class EnrichedTextInputView : setEditableFactory(EnrichedEditableFactory(spanWatcher)) addTextChangedListener(EnrichedTextWatcher(this)) + // a single filter covers every change made to the text - typing, dictation, + // IME composition, pasting and setting the value imperatively + filters = arrayOf(MaxLengthFilter(this)) + // Handle checkbox list item clicks this.setCheckboxClickListener() @@ -376,7 +385,7 @@ class EnrichedTextInputView : val end = selectionEnd.coerceAtLeast(0) val lengthBefore = currentText.length - val pastedSpannable: Spannable = + val pasted: Spannable = when { item.htmlText != null -> { val parsed = parseText(item.htmlText) @@ -392,6 +401,10 @@ class EnrichedTextInputView : } } + // the pasted fragment is what gets truncated - everything that follows the + // caret has to stay intact, so it can't be left to the maxLength filter + val pastedSpannable = truncateToRemainingLength(pasted, start, end) ?: return + val finalText = currentText.mergeSpannables(start, end, pastedSpannable, htmlStyle) setValue(finalText, false) @@ -405,6 +418,30 @@ class EnrichedTextInputView : parametrizedStyles?.afterTextChanged(editable, start.coerceAtMost(pasteEnd), pasteEnd) } + /** + * Shortens [pasted] so that it fits in what's left of [maxLength] once the `[start, end)` + * selection is replaced. Returns null when there's no room for it at all. + */ + private fun truncateToRemainingLength( + pasted: Spannable, + start: Int, + end: Int, + ): Spannable? { + if (maxLength == MaxLength.UNLIMITED) return pasted + + val currentText = text ?: return pasted + val keptLength = MaxLength.plainLengthOf(currentText) - MaxLength.plainLengthOf(currentText, start, end) + val capacity = maxLength - keptLength + + if (MaxLength.plainLengthOf(pasted) <= capacity) return pasted + + val cut = MaxLength.cutIndexIn(pasted, 0, pasted.length, capacity) + if (cut == 0) return if (start == end) null else SpannableString("") + + // subSequence keeps the spans, so the truncated fragment keeps its formatting + return pasted.subSequence(0, cut) as? Spannable ?: SpannableString(pasted.subSequence(0, cut)) + } + fun requestFocusProgrammatically() { requestFocus() inputMethodManager?.showSoftInput(this, 0) 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 9dcbb8244..b35d36786 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -31,6 +31,7 @@ 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.MaxLength import com.swmansion.enriched.textinput.utils.jsonStringToStringMap @ReactModule(name = EnrichedTextInputViewManager.NAME) @@ -160,6 +161,14 @@ class EnrichedTextInputViewManager : view?.isEnabled = editable } + @ReactProp(name = "maxLength", defaultInt = MaxLength.UNLIMITED) + override fun setMaxLength( + view: EnrichedTextInputView?, + maxLength: Int, + ) { + view?.maxLength = maxLength + } + @ReactProp(name = "mentionIndicators") override fun setMentionIndicators( view: EnrichedTextInputView?, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt index 387eec351..6fa482e07 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt @@ -13,6 +13,7 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputLinkSpan import com.swmansion.enriched.textinput.spans.EnrichedInputMentionSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries +import com.swmansion.enriched.textinput.utils.replaceCountingInserted import com.swmansion.enriched.textinput.utils.safelyRemoveZWS class ParametrizedStyles( @@ -57,16 +58,15 @@ class ParametrizedStyles( spannable.removeSpan(span) } - if (start == end) { - spannable.insert(start, text) - } else { - spannable.replace(start, end, text) - } + val insertedLength = spannable.replaceCountingInserted(start, end, text) - val spanEnd = start + text.length - val span = EnrichedInputLinkSpan(url, view.htmlStyle, true) - val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, spanEnd) - spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) + // maxLength may have shortened the text, the link covers only what really + // made it into the input then + if (insertedLength > 0) { + val span = EnrichedInputLinkSpan(url, view.htmlStyle, true) + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, start + insertedLength) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) + } view.selection?.validateStyles() isSettingLinkSpan = false @@ -343,18 +343,20 @@ class ParametrizedStyles( val spannable = view.text as SpannableStringBuilder val (start, originalEnd) = view.selection.getInlineSelection() - if (start == originalEnd) { - spannable.insert(start, EnrichedConstants.ORC_STRING) - } else { + if (start != originalEnd) { val spans = spannable.getSpans(start, originalEnd, EnrichedInputImageSpan::class.java) for (s in spans) { spannable.removeSpan(s) } - - spannable.replace(start, originalEnd, EnrichedConstants.ORC_STRING) } - val (imageStart, imageEnd) = spannable.getSafeSpanBoundaries(start, start + 1) + // an image takes a single character and can't be truncated, so it is simply + // not added when maxLength leaves no room for it + val insertedLength = + spannable.replaceCountingInserted(start, originalEnd, EnrichedConstants.ORC_STRING) + if (insertedLength == 0) return + + val (imageStart, imageEnd) = spannable.getSafeSpanBoundaries(start, start + insertedLength) val span = EnrichedInputImageSpan.createEnrichedImageSpan(src, width.toInt(), height.toInt()) span.observeAsyncDrawableLoaded(view.text) @@ -393,12 +395,17 @@ class ParametrizedStyles( val end = mentionEnd ?: selectionEnd view.runAsATransaction { - spannable.replace(start, end, text) + val insertedLength = spannable.replaceCountingInserted(start, end, text) + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, start + insertedLength) - val span = EnrichedInputMentionSpan(text, indicator, attributes, view.htmlStyle) - val spanEnd = start + text.length - val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, spanEnd) - spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) + if (insertedLength > 0) { + val span = EnrichedInputMentionSpan(text, indicator, attributes, view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) + + // a mention that got shortened by maxLength isn't the text it was created + // with anymore, so it keeps the text but stops being a mention + removeStaleMentionSpans(spannable, safeStart, safeEnd) + } val hasSpaceAtTheEnd = spannable.length > safeEnd && spannable[safeEnd] == ' ' if (!hasSpaceAtTheEnd) { @@ -413,6 +420,25 @@ class ParametrizedStyles( mentionEnd = null } + /** + * Drops the mention spans in `[start, end)` that no longer hold the text they were created with - + * the same staleness rule that ends an edited mention during detection. + */ + private fun removeStaleMentionSpans( + spannable: Spannable, + start: Int, + end: Int, + ) { + for (span in spannable.getSpans(start, end, EnrichedInputMentionSpan::class.java)) { + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + + if (spannable.subSequence(spanStart, spanEnd).toString() != span.getText()) { + spannable.removeSpan(span) + } + } + } + fun getStyleRange(): Pair = view.selection?.getInlineSelection() ?: Pair(0, 0) fun removeStyle( diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt index 576e10218..0e7e1c3ab 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt @@ -20,6 +20,26 @@ fun Spannable.getSafeSpanBoundaries( return Pair(safeStart, safeEnd) } +/** + * Replaces `[start, end)` with [text] and returns how many characters actually landed in the + * buffer - the `maxLength` filter may have shortened, or entirely rejected, the replacement. + */ +fun SpannableStringBuilder.replaceCountingInserted( + start: Int, + end: Int, + text: CharSequence, +): Int { + val lengthBefore = length + + if (start == end) { + insert(start, text) + } else { + replace(start, end, text) + } + + return length - lengthBefore + (end - start) +} + fun Spannable.getParagraphBounds( start: Int, end: Int, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt new file mode 100644 index 000000000..12afea10b --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt @@ -0,0 +1,112 @@ +package com.swmansion.enriched.textinput.utils + +import android.text.InputFilter +import android.text.Spanned +import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.textinput.EnrichedTextInputView +import java.text.BreakIterator + +/** + * Helpers enforcing the `maxLength` prop. + * + * The limit is counted in the editor's plain text, so the zero width spaces that are internally + * used as style anchors don't take up any of it. + */ +object MaxLength { + const val UNLIMITED = -1 + + /** Length of [text] as seen by the user (zero width spaces excluded). */ + fun plainLengthOf( + text: CharSequence, + start: Int = 0, + end: Int = text.length, + ): Int { + var length = 0 + for (i in start until end) { + if (text[i] != EnrichedConstants.ZWS) length++ + } + return length + } + + /** + * Index in `[start, end]` at which [text] has to be cut so that at most [capacity] plain + * characters are kept. The cut point snaps outwards to a whole grapheme cluster, so emoji, + * surrogate pairs and combining marks never end up split in half. + */ + fun cutIndexIn( + text: CharSequence, + start: Int, + end: Int, + capacity: Int, + ): Int { + var index = start + var kept = 0 + + while (index < end) { + if (text[index] != EnrichedConstants.ZWS) { + // zero width spaces are free, any other character needs the capacity + if (kept >= capacity) break + kept++ + } + index++ + } + + return snapOutwards(text, start, end, index) + } + + private fun snapOutwards( + text: CharSequence, + start: Int, + end: Int, + cut: Int, + ): Int { + if (cut <= start || cut >= end) return cut + + val iterator = BreakIterator.getCharacterInstance() + iterator.setText(text.subSequence(start, end).toString()) + + val localCut = cut - start + if (iterator.isBoundary(localCut)) return cut + + val next = iterator.following(localCut) + return if (next == BreakIterator.DONE) end else start + next + } +} + +/** + * Applies the `maxLength` limit to every change made to the editor's text - typing, dictation, IME + * composition, pasting and setting the value imperatively all go through the filters of the + * underlying `Editable`. + * + * Text that doesn't fit is truncated rather than rejected, so a change is dropped entirely only + * when there's no capacity left at all (e.g. typing a character at the limit). + */ +class MaxLengthFilter( + private val view: EnrichedTextInputView, +) : InputFilter { + override fun filter( + source: CharSequence, + start: Int, + end: Int, + dest: Spanned, + dstart: Int, + dend: Int, + ): CharSequence? { + val maxLength = view.maxLength + if (maxLength == MaxLength.UNLIMITED) return null + + val keptLength = MaxLength.plainLengthOf(dest) - MaxLength.plainLengthOf(dest, dstart, dend) + val capacity = maxLength - keptLength + + if (MaxLength.plainLengthOf(source, start, end) <= capacity) { + // null keeps the original change + return null + } + + val cut = MaxLength.cutIndexIn(source, start, end, capacity) + + // subSequence keeps the spans of the incoming text, so pasted content + // doesn't lose its formatting when it gets truncated + return if (cut <= start) "" else source.subSequence(start, cut) + } +} diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index 12d1341b3..46378d568 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -124,6 +124,19 @@ Keep in mind that not all JS regex features are supported, for example variable- > [!TIP] > With this approach you can also disable link detection completely by providing a `null` value as the prop. +### `maxLength` + +Maximum number of characters the input's plain text may contain. The zero-width spaces that are internally used as style anchors are not counted, so the limit always matches what the user actually sees. + +Text that doesn't fit is truncated instead of being rejected, no matter how it got into the input - typing, dictation, IME composition, pasting or setting the value with `defaultValue` and `ref.setValue()`. Pasting a too long fragment shortens the fragment itself and keeps whatever followed the caret intact, and the cut point never splits an emoji, a surrogate pair or a combining mark in half. Truncated text goes through the regular editing path, so its formatting is kept and link detection, mentions, `onChangeText` and `onChangeHtml` all work with what really ended up in the input. + +| Type | Default Value | Platform | +| -------- | ------------- | ------------ | +| `number` | no limit | iOS, Android | + +> [!NOTE] +> Programmatic insertions respect the limit too - `ref.setLink()` shortens the link's text, `ref.setMention()` inserts as much of the mention's text as fits but stops being a mention when it doesn't fit whole, and `ref.setImage()` is skipped when there's no room left for it. Lowering the limit doesn't shorten the text that's already in the input. + ### `onBlur` Callback that's called whenever the input loses focus (is blurred). diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index a899aa629..60059302e 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -7,6 +7,7 @@ #import "ImageAttachment.h" #import "KeyboardUtils.h" #import "LayoutManagerExtension.h" +#import "MaxLengthUtils.h" #import "ParagraphAttributesUtils.h" #import "RCTFabricComponentsPlugins.h" #import "ShortcutsUtils.h" @@ -688,6 +689,11 @@ - (void)updateProps:(Props::Shared const &)props textView.editable = newViewProps.editable; } + // maxLength + if (newViewProps.maxLength != oldViewProps.maxLength) { + [config setMaxLength:newViewProps.maxLength]; + } + // useHtmlNormalizer if (newViewProps.useHtmlNormalizer != oldViewProps.useHtmlNormalizer) { useHtmlNormalizer = newViewProps.useHtmlNormalizer; @@ -738,7 +744,9 @@ - (void)updateProps:(Props::Shared const &)props [parser initiallyProcessHtml:newDefaultValue]; if (initiallyProcessedHtml == nullptr) { // just plain text - textView.text = newDefaultValue; + textView.text = [MaxLengthUtils + truncate:newDefaultValue + toCapacity:[MaxLengthUtils wholeContentCapacityForHost:self]]; } else { // we've got some seemingly proper html [parser replaceWholeFromHtml:initiallyProcessedHtml]; @@ -1324,7 +1332,9 @@ - (void)setValue:(NSString *)value { textView.text = @""; textView.typingAttributes = defaultTypingAttributes; // set new text - textView.text = value; + textView.text = [MaxLengthUtils + truncate:value + toCapacity:[MaxLengthUtils wholeContentCapacityForHost:self]]; } else { // we've got some seemingly proper html [parser replaceWholeFromHtml:initiallyProcessedHtml]; @@ -1530,11 +1540,22 @@ - (void)addLinkAt:(NSInteger)start // translate the output start-end notation to range NSRange linkRange = NSMakeRange(start, end - start); + + // the link text is truncated when it doesn't fit in maxLength, the link + // itself is still applied to whatever made it into the input + NSString *linkText = + [MaxLengthUtils truncate:text + toCapacity:[MaxLengthUtils capacityForHost:self + replacingRange:linkRange]]; + if (linkText.length == 0) { + return; + } + if ([StyleUtils handleStyleBlocksAndConflicts:[LinkStyle getType] range:linkRange forHost:self]) { LinkData *linkData = [[LinkData alloc] init]; - linkData.text = text; + linkData.text = linkText; linkData.url = url; linkData.isManual = YES; [linkStyleClass addLink:linkData range:linkRange withSelection:YES]; @@ -1593,6 +1614,13 @@ - (void)addImage:(NSString *)uri width:(float)width height:(float)height { return; } + // an image takes a single character and can't be truncated, so it is simply + // not added when maxLength leaves no room for it + if ([MaxLengthUtils capacityForHost:self + replacingRange:textView.selectedRange] < 1) { + return; + } + if ([StyleUtils handleStyleBlocksAndConflicts:[ImageStyle getType] range:textView.selectedRange forHost:self]) { @@ -2000,6 +2028,48 @@ - (bool)textView:(UITextView *)textView return NO; } + // maxLength has to be checked as the very last thing - the handlers above + // manage the text on their own and none of them makes it any longer + if ([self handleMaxLengthInRange:range replacementText:text]) { + return NO; + } + + return YES; +} + +/** + * Shortens `text` when it doesn't fit in the remaining `maxLength` capacity. + * Multi character changes (paste, dictation, IME) get truncated and inserted + * through the regular insertion path, single characters that don't fit at all + * are simply rejected. + * + * Returns YES when the change has been handled here and must not be applied + * by the text view itself. + */ +- (BOOL)handleMaxLengthInRange:(NSRange)range replacementText:(NSString *)text { + NSInteger capacity = [MaxLengthUtils capacityForHost:self + replacingRange:range]; + if ([MaxLengthUtils plainLengthOf:text] <= capacity) { + return NO; + } + + NSString *truncated = [MaxLengthUtils truncate:text toCapacity:capacity]; + if (truncated.length == 0) { + return YES; + } + + range.length > 0 ? [TextInsertionUtils replaceText:truncated + at:range + additionalAttributes:nullptr + host:self + withSelection:YES] + : [TextInsertionUtils insertText:truncated + at:range.location + additionalAttributes:nullptr + host:self + withSelection:YES]; + + [self anyTextMayHaveBeenModified]; return YES; } diff --git a/ios/config/EnrichedConfig.h b/ios/config/EnrichedConfig.h index d39248baf..01f7cc00d 100644 --- a/ios/config/EnrichedConfig.h +++ b/ios/config/EnrichedConfig.h @@ -4,6 +4,9 @@ #import "TextDecorationLineEnum.h" #import +/// Value of the `maxLength` prop meaning "no limit". +static const NSInteger EnrichedMaxLengthUnlimited = -1; + @interface EnrichedConfig : NSObject - (instancetype)init; @@ -104,6 +107,8 @@ - (UIImage *)checkboxUncheckedImage; // MARK: - Input only props +- (NSInteger)maxLength; +- (void)setMaxLength:(NSInteger)newValue; - (LinkRegexConfig *)linkRegexConfig; - (void)setLinkRegexConfig:(LinkRegexConfig *)newValue; - (NSRegularExpression *)parsedLinkRegex; diff --git a/ios/config/EnrichedConfig.mm b/ios/config/EnrichedConfig.mm index 05d0e675e..bd9afcfbb 100644 --- a/ios/config/EnrichedConfig.mm +++ b/ios/config/EnrichedConfig.mm @@ -58,6 +58,7 @@ @implementation EnrichedConfig { // input only LinkRegexConfig *_linkRegexConfig; NSRegularExpression *_parsedLinkRegex; + NSInteger _maxLength; // text only UIColor *_linkPressColor; @@ -68,6 +69,7 @@ - (instancetype)init { _primaryFontNeedsRecreation = YES; _monospacedFontNeedsRecreation = YES; _olMarkerFontNeedsRecreation = YES; + _maxLength = EnrichedMaxLengthUnlimited; return self; } @@ -126,6 +128,7 @@ - (id)copyWithZone:(NSZone *)zone { // input only copy->_linkRegexConfig = [_linkRegexConfig copy]; copy->_parsedLinkRegex = [_parsedLinkRegex copy]; + copy->_maxLength = _maxLength; // text only copy->_linkPressColor = [_linkPressColor copy]; @@ -678,6 +681,14 @@ - (UIImage *)generateCheckboxImage:(BOOL)isChecked { // MARK: - Input only props +- (NSInteger)maxLength { + return _maxLength; +} + +- (void)setMaxLength:(NSInteger)newValue { + _maxLength = newValue < 0 ? EnrichedMaxLengthUnlimited : newValue; +} + - (LinkRegexConfig *)linkRegexConfig { return _linkRegexConfig; } diff --git a/ios/enrichedInputTextView/EnrichedInputTextView.mm b/ios/enrichedInputTextView/EnrichedInputTextView.mm index 4e8dd50ba..f5ea15ce4 100644 --- a/ios/enrichedInputTextView/EnrichedInputTextView.mm +++ b/ios/enrichedInputTextView/EnrichedInputTextView.mm @@ -2,6 +2,7 @@ #import "AlignmentUtils.h" #import "EnrichedTextInputView.h" #import "HtmlParser.h" +#import "MaxLengthUtils.h" #import "StringExtension.h" #import "TextInsertionUtils.h" #import "TextListsUtils.h" @@ -292,6 +293,15 @@ - (void)tryHandlingPlainTextItemsIn:(UIPasteboard *)pasteboard return; } + // a pasted fragment that doesn't fit gets truncated, not rejected + plainText = [MaxLengthUtils truncate:plainText + toCapacity:[MaxLengthUtils capacityForHost:input + replacingRange:range]]; + + if (plainText.length == 0 && range.length == 0) { + return; + } + range.length > 0 ? [TextInsertionUtils replaceText:plainText at:range additionalAttributes:nullptr diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index 05c331b75..cf5580c3a 100644 --- a/ios/inputHtmlParser/InputHtmlParser.mm +++ b/ios/inputHtmlParser/InputHtmlParser.mm @@ -2,6 +2,7 @@ #import "AlignmentEntry.h" #import "EnrichedTextInputView.h" #import "HtmlParser.h" +#import "MaxLengthUtils.h" #import "StringExtension.h" #import "StyleHeaders.h" #import "StyleUtils.h" @@ -23,9 +24,13 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { _input->textView.text = @""; _input->textView.typingAttributes = _input->defaultTypingAttributes; + NSInteger capacity = [MaxLengthUtils wholeContentCapacityForHost:_input]; + @try { - NSArray *processingResult = - [HtmlParser getTextAndStylesFromHtml:html config:_input.config]; + NSArray *parsed = [HtmlParser getTextAndStylesFromHtml:html + config:_input.config]; + NSArray *processingResult = [self truncateProcessingResult:parsed + toCapacity:capacity]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; NSArray *alignments = (NSArray *)processingResult[2]; @@ -45,14 +50,19 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { exception.reason); // set new text - _input->textView.text = html; + _input->textView.text = [MaxLengthUtils truncate:html toCapacity:capacity]; } } - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { + NSInteger capacity = [MaxLengthUtils capacityForHost:_input + replacingRange:range]; + @try { - NSArray *processingResult = - [HtmlParser getTextAndStylesFromHtml:html config:_input.config]; + NSArray *parsed = [HtmlParser getTextAndStylesFromHtml:html + config:_input.config]; + NSArray *processingResult = [self truncateProcessingResult:parsed + toCapacity:capacity]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; NSArray *alignments = (NSArray *)processingResult[2]; @@ -73,7 +83,8 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { RCTLogWarn(@"[EnrichedTextInput]: Failed to parse HTML: (%@), falling back " @"to raw input.", exception.reason); - [TextInsertionUtils replaceText:html + [TextInsertionUtils replaceText:[MaxLengthUtils truncate:html + toCapacity:capacity] at:range additionalAttributes:nil host:_input @@ -82,9 +93,15 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { } - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { + NSInteger capacity = + [MaxLengthUtils capacityForHost:_input + replacingRange:NSMakeRange(location, 0)]; + @try { - NSArray *processingResult = - [HtmlParser getTextAndStylesFromHtml:html config:_input.config]; + NSArray *parsed = [HtmlParser getTextAndStylesFromHtml:html + config:_input.config]; + NSArray *processingResult = [self truncateProcessingResult:parsed + toCapacity:capacity]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; NSArray *alignments = (NSArray *)processingResult[2]; @@ -105,7 +122,8 @@ - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { RCTLogWarn(@"[EnrichedTextInput]: Failed to parse HTML: (%@), falling back " @"to raw input.", exception.reason); - [TextInsertionUtils insertText:html + [TextInsertionUtils insertText:[MaxLengthUtils truncate:html + toCapacity:capacity] at:location additionalAttributes:nil host:_input @@ -220,6 +238,74 @@ - (void)applyProcessedAlignments:(NSArray *)alignments } } +/** + * Shortens the parsed html so that it fits in `capacity` plain characters, + * dropping and clamping the parsed styles and alignments accordingly. + */ +- (NSArray *)truncateProcessingResult:(NSArray *)processingResult + toCapacity:(NSInteger)capacity { + NSString *plainText = (NSString *)processingResult[0]; + + if ([MaxLengthUtils plainLengthOf:plainText] <= capacity) { + return processingResult; + } + + NSUInteger cut = [MaxLengthUtils cutIndexIn:plainText capacity:capacity]; + NSMutableArray *styles = [NSMutableArray new]; + NSMutableArray *alignments = [NSMutableArray new]; + + for (NSArray *styleInfo in (NSArray *)processingResult[1]) { + StylePair *stylePair = (StylePair *)styleInfo[1]; + NSRange range = [stylePair.rangeValue rangeValue]; + + if (range.location >= cut) { + continue; + } + + StylePair *clampedPair = [[StylePair alloc] init]; + clampedPair.rangeValue = [NSValue + valueWithRange:NSMakeRange(range.location, + MIN(range.length, cut - range.location))]; + clampedPair.styleValue = [self clampStyleValue:stylePair.styleValue + toLength:cut]; + [styles addObject:@[ styleInfo[0], clampedPair ]]; + } + + for (AlignmentEntry *entry in ( + NSArray *)processingResult[2]) { + if (entry.range.location >= cut) { + continue; + } + + AlignmentEntry *clampedEntry = [[AlignmentEntry alloc] init]; + clampedEntry.range = + NSMakeRange(entry.range.location, + MIN(entry.range.length, cut - entry.range.location)); + clampedEntry.alignment = entry.alignment; + [alignments addObject:clampedEntry]; + } + + return @[ [plainText substringToIndex:cut], styles, alignments ]; +} + +// checkbox lists carry a { position: isChecked } dictionary which has to lose +// the entries pointing behind the truncated text +- (id)clampStyleValue:(id)styleValue toLength:(NSUInteger)length { + if (![styleValue isKindOfClass:[NSDictionary class]]) { + return styleValue; + } + + NSDictionary *dictionary = (NSDictionary *)styleValue; + NSMutableDictionary *clamped = [NSMutableDictionary new]; + for (NSNumber *key in dictionary) { + if ([key unsignedIntegerValue] < length) { + clamped[key] = dictionary[key]; + } + } + + return clamped; +} + - (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html { return [HtmlParser initiallyProcessHtml:html useHtmlNormalizer:_input->useHtmlNormalizer]; diff --git a/ios/styles/MentionStyle.mm b/ios/styles/MentionStyle.mm index e852cb4e3..23175f3a2 100644 --- a/ios/styles/MentionStyle.mm +++ b/ios/styles/MentionStyle.mm @@ -1,6 +1,7 @@ #import "AttributeEntry.h" #import "ColorExtension.h" #import "EnrichedTextInputView.h" +#import "MaxLengthUtils.h" #import "StyleHeaders.h" #import "TextInsertionUtils.h" #import "UIView+React.h" @@ -176,6 +177,15 @@ - (void)addMention:(NSString *)indicator NSString *newText = hasSpaceAfter ? text : [NSString stringWithFormat:@"%@ ", text]; + // a mention that doesn't fit in maxLength inserts as much of its text as it + // can. It stops being a mention then - the meta is still applied below with + // the original text, so the staleness check in handleExistingMentions (ran + // by anyTextMayHaveBeenModified right after) strips the styling for us + newText = [MaxLengthUtils + truncate:newText + toCapacity:[MaxLengthUtils capacityForHost:self.host + replacingRange:rangeToBeReplaced]]; + [TextInsertionUtils replaceText:newText at:rangeToBeReplaced additionalAttributes:nullptr @@ -191,7 +201,8 @@ - (void)addMention:(NSString *)indicator } // THEN, add the attributes to not apply them on the space - NSRange mentionRange = NSMakeRange(rangeToBeReplaced.location, text.length); + NSRange mentionRange = + NSMakeRange(rangeToBeReplaced.location, MIN(text.length, newText.length)); [self applyMentionMeta:params range:mentionRange]; [self.host.attributesManager addDirtyRange:mentionRange]; // mention editing should finish diff --git a/ios/utils/MaxLengthUtils.h b/ios/utils/MaxLengthUtils.h new file mode 100644 index 000000000..1682e7709 --- /dev/null +++ b/ios/utils/MaxLengthUtils.h @@ -0,0 +1,29 @@ +#pragma once +#import "EnrichedViewHost.h" +#import + +/** + * Helpers enforcing the `maxLength` prop. + * + * The limit is counted in the editor's plain text, so the zero width spaces + * that are internally used as style anchors don't take up any of it. + * Text that doesn't fit gets truncated instead of being rejected and the cut + * point always snaps outwards to a whole composed character, so emoji, + * surrogate pairs and combining marks never end up split in half. + */ +@interface MaxLengthUtils : NSObject +/// Length of `text` as seen by the user (zero width spaces excluded). ++ (NSInteger)plainLengthOf:(NSString *_Nonnull)text; +/// How many plain characters may still be inserted by a change replacing +/// `range`. Returns `NSIntegerMax` when no limit is set. ++ (NSInteger)capacityForHost:(id _Nullable)host + replacingRange:(NSRange)range; +/// Capacity of a change replacing the whole content of the editor. ++ (NSInteger)wholeContentCapacityForHost:(id _Nullable)host; +/// Index `text` has to be cut at so that at most `capacity` plain characters +/// are kept. ++ (NSUInteger)cutIndexIn:(NSString *_Nonnull)text capacity:(NSInteger)capacity; +/// `text` shortened so that it fits in `capacity`. ++ (NSString *_Nonnull)truncate:(NSString *_Nonnull)text + toCapacity:(NSInteger)capacity; +@end diff --git a/ios/utils/MaxLengthUtils.mm b/ios/utils/MaxLengthUtils.mm new file mode 100644 index 000000000..f486e12da --- /dev/null +++ b/ios/utils/MaxLengthUtils.mm @@ -0,0 +1,75 @@ +#import "MaxLengthUtils.h" + +static const unichar kZeroWidthSpace = 0x200B; + +@implementation MaxLengthUtils + ++ (NSInteger)plainLengthOf:(NSString *)text { + return [self plainLengthOf:text inRange:NSMakeRange(0, text.length)]; +} + ++ (NSInteger)plainLengthOf:(NSString *)text inRange:(NSRange)range { + NSInteger length = 0; + for (NSUInteger i = range.location; i < NSMaxRange(range); i++) { + if ([text characterAtIndex:i] != kZeroWidthSpace) { + length++; + } + } + return length; +} + ++ (NSInteger)capacityForHost:(id)host + replacingRange:(NSRange)range { + if (host == nullptr || host.config.maxLength == EnrichedMaxLengthUnlimited) { + return NSIntegerMax; + } + + NSInteger maxLength = host.config.maxLength; + NSString *text = host.textView.textStorage.string; + NSRange safeRange = NSIntersectionRange(range, NSMakeRange(0, text.length)); + NSInteger keptLength = + [self plainLengthOf:text] - [self plainLengthOf:text inRange:safeRange]; + + return maxLength - keptLength; +} + ++ (NSInteger)wholeContentCapacityForHost:(id)host { + if (host == nullptr || host.config.maxLength == EnrichedMaxLengthUnlimited) { + return NSIntegerMax; + } + return host.config.maxLength; +} + ++ (NSUInteger)cutIndexIn:(NSString *)text capacity:(NSInteger)capacity { + NSUInteger index = 0; + NSInteger kept = 0; + + while (index < text.length) { + if ([text characterAtIndex:index] != kZeroWidthSpace) { + // zero width spaces are free, any other character needs the capacity + if (kept >= capacity) { + break; + } + kept++; + } + index++; + } + + if (index == 0 || index == text.length) { + return index; + } + + // never cut a composed character (emoji, surrogate pair, combining mark) + // in half - snap the cut point outwards instead + NSRange composed = [text rangeOfComposedCharacterSequenceAtIndex:index]; + return composed.location == index ? index : NSMaxRange(composed); +} + ++ (NSString *)truncate:(NSString *)text toCapacity:(NSInteger)capacity { + if ([self plainLengthOf:text] <= capacity) { + return text; + } + return [text substringToIndex:[self cutIndexIn:text capacity:capacity]]; +} + +@end diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index feffab4cc..55cb6d7bc 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -52,6 +52,7 @@ export const EnrichedTextInput = ({ editable = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.editable, mentionIndicators = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.mentionIndicators.slice(), defaultValue, + maxLength = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.maxLength, placeholder, placeholderTextColor, cursorColor, @@ -334,6 +335,7 @@ export const EnrichedTextInput = ({ editable={editable} autoFocus={autoFocus} defaultValue={defaultValue} + maxLength={maxLength} placeholder={placeholder} placeholderTextColor={placeholderTextColor} cursorColor={cursorColor} diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 20e9d12b4..092eab6a7 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -5,6 +5,7 @@ import type { Float, Int32, UnsafeMixed, + WithDefault, } from 'react-native/Libraries/Types/CodegenTypes'; import type { ColorValue, HostComponent, ViewProps } from 'react-native'; import React from 'react'; @@ -377,6 +378,8 @@ export interface NativeProps extends ViewProps { returnKeyLabel?: string; submitBehavior?: string; allowFontScaling?: boolean; + // -1 means no limit + maxLength?: WithDefault; // event callbacks onInputFocus?: DirectEventHandler; diff --git a/src/types.ts b/src/types.ts index d4d0dfd8b..46d31ee75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -636,6 +636,21 @@ export interface EnrichedTextInputProps extends Omit { */ defaultValue?: string; + /** + * Maximum number of characters the editor's plain text may contain. + * Zero-width spaces used internally as layout anchors are not counted. + * + * Text that doesn't fit (typed, dictated, pasted or set with + * `ref.setValue()`) is truncated instead of being rejected, and the cut + * never splits an emoji, a surrogate pair or a combining mark. + * + * Programmatic insertions respect the limit too - `ref.setLink()` shortens + * the link's text, `ref.setMention()` inserts as much of the mention's text + * as fits but stops being a mention when it doesn't fit whole, and + * `ref.setImage()` is skipped when there's no room left for it. + */ + maxLength?: number; + /** Placeholder text shown when the editor is empty. */ placeholder?: string; diff --git a/src/utils/EnrichedTextInputDefaultProps.ts b/src/utils/EnrichedTextInputDefaultProps.ts index 0cf2b3170..aebc4177f 100644 --- a/src/utils/EnrichedTextInputDefaultProps.ts +++ b/src/utils/EnrichedTextInputDefaultProps.ts @@ -3,6 +3,8 @@ import type { TextShortcut } from '../types'; export const ENRICHED_TEXT_INPUT_DEFAULT_PROPS = { mentionIndicators: ['@'], editable: true, + // -1 means no limit + maxLength: -1, htmlStyle: {}, autoCapitalize: 'sentences', scrollEnabled: true, From 19e869533ee20aba4d6fd4b82f2f2f3d6fbd2328 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 7 Aug 2026 10:13:53 +0200 Subject: [PATCH 02/13] docs: tweak docs maxlength content --- docs/INPUT_API_REFERENCE.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index 46378d568..530349940 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -126,16 +126,16 @@ Keep in mind that not all JS regex features are supported, for example variable- ### `maxLength` -Maximum number of characters the input's plain text may contain. The zero-width spaces that are internally used as style anchors are not counted, so the limit always matches what the user actually sees. +Maximum number of characters the input's plain text may contain. -Text that doesn't fit is truncated instead of being rejected, no matter how it got into the input - typing, dictation, IME composition, pasting or setting the value with `defaultValue` and `ref.setValue()`. Pasting a too long fragment shortens the fragment itself and keeps whatever followed the caret intact, and the cut point never splits an emoji, a surrogate pair or a combining mark in half. Truncated text goes through the regular editing path, so its formatting is kept and link detection, mentions, `onChangeText` and `onChangeHtml` all work with what really ended up in the input. +Text that doesn't fit is truncated instead of being rejected, no matter how it got into the input - typing, dictation, IME composition, pasting or setting the value with `defaultValue` and `ref.setValue()`. -| Type | Default Value | Platform | -| -------- | ------------- | ------------ | -| `number` | no limit | iOS, Android | +| Type | Default Value | Platform | +| -------- | ------------- | ----------------- | +| `number` | no limit | iOS, Android, Web | > [!NOTE] -> Programmatic insertions respect the limit too - `ref.setLink()` shortens the link's text, `ref.setMention()` inserts as much of the mention's text as fits but stops being a mention when it doesn't fit whole, and `ref.setImage()` is skipped when there's no room left for it. Lowering the limit doesn't shorten the text that's already in the input. +> Programmatic insertions respect the limit too - e.g. `ref.setLink()`, `ref.setMention()`, `ref.setImage()`. ### `onBlur` From e88636c8e09b62e36234b4433590fb2c966cf3da Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 7 Aug 2026 10:38:08 +0200 Subject: [PATCH 03/13] refactor: simplify the default value flow --- src/native/EnrichedTextInput.tsx | 2 +- src/spec/EnrichedTextInputNativeComponent.ts | 1 - src/types.ts | 11 +---------- src/utils/EnrichedTextInputDefaultProps.ts | 2 -- 4 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index 55cb6d7bc..52cc20b6b 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -52,7 +52,7 @@ export const EnrichedTextInput = ({ editable = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.editable, mentionIndicators = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.mentionIndicators.slice(), defaultValue, - maxLength = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.maxLength, + maxLength, placeholder, placeholderTextColor, cursorColor, diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 092eab6a7..9c213e91f 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -378,7 +378,6 @@ export interface NativeProps extends ViewProps { returnKeyLabel?: string; submitBehavior?: string; allowFontScaling?: boolean; - // -1 means no limit maxLength?: WithDefault; // event callbacks diff --git a/src/types.ts b/src/types.ts index 46d31ee75..ca2801165 100644 --- a/src/types.ts +++ b/src/types.ts @@ -638,16 +638,7 @@ export interface EnrichedTextInputProps extends Omit { /** * Maximum number of characters the editor's plain text may contain. - * Zero-width spaces used internally as layout anchors are not counted. - * - * Text that doesn't fit (typed, dictated, pasted or set with - * `ref.setValue()`) is truncated instead of being rejected, and the cut - * never splits an emoji, a surrogate pair or a combining mark. - * - * Programmatic insertions respect the limit too - `ref.setLink()` shortens - * the link's text, `ref.setMention()` inserts as much of the mention's text - * as fits but stops being a mention when it doesn't fit whole, and - * `ref.setImage()` is skipped when there's no room left for it. + * If inserted content doesn't meet this limit, it gets truncated. */ maxLength?: number; diff --git a/src/utils/EnrichedTextInputDefaultProps.ts b/src/utils/EnrichedTextInputDefaultProps.ts index aebc4177f..0cf2b3170 100644 --- a/src/utils/EnrichedTextInputDefaultProps.ts +++ b/src/utils/EnrichedTextInputDefaultProps.ts @@ -3,8 +3,6 @@ import type { TextShortcut } from '../types'; export const ENRICHED_TEXT_INPUT_DEFAULT_PROPS = { mentionIndicators: ['@'], editable: true, - // -1 means no limit - maxLength: -1, htmlStyle: {}, autoCapitalize: 'sentences', scrollEnabled: true, From f93c3c98c7da04ec05ecdb253b66a0df92865768 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 7 Aug 2026 12:14:30 +0200 Subject: [PATCH 04/13] refactor(ios): comment clean up --- ios/EnrichedTextInputView.mm | 3 --- .../EnrichedInputTextView.mm | 2 +- ios/styles/MentionStyle.mm | 4 +--- ios/utils/MaxLengthUtils.h | 16 ---------------- ios/utils/MaxLengthUtils.mm | 4 ++-- 5 files changed, 4 insertions(+), 25 deletions(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 60059302e..246242406 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -2039,9 +2039,6 @@ - (bool)textView:(UITextView *)textView /** * Shortens `text` when it doesn't fit in the remaining `maxLength` capacity. - * Multi character changes (paste, dictation, IME) get truncated and inserted - * through the regular insertion path, single characters that don't fit at all - * are simply rejected. * * Returns YES when the change has been handled here and must not be applied * by the text view itself. diff --git a/ios/enrichedInputTextView/EnrichedInputTextView.mm b/ios/enrichedInputTextView/EnrichedInputTextView.mm index f5ea15ce4..395b45ea5 100644 --- a/ios/enrichedInputTextView/EnrichedInputTextView.mm +++ b/ios/enrichedInputTextView/EnrichedInputTextView.mm @@ -293,7 +293,7 @@ - (void)tryHandlingPlainTextItemsIn:(UIPasteboard *)pasteboard return; } - // a pasted fragment that doesn't fit gets truncated, not rejected + // a pasted fragment that doesn't fit gets truncated plainText = [MaxLengthUtils truncate:plainText toCapacity:[MaxLengthUtils capacityForHost:input replacingRange:range]]; diff --git a/ios/styles/MentionStyle.mm b/ios/styles/MentionStyle.mm index 23175f3a2..dbf4934d1 100644 --- a/ios/styles/MentionStyle.mm +++ b/ios/styles/MentionStyle.mm @@ -178,9 +178,7 @@ - (void)addMention:(NSString *)indicator hasSpaceAfter ? text : [NSString stringWithFormat:@"%@ ", text]; // a mention that doesn't fit in maxLength inserts as much of its text as it - // can. It stops being a mention then - the meta is still applied below with - // the original text, so the staleness check in handleExistingMentions (ran - // by anyTextMayHaveBeenModified right after) strips the styling for us + // can newText = [MaxLengthUtils truncate:newText toCapacity:[MaxLengthUtils capacityForHost:self.host diff --git a/ios/utils/MaxLengthUtils.h b/ios/utils/MaxLengthUtils.h index 1682e7709..aa4031886 100644 --- a/ios/utils/MaxLengthUtils.h +++ b/ios/utils/MaxLengthUtils.h @@ -2,28 +2,12 @@ #import "EnrichedViewHost.h" #import -/** - * Helpers enforcing the `maxLength` prop. - * - * The limit is counted in the editor's plain text, so the zero width spaces - * that are internally used as style anchors don't take up any of it. - * Text that doesn't fit gets truncated instead of being rejected and the cut - * point always snaps outwards to a whole composed character, so emoji, - * surrogate pairs and combining marks never end up split in half. - */ @interface MaxLengthUtils : NSObject -/// Length of `text` as seen by the user (zero width spaces excluded). + (NSInteger)plainLengthOf:(NSString *_Nonnull)text; -/// How many plain characters may still be inserted by a change replacing -/// `range`. Returns `NSIntegerMax` when no limit is set. + (NSInteger)capacityForHost:(id _Nullable)host replacingRange:(NSRange)range; -/// Capacity of a change replacing the whole content of the editor. + (NSInteger)wholeContentCapacityForHost:(id _Nullable)host; -/// Index `text` has to be cut at so that at most `capacity` plain characters -/// are kept. + (NSUInteger)cutIndexIn:(NSString *_Nonnull)text capacity:(NSInteger)capacity; -/// `text` shortened so that it fits in `capacity`. + (NSString *_Nonnull)truncate:(NSString *_Nonnull)text toCapacity:(NSInteger)capacity; @end diff --git a/ios/utils/MaxLengthUtils.mm b/ios/utils/MaxLengthUtils.mm index f486e12da..e552c38da 100644 --- a/ios/utils/MaxLengthUtils.mm +++ b/ios/utils/MaxLengthUtils.mm @@ -59,8 +59,8 @@ + (NSUInteger)cutIndexIn:(NSString *)text capacity:(NSInteger)capacity { return index; } - // never cut a composed character (emoji, surrogate pair, combining mark) - // in half - snap the cut point outwards instead + // never cut a composed character in half - snap + // the cut point outwards instead NSRange composed = [text rangeOfComposedCharacterSequenceAtIndex:index]; return composed.location == index ? index : NSMaxRange(composed); } From e678e79aaa7e97c680334dc8f4c0c53cce436932 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 7 Aug 2026 14:03:43 +0200 Subject: [PATCH 05/13] refactor(android): stale mention check logic refinement --- .../textinput/EnrichedTextInputView.kt | 8 +++--- .../textinput/styles/ParametrizedStyles.kt | 25 +------------------ .../textinput/utils/MaxLengthFilter.kt | 8 ------ 3 files changed, 4 insertions(+), 37 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 cf5ab4253..b331bc404 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -125,7 +125,6 @@ class EnrichedTextInputView : var spanWatcher: EnrichedSpanWatcher? = null var layoutManager: EnrichedTextInputViewLayoutManager = EnrichedTextInputViewLayoutManager(this) - // -1 means no limit, see MaxLengthFilter var maxLength: Int = MaxLength.UNLIMITED var shouldEmitHtml: Boolean = false @@ -385,7 +384,7 @@ class EnrichedTextInputView : val end = selectionEnd.coerceAtLeast(0) val lengthBefore = currentText.length - val pasted: Spannable = + val pastedSpannable: Spannable = when { item.htmlText != null -> { val parsed = parseText(item.htmlText) @@ -403,9 +402,9 @@ class EnrichedTextInputView : // the pasted fragment is what gets truncated - everything that follows the // caret has to stay intact, so it can't be left to the maxLength filter - val pastedSpannable = truncateToRemainingLength(pasted, start, end) ?: return + val truncatedPastedSpannable = truncateToRemainingLength(pastedSpannable, start, end) ?: return - val finalText = currentText.mergeSpannables(start, end, pastedSpannable, htmlStyle) + val finalText = currentText.mergeSpannables(start, end, truncatedPastedSpannable, htmlStyle) setValue(finalText, false) // replacement-safe: oldLength - removed + inserted @@ -438,7 +437,6 @@ class EnrichedTextInputView : val cut = MaxLength.cutIndexIn(pasted, 0, pasted.length, capacity) if (cut == 0) return if (start == end) null else SpannableString("") - // subSequence keeps the spans, so the truncated fragment keeps its formatting return pasted.subSequence(0, cut) as? Spannable ?: SpannableString(pasted.subSequence(0, cut)) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt index 6fa482e07..67bc01bd1 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt @@ -398,13 +398,9 @@ class ParametrizedStyles( val insertedLength = spannable.replaceCountingInserted(start, end, text) val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, start + insertedLength) - if (insertedLength > 0) { + if (insertedLength == text.length) { val span = EnrichedInputMentionSpan(text, indicator, attributes, view.htmlStyle) spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) - - // a mention that got shortened by maxLength isn't the text it was created - // with anymore, so it keeps the text but stops being a mention - removeStaleMentionSpans(spannable, safeStart, safeEnd) } val hasSpaceAtTheEnd = spannable.length > safeEnd && spannable[safeEnd] == ' ' @@ -420,25 +416,6 @@ class ParametrizedStyles( mentionEnd = null } - /** - * Drops the mention spans in `[start, end)` that no longer hold the text they were created with - - * the same staleness rule that ends an edited mention during detection. - */ - private fun removeStaleMentionSpans( - spannable: Spannable, - start: Int, - end: Int, - ) { - for (span in spannable.getSpans(start, end, EnrichedInputMentionSpan::class.java)) { - val spanStart = spannable.getSpanStart(span) - val spanEnd = spannable.getSpanEnd(span) - - if (spannable.subSequence(spanStart, spanEnd).toString() != span.getText()) { - spannable.removeSpan(span) - } - } - } - fun getStyleRange(): Pair = view.selection?.getInlineSelection() ?: Pair(0, 0) fun removeStyle( diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt index 12afea10b..35298eb2a 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt @@ -8,9 +8,6 @@ import java.text.BreakIterator /** * Helpers enforcing the `maxLength` prop. - * - * The limit is counted in the editor's plain text, so the zero width spaces that are internally - * used as style anchors don't take up any of it. */ object MaxLength { const val UNLIMITED = -1 @@ -77,9 +74,6 @@ object MaxLength { * Applies the `maxLength` limit to every change made to the editor's text - typing, dictation, IME * composition, pasting and setting the value imperatively all go through the filters of the * underlying `Editable`. - * - * Text that doesn't fit is truncated rather than rejected, so a change is dropped entirely only - * when there's no capacity left at all (e.g. typing a character at the limit). */ class MaxLengthFilter( private val view: EnrichedTextInputView, @@ -105,8 +99,6 @@ class MaxLengthFilter( val cut = MaxLength.cutIndexIn(source, start, end, capacity) - // subSequence keeps the spans of the incoming text, so pasted content - // doesn't lose its formatting when it gets truncated return if (cut <= start) "" else source.subSequence(start, cut) } } From a8e488e62cdeca074e99c48bb0b332f1f12b801d Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 7 Aug 2026 15:13:58 +0200 Subject: [PATCH 06/13] fix(ios, android): startMention respects maxLength --- .../enriched/textinput/styles/ParametrizedStyles.kt | 6 +----- ios/styles/MentionStyle.mm | 6 ++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt index 67bc01bd1..774557750 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt @@ -369,11 +369,7 @@ class ParametrizedStyles( val spannable = view.text as SpannableStringBuilder val (start, end) = selection.getInlineSelection() - if (start == end) { - spannable.insert(start, indicator) - } else { - spannable.replace(start, end, indicator) - } + spannable.replaceCountingInserted(start, end, indicator) } fun setMentionSpan( diff --git a/ios/styles/MentionStyle.mm b/ios/styles/MentionStyle.mm index dbf4934d1..0b0d14cac 100644 --- a/ios/styles/MentionStyle.mm +++ b/ios/styles/MentionStyle.mm @@ -247,6 +247,12 @@ - (void)startMentionWithIndicator:(NSString *)indicator { [NSString stringWithFormat:@"%@%@%@", addSpaceBefore ? @" " : @"", indicator, addSpaceAfter ? @" " : @""]; + // check if an indicator can be inserted with the maxLength constraint + if ([MaxLengthUtils plainLengthOf:finalString] > + [MaxLengthUtils capacityForHost:self.host replacingRange:currentRange]) { + return; + } + NSRange newSelect = NSMakeRange( currentRange.location + finalString.length + (addSpaceAfter ? -1 : 0), 0); From a374952213a86ed574ddbbf436cdb653d4297443 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 7 Aug 2026 16:36:01 +0200 Subject: [PATCH 07/13] feat(web): maxLength --- src/web/EnrichedTextInput.tsx | 14 +- src/web/pmPlugins/MaxLengthPlugin.ts | 189 +++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/web/pmPlugins/MaxLengthPlugin.ts diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 692dec9e7..a233dde0b 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -76,6 +76,7 @@ import { useMentionEvents, } from './pmPlugins/MentionPlugin'; import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin'; +import { MaxLengthPlugin } from './pmPlugins/MaxLengthPlugin'; import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin'; import { TextShortcutsPlugin } from './pmPlugins/TextShortcutsPlugin'; import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint'; @@ -129,6 +130,7 @@ export const EnrichedTextInput = ({ useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer, sanitizationConfig, textShortcuts = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.textShortcuts, + maxLength, }: EnrichedTextInputProps) => { assertBrowserEnvironment('EnrichedTextInput'); @@ -165,6 +167,7 @@ export const EnrichedTextInput = ({ const sanitizationConfigRef = useStableRef(sanitizationConfig); const mentionCallbacksRef = useStableRef(mentionCallbacks); const textShortcutsRef = useStableRef(textShortcuts); + const maxLengthRef = useStableRef(maxLength); const editorInstanceRef = useRef(null); @@ -231,6 +234,9 @@ export const EnrichedTextInput = ({ MergeAdjacentSameKindBlocksPlugin, OrderedListMarkerWidthPlugin, StrictMarksPlugin, + MaxLengthPlugin.configure({ + getMaxLength: () => maxLengthRef.current, + }), MentionPlugin.configure({ getIndicators: () => mentionIndicatorsRef.current, }), @@ -249,7 +255,13 @@ export const EnrichedTextInput = ({ showOnlyWhenEditable: true, }), ], - [placeholder, htmlStyleRef, mentionIndicatorsRef, textShortcutsRef] + [ + placeholder, + htmlStyleRef, + mentionIndicatorsRef, + textShortcutsRef, + maxLengthRef, + ] ); const editor = useEditor( diff --git a/src/web/pmPlugins/MaxLengthPlugin.ts b/src/web/pmPlugins/MaxLengthPlugin.ts new file mode 100644 index 000000000..606270853 --- /dev/null +++ b/src/web/pmPlugins/MaxLengthPlugin.ts @@ -0,0 +1,189 @@ +import { Extension } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'; +import { Mapping } from '@tiptap/pm/transform'; +import { nativeLeafText } from '../positionMapping'; + +interface MaxLengthPluginOptions { + getMaxLength: () => number | undefined; +} + +const ZERO_WIDTH_SPACE = '\u200B'; + +function plainLength(text: string): number { + return text.replaceAll(ZERO_WIDTH_SPACE, '').length; +} + +function docPlainLength(doc: PMNode): number { + return plainLength(nativeLeafText(doc, 0, doc.content.size)); +} + +/** + * Ranges newly inserted by `transactions`, expressed in the coordinates of + * the final document. Used to know what to trim so truncation only eats into + * the content that was just typed/pasted/inserted, leaving everything else + * (in particular, anything that followed the caret) untouched. + */ +function getInsertedRanges( + transactions: readonly Transaction[] +): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + + transactions.forEach((tr, trIndex) => { + const maps = tr.mapping.maps; + const restOfBatch = new Mapping(); + for (let i = trIndex + 1; i < transactions.length; i++) { + restOfBatch.appendMapping(transactions[i]!.mapping); + } + + maps.forEach((stepMap, stepIndex) => { + stepMap.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + if (newEnd <= newStart) return; + let from = newStart; + let to = newEnd; + for (let i = stepIndex + 1; i < maps.length; i++) { + from = maps[i]!.map(from, -1); + to = maps[i]!.map(to, 1); + } + from = restOfBatch.map(from, -1); + to = restOfBatch.map(to, 1); + if (to > from) ranges.push([from, to]); + }); + }); + }); + + return ranges; +} + +/** + * Finds the position within [from, to) where the plain-text length + * of [from, cut) is `keep`. Snaps outward inside composed characters, + * so e.g. emojis, surrogate pairs and are never split. + */ +function findCutPosition( + doc: PMNode, + from: number, + to: number, + keep: number +): number { + if (keep <= 0) return from; + + const segmenter = + typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null; + + let units = 0; + let cut = to; + let done = false; + let visitedBlock = false; + + doc.nodesBetween(from, to, (node, pos) => { + if (done) return false; + + if (node.isBlock) { + if (visitedBlock) { + // Implicit '\n' separator `nativeLeafText` inserts between blocks. + if (units >= keep) { + cut = pos; + done = true; + return false; + } + units += 1; + } + visitedBlock = true; + return true; + } + + if (!node.isLeaf) return true; + + const nodeFrom = Math.max(from, pos); + const nodeTo = Math.min(to, pos + node.nodeSize); + + if (!node.isText) { + // Atomic leaf (e.g. image): one unit, cannot be partially kept. + if (units >= keep) { + cut = pos; + done = true; + return false; + } + units += 1; + return false; + } + + const text = node.text ?? ''; + const slice = text.slice(nodeFrom - pos, nodeTo - pos); + const segments = segmenter + ? Array.from(segmenter.segment(slice), (s) => s.segment) + : Array.from(slice); + + let offset = 0; + for (const segment of segments) { + const segmentUnits = segment === ZERO_WIDTH_SPACE ? 0 : segment.length; + if (units + segmentUnits > keep) { + cut = nodeFrom + offset; + done = true; + return false; + } + units += segmentUnits; + offset += segment.length; + } + + return false; + }); + + return cut; +} + +export const MaxLengthPlugin = Extension.create({ + name: 'maxLength', + + addOptions() { + return { getMaxLength: () => undefined }; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('maxLength'), + appendTransaction: (transactions, _oldState, newState) => { + const maxLength = this.options.getMaxLength(); + if (maxLength == null) return null; + if (!transactions.some((tr) => tr.docChanged)) return null; + + let overflow = docPlainLength(newState.doc) - maxLength; + if (overflow <= 0) return null; + + const insertedRanges = getInsertedRanges(transactions); + const tr = newState.tr; + + for (let i = insertedRanges.length - 1; i >= 0 && overflow > 0; i--) { + const [rangeFrom, rangeTo] = insertedRanges[i]!; + const from = tr.mapping.map(rangeFrom, -1); + const to = tr.mapping.map(rangeTo, 1); + if (to <= from) continue; + + const rangeLength = plainLength(nativeLeafText(tr.doc, from, to)); + const removable = Math.min(overflow, rangeLength); + if (removable <= 0) continue; + + const cut = findCutPosition( + tr.doc, + from, + to, + rangeLength - removable + ); + if (cut >= to) continue; + + tr.delete(cut, to); + overflow -= removable; + } + + if (!tr.docChanged) return null; + tr.setMeta('addToHistory', false); + return tr; + }, + }), + ]; + }, +}); From 45bce5b8a2426b2f720a4f34e2face61305d14e8 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 7 Aug 2026 19:12:28 +0200 Subject: [PATCH 08/13] feat(web): e2e maxLength tests --- .playwright/tests/maxLength.spec.ts | 286 ++++++++++++++++++ apps/example-web/src/RouteSelector.tsx | 5 + .../src/testScreens/TestMaxLength.tsx | 254 ++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 .playwright/tests/maxLength.spec.ts create mode 100644 apps/example-web/src/testScreens/TestMaxLength.tsx diff --git a/.playwright/tests/maxLength.spec.ts b/.playwright/tests/maxLength.spec.ts new file mode 100644 index 000000000..f9eab4b54 --- /dev/null +++ b/.playwright/tests/maxLength.spec.ts @@ -0,0 +1,286 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { copySelectionFrom } from '../helpers/clipboard'; + +async function pasteAtCurrentSelection(page: Page): Promise { + await page.keyboard.press('ControlOrMeta+V'); +} + +test.setTimeout(90_000); + +const sel = { + editorInner: '[data-testid="test-max-length-editor"] .eti-editor', + maxLengthInput: '[data-testid="test-max-length-maxlength-input"]', + htmlInput: '[data-testid="test-max-length-html-input"]', + setValueButton: '[data-testid="test-max-length-set-value-button"]', + htmlOutput: '[data-testid="test-max-length-html-output"]', + selectionStart: '[data-testid="test-max-length-selection-start"]', + selectionEnd: '[data-testid="test-max-length-selection-end"]', + applySelection: '[data-testid="test-max-length-apply-selection-button"]', + setLinkStart: '[data-testid="test-max-length-setlink-start"]', + setLinkEnd: '[data-testid="test-max-length-setlink-end"]', + setLinkText: '[data-testid="test-max-length-setlink-text"]', + setLinkUrl: '[data-testid="test-max-length-setlink-url"]', + applySetLink: '[data-testid="test-max-length-apply-setlink-button"]', + mentionIndicator: '[data-testid="test-max-length-mention-indicator"]', + mentionText: '[data-testid="test-max-length-mention-text"]', + setMentionButton: '[data-testid="test-max-length-set-mention-button"]', + startMentionButton: '[data-testid="test-max-length-start-mention-button"]', + imageSrc: '[data-testid="test-max-length-image-src"]', + imageWidth: '[data-testid="test-max-length-image-width"]', + imageHeight: '[data-testid="test-max-length-image-height"]', + setImageButton: '[data-testid="test-max-length-set-image-button"]', +} as const; + +async function gotoTestMaxLength(page: Page): Promise { + await page.goto('/test-max-length'); + await page.waitForSelector(sel.editorInner); + + const routePattern = '**/pw-e2e-ok.png'; + const pngBody = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' + ); + await page.route(routePattern, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/png', + body: pngBody, + }); + }); +} + +async function getHtmlOutput(page: Page): Promise { + return (await page.locator(sel.htmlOutput).textContent()) ?? ''; +} + +async function setValue(page: Page, html: string): Promise { + await page.fill(sel.htmlInput, html); + await page.click(sel.setValueButton); + await expect + .poll(async () => { + const t = await getHtmlOutput(page); + return t.startsWith(''); + }) + .toBe(true); +} + +async function setSelection( + page: Page, + start: number, + end: number +): Promise { + await page.fill(sel.selectionStart, String(start)); + await page.fill(sel.selectionEnd, String(end)); + await page.click(sel.applySelection); + // Clicking the button moves DOM focus away from the editor even though the + // ProseMirror selection itself is preserved; refocus so keyboard input lands. + await page.locator(`${sel.editorInner} .ProseMirror`).focus(); +} + +function firstParagraph(page: Page) { + return page.locator(sel.editorInner).locator('p').first(); +} + +async function focusEditor(page: Page) { + const editor = page.locator(sel.editorInner); + await firstParagraph(page).click(); + await expect(editor.locator('.ProseMirror')).toBeFocused(); + return editor; +} + +test.describe('test-max-length typing', () => { + test('typing stops accepting characters once maxLength is reached', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '

'); + + await focusEditor(page); + await page.keyboard.type('12345678901234', { delay: 20 }); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('

1234567890

'); + }); + + test('typing under maxLength is unaffected', async ({ page }) => { + await gotoTestMaxLength(page); + await setValue(page, '

'); + + await focusEditor(page); + await page.keyboard.type('12345', { delay: 20 }); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('

12345

'); + }); +}); + +test.describe('test-max-length pasting', () => { + test.use({ permissions: ['clipboard-read', 'clipboard-write'] }); + + test('pasting content that fits exactly is not truncated', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '

1234567

'); + + await copySelectionFrom(firstParagraph(page)); + + await setValue(page, '

'); + await focusEditor(page); + await page.keyboard.type('123', { delay: 20 }); + await pasteAtCurrentSelection(page); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('

1231234567

'); + }); + + test('pasting into non-empty content truncates the pasted portion, keeping earlier text', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '

1234567

'); + + await copySelectionFrom(firstParagraph(page)); + + await setValue(page, '

abcde

'); + await firstParagraph(page).click(); + await page.keyboard.press('End'); + await pasteAtCurrentSelection(page); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('

abcde12345

'); + }); +}); + +test.describe('test-max-length mentions', () => { + test('mention that fits entirely keeps its mark', async ({ page }) => { + await gotoTestMaxLength(page); + await setValue(page, '

123

'); + await setSelection(page, 4, 4); + await page.keyboard.type(' '); + await page.click(sel.startMentionButton); + + await page.fill(sel.mentionText, '@John'); + await page.click(sel.setMentionButton); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('@John'); + }); + + test('mention overflowing maxLength is truncated and loses its mark', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '

1234

'); + await setSelection(page, 4, 4); + await page.keyboard.type(' '); + await page.click(sel.startMentionButton); + + await page.fill(sel.mentionText, '@Jonathan'); + await page.click(sel.setMentionButton); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('

1234 @Jona

'); + await expect + .poll(async () => getHtmlOutput(page)) + .not.toContain(' { + test('manual link that fits entirely keeps its mark', async ({ page }) => { + await gotoTestMaxLength(page); + await setValue(page, '

123456

'); + + await page.fill(sel.setLinkStart, '6'); + await page.fill(sel.setLinkEnd, '6'); + await page.fill(sel.setLinkText, 'ab'); + await page.fill(sel.setLinkUrl, 'https://example.com'); + await page.click(sel.applySetLink); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('

123456ab

'); + }); + + test('manual link overflowing maxLength is truncated but keeps link styling', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '

123456

'); + + await page.fill(sel.setLinkStart, '6'); + await page.fill(sel.setLinkEnd, '6'); + await page.fill(sel.setLinkText, 'abcdefgh'); + await page.fill(sel.setLinkUrl, 'https://example.com'); + await page.click(sel.applySetLink); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('abcd'); + await expect.poll(async () => getHtmlOutput(page)).not.toContain('efgh'); + }); +}); + +test.describe('test-max-length images', () => { + test('image that fits within maxLength is inserted', async ({ page }) => { + await gotoTestMaxLength(page); + await setValue(page, '

123456789

'); + + await page.fill(sel.imageSrc, '/pw-e2e-ok.png'); + await page.fill(sel.imageWidth, '40'); + await page.fill(sel.imageHeight, '40'); + await page.click(sel.setImageButton); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain(' { + await gotoTestMaxLength(page); + await setValue(page, '

1234567890

'); + + await page.fill(sel.imageSrc, '/pw-e2e-ok.png'); + await page.fill(sel.imageWidth, '40'); + await page.fill(sel.imageHeight, '40'); + await page.click(sel.setImageButton); + + await page.waitForTimeout(200); + await expect.poll(async () => getHtmlOutput(page)).not.toContain(' getHtmlOutput(page)) + .toContain('

1234567890

'); + }); +}); + +test.describe('test-max-length replacing a selection with longer content', () => { + test('pasting over a selection that would grow the doc past maxLength truncates the paste', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '

overflowing-clip-source

'); + + await copySelectionFrom(firstParagraph(page)); + + await setValue(page, '

123xxx789

'); + // Select the "xxx" in the middle (positions 3..6). + await setSelection(page, 3, 6); + + await pasteAtCurrentSelection(page); + + await expect.poll(async () => getHtmlOutput(page)).toContain('

123'); + const html = await getHtmlOutput(page); + expect(html).toContain('789

'); + expect(html).not.toContain('overflowing-clip-source'); + }); +}); diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index e40322d84..61f26ebea 100644 --- a/apps/example-web/src/RouteSelector.tsx +++ b/apps/example-web/src/RouteSelector.tsx @@ -6,6 +6,7 @@ import { VisualRegression } from './testScreens/VisualRegression'; import { TestSubmitProps } from './testScreens/TestSubmitProps'; import { TestEnrichedText } from './testScreens/TestEnrichedText'; import { TestEllipsize } from './testScreens/TestEllipsize'; +import { TestMaxLength } from './testScreens/TestMaxLength'; import { useEffect, useState } from 'react'; export default function RouteSelector() { @@ -50,5 +51,9 @@ export default function RouteSelector() { return ; } + if (path === '/test-max-length') { + return ; + } + return ; } diff --git a/apps/example-web/src/testScreens/TestMaxLength.tsx b/apps/example-web/src/testScreens/TestMaxLength.tsx new file mode 100644 index 000000000..12362e126 --- /dev/null +++ b/apps/example-web/src/testScreens/TestMaxLength.tsx @@ -0,0 +1,254 @@ +import { useRef, useState, type ChangeEvent } from 'react'; +import { + EnrichedTextInput, + type EnrichedInputStyle, + type EnrichedTextInputInstance, +} from 'react-native-enriched-html'; +import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; + +function toInteger(value: string): number { + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function TestMaxLength() { + const ref = useRef(null); + const [maxLengthInput, setMaxLengthInput] = useState('10'); + const [htmlInput, setHtmlInput] = useState('

'); + const [editorHtml, setEditorHtml] = useState(''); + const [selStartInput, setSelStartInput] = useState('0'); + const [selEndInput, setSelEndInput] = useState('0'); + const [linkStartInput, setLinkStartInput] = useState('0'); + const [linkEndInput, setLinkEndInput] = useState('0'); + const [linkTextInput, setLinkTextInput] = useState('link'); + const [linkUrlInput, setLinkUrlInput] = useState('https://example.com'); + const [mentionIndicatorInput, setMentionIndicatorInput] = useState('@'); + const [mentionTextInput, setMentionTextInput] = useState('Jane'); + const [imageSrcInput, setImageSrcInput] = useState('/pw-e2e-ok.png'); + const [imageWidthInput, setImageWidthInput] = useState('40'); + const [imageHeightInput, setImageHeightInput] = useState('40'); + + const maxLength = + maxLengthInput.trim() === '' ? undefined : toInteger(maxLengthInput); + + return ( +
+
ref.current?.focus()} + > + { + setEditorHtml(e.nativeEvent.value); + }} + /> +
+ +
+ +
+ +