From bd31ae534e057085a9fdaee1c3c7016773250272 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Wed, 3 Jun 2026 16:19:22 +0200 Subject: [PATCH] fix: handle typing in non editable paragraphs --- .../NonEditableParagraphFilter.kt | 21 +++++ .../enriched/watchers/EnrichedTextWatcher.kt | 33 ++++++++ ios/EnrichedTextInputView.mm | 17 +++- ios/utils/Text/ParagraphsUtils.h | 2 + ios/utils/Text/ParagraphsUtils.mm | 14 ++++ ios/utils/Text/TextInsertionUtils.h | 6 ++ ios/utils/Text/TextInsertionUtils.mm | 77 +++++++++++++++++++ 7 files changed, 169 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/swmansion/enriched/inputFilters/NonEditableParagraphFilter.kt b/android/src/main/java/com/swmansion/enriched/inputFilters/NonEditableParagraphFilter.kt index def45f89..626c822e 100644 --- a/android/src/main/java/com/swmansion/enriched/inputFilters/NonEditableParagraphFilter.kt +++ b/android/src/main/java/com/swmansion/enriched/inputFilters/NonEditableParagraphFilter.kt @@ -41,6 +41,13 @@ class NonEditableParagraphFilter : InputFilter { return "" } + if (dstart == dend) { + val spanBeforeCursor = getNonEditableSpanBeforeCursor(dest, dstart) + if (spanBeforeCursor != null && dest.getSpanEnd(spanBeforeCursor) == dstart) { + return Strings.NEWLINE_STRING + source + } + } + // Block insert BEFORE non-editable block if (dstart > 0) { val before = @@ -69,4 +76,18 @@ class NonEditableParagraphFilter : InputFilter { return null } + + private fun getNonEditableSpanBeforeCursor( + dest: Spanned, + cursorPosition: Int, + ): EnrichedNonEditableParagraphSpan? { + if (cursorPosition <= 0) return null + + return dest + .getSpans( + cursorPosition - 1, + cursorPosition, + EnrichedNonEditableParagraphSpan::class.java, + ).firstOrNull() + } } diff --git a/android/src/main/java/com/swmansion/enriched/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/watchers/EnrichedTextWatcher.kt index ed322229..78e85f6e 100644 --- a/android/src/main/java/com/swmansion/enriched/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/watchers/EnrichedTextWatcher.kt @@ -6,6 +6,7 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.enriched.EnrichedTextInputView import com.swmansion.enriched.events.OnChangeTextEvent +import com.swmansion.enriched.spans.interfaces.EnrichedNonEditableParagraphSpan import com.swmansion.enriched.utils.InlineSpanPreserver import com.swmansion.enriched.utils.ParagraphSpanNormalizer import com.swmansion.enriched.utils.ZWSNormalizer @@ -17,6 +18,7 @@ class EnrichedTextWatcher( private var previousTextLength: Int = 0 private var startCursorPosition: Int = 0 private var prevText: String? = view.text?.toString() ?: "" + private var nonEditableParagraphToRemove: EnrichedNonEditableParagraphSpan? = null private val inlineSpanPreserver = InlineSpanPreserver() @@ -28,6 +30,7 @@ class EnrichedTextWatcher( ) { previousTextLength = s?.length ?: 0 startCursorPosition = start + nonEditableParagraphToRemove = getNonEditableParagraphBeforeDeletedRange(s, start, count, after) inlineSpanPreserver.beforeTextChanged( text = s, @@ -62,6 +65,7 @@ class EnrichedTextWatcher( view.transactionManager.runWithIgnoredSpanWatcher { inlineSpanPreserver.afterTextChanged() if (!view.isDuringTransaction) { + removePendingNonEditableParagraph(s) applyStyles(s) } } @@ -78,6 +82,35 @@ class EnrichedTextWatcher( ZWSNormalizer.normalizeNonEmptyParagraphs(s) } + private fun getNonEditableParagraphBeforeDeletedRange( + text: CharSequence?, + start: Int, + count: Int, + after: Int, + ): EnrichedNonEditableParagraphSpan? { + if (text !is Editable || count != 1 || after != 0 || start <= 0) return null + + return text + .getSpans( + start - 1, + start, + EnrichedNonEditableParagraphSpan::class.java, + ).firstOrNull { + text.getSpanStart(it) < start && text.getSpanEnd(it) >= start + } + } + + private fun removePendingNonEditableParagraph(text: Editable) { + val span = nonEditableParagraphToRemove ?: return + nonEditableParagraphToRemove = null + + val start = text.getSpanStart(span).coerceIn(0, text.length) + val end = text.getSpanEnd(span).coerceIn(start, text.length) + if (start < end) { + text.delete(start, end) + } + } + private fun emitChangeText(text: String?) { if (!view.shouldEmitOnChangeText) { return diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index f8540005..23d22da1 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -31,6 +31,7 @@ #import "Strings.h" #import "StyleHeaders.h" #import "TextBlockTapGestureRecognizer.h" +#import "TextInsertionUtils.h" #import "UIScrollViewKeyboardDismissMode+Parsing.h" #import "UIView+React.h" #import "WordsUtils.h" @@ -1320,8 +1321,22 @@ - (bool)textView:(UITextView *)textView if (!isNewLine && [ParagraphsUtils isReadOnlyParagraphAtLocation:textView.textStorage location:range.location]) { - if (text.length == 0) + if (text.length == 0) { + if ([TextInsertionUtils tryDeleteReadOnlyParagraphBeforeRange:range + input:self]) { + [self anyTextMayHaveBeenModified]; + return NO; + } return YES; + } + + if ([TextInsertionUtils tryInsertText:text + afterReadOnlyParagraphInRange:range + input:self + paragraphsLimit:_paragraphsLimit]) { + [self anyTextMayHaveBeenModified]; + } + return NO; } recentlyChangedRange = NSMakeRange(range.location, text.length); diff --git a/ios/utils/Text/ParagraphsUtils.h b/ios/utils/Text/ParagraphsUtils.h index 17e815cf..ef50d848 100644 --- a/ios/utils/Text/ParagraphsUtils.h +++ b/ios/utils/Text/ParagraphsUtils.h @@ -11,6 +11,8 @@ + (NSArray *)getNonNewlineRangesIn:(UITextView *)textView range:(NSRange)range; + (BOOL)isReadOnlyParagraphAtLocation:(NSAttributedString *)attributedString location:(NSUInteger)location; ++ (BOOL)isAtEndOfReadOnlyParagraph:(NSAttributedString *)attributedString + location:(NSUInteger)location; + (NSInteger)paragraphsCountInTextView:(UITextView *)textView; + (BOOL)isReplacingNewlineInRange:(NSRange)range text:(NSString *)text diff --git a/ios/utils/Text/ParagraphsUtils.mm b/ios/utils/Text/ParagraphsUtils.mm index 1c7ff1e2..7dfbe136 100644 --- a/ios/utils/Text/ParagraphsUtils.mm +++ b/ios/utils/Text/ParagraphsUtils.mm @@ -80,6 +80,20 @@ + (BOOL)isReadOnlyParagraphAtLocation:(NSAttributedString *)attributedString return NO; } ++ (BOOL)isAtEndOfReadOnlyParagraph:(NSAttributedString *)attributedString + location:(NSUInteger)location { + if (location == 0 || location > attributedString.length) { + return NO; + } + + NSRange effectiveRange = NSMakeRange(0, 0); + id readOnly = [attributedString attribute:ReadOnlyParagraphKey + atIndex:location - 1 + effectiveRange:&effectiveRange]; + + return readOnly != nil && NSMaxRange(effectiveRange) == location; +} + + (NSArray *)separateParagraphRangesInString:(NSString *)string range:(NSRange)range { NSRange fullRange = [string paragraphRangeForRange:range]; diff --git a/ios/utils/Text/TextInsertionUtils.h b/ios/utils/Text/TextInsertionUtils.h index 1f14ed80..f9090e4d 100644 --- a/ios/utils/Text/TextInsertionUtils.h +++ b/ios/utils/Text/TextInsertionUtils.h @@ -15,6 +15,12 @@ input:(id)input withSelection:(BOOL)withSelection; ; ++ (BOOL)tryInsertText:(NSString *)text + afterReadOnlyParagraphInRange:(NSRange)range + input:(id)input + paragraphsLimit:(NSInteger)paragraphsLimit; ++ (BOOL)tryDeleteReadOnlyParagraphBeforeRange:(NSRange)range input:(id)input; + + (void)insertEscapingParagraphsAtIndex:(NSUInteger)index text:(NSString *)text attributes: diff --git a/ios/utils/Text/TextInsertionUtils.mm b/ios/utils/Text/TextInsertionUtils.mm index 0873e932..909d0df0 100644 --- a/ios/utils/Text/TextInsertionUtils.mm +++ b/ios/utils/Text/TextInsertionUtils.mm @@ -1,6 +1,8 @@ #import "TextInsertionUtils.h" #import "EnrichedTextInputView.h" +#import "ParagraphsUtils.h" #import "Strings.h" +#import "StyleHeaders.h" #import "UIView+React.h" @implementation TextInsertionUtils @@ -65,6 +67,81 @@ + (void)replaceText:(NSString *)text typedInput->recentlyChangedRange = NSMakeRange(range.location, text.length); } ++ (BOOL)tryInsertText:(NSString *)text + afterReadOnlyParagraphInRange:(NSRange)range + input:(id)input + paragraphsLimit:(NSInteger)paragraphsLimit { + EnrichedTextInputView *typedInput = (EnrichedTextInputView *)input; + if (typedInput == nullptr || text.length == 0 || range.length != 0) { + return NO; + } + + UITextView *textView = typedInput->textView; + NSTextStorage *storage = textView.textStorage; + if (![ParagraphsUtils isAtEndOfReadOnlyParagraph:storage + location:range.location]) { + return NO; + } + + NSString *replacementText = [NewLine stringByAppendingString:text]; + if (paragraphsLimit > 0) { + NSInteger existing = [ParagraphsUtils paragraphsCountInTextView:textView]; + NSInteger incoming = + [ParagraphsUtils incomingParagraphsCountFromString:replacementText]; + + if (existing + incoming - 1 > paragraphsLimit) { + return NO; + } + } + + NSAttributedString *replacement = [[NSAttributedString alloc] + initWithString:replacementText + attributes:typedInput->defaultTypingAttributes]; + NSRange replacementRange = NSMakeRange(range.location, replacement.length); + + [storage beginEditing]; + [storage replaceCharactersInRange:range withAttributedString:replacement]; + [storage removeAttribute:ReadOnlyParagraphKey range:replacementRange]; + [storage endEditing]; + + textView.selectedRange = NSMakeRange(NSMaxRange(replacementRange), 0); + typedInput->recentlyChangedRange = replacementRange; + + return YES; +} + ++ (BOOL)tryDeleteReadOnlyParagraphBeforeRange:(NSRange)range input:(id)input { + EnrichedTextInputView *typedInput = (EnrichedTextInputView *)input; + if (typedInput == nullptr || range.length != 1 || range.location == 0) { + return NO; + } + + UITextView *textView = typedInput->textView; + NSTextStorage *storage = textView.textStorage; + NSRange readOnlyRange = NSMakeRange(0, 0); + id readOnly = [storage attribute:ReadOnlyParagraphKey + atIndex:range.location - 1 + effectiveRange:&readOnlyRange]; + + if (readOnly == nil || NSMaxRange(readOnlyRange) < range.location) { + return NO; + } + + NSRange deletionRange = + NSMakeRange(readOnlyRange.location, + MAX(NSMaxRange(readOnlyRange), NSMaxRange(range)) - + readOnlyRange.location); + + [storage beginEditing]; + [storage deleteCharactersInRange:deletionRange]; + [storage endEditing]; + + textView.selectedRange = NSMakeRange(deletionRange.location, 0); + typedInput->recentlyChangedRange = deletionRange; + + return YES; +} + + (void)insertEscapingParagraphsAtIndex:(NSUInteger)index text:(NSString *)text attributes: