Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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.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:
Expand All @@ -16,6 +19,20 @@ 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<InlineSpanSnapshot>,
)

private var isBatchEdit = false
private var key: String? = null

Expand All @@ -39,9 +56,14 @@ class EnrichedTextInputConnectionWrapper(
): Boolean {
val previousSelectionStart = editText.selectionStart
val previousSelectionEnd = editText.selectionEnd
val composingTextSnapshot = captureComposingText()

val consumed = super.setComposingText(text, newCursorPosition)

if (consumed && composingTextSnapshot != null) {
restoreInlineSpans(composingTextSnapshot)
}

val currentSelectionStart = editText.selectionStart
val noPreviousSelection = previousSelectionStart == previousSelectionEnd
val cursorDidNotMove = currentSelectionStart == previousSelectionStart
Expand All @@ -61,6 +83,138 @@ 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 = inlineSpans,
)
}

private fun captureInlineSpans(
editable: Editable,
composingStart: Int,
composingEnd: Int,
): List<InlineSpanSnapshot> =
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)

if (start < end) {
InlineSpanSnapshot(style, start - composingStart, end - composingStart)
} else {
null
}
}
}

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?.applyStyleOnRange(
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,
Expand All @@ -73,7 +227,13 @@ class EnrichedTextInputConnectionWrapper(
}
dispatchKeyEventOrEnqueue(inputKey)
}
return super.commitText(text, newCursorPosition)

val composingTextSnapshot = captureComposingText()
val consumed = super.commitText(text, newCursorPosition)
if (consumed && composingTextSnapshot != null) {
restoreInlineSpans(composingTextSnapshot)
}
return consumed
}

override fun deleteSurroundingText(
Expand Down
Loading