diff --git a/.maestro/flows/extending_paragraph_style_on_paste_after_copy.yaml b/.maestro/flows/extending_paragraph_style_on_paste_after_copy.yaml new file mode 100644 index 00000000..9b8dde96 --- /dev/null +++ b/.maestro/flows/extending_paragraph_style_on_paste_after_copy.yaml @@ -0,0 +1,45 @@ +appId: swmansion.enriched.example +tags: + - android-only +--- +- launchApp +- tapOn: + id: "toggle-screen-button" + +- tapOn: + id: "focus-button" + +- inputText: "EADING" +- doubleTapOn: + id: "editor-input" + point: "10%, 50%" + +- tapOn: + id: "android:id/floating_toolbar_menu_item_text" + text: "Copy" + +# Dismiss toolbar with Copy/Cut etc. options +- tapOn: + id: "editor-input" +- tapOn: + id: "clear-button" +- tapOn: + id: "focus-button" + +- tapOn: + id: "toolbar-heading-1" + +- inputText: "H" + +- longPressOn: + id: "editor-input" + point: "50%, 50%" + +- tapOn: + id: "android:id/floating_toolbar_menu_item_text" + text: "Paste" + +- runFlow: + file: "../subflows/capture_or_assert_screenshot.yaml" + env: + SCREENSHOT_NAME: "extending_paragraph_style_on_paste_after_copy" diff --git a/.maestro/flows/extending_paragraph_style_on_paste_after_cut.yaml b/.maestro/flows/extending_paragraph_style_on_paste_after_cut.yaml new file mode 100644 index 00000000..7f6d5485 --- /dev/null +++ b/.maestro/flows/extending_paragraph_style_on_paste_after_cut.yaml @@ -0,0 +1,37 @@ +appId: swmansion.enriched.example +tags: + - android-only +--- +- launchApp +- tapOn: + id: "toggle-screen-button" + +- tapOn: + id: "focus-button" + +- inputText: "EADING" +- doubleTapOn: + id: "editor-input" + point: "10%, 50%" + +- tapOn: + id: "android:id/floating_toolbar_menu_item_text" + text: "Cut" + +- tapOn: + id: "toolbar-heading-1" + +- inputText: "H" + +- longPressOn: + id: "editor-input" + point: "50%, 50%" + +- tapOn: + id: "android:id/floating_toolbar_menu_item_text" + text: "Paste" + +- runFlow: + file: "../subflows/capture_or_assert_screenshot.yaml" + env: + SCREENSHOT_NAME: "extending_paragraph_style_on_paste_after_cut" diff --git a/.maestro/screenshots/android/extending_paragraph_style_on_paste_after_copy.png b/.maestro/screenshots/android/extending_paragraph_style_on_paste_after_copy.png new file mode 100644 index 00000000..f717ac4f Binary files /dev/null and b/.maestro/screenshots/android/extending_paragraph_style_on_paste_after_copy.png differ diff --git a/.maestro/screenshots/android/extending_paragraph_style_on_paste_after_cut.png b/.maestro/screenshots/android/extending_paragraph_style_on_paste_after_cut.png new file mode 100644 index 00000000..f717ac4f Binary files /dev/null and b/.maestro/screenshots/android/extending_paragraph_style_on_paste_after_cut.png differ 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 4dfb2396..42264b4e 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -12,6 +12,7 @@ import android.os.Build import android.text.Editable import android.text.InputType import android.text.Spannable +import android.text.SpannableString import android.util.AttributeSet import android.util.Log import android.util.Patterns @@ -338,28 +339,37 @@ class EnrichedTextInputView : } fun handleTextPaste(item: ClipData.Item) { - val htmlText = item.htmlText val currentText = text as Spannable val start = selectionStart.coerceAtLeast(0) val end = selectionEnd.coerceAtLeast(0) + val lengthBefore = currentText.length + + val pastedSpannable: Spannable = + when { + item.htmlText != null -> { + val parsed = parseText(item.htmlText) + (parsed as? Spannable) ?: return + } + + item.text != null -> { + SpannableString(item.text.toString()) + } - if (htmlText != null) { - val parsedText = parseText(htmlText) - if (parsedText is Spannable) { - val finalText = currentText.mergeSpannables(start, end, parsedText) - setValue(finalText, false) - return + else -> { + return + } } - } - if (item.text == null) return - val lengthBefore = currentText.length - val finalText = currentText.mergeSpannables(start, end, item.text.toString()) - setValue(finalText) + val finalText = currentText.mergeSpannables(start, end, pastedSpannable) + setValue(finalText, false) + + // replacement-safe: oldLength - removed + inserted + val insertedLength = finalText.length - (lengthBefore - (end - start)) + val pasteEnd = (start + insertedLength).coerceIn(0, finalText.length) + setSelection(pasteEnd) // Detect links in the newly pasted range - val finalEndIndex = start + finalText.length - lengthBefore - parametrizedStyles?.detectLinksInRange(finalText, start, finalEndIndex) + parametrizedStyles?.detectLinksInRange(finalText, start.coerceAtMost(pasteEnd), pasteEnd) } fun requestFocusProgrammatically() { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt index ccbf0146..1168165e 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt @@ -100,9 +100,14 @@ class ParametrizedStyles( end: Int, ) { val regex = view.linkRegex ?: return - val contextText = spannable.subSequence(start, end).toString() + val textLength = spannable.length + val safeStart = minOf(start, end).coerceIn(0, textLength) + val safeEnd = maxOf(start, end).coerceIn(0, textLength) + if (safeStart >= safeEnd) return - val spans = spannable.getSpans(start, end, EnrichedInputLinkSpan::class.java) + val contextText = spannable.subSequence(safeStart, safeEnd).toString() + + val spans = spannable.getSpans(safeStart, safeEnd, EnrichedInputLinkSpan::class.java) for (span in spans) { spannable.removeSpan(span) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt index 1433e78d..ebc5e83c 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt @@ -66,6 +66,10 @@ fun Spannable.mergeSpannables( val isNewLineStart = startBlockSpans.isNotEmpty() || startParagraphSpans.isNotEmpty() val isNewLineEnd = endBlockSpans.isNotEmpty() || endParagraphSpans.isNotEmpty() + val pastedHasOwnStyles = + spannable.getSpans(0, spannable.length, EnrichedBlockSpan::class.java).isNotEmpty() || + spannable.getSpans(0, spannable.length, EnrichedParagraphSpan::class.java).isNotEmpty() + if (isNewLineStart && start != paragraphStart) { builder.insert(start, "\n") finalStart = start + 1 @@ -78,5 +82,25 @@ fun Spannable.mergeSpannables( builder.replace(finalStart, finalEnd, spannable) + // Manually extend existing paragraph/block spans to cover the pasted text. + if (!pastedHasOwnStyles) { + val pasteEnd = finalStart + spannable.length + + val affectedParagraphSpans = builder.getSpans(finalStart, finalStart, EnrichedParagraphSpan::class.java) + val affectedBlockSpans = builder.getSpans(finalStart, finalStart, EnrichedBlockSpan::class.java) + val affectedSpans = affectedBlockSpans.toList() + affectedParagraphSpans.toList() + + for (span in affectedSpans) { + val spanStart = builder.getSpanStart(span) + val spanEnd = builder.getSpanEnd(span) + if (spanStart == -1 || spanEnd >= pasteEnd) continue + + val (_, newParagraphEnd) = builder.getParagraphBounds(spanStart, pasteEnd) + val flags = builder.getSpanFlags(span) + builder.removeSpan(span) + builder.setSpan(span, spanStart, newParagraphEnd, flags) + } + } + return builder }