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 `123456 John Doe`, style should be stripped +- runFlow: + file: '../subflows/set_editor_value.yaml' + env: + VALUE: '

12345 @John Doe

' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'maxlength_mention_stripped' diff --git a/.maestro/enrichedInput/screenshots/android/maxlength_link_kept.png b/.maestro/enrichedInput/screenshots/android/maxlength_link_kept.png new file mode 100644 index 000000000..cbe6180c2 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/maxlength_link_kept.png differ diff --git a/.maestro/enrichedInput/screenshots/android/maxlength_mention_stripped.png b/.maestro/enrichedInput/screenshots/android/maxlength_mention_stripped.png new file mode 100644 index 000000000..00039932c Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/maxlength_mention_stripped.png differ diff --git a/.maestro/enrichedInput/screenshots/android/maxlength_paste_limit.png b/.maestro/enrichedInput/screenshots/android/maxlength_paste_limit.png new file mode 100644 index 000000000..0935ba5b5 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/maxlength_paste_limit.png differ diff --git a/.maestro/enrichedInput/screenshots/android/maxlength_typing_limit.png b/.maestro/enrichedInput/screenshots/android/maxlength_typing_limit.png new file mode 100644 index 000000000..b5f70195a Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/maxlength_typing_limit.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png index 74947bcfd..eccc3da31 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png index 7a053e6c5..ffc98e142 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png index d80b9a756..990ccc8ea 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/maxlength_link_kept.png b/.maestro/enrichedInput/screenshots/ios/maxlength_link_kept.png new file mode 100644 index 000000000..991bbf340 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/maxlength_link_kept.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/maxlength_mention_stripped.png b/.maestro/enrichedInput/screenshots/ios/maxlength_mention_stripped.png new file mode 100644 index 000000000..75ac23688 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/maxlength_mention_stripped.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/maxlength_paste_limit.png b/.maestro/enrichedInput/screenshots/ios/maxlength_paste_limit.png new file mode 100644 index 000000000..14a17fe40 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/maxlength_paste_limit.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/maxlength_typing_limit.png b/.maestro/enrichedInput/screenshots/ios/maxlength_typing_limit.png new file mode 100644 index 000000000..35975e132 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/maxlength_typing_limit.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png index 7f0cfabe9..22d64de7e 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png index a32731ece..8875e5066 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png index b0c1a44ca..a1202dd8a 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.playwright/tests/maxLength.spec.ts b/.playwright/tests/maxLength.spec.ts new file mode 100644 index 000000000..6b6718c23 --- /dev/null +++ b/.playwright/tests/maxLength.spec.ts @@ -0,0 +1,287 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { copySelectionFrom } from '../helpers/clipboard'; + +async function pasteAtCurrentSelection(page: Page): Promise { + await page.keyboard.press('ControlOrMeta+V'); +} + +test.setTimeout(90_000); + +const sel = { + editorInner: '[data-testid="test-max-length-editor"] .eti-editor', + maxLengthInput: '[data-testid="test-max-length-maxlength-input"]', + htmlInput: '[data-testid="test-max-length-html-input"]', + setValueButton: '[data-testid="test-max-length-set-value-button"]', + htmlOutput: '[data-testid="test-max-length-html-output"]', + selectionStart: '[data-testid="test-max-length-selection-start"]', + selectionEnd: '[data-testid="test-max-length-selection-end"]', + applySelection: '[data-testid="test-max-length-apply-selection-button"]', + setLinkStart: '[data-testid="test-max-length-setlink-start"]', + setLinkEnd: '[data-testid="test-max-length-setlink-end"]', + setLinkText: '[data-testid="test-max-length-setlink-text"]', + setLinkUrl: '[data-testid="test-max-length-setlink-url"]', + applySetLink: '[data-testid="test-max-length-apply-setlink-button"]', + mentionIndicator: '[data-testid="test-max-length-mention-indicator"]', + mentionText: '[data-testid="test-max-length-mention-text"]', + setMentionButton: '[data-testid="test-max-length-set-mention-button"]', + startMentionButton: '[data-testid="test-max-length-start-mention-button"]', + imageSrc: '[data-testid="test-max-length-image-src"]', + imageWidth: '[data-testid="test-max-length-image-width"]', + imageHeight: '[data-testid="test-max-length-image-height"]', + setImageButton: '[data-testid="test-max-length-set-image-button"]', +} as const; + +async function gotoTestMaxLength(page: Page): Promise { + await page.goto('/test-max-length'); + await page.waitForSelector(sel.editorInner); + + const routePattern = '**/pw-e2e-ok.png'; + const pngBody = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' + ); + await page.route(routePattern, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/png', + body: pngBody, + }); + }); +} + +async function getHtmlOutput(page: Page): Promise { + return (await page.locator(sel.htmlOutput).textContent()) ?? ''; +} + +async function setValue(page: Page, html: string): Promise { + await page.fill(sel.htmlInput, html); + await page.click(sel.setValueButton); + await expect + .poll(async () => { + const t = await getHtmlOutput(page); + return t.startsWith(''); + }) + .toBe(true); +} + +async function setSelection( + page: Page, + start: number, + end: number +): Promise { + await page.fill(sel.selectionStart, String(start)); + await page.fill(sel.selectionEnd, String(end)); + await page.click(sel.applySelection); + // Clicking the button moves DOM focus away from the editor even though the + // ProseMirror selection itself is preserved; refocus so keyboard input lands. + await page.locator(`${sel.editorInner} .ProseMirror`).focus(); +} + +function firstParagraph(page: Page) { + return page.locator(sel.editorInner).locator('p').first(); +} + +async function focusEditor(page: Page) { + const editor = page.locator(sel.editorInner); + await firstParagraph(page).click(); + await expect(editor.locator('.ProseMirror')).toBeFocused(); + return editor; +} + +test.describe('test-max-length typing', () => { + test('typing stops accepting characters once maxLength is reached', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '

'); + + await focusEditor(page); + await page.keyboard.type('12345678901234', { delay: 20 }); + + await expect + .poll(async () => getHtmlOutput(page)) + .toContain('

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('@John'); + }); + + test('mention overflowing maxLength is truncated and loses its mark', async ({ + page, + }) => { + await gotoTestMaxLength(page); + await setValue(page, '

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(' { + test('manual link that fits entirely keeps its mark', 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, '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(' getHtmlOutput(page)) + .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 ; } + if (path === '/test-max-length') { + return ; + } + return ; } diff --git a/apps/example-web/src/testScreens/TestMaxLength.tsx b/apps/example-web/src/testScreens/TestMaxLength.tsx new file mode 100644 index 000000000..12362e126 --- /dev/null +++ b/apps/example-web/src/testScreens/TestMaxLength.tsx @@ -0,0 +1,254 @@ +import { useRef, useState, type ChangeEvent } from 'react'; +import { + EnrichedTextInput, + type EnrichedInputStyle, + type EnrichedTextInputInstance, +} from 'react-native-enriched-html'; +import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; + +function toInteger(value: string): number { + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function TestMaxLength() { + const ref = useRef(null); + const [maxLengthInput, setMaxLengthInput] = useState('10'); + const [htmlInput, setHtmlInput] = useState('

'); + const [editorHtml, setEditorHtml] = useState(''); + const [selStartInput, setSelStartInput] = useState('0'); + const [selEndInput, setSelEndInput] = useState('0'); + const [linkStartInput, setLinkStartInput] = useState('0'); + const [linkEndInput, setLinkEndInput] = useState('0'); + const [linkTextInput, setLinkTextInput] = useState('link'); + const [linkUrlInput, setLinkUrlInput] = useState('https://example.com'); + const [mentionIndicatorInput, setMentionIndicatorInput] = useState('@'); + const [mentionTextInput, setMentionTextInput] = useState('Jane'); + const [imageSrcInput, setImageSrcInput] = useState('/pw-e2e-ok.png'); + const [imageWidthInput, setImageWidthInput] = useState('40'); + const [imageHeightInput, setImageHeightInput] = useState('40'); + + const maxLength = + maxLengthInput.trim() === '' ? undefined : toInteger(maxLengthInput); + + return ( +
+
ref.current?.focus()} + > + { + setEditorHtml(e.nativeEvent.value); + }} + /> +
+ +
+ +
+ +