From 4fbdbded3dd5c7aaa6808637fa513506ccf4b6dd Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Tue, 7 Jul 2026 12:48:21 +0200 Subject: [PATCH 1/2] refactor: deduplicate post-edit store adjustment pipeline (iOS + Android) iOS: Extract -adjustStoresForEditAtLocation:deletedLength:insertedLength: to deduplicate the 4-step sequence in replaceTextInRange and handleTextChanged. Android: Extract replaceTextInRange(start, end, newText, postAdjust) to consolidate the isProcessingTextChange / replace / adjustStoresForEdit / lastProcessedText / applyFormattingAndEmit / emitChangeText boilerplate across pasteMarkdown, insertLinkAtCursor, insertMention, startMention, and deleteLinkBeforeCursor. Co-authored-by: Cursor --- .../input/EnrichedMarkdownTextInputView.kt | 92 +++++++------------ .../ios/input/EnrichedMarkdownTextInput.mm | 22 +++-- 2 files changed, 45 insertions(+), 69 deletions(-) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt index 211a9e774..4c6d5555c 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt @@ -379,6 +379,26 @@ class EnrichedMarkdownTextInputView( text?.let { blockStore.normalizeToLineBounds(it) } } + private inline fun replaceTextInRange( + start: Int, + end: Int, + newText: String, + postAdjust: (Editable) -> Unit = {}, + ) { + val editable = text ?: return + isProcessingTextChange = true + try { + editable.replace(start, end, newText) + adjustStoresForEdit(start, end - start, newText.length) + postAdjust(editable) + lastProcessedText = editable.toString() + applyFormattingAndEmit() + eventEmitter.emitChangeText() + } finally { + isProcessingTextChange = false + } + } + fun applyFormatting() { val editable = text ?: return formatter.applyFormatting(editable, formattingStore.allRanges) @@ -576,16 +596,11 @@ class EnrichedMarkdownTextInputView( * and block ranges (headings etc.) into the stores — mirrors iOS pasteMarkdown. */ fun pasteMarkdown(markdown: String) { - val editable = text ?: return val parsed = InputParser.parseToPlainTextAndRanges(markdown) - val selStart = selectionStart.coerceIn(0, editable.length) - val selEnd = selectionEnd.coerceIn(selStart, editable.length) - - isProcessingTextChange = true - try { - editable.replace(selStart, selEnd, parsed.plainText) - adjustStoresForEdit(selStart, selEnd - selStart, parsed.plainText.length) + val selStart = selectionStart.coerceIn(0, text?.length ?: 0) + val selEnd = selectionEnd.coerceIn(selStart, text?.length ?: 0) + replaceTextInRange(selStart, selEnd, parsed.plainText) { editable -> for (range in parsed.formattingRanges) { formattingStore.addRange( FormattingRange(range.type, range.start + selStart, range.end + selStart, range.url), @@ -594,15 +609,8 @@ class EnrichedMarkdownTextInputView( for (block in parsed.blockRanges) { blockStore.setBlock(block.type, block.level, block.start + selStart, block.end + selStart, editable) } - - val currentText = editable.toString() - lastProcessedText = currentText setSelection(selStart + parsed.plainText.length) - applyFormattingAndEmit() - detectorPipeline.processTextChange(editable, currentText, selStart, parsed.plainText.length) - eventEmitter.emitChangeText() - } finally { - isProcessingTextChange = false + detectorPipeline.processTextChange(editable, editable.toString(), selStart, parsed.plainText.length) } } @@ -712,24 +720,14 @@ class EnrichedMarkdownTextInputView( displayText: String, url: String, ) { - val editable = text ?: return val selStart = selectionStart val selEnd = selectionEnd val linkEnd = selStart + displayText.length - isProcessingTextChange = true - try { - editable.replace(selStart, selEnd, displayText) - adjustStoresForEdit(selStart, selEnd - selStart, displayText.length) + replaceTextInRange(selStart, selEnd, displayText) { editable -> autoLinkDetector.clearAutoLinkInRange(editable, selStart, linkEnd) formattingStore.addRange(FormattingRange(StyleType.LINK, selStart, linkEnd, sanitizeLinkUrl(url))) - lastProcessedText = editable.toString() - setSelection(linkEnd) - applyFormattingAndEmit() - eventEmitter.emitChangeText() - } finally { - isProcessingTextChange = false } } @@ -749,41 +747,23 @@ class EnrichedMarkdownTextInputView( val replacement = if (shouldAppendSpace) "$displayText " else displayText val linkEnd = start + displayText.length - isProcessingTextChange = true - try { - editable.replace(start, end, replacement) - adjustStoresForEdit(start, end - start, replacement.length) - autoLinkDetector.clearAutoLinkInRange(editable, start, linkEnd) + replaceTextInRange(start, end, replacement) { ed -> + autoLinkDetector.clearAutoLinkInRange(ed, start, linkEnd) formattingStore.addRange(FormattingRange(StyleType.LINK, start, linkEnd, sanitizedUrl)) - lastProcessedText = editable.toString() - clearActiveMention(emit = true, indicatorOverride = indicator) setSelection(start + replacement.length) - applyFormattingAndEmit() - eventEmitter.emitChangeText() - } finally { - isProcessingTextChange = false } } fun startMention(indicator: String) { if (indicator.isEmpty() || indicator !in mentionIndicators) return - val editable = text ?: return val selStart = selectionStart val selEnd = selectionEnd - isProcessingTextChange = true - try { - editable.replace(selStart, selEnd, indicator) - adjustStoresForEdit(selStart, selEnd - selStart, indicator.length) - lastProcessedText = editable.toString() + replaceTextInRange(selStart, selEnd, indicator) { setSelection(selStart + indicator.length) - applyFormattingAndEmit() - eventEmitter.emitChangeText() - updateActiveMention() - } finally { - isProcessingTextChange = false } + updateActiveMention() } fun removeLinkAtCursor() { @@ -794,25 +774,17 @@ class EnrichedMarkdownTextInputView( } fun deleteLinkBeforeCursor(): Boolean { - val editable = text ?: return false val cursorStart = selectionStart val cursorEnd = selectionEnd if (cursorStart != cursorEnd || cursorStart <= 0) return false + if (text == null) return false val linkRange = formattingStore.rangeOfType(StyleType.LINK, cursorStart - 1) ?: return false - isProcessingTextChange = true - try { - editable.delete(linkRange.start, linkRange.end) - adjustStoresForEdit(linkRange.start, linkRange.length, 0) - lastProcessedText = editable.toString() + replaceTextInRange(linkRange.start, linkRange.end, "") { editable -> setSelection(linkRange.start.coerceAtMost(editable.length)) - applyFormattingAndEmit() - eventEmitter.emitChangeText() - clearActiveMention() - } finally { - isProcessingTextChange = false } + clearActiveMention() return true } diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index 2bf406a57..5e5c24955 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -607,12 +607,9 @@ - (void)replaceTextInRange:(NSRange)selection ENRMReplaceTextInRange(_textView, text, selection); _isApplyingFormatting = NO; - NSString *plainText = ENRMGetPlainText(_textView); - [_formattingStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; - [_blockStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; - [self pruneOrphanedHeadingBlocks]; - [_blockStore normalizeToLineBoundsInText:plainText]; + [self adjustStoresForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; + NSString *plainText = ENRMGetPlainText(_textView); for (ENRMFormattingRange *range in ranges) { NSRange shifted = NSMakeRange(range.range.location + editLocation, range.range.length); [_formattingStore addRange:[ENRMFormattingRange rangeWithType:range.type range:shifted url:range.url]]; @@ -956,6 +953,16 @@ - (void)syncTypingAttributesWithCursorBlock _textView.typingAttributes = attrs; } +- (void)adjustStoresForEditAtLocation:(NSUInteger)editLocation + deletedLength:(NSUInteger)deletedLength + insertedLength:(NSUInteger)insertedLength +{ + [_formattingStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; + [_blockStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; + [self pruneOrphanedHeadingBlocks]; + [_blockStore normalizeToLineBoundsInText:ENRMGetPlainText(_textView)]; +} + /// Reverts to a plain paragraph any heading no longer anchored at a line start /// (e.g. Backspace merged its line into the previous one). Must run BEFORE /// normalizeToLineBoundsInText: so a merged range is judged on its unsnapped @@ -1671,10 +1678,7 @@ - (void)handleTextChanged } } - [_formattingStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; - [_blockStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; - [self pruneOrphanedHeadingBlocks]; - [_blockStore normalizeToLineBoundsInText:ENRMGetPlainText(_textView)]; + [self adjustStoresForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; if (insertedLength > 0) { NSRange insertedRange = NSMakeRange(editLocation, insertedLength); From f14eded323093cf9c930f77790838b5e8488f6bc Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Tue, 7 Jul 2026 14:16:02 +0200 Subject: [PATCH 2/2] fix: ensure correct selection bounds in pasteMarkdown and adjustStoresForEdit --- .../input/EnrichedMarkdownTextInputView.kt | 5 ++-- .../ios/input/EnrichedMarkdownTextInput.mm | 26 +++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt index 4c6d5555c..a31477b67 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt @@ -596,9 +596,10 @@ class EnrichedMarkdownTextInputView( * and block ranges (headings etc.) into the stores — mirrors iOS pasteMarkdown. */ fun pasteMarkdown(markdown: String) { + val editable = text ?: return val parsed = InputParser.parseToPlainTextAndRanges(markdown) - val selStart = selectionStart.coerceIn(0, text?.length ?: 0) - val selEnd = selectionEnd.coerceIn(selStart, text?.length ?: 0) + val selStart = selectionStart.coerceIn(0, editable.length) + val selEnd = selectionEnd.coerceIn(selStart, editable.length) replaceTextInRange(selStart, selEnd, parsed.plainText) { editable -> for (range in parsed.formattingRanges) { diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index 5e5c24955..de8e41dcf 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -607,9 +607,10 @@ - (void)replaceTextInRange:(NSRange)selection ENRMReplaceTextInRange(_textView, text, selection); _isApplyingFormatting = NO; - [self adjustStoresForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; + NSString *plainText = [self adjustStoresForEditAtLocation:editLocation + deletedLength:selection.length + insertedLength:text.length]; - NSString *plainText = ENRMGetPlainText(_textView); for (ENRMFormattingRange *range in ranges) { NSRange shifted = NSMakeRange(range.range.location + editLocation, range.range.length); [_formattingStore addRange:[ENRMFormattingRange rangeWithType:range.type range:shifted url:range.url]]; @@ -625,8 +626,7 @@ - (void)replaceTextInRange:(NSRange)selection [self applyFormatting]; - [_detectorPipeline processTextChange:ENRMGetPlainText(_textView) - modificationRange:NSMakeRange(editLocation, text.length)]; + [_detectorPipeline processTextChange:plainText modificationRange:NSMakeRange(editLocation, text.length)]; [self updatePlaceholderVisibility]; [self emitOnChangeText]; @@ -953,14 +953,16 @@ - (void)syncTypingAttributesWithCursorBlock _textView.typingAttributes = attrs; } -- (void)adjustStoresForEditAtLocation:(NSUInteger)editLocation - deletedLength:(NSUInteger)deletedLength - insertedLength:(NSUInteger)insertedLength +- (NSString *)adjustStoresForEditAtLocation:(NSUInteger)editLocation + deletedLength:(NSUInteger)deletedLength + insertedLength:(NSUInteger)insertedLength { [_formattingStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; [_blockStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; [self pruneOrphanedHeadingBlocks]; - [_blockStore normalizeToLineBoundsInText:ENRMGetPlainText(_textView)]; + NSString *plainText = ENRMGetPlainText(_textView); + [_blockStore normalizeToLineBoundsInText:plainText]; + return plainText; } /// Reverts to a plain paragraph any heading no longer anchored at a line start @@ -1678,14 +1680,12 @@ - (void)handleTextChanged } } - [self adjustStoresForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; + NSString *plainText = [self adjustStoresForEditAtLocation:editLocation + deletedLength:deletedLength + insertedLength:insertedLength]; if (insertedLength > 0) { NSRange insertedRange = NSMakeRange(editLocation, insertedLength); - - // Skip applying pending styles when the insertion is only line breaks — - // a phantom range over a bare newline corrupts isStyleActive() at the boundary. - NSString *plainText = ENRMGetPlainText(_textView); NSUInteger insertedEnd = NSMaxRange(insertedRange); BOOL insertedHasGlyphContent = NO; if (insertedEnd <= plainText.length) {