From 3a31c7632d53b981b24c4dc37ad2be1c265a3309 Mon Sep 17 00:00:00 2001 From: SangGyu Date: Fri, 31 Jul 2026 12:09:07 +0900 Subject: [PATCH 1/3] fix(android): preserve inline styles during IME composition --- .../EnrichedTextInputConnectionWrapper.kt | 205 +++++++++++++++++- .../textinput/EnrichedTextInputView.kt | 6 + .../enriched/textinput/styles/InlineStyles.kt | 18 ++ 3 files changed, 228 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt index a9ed68089..b94ee48a5 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt @@ -1,11 +1,15 @@ package com.swmansion.enriched.textinput +import android.text.Editable import android.view.KeyEvent +import android.view.inputmethod.BaseInputConnection import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnectionWrapper import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper +import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan import com.swmansion.enriched.textinput.events.OnInputKeyPressEvent +import com.swmansion.enriched.textinput.spans.EnrichedSpans // This class is based on the implementation from Facebook React Native to provide 'onKeyPress' API on android. // Original source: @@ -16,9 +20,33 @@ class EnrichedTextInputConnectionWrapper( private val editText: EnrichedTextInputView, private val experimentalSynchronousEvents: Boolean, ) : InputConnectionWrapper(target, false) { + private data class InlineSpanSnapshot( + val style: String, + val start: Int, + val end: Int, + ) + + private data class ComposingTextSnapshot( + val start: Int, + val end: Int, + val editableLength: Int, + val text: String, + val inlineSpans: List, + ) + private var isBatchEdit = false + private var shouldPreserveInlineStyles = false private var key: String? = null + fun preserveInlineStylesForCurrentComposition() { + val editable = editText.text + val composingStart = editable?.let(BaseInputConnection::getComposingSpanStart) ?: -1 + val composingEnd = editable?.let(BaseInputConnection::getComposingSpanEnd) ?: -1 + shouldPreserveInlineStyles = + composingStart >= 0 && + composingEnd > composingStart + } + override fun beginBatchEdit(): Boolean { isBatchEdit = true return super.beginBatchEdit() @@ -39,9 +67,23 @@ class EnrichedTextInputConnectionWrapper( ): Boolean { val previousSelectionStart = editText.selectionStart val previousSelectionEnd = editText.selectionEnd + val composingTextSnapshot = + if (shouldPreserveInlineStyles) { + captureComposingText() + } else { + null + } + if (shouldPreserveInlineStyles && composingTextSnapshot == null) { + shouldPreserveInlineStyles = false + } val consumed = super.setComposingText(text, newCursorPosition) + if (consumed && composingTextSnapshot != null) { + restoreInlineSpans(composingTextSnapshot) + } + clearPreservationIfCompositionFinished() + val currentSelectionStart = editText.selectionStart val noPreviousSelection = previousSelectionStart == previousSelectionEnd val cursorDidNotMove = currentSelectionStart == previousSelectionStart @@ -61,6 +103,149 @@ class EnrichedTextInputConnectionWrapper( return consumed } + private fun captureComposingText(): ComposingTextSnapshot? { + val editable = editText.text ?: return null + val composingStart = BaseInputConnection.getComposingSpanStart(editable) + val composingEnd = BaseInputConnection.getComposingSpanEnd(editable) + if (composingStart < 0 || composingEnd <= composingStart) return null + + return ComposingTextSnapshot( + start = composingStart, + end = composingEnd, + editableLength = editable.length, + text = editable.substring(composingStart, composingEnd), + inlineSpans = captureInlineSpans(editable, composingStart, composingEnd), + ) + } + + private fun captureInlineSpans( + editable: Editable, + composingStart: Int, + composingEnd: Int, + ): List = + editable + .getSpans(composingStart, composingEnd, EnrichedInlineSpan::class.java) + .mapNotNull { span -> + val style = + EnrichedSpans.inlineSpans.entries + .firstOrNull { (_, config) -> config.clazz.isInstance(span) } + ?.key + ?: return@mapNotNull null + val start = editable.getSpanStart(span).coerceAtLeast(composingStart) + val end = editable.getSpanEnd(span).coerceAtMost(composingEnd) + + if (start < end) { + InlineSpanSnapshot(style, start - composingStart, end - composingStart) + } else { + null + } + } + + private fun clearPreservationIfCompositionFinished() { + val editable = editText.text + if ( + editable == null || + BaseInputConnection.getComposingSpanStart(editable) < 0 || + BaseInputConnection.getComposingSpanEnd(editable) < 0 + ) { + shouldPreserveInlineStyles = false + } + } + + private fun restoreInlineSpans(snapshot: ComposingTextSnapshot) { + val editable = editText.text ?: return + val previousComposingLength = snapshot.end - snapshot.start + val currentComposingLength = + editable.length - (snapshot.editableLength - previousComposingLength) + val currentComposingStart = snapshot.start.coerceAtMost(editable.length) + val currentComposingEnd = + (currentComposingStart + currentComposingLength) + .coerceAtLeast(currentComposingStart) + .coerceAtMost(editable.length) + if (currentComposingStart >= currentComposingEnd) return + + val currentComposingText = editable.substring(currentComposingStart, currentComposingEnd) + val commonPrefixLength = commonPrefixLength(snapshot.text, currentComposingText) + val commonSuffixLength = + commonSuffixLength( + snapshot.text, + currentComposingText, + commonPrefixLength, + ) + + for (inlineSpan in snapshot.inlineSpans) { + restoreSnapshotIntersection( + inlineSpan, + oldRangeStart = 0, + oldRangeEnd = commonPrefixLength, + newRangeStart = 0, + composingStart = currentComposingStart, + ) + + val previousSuffixStart = snapshot.text.length - commonSuffixLength + val currentSuffixStart = currentComposingText.length - commonSuffixLength + restoreSnapshotIntersection( + inlineSpan, + oldRangeStart = previousSuffixStart, + oldRangeEnd = snapshot.text.length, + newRangeStart = currentSuffixStart, + composingStart = currentComposingStart, + ) + } + } + + private fun restoreSnapshotIntersection( + snapshot: InlineSpanSnapshot, + oldRangeStart: Int, + oldRangeEnd: Int, + newRangeStart: Int, + composingStart: Int, + ) { + val intersectionStart = snapshot.start.coerceAtLeast(oldRangeStart) + val intersectionEnd = snapshot.end.coerceAtMost(oldRangeEnd) + if (intersectionStart >= intersectionEnd) return + + val mappedStart = newRangeStart + intersectionStart - oldRangeStart + val mappedEnd = newRangeStart + intersectionEnd - oldRangeStart + editText.inlineStyles?.restoreStyleOnRange( + snapshot.style, + composingStart + mappedStart, + composingStart + mappedEnd, + ) + } + + private fun commonPrefixLength( + previousText: String, + currentText: String, + ): Int { + val maximum = minOf(previousText.length, currentText.length) + var length = 0 + while (length < maximum && previousText[length] == currentText[length]) { + length++ + } + return length + } + + private fun commonSuffixLength( + previousText: String, + currentText: String, + commonPrefixLength: Int, + ): Int { + val maximum = + minOf( + previousText.length - commonPrefixLength, + currentText.length - commonPrefixLength, + ) + var length = 0 + while ( + length < maximum && + previousText[previousText.lastIndex - length] == currentText[currentText.lastIndex - length] + ) { + length++ + } + return length + } + override fun commitText( text: CharSequence, newCursorPosition: Int, @@ -73,7 +258,25 @@ class EnrichedTextInputConnectionWrapper( } dispatchKeyEventOrEnqueue(inputKey) } - return super.commitText(text, newCursorPosition) + + val composingTextSnapshot = + if (shouldPreserveInlineStyles) { + captureComposingText() + } else { + null + } + val consumed = super.commitText(text, newCursorPosition) + if (consumed && composingTextSnapshot != null) { + restoreInlineSpans(composingTextSnapshot) + } + shouldPreserveInlineStyles = false + return consumed + } + + override fun finishComposingText(): Boolean { + val consumed = super.finishComposingText() + shouldPreserveInlineStyles = false + return consumed } override fun deleteSurroundingText( 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..626b8f038 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -146,6 +146,7 @@ class EnrichedTextInputView : private var defaultValueDirty: Boolean = false private var inputMethodManager: InputMethodManager? = null + private var enrichedInputConnection: EnrichedTextInputConnectionWrapper? = null private val spannableFactory = EnrichedTextInputSpannableFactory() private var contextMenuItems: List> = emptyList() @@ -199,6 +200,7 @@ class EnrichedTextInputView : ) } + enrichedInputConnection = inputConnection as? EnrichedTextInputConnectionWrapper return inputConnection } @@ -933,6 +935,10 @@ class EnrichedTextInputView : val isValid = verifyStyle(name) if (!isValid) return + if (name in EnrichedSpans.inlineSpans) { + enrichedInputConnection?.preserveInlineStylesForCurrentComposition() + } + val (rangeStart, rangeEnd) = getTargetRange(name) runAsATransaction { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt index fd024b884..6f3369358 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt @@ -161,6 +161,24 @@ class InlineStyles( setAndMergeSpans(spannable, type, start, end) } + fun restoreStyleOnRange( + name: String, + start: Int, + end: Int, + ) { + if (start >= end) return + + val config = EnrichedSpans.inlineSpans[name] ?: return + val spannable = view.text as? Spannable ?: return + val spans = spannable.getSpans(start, end, config.clazz) + + if (spans.any { spannable.getSpanStart(it) <= start && spannable.getSpanEnd(it) >= end }) { + return + } + + setSpan(spannable, config.clazz, start, end) + } + fun toggleStyle(name: String) { if (view.selection == null) return val (start, end) = view.selection.getInlineSelection() From 25a3c8fe89517b127b524ce1b23a176641de2620 Mon Sep 17 00:00:00 2001 From: SangGyu Date: Wed, 5 Aug 2026 11:53:54 +0900 Subject: [PATCH 2/3] fix(android): preserve inline styles on every composing update The previous approach only armed span preservation when an inline style was toggled during an active composition. Moving the caret ends the composition and clears that flag, so returning to the end of the word and typing again let the IME replace the whole word with no snapshot taken, dropping the styled run. Gate on the content being replaced instead of on the toggle event: snapshot whenever the composing region carries inline spans, and restore afterwards. captureComposingText() returns null when there is nothing to preserve, so plain typing still costs a single span lookup. This also removes the cached EnrichedTextInputConnectionWrapper on the view, which silently no-opped whenever the cached instance differed from the one the IME was driving. Co-Authored-By: Claude Opus 5 (1M context) --- .../EnrichedTextInputConnectionWrapper.kt | 53 +++---------------- .../textinput/EnrichedTextInputView.kt | 6 --- 2 files changed, 8 insertions(+), 51 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt index b94ee48a5..f7016fae8 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt @@ -35,18 +35,8 @@ class EnrichedTextInputConnectionWrapper( ) private var isBatchEdit = false - private var shouldPreserveInlineStyles = false private var key: String? = null - fun preserveInlineStylesForCurrentComposition() { - val editable = editText.text - val composingStart = editable?.let(BaseInputConnection::getComposingSpanStart) ?: -1 - val composingEnd = editable?.let(BaseInputConnection::getComposingSpanEnd) ?: -1 - shouldPreserveInlineStyles = - composingStart >= 0 && - composingEnd > composingStart - } - override fun beginBatchEdit(): Boolean { isBatchEdit = true return super.beginBatchEdit() @@ -67,22 +57,13 @@ class EnrichedTextInputConnectionWrapper( ): Boolean { val previousSelectionStart = editText.selectionStart val previousSelectionEnd = editText.selectionEnd - val composingTextSnapshot = - if (shouldPreserveInlineStyles) { - captureComposingText() - } else { - null - } - if (shouldPreserveInlineStyles && composingTextSnapshot == null) { - shouldPreserveInlineStyles = false - } + val composingTextSnapshot = captureComposingText() val consumed = super.setComposingText(text, newCursorPosition) if (consumed && composingTextSnapshot != null) { restoreInlineSpans(composingTextSnapshot) } - clearPreservationIfCompositionFinished() val currentSelectionStart = editText.selectionStart val noPreviousSelection = previousSelectionStart == previousSelectionEnd @@ -103,18 +84,23 @@ class EnrichedTextInputConnectionWrapper( return consumed } + // Snapshots the inline styles carried by the text the IME is about to replace. Returns null + // whenever there is nothing to preserve, which keeps plain typing down to a single span lookup. private fun captureComposingText(): ComposingTextSnapshot? { val editable = editText.text ?: return null val composingStart = BaseInputConnection.getComposingSpanStart(editable) val composingEnd = BaseInputConnection.getComposingSpanEnd(editable) if (composingStart < 0 || composingEnd <= composingStart) return null + val inlineSpans = captureInlineSpans(editable, composingStart, composingEnd) + if (inlineSpans.isEmpty()) return null + return ComposingTextSnapshot( start = composingStart, end = composingEnd, editableLength = editable.length, text = editable.substring(composingStart, composingEnd), - inlineSpans = captureInlineSpans(editable, composingStart, composingEnd), + inlineSpans = inlineSpans, ) } @@ -141,17 +127,6 @@ class EnrichedTextInputConnectionWrapper( } } - private fun clearPreservationIfCompositionFinished() { - val editable = editText.text - if ( - editable == null || - BaseInputConnection.getComposingSpanStart(editable) < 0 || - BaseInputConnection.getComposingSpanEnd(editable) < 0 - ) { - shouldPreserveInlineStyles = false - } - } - private fun restoreInlineSpans(snapshot: ComposingTextSnapshot) { val editable = editText.text ?: return val previousComposingLength = snapshot.end - snapshot.start @@ -259,23 +234,11 @@ class EnrichedTextInputConnectionWrapper( dispatchKeyEventOrEnqueue(inputKey) } - val composingTextSnapshot = - if (shouldPreserveInlineStyles) { - captureComposingText() - } else { - null - } + val composingTextSnapshot = captureComposingText() val consumed = super.commitText(text, newCursorPosition) if (consumed && composingTextSnapshot != null) { restoreInlineSpans(composingTextSnapshot) } - shouldPreserveInlineStyles = false - return consumed - } - - override fun finishComposingText(): Boolean { - val consumed = super.finishComposingText() - shouldPreserveInlineStyles = false return consumed } 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 626b8f038..023cc20fc 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -146,7 +146,6 @@ class EnrichedTextInputView : private var defaultValueDirty: Boolean = false private var inputMethodManager: InputMethodManager? = null - private var enrichedInputConnection: EnrichedTextInputConnectionWrapper? = null private val spannableFactory = EnrichedTextInputSpannableFactory() private var contextMenuItems: List> = emptyList() @@ -200,7 +199,6 @@ class EnrichedTextInputView : ) } - enrichedInputConnection = inputConnection as? EnrichedTextInputConnectionWrapper return inputConnection } @@ -935,10 +933,6 @@ class EnrichedTextInputView : val isValid = verifyStyle(name) if (!isValid) return - if (name in EnrichedSpans.inlineSpans) { - enrichedInputConnection?.preserveInlineStylesForCurrentComposition() - } - val (rangeStart, rangeEnd) = getTargetRange(name) runAsATransaction { From 8e5bd89c977e08ca2b963507a5e1fc07b2208187 Mon Sep 17 00:00:00 2001 From: SangGyu Date: Fri, 7 Aug 2026 12:02:51 +0900 Subject: [PATCH 3/3] refactor(android): look up inline spans by type and reuse applyStyleOnRange --- .../EnrichedTextInputConnectionWrapper.kt | 14 ++++---------- .../enriched/textinput/styles/InlineStyles.kt | 18 ------------------ 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt index f7016fae8..55a330015 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputConnectionWrapper.kt @@ -7,7 +7,6 @@ import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnectionWrapper import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper -import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan import com.swmansion.enriched.textinput.events.OnInputKeyPressEvent import com.swmansion.enriched.textinput.spans.EnrichedSpans @@ -109,14 +108,8 @@ class EnrichedTextInputConnectionWrapper( composingStart: Int, composingEnd: Int, ): List = - editable - .getSpans(composingStart, composingEnd, EnrichedInlineSpan::class.java) - .mapNotNull { span -> - val style = - EnrichedSpans.inlineSpans.entries - .firstOrNull { (_, config) -> config.clazz.isInstance(span) } - ?.key - ?: return@mapNotNull null + EnrichedSpans.inlineSpans.flatMap { (style, config) -> + editable.getSpans(composingStart, composingEnd, config.clazz).mapNotNull { span -> val start = editable.getSpanStart(span).coerceAtLeast(composingStart) val end = editable.getSpanEnd(span).coerceAtMost(composingEnd) @@ -126,6 +119,7 @@ class EnrichedTextInputConnectionWrapper( null } } + } private fun restoreInlineSpans(snapshot: ComposingTextSnapshot) { val editable = editText.text ?: return @@ -182,7 +176,7 @@ class EnrichedTextInputConnectionWrapper( val mappedStart = newRangeStart + intersectionStart - oldRangeStart val mappedEnd = newRangeStart + intersectionEnd - oldRangeStart - editText.inlineStyles?.restoreStyleOnRange( + editText.inlineStyles?.applyStyleOnRange( snapshot.style, composingStart + mappedStart, composingStart + mappedEnd, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt index 6f3369358..fd024b884 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt @@ -161,24 +161,6 @@ class InlineStyles( setAndMergeSpans(spannable, type, start, end) } - fun restoreStyleOnRange( - name: String, - start: Int, - end: Int, - ) { - if (start >= end) return - - val config = EnrichedSpans.inlineSpans[name] ?: return - val spannable = view.text as? Spannable ?: return - val spans = spannable.getSpans(start, end, config.clazz) - - if (spans.any { spannable.getSpanStart(it) <= start && spannable.getSpanEnd(it) >= end }) { - return - } - - setSpan(spannable, config.clazz, start, end) - } - fun toggleStyle(name: String) { if (view.selection == null) return val (start, end) = view.selection.getInlineSelection()