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..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 @@ -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) @@ -581,11 +601,7 @@ class EnrichedMarkdownTextInputView( 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) - + 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 +610,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 +721,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 +748,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 +775,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..de8e41dcf 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -607,11 +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]; + NSString *plainText = [self adjustStoresForEditAtLocation:editLocation + deletedLength:selection.length + insertedLength:text.length]; for (ENRMFormattingRange *range in ranges) { NSRange shifted = NSMakeRange(range.range.location + editLocation, range.range.length); @@ -628,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]; @@ -956,6 +953,18 @@ - (void)syncTypingAttributesWithCursorBlock _textView.typingAttributes = attrs; } +- (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]; + NSString *plainText = ENRMGetPlainText(_textView); + [_blockStore normalizeToLineBoundsInText:plainText]; + return plainText; +} + /// 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,17 +1680,12 @@ - (void)handleTextChanged } } - [_formattingStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; - [_blockStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; - [self pruneOrphanedHeadingBlocks]; - [_blockStore normalizeToLineBoundsInText:ENRMGetPlainText(_textView)]; + 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) {