From cbd4bf01839d76b09bc644da2a5df467136f9d50 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Sat, 11 Jul 2026 19:08:05 +0200 Subject: [PATCH] fix: last paragraph styles applying on android --- .../enriched/EnrichedTextInputView.kt | 9 ++++ .../enriched/styles/ParagraphStyles.kt | 51 ++++++++++--------- .../enriched/utils/EnrichedSelection.kt | 26 +++++----- .../enriched/watchers/EnrichedTextWatcher.kt | 37 ++++++++++++-- 4 files changed, 83 insertions(+), 40 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt index fdbee8a5e..b310a3330 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt @@ -83,6 +83,15 @@ class EnrichedTextInputView : AppCompatEditText { get() = transactionManager.isDuringTransaction var isRemovingMany: Boolean = false + var textVersion = 0 + set(value) { + field = + if (value >= 255) { + 0 + } else { + value + } + } val blockTextEventEmitting: Boolean get() = transactionManager.blockTextEventEmitting diff --git a/android/src/main/java/com/swmansion/enriched/styles/ParagraphStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ParagraphStyles.kt index 059979328..81d615fec 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ParagraphStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ParagraphStyles.kt @@ -49,7 +49,7 @@ class ParagraphStyles( for (range in paragraphRanges) { val paragraphStart = range.first val paragraphEnd = range.last - spannableStringBuilder.removeZWS(paragraphStart, paragraphEnd + 1) + spannableStringBuilder.removeZWS(paragraphStart, paragraphEnd) val spans = spannableStringBuilder.getSpans(paragraphStart, paragraphEnd, clazz) if (spans.isEmpty()) continue @@ -191,10 +191,14 @@ class ParagraphStyles( val span = createSpan(name) ?: return if (!hasRealText) { - // Insert ZWS with paragraph style - val zeroWidthSpace = buildZWSWithSpan(span) + val zwsIndex = (pStart until pEnd).firstOrNull { ssb[it] == Strings.ZERO_WIDTH_SPACE_CHAR } - ssb.replace(pStart, pEnd, zeroWidthSpace) + if (zwsIndex != null) { + applyParagraphSpan(ssb, span, zwsIndex, zwsIndex + 1) + } else { + val zeroWidthSpace = buildZWSWithSpan(span) + ssb.insert(pStart, zeroWidthSpace) + } view.setSelection(pStart + 1) view.selection.validateStyles() @@ -366,7 +370,6 @@ class ParagraphStyles( private fun insertEscapingParagraph(span: EnrichedSpan?) { val editable = view.editableText val index = view.selection.end - val text = editable.toString() val hasNewlineBefore = index > 0 && text[index - 1] == Strings.NEWLINE @@ -376,32 +379,32 @@ class ParagraphStyles( (index == 0 || hasNewlineBefore) && (index == text.length || hasNewlineAfter) - var insertIndex = index + val prefix = if (!isParagraphEmpty && !hasNewlineBefore) Strings.NEWLINE_STRING else "" + val suffix = if (!hasNewlineAfter) Strings.NEWLINE_STRING else "" - if (!isParagraphEmpty && !hasNewlineBefore) { - editable.insert(insertIndex, Strings.NEWLINE_STRING) - insertIndex += 1 - } + val builder = SpannableStringBuilder() + builder.append(prefix) - val builder = - SpannableStringBuilder(Strings.OBJECT_REPLACEMENT_STRING).apply { - if (span != null) { - setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - } + val objectStartInBuilder = builder.length + builder.append(Strings.OBJECT_REPLACEMENT_STRING) - val objectStart = insertIndex - editable.insert(insertIndex, builder) - insertIndex += builder.length - - if (!hasNewlineAfter) { - editable.insert(insertIndex, Strings.NEWLINE_STRING) - insertIndex += 1 + if (span != null) { + builder.setSpan( + span, + objectStartInBuilder, + objectStartInBuilder + Strings.OBJECT_REPLACEMENT_STRING.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) } + builder.append(suffix) + + editable.insert(index, builder) + + val objectStart = index + objectStartInBuilder isolateNonEditableParagraph(editable, objectStart, span) - view.setSelection(insertIndex) + view.setSelection(index + builder.length) } private fun isolateNonEditableParagraph( diff --git a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt index 5f4286224..41210daee 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt @@ -23,7 +23,7 @@ class EnrichedSelection( ) { var start: Int = 0 var end: Int = 0 - var prevText: String? = null + var prevTextVersion: Int? = null val inlineStylesList = EnrichedSpans.inlineSpans.map { (type, config) -> type to config } + @@ -50,10 +50,9 @@ class EnrichedSelection( shouldValidateStyles = true } - val stringText = view.text.toString() - - if (prevText != stringText) { + if (prevTextVersion != view.textVersion) { shouldValidateStyles = true + prevTextVersion = view.textVersion } val textLength = view.text?.length ?: 0 @@ -69,7 +68,6 @@ class EnrichedSelection( start = finalStart end = finalEnd - prevText = stringText validateStyles() val (paragraphStart, paragraphEnd) = getParagraphSelection() emitSelectionChangeEvent(view.text, finalStart, finalEnd, paragraphStart, paragraphEnd) @@ -108,10 +106,12 @@ class EnrichedSelection( view.isRemovingMany = false } - handleParagraphStyleState() + val paragraphSelection = getParagraphSelection() + + handleParagraphStyleState(paragraphSelection) for ((style, config) in EnrichedSpans.listSpans) { - state.setStart(style, getListStyleStart(config.clazz)) + state.setStart(style, getListStyleStart(config.clazz, paragraphSelection)) } state.emitStateChangeEvent() @@ -189,15 +189,14 @@ class EnrichedSelection( return spannable.getParagraphBounds(currentStart, currentEnd) } - private fun handleParagraphStyleState() { + private fun handleParagraphStyleState(paragraphSelection: Pair) { val spanState = view.spanState - val (start, end) = getParagraphSelection() + val (start, end) = paragraphSelection val spannable = view.text as? Spannable ?: return val spans = spannable .getSpans(start, end, EnrichedParagraphSpan::class.java) - .toList() if (spans.isEmpty()) { EnrichedSpans.paragraphSpans.keys.forEach { @@ -226,8 +225,11 @@ class EnrichedSelection( } } - private fun getListStyleStart(type: Class): Int? { - val (start, end) = getParagraphSelection() + private fun getListStyleStart( + type: Class, + paragraphSelection: Pair, + ): Int? { + val (start, end) = paragraphSelection val spannable = view.text as Spannable var styleStart: Int? = null 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 d96ae25b6..5d7957ee1 100644 --- a/android/src/main/java/com/swmansion/enriched/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/watchers/EnrichedTextWatcher.kt @@ -19,6 +19,7 @@ class EnrichedTextWatcher( private var startCursorPosition: Int = 0 private var prevText: String? = view.text?.toString() ?: "" private var nonEditableParagraphToRemove: EnrichedNonEditableParagraphSpan? = null + private var isApplyingInternalTextChange = false private val inlineSpanPreserver = InlineSpanPreserver() @@ -28,6 +29,8 @@ class EnrichedTextWatcher( count: Int, after: Int, ) { + if (isApplyingInternalTextChange) return + previousTextLength = s?.length ?: 0 startCursorPosition = start nonEditableParagraphToRemove = getNonEditableParagraphBeforeDeletedRange(s, start, count, after) @@ -49,6 +52,8 @@ class EnrichedTextWatcher( before: Int, count: Int, ) { + if (isApplyingInternalTextChange) return + endCursorPosition = start + count view.isRemovingMany = !view.isDuringTransaction && before > count + 1 inlineSpanPreserver.onTextChanged( @@ -58,18 +63,28 @@ class EnrichedTextWatcher( } override fun afterTextChanged(s: Editable?) { - emitEvents(s) - if (s == null) return + view.textVersion += 1 + + if (isApplyingInternalTextChange) return + + if (s == null) { + emitEvents(null) + return + } view.transactionManager.runWithBlockedTextEvents { view.transactionManager.runWithIgnoredSpanWatcher { inlineSpanPreserver.afterTextChanged() if (!view.isDuringTransaction) { - removePendingNonEditableParagraph(s) - applyStyles(s) + runWithInternalTextChange { + removePendingNonEditableParagraph(s) + applyStyles(s) + } } } } + + emitEvents(s) view.correctScrollPositionIfNeeded() } @@ -83,6 +98,15 @@ class EnrichedTextWatcher( ZWSNormalizer.normalizeNonEmptyParagraphs(s) } + private fun runWithInternalTextChange(block: () -> Unit) { + isApplyingInternalTextChange = true + try { + block() + } finally { + isApplyingInternalTextChange = false + } + } + private fun getNonEditableParagraphBeforeDeletedRange( text: CharSequence?, start: Int, @@ -129,6 +153,11 @@ class EnrichedTextWatcher( } private fun emitEvents(s: Editable?) { + if (!view.shouldEmitOnChangeText) { + view.emitOnAnyContentChangeEvent() + return + } + val nextText = s?.toString() ?: "" if (prevText != nextText) { prevText = nextText