diff --git a/.maestro/enrichedInput/flows/max_length.yaml b/.maestro/enrichedInput/flows/max_length.yaml new file mode 100644 index 000000000..e5abd48cb --- /dev/null +++ b/.maestro/enrichedInput/flows/max_length.yaml @@ -0,0 +1,78 @@ +appId: swmansion.enriched.example +--- +# Test maxLength behavior: typing, native copy/paste, and HTML imperative setting +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +# 1. Press the button to set maxLength to 10 +- tapOn: + id: 'max-length-10-button' + +- tapOn: + id: 'editor-input' + +# 2. Write 1234567890, try to write more. The extra '123' should be rejected. +- inputText: '1234567890' +- inputText: '123' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'maxlength_typing_limit' + +# 3. Remove 4 characters to have '123456', select it, copy, and paste at the end. +- tapOn: + id: 'editor-input' + point: '50%, 50%' + +- pressKey: backspace +- pressKey: backspace +- pressKey: backspace +- pressKey: backspace + +- doubleTapOn: + id: 'editor-input' + point: '10%, 30%' + +- tapOn: + text: 'Copy' + +- tapOn: + id: 'editor-input' + point: '50%, 30%' + +- longPressOn: + id: 'editor-input' + point: '50%, 30%' + +- tapOn: + text: 'Paste' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'maxlength_paste_limit' + +# 4. Set inputs value as `123456 some longer link`, style should be present on remaining text +- runFlow: + file: '../subflows/set_editor_value.yaml' + env: + VALUE: '
123456 some longer link
' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'maxlength_link_kept' + +# 5. Set inputs value as `12345612345
1234567890
'); + }); + + test('typing under maxLength is unaffected', async ({ page }) => { + await gotoTestMaxLength(page); + await setValue(page, ''); + + await focusEditor(page); + await page.keyboard.type('12345', { delay: 20 }); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('12345
'); + }); +}); + +test.describe('test-max-length pasting', () => { + test.use({ permissions: ['clipboard-read', 'clipboard-write'] }); + + test('pasting content that fits exactly is not truncated', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '1234567
'); + + await copySelectionFrom(firstParagraph(page)); + + await setValue(page, ''); + await focusEditor(page); + await page.keyboard.type('123', { delay: 20 }); + await pasteAtCurrentSelection(page); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('1231234567
'); + }); + + test('pasting into non-empty content truncates the pasted portion, keeping earlier text', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '1234567
'); + + await copySelectionFrom(firstParagraph(page)); + + await setValue(page, 'abcde
'); + await firstParagraph(page).click(); + await page.keyboard.press('End'); + await pasteAtCurrentSelection(page); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('abcde12345
'); + }); +}); + +test.describe('test-max-length mentions', () => { + test('mention that fits entirely keeps its mark', async ({ page }) => { + await gotoTestMaxLength(page); + await setValue(page, '123
'); + await setSelection(page, 4, 4); + await page.keyboard.type(' '); + await page.click(sel.startMentionButton); + + await page.fill(sel.mentionText, '@John'); + await page.click(sel.setMentionButton); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('1234
'); + await setSelection(page, 4, 4); + await page.waitForTimeout(100); + await page.keyboard.type(' '); + await page.click(sel.startMentionButton); + + await page.fill(sel.mentionText, '@Jonathan'); + await page.click(sel.setMentionButton); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('1234 @Jona
'); + await expect + .poll(async () => getHtmlOutput(page)) + .not.toContain('123456
'); + + await page.fill(sel.setLinkStart, '6'); + await page.fill(sel.setLinkEnd, '6'); + await page.fill(sel.setLinkText, 'ab'); + await page.fill(sel.setLinkUrl, 'https://example.com'); + await page.click(sel.applySetLink); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('123456ab
'); + }); + + test('manual link overflowing maxLength is truncated but keeps link styling', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '123456
'); + + await page.fill(sel.setLinkStart, '6'); + await page.fill(sel.setLinkEnd, '6'); + await page.fill(sel.setLinkText, 'abcdefgh'); + await page.fill(sel.setLinkUrl, 'https://example.com'); + await page.click(sel.applySetLink); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('abcd'); + await expect.poll(async () => getHtmlOutput(page)).not.toContain('efgh'); + }); +}); + +test.describe('test-max-length images', () => { + test('image that fits within maxLength is inserted', async ({ page }) => { + await gotoTestMaxLength(page); + await setValue(page, '123456789
'); + + await page.fill(sel.imageSrc, '/pw-e2e-ok.png'); + await page.fill(sel.imageWidth, '40'); + await page.fill(sel.imageHeight, '40'); + await page.click(sel.setImageButton); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('
{
+ await gotoTestMaxLength(page);
+ await setValue(page, '1234567890
'); + + await page.fill(sel.imageSrc, '/pw-e2e-ok.png'); + await page.fill(sel.imageWidth, '40'); + await page.fill(sel.imageHeight, '40'); + await page.click(sel.setImageButton); + + await page.waitForTimeout(200); + await expect.poll(async () => getHtmlOutput(page)).not.toContain('1234567890
'); + }); +}); + +test.describe('test-max-length replacing a selection with longer content', () => { + test('pasting over a selection that would grow the doc past maxLength truncates the paste', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, 'overflowing-clip-source
'); + + await copySelectionFrom(firstParagraph(page)); + + await setValue(page, '123xxx789
'); + // Select the "xxx" in the middle (positions 3..6). + await setSelection(page, 3, 6); + + await pasteAtCurrentSelection(page); + + await expect.poll(async () => getHtmlOutput(page)).toContain('123'); + const html = await getHtmlOutput(page); + expect(html).toContain('789
'); + expect(html).not.toContain('overflowing-clip-source'); + }); +}); 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..b331bc404 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -68,6 +68,8 @@ import com.swmansion.enriched.textinput.styles.ParametrizedStyles import com.swmansion.enriched.textinput.utils.EnrichedEditableFactory import com.swmansion.enriched.textinput.utils.EnrichedSelection import com.swmansion.enriched.textinput.utils.EnrichedSpanState +import com.swmansion.enriched.textinput.utils.MaxLength +import com.swmansion.enriched.textinput.utils.MaxLengthFilter import com.swmansion.enriched.textinput.utils.RichContentReceiver import com.swmansion.enriched.textinput.utils.ShortcutsHandler import com.swmansion.enriched.textinput.utils.mergeSpannables @@ -123,6 +125,8 @@ class EnrichedTextInputView : var spanWatcher: EnrichedSpanWatcher? = null var layoutManager: EnrichedTextInputViewLayoutManager = EnrichedTextInputViewLayoutManager(this) + var maxLength: Int = MaxLength.UNLIMITED + var shouldEmitHtml: Boolean = false var shouldEmitOnChangeText: Boolean = false var experimentalSynchronousEvents: Boolean = false @@ -232,6 +236,10 @@ class EnrichedTextInputView : setEditableFactory(EnrichedEditableFactory(spanWatcher)) addTextChangedListener(EnrichedTextWatcher(this)) + // a single filter covers every change made to the text - typing, dictation, + // IME composition, pasting and setting the value imperatively + filters = arrayOf(MaxLengthFilter(this)) + // Handle checkbox list item clicks this.setCheckboxClickListener() @@ -392,7 +400,11 @@ class EnrichedTextInputView : } } - val finalText = currentText.mergeSpannables(start, end, pastedSpannable, htmlStyle) + // the pasted fragment is what gets truncated - everything that follows the + // caret has to stay intact, so it can't be left to the maxLength filter + val truncatedPastedSpannable = truncateToRemainingLength(pastedSpannable, start, end) ?: return + + val finalText = currentText.mergeSpannables(start, end, truncatedPastedSpannable, htmlStyle) setValue(finalText, false) // replacement-safe: oldLength - removed + inserted @@ -405,6 +417,29 @@ class EnrichedTextInputView : parametrizedStyles?.afterTextChanged(editable, start.coerceAtMost(pasteEnd), pasteEnd) } + /** + * Shortens [pasted] so that it fits in what's left of [maxLength] once the `[start, end)` + * selection is replaced. Returns null when there's no room for it at all. + */ + private fun truncateToRemainingLength( + pasted: Spannable, + start: Int, + end: Int, + ): Spannable? { + if (maxLength == MaxLength.UNLIMITED) return pasted + + val currentText = text ?: return pasted + val keptLength = MaxLength.plainLengthOf(currentText) - MaxLength.plainLengthOf(currentText, start, end) + val capacity = maxLength - keptLength + + if (MaxLength.plainLengthOf(pasted) <= capacity) return pasted + + val cut = MaxLength.cutIndexIn(pasted, 0, pasted.length, capacity) + if (cut == 0) return if (start == end) null else SpannableString("") + + return pasted.subSequence(0, cut) as? Spannable ?: SpannableString(pasted.subSequence(0, cut)) + } + fun requestFocusProgrammatically() { requestFocus() inputMethodManager?.showSoftInput(this, 0) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 9dcbb8244..b35d36786 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -31,6 +31,7 @@ import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.styles.HtmlStyle +import com.swmansion.enriched.textinput.utils.MaxLength import com.swmansion.enriched.textinput.utils.jsonStringToStringMap @ReactModule(name = EnrichedTextInputViewManager.NAME) @@ -160,6 +161,14 @@ class EnrichedTextInputViewManager : view?.isEnabled = editable } + @ReactProp(name = "maxLength", defaultInt = MaxLength.UNLIMITED) + override fun setMaxLength( + view: EnrichedTextInputView?, + maxLength: Int, + ) { + view?.maxLength = maxLength + } + @ReactProp(name = "mentionIndicators") override fun setMentionIndicators( view: EnrichedTextInputView?, 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 387eec351..774557750 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 @@ -13,6 +13,7 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputLinkSpan import com.swmansion.enriched.textinput.spans.EnrichedInputMentionSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries +import com.swmansion.enriched.textinput.utils.replaceCountingInserted import com.swmansion.enriched.textinput.utils.safelyRemoveZWS class ParametrizedStyles( @@ -57,16 +58,15 @@ class ParametrizedStyles( spannable.removeSpan(span) } - if (start == end) { - spannable.insert(start, text) - } else { - spannable.replace(start, end, text) - } + val insertedLength = spannable.replaceCountingInserted(start, end, text) - val spanEnd = start + text.length - val span = EnrichedInputLinkSpan(url, view.htmlStyle, true) - val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, spanEnd) - spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) + // maxLength may have shortened the text, the link covers only what really + // made it into the input then + if (insertedLength > 0) { + val span = EnrichedInputLinkSpan(url, view.htmlStyle, true) + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, start + insertedLength) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) + } view.selection?.validateStyles() isSettingLinkSpan = false @@ -343,18 +343,20 @@ class ParametrizedStyles( val spannable = view.text as SpannableStringBuilder val (start, originalEnd) = view.selection.getInlineSelection() - if (start == originalEnd) { - spannable.insert(start, EnrichedConstants.ORC_STRING) - } else { + if (start != originalEnd) { val spans = spannable.getSpans(start, originalEnd, EnrichedInputImageSpan::class.java) for (s in spans) { spannable.removeSpan(s) } - - spannable.replace(start, originalEnd, EnrichedConstants.ORC_STRING) } - val (imageStart, imageEnd) = spannable.getSafeSpanBoundaries(start, start + 1) + // an image takes a single character and can't be truncated, so it is simply + // not added when maxLength leaves no room for it + val insertedLength = + spannable.replaceCountingInserted(start, originalEnd, EnrichedConstants.ORC_STRING) + if (insertedLength == 0) return + + val (imageStart, imageEnd) = spannable.getSafeSpanBoundaries(start, start + insertedLength) val span = EnrichedInputImageSpan.createEnrichedImageSpan(src, width.toInt(), height.toInt()) span.observeAsyncDrawableLoaded(view.text) @@ -367,11 +369,7 @@ class ParametrizedStyles( val spannable = view.text as SpannableStringBuilder val (start, end) = selection.getInlineSelection() - if (start == end) { - spannable.insert(start, indicator) - } else { - spannable.replace(start, end, indicator) - } + spannable.replaceCountingInserted(start, end, indicator) } fun setMentionSpan( @@ -393,12 +391,13 @@ class ParametrizedStyles( val end = mentionEnd ?: selectionEnd view.runAsATransaction { - spannable.replace(start, end, text) + val insertedLength = spannable.replaceCountingInserted(start, end, text) + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, start + insertedLength) - val span = EnrichedInputMentionSpan(text, indicator, attributes, view.htmlStyle) - val spanEnd = start + text.length - val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, spanEnd) - spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) + if (insertedLength == text.length) { + val span = EnrichedInputMentionSpan(text, indicator, attributes, view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) + } val hasSpaceAtTheEnd = spannable.length > safeEnd && spannable[safeEnd] == ' ' if (!hasSpaceAtTheEnd) { 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 576e10218..0e7e1c3ab 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 @@ -20,6 +20,26 @@ fun Spannable.getSafeSpanBoundaries( return Pair(safeStart, safeEnd) } +/** + * Replaces `[start, end)` with [text] and returns how many characters actually landed in the + * buffer - the `maxLength` filter may have shortened, or entirely rejected, the replacement. + */ +fun SpannableStringBuilder.replaceCountingInserted( + start: Int, + end: Int, + text: CharSequence, +): Int { + val lengthBefore = length + + if (start == end) { + insert(start, text) + } else { + replace(start, end, text) + } + + return length - lengthBefore + (end - start) +} + fun Spannable.getParagraphBounds( start: Int, end: Int, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt new file mode 100644 index 000000000..35298eb2a --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/MaxLengthFilter.kt @@ -0,0 +1,104 @@ +package com.swmansion.enriched.textinput.utils + +import android.text.InputFilter +import android.text.Spanned +import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.textinput.EnrichedTextInputView +import java.text.BreakIterator + +/** + * Helpers enforcing the `maxLength` prop. + */ +object MaxLength { + const val UNLIMITED = -1 + + /** Length of [text] as seen by the user (zero width spaces excluded). */ + fun plainLengthOf( + text: CharSequence, + start: Int = 0, + end: Int = text.length, + ): Int { + var length = 0 + for (i in start until end) { + if (text[i] != EnrichedConstants.ZWS) length++ + } + return length + } + + /** + * Index in `[start, end]` at which [text] has to be cut so that at most [capacity] plain + * characters are kept. The cut point snaps outwards to a whole grapheme cluster, so emoji, + * surrogate pairs and combining marks never end up split in half. + */ + fun cutIndexIn( + text: CharSequence, + start: Int, + end: Int, + capacity: Int, + ): Int { + var index = start + var kept = 0 + + while (index < end) { + if (text[index] != EnrichedConstants.ZWS) { + // zero width spaces are free, any other character needs the capacity + if (kept >= capacity) break + kept++ + } + index++ + } + + return snapOutwards(text, start, end, index) + } + + private fun snapOutwards( + text: CharSequence, + start: Int, + end: Int, + cut: Int, + ): Int { + if (cut <= start || cut >= end) return cut + + val iterator = BreakIterator.getCharacterInstance() + iterator.setText(text.subSequence(start, end).toString()) + + val localCut = cut - start + if (iterator.isBoundary(localCut)) return cut + + val next = iterator.following(localCut) + return if (next == BreakIterator.DONE) end else start + next + } +} + +/** + * Applies the `maxLength` limit to every change made to the editor's text - typing, dictation, IME + * composition, pasting and setting the value imperatively all go through the filters of the + * underlying `Editable`. + */ +class MaxLengthFilter( + private val view: EnrichedTextInputView, +) : InputFilter { + override fun filter( + source: CharSequence, + start: Int, + end: Int, + dest: Spanned, + dstart: Int, + dend: Int, + ): CharSequence? { + val maxLength = view.maxLength + if (maxLength == MaxLength.UNLIMITED) return null + + val keptLength = MaxLength.plainLengthOf(dest) - MaxLength.plainLengthOf(dest, dstart, dend) + val capacity = maxLength - keptLength + + if (MaxLength.plainLengthOf(source, start, end) <= capacity) { + // null keeps the original change + return null + } + + val cut = MaxLength.cutIndexIn(source, start, end, capacity) + + return if (cut <= start) "" else source.subSequence(start, cut) + } +} diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index e40322d84..61f26ebea 100644 --- a/apps/example-web/src/RouteSelector.tsx +++ b/apps/example-web/src/RouteSelector.tsx @@ -6,6 +6,7 @@ import { VisualRegression } from './testScreens/VisualRegression'; import { TestSubmitProps } from './testScreens/TestSubmitProps'; import { TestEnrichedText } from './testScreens/TestEnrichedText'; import { TestEllipsize } from './testScreens/TestEllipsize'; +import { TestMaxLength } from './testScreens/TestMaxLength'; import { useEffect, useState } from 'react'; export default function RouteSelector() { @@ -50,5 +51,9 @@ export default function RouteSelector() { return