@@ -25,11 +25,14 @@ import android.view.MotionEvent
2525import android.view.KeyEvent
2626import android.view.View
2727import android.view.ViewGroup
28+ import android.view.inputmethod.BaseInputConnection
2829import android.view.inputmethod.EditorInfo
2930import android.view.inputmethod.InputConnection
3031import android.view.inputmethod.InputConnectionWrapper
3132import android.view.inputmethod.InputMethodManager
33+ import android.view.inputmethod.TextAttribute
3234import android.widget.EditText
35+ import androidx.annotation.RequiresApi
3336import com.facebook.react.common.assets.ReactFontManager
3437import expo.modules.kotlin.AppContext
3538import expo.modules.kotlin.viewevent.EventDispatcher
@@ -131,8 +134,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
131134 val token =
132135 tokens.firstOrNull {
133136 (it.type == " context" || it.type == " mention" || it.type == " skill" ) &&
134- offset >= it.start &&
135- offset < it.end
137+ offset in it.start.. it.end &&
138+ it.end <= editor.length() &&
139+ chipContains(it, event.x, event.y)
136140 }
137141 ? : return false
138142 if (token.end <= editor.length() &&
@@ -450,6 +454,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
450454 )
451455 }
452456
457+ // A chip is one replacement span, so a tap on its right half reports the offset at its end,
458+ // and a tap below the last line clamps to it; only the chip's drawn bounds count.
459+ private fun chipContains (token : ComposerToken , viewX : Float , viewY : Float ): Boolean {
460+ val layout = editor.layout ? : return false
461+ val x = viewX - editor.totalPaddingLeft + editor.scrollX
462+ val y = viewY - editor.totalPaddingTop + editor.scrollY
463+ val line = layout.getLineForOffset(token.start)
464+ val left = layout.getPrimaryHorizontal(token.start)
465+ val right =
466+ if (layout.getLineForOffset(token.end) == line) {
467+ layout.getPrimaryHorizontal(token.end)
468+ } else {
469+ layout.getLineRight(line)
470+ }
471+ return y >= layout.getLineTop(line) && y <= layout.getLineBottom(line) &&
472+ x >= minOf(left, right) && x <= maxOf(left, right)
473+ }
474+
453475 private fun applyTokenSpans () {
454476 val editable = editor.text ? : return
455477 editable.getSpans(
@@ -680,8 +702,57 @@ internal class SelectionAwareEditText(
680702 afterLength : Int
681703 ): Boolean = deleteAdjacentChip(beforeLength, afterLength) ||
682704 super .deleteSurroundingTextInCodePoints(beforeLength, afterLength)
705+ override fun commitText (text : CharSequence? , newCursorPosition : Int ): Boolean =
706+ commitUnlessPasted(text) { super .commitText(text, newCursorPosition) }
707+
708+ // InputConnectionWrapper sends this overload straight to its target, not through the
709+ // two-argument commitText.
710+ @RequiresApi(Build .VERSION_CODES .TIRAMISU )
711+ override fun commitText (
712+ text : CharSequence ,
713+ newCursorPosition : Int ,
714+ textAttribute : TextAttribute ?
715+ ): Boolean = commitUnlessPasted(text) {
716+ super .commitText(text, newCursorPosition, textAttribute)
717+ }
718+
719+ private fun commitUnlessPasted (text : CharSequence? , commit : () -> Boolean ): Boolean =
720+ if (interceptKeyboardPaste(text)) {
721+ // The keyboard now treats its composing text as replaced; a span left behind would
722+ // let its next word overwrite text the paste did not remove.
723+ finishComposingText()
724+ true
725+ } else {
726+ commit()
727+ }
728+ }
729+ }
730+
731+ // IME clipboard inserts replace the composing range, just like native commitText.
732+ private fun interceptKeyboardPaste (text : CharSequence? ): Boolean {
733+ if (readOnly || textPasteThresholdBytes <= 0 || text == null ) return false
734+ val value = text.toString()
735+ val composingStart = BaseInputConnection .getComposingSpanStart(editableText)
736+ val composingEnd = BaseInputConnection .getComposingSpanEnd(editableText)
737+ return when {
738+ Character .codePointCount(text, 0 , text.length) < 2 -> false
739+ composingStart < 0 -> interceptPaste(value)
740+ else -> {
741+ val continuesWord = value.startsWith(editableText.subSequence(composingStart, composingEnd))
742+ val exceedsInputLimit =
743+ length().toLong() - (composingEnd - composingStart) + value.length > maxInputChars
744+ val keepTyping = if (continuesWord && exceedsInputLimit && ! shouldFoldText(value)) {
745+ val clipboard = context.getSystemService(Context .CLIPBOARD_SERVICE ) as ? ClipboardManager
746+ // commitText has no paste flag; inspect the clipboard only for ambiguous overflow commits.
747+ clipboard?.primaryClip?.plainText() != value
748+ } else {
749+ false
750+ }
751+ ! keepTyping && interceptPaste(value, start = composingStart, end = composingEnd)
752+ }
683753 }
684754 }
755+
685756 override fun onSelectionChanged (selStart : Int , selEnd : Int ) {
686757 super .onSelectionChanged(selStart, selEnd)
687758 selectionListener?.invoke(selStart, selEnd)
@@ -793,13 +864,17 @@ internal class SelectionAwareEditText(
793864 private fun pasteInterceptedText (clip : ClipData ? , foldLargeText : Boolean = true): Boolean {
794865 val text = if (textPasteThresholdBytes > 0 ) clip?.plainText() else null
795866 if (text.isNullOrEmpty()) return false
796- val start = minOf(selectionStart, selectionEnd).coerceIn(0 , length())
797- val end = maxOf(selectionStart, selectionEnd).coerceIn(start, length())
867+ return interceptPaste(text, foldLargeText)
868+ }
869+
870+ private fun interceptPaste (
871+ text : String ,
872+ foldLargeText : Boolean = true,
873+ start : Int = minOf(selectionStart, selectionEnd).coerceIn(0, length()),
874+ end : Int = maxOf(selectionStart, selectionEnd).coerceIn(start, length())
875+ ): Boolean {
798876 val exceedsInputLimit = length().toLong() - (end - start) + text.length > maxInputChars
799- val shouldFold = foldLargeText && (
800- text.length >= textPasteThresholdBytes ||
801- text.toByteArray(Charsets .UTF_8 ).size >= textPasteThresholdBytes
802- )
877+ val shouldFold = foldLargeText && shouldFoldText(text)
803878 val shouldIntercept = exceedsInputLimit || shouldFold
804879 if (shouldIntercept) {
805880 pasteTextListener?.invoke(text, start, end)
@@ -808,6 +883,10 @@ internal class SelectionAwareEditText(
808883 return shouldIntercept
809884 }
810885
886+ private fun shouldFoldText (text : String ): Boolean =
887+ text.length >= textPasteThresholdBytes ||
888+ text.toByteArray(Charsets .UTF_8 ).size >= textPasteThresholdBytes
889+
811890 // Match native paste's newlines, including empty items.
812891 // URI-backed clips stay native: coerceToText can read a provider synchronously.
813892 private fun ClipData.plainText (): String? {
0 commit comments