diff --git a/.maestro/enrichedInput/flows/insert_value.yaml b/.maestro/enrichedInput/flows/insert_value.yaml new file mode 100644 index 000000000..bf7caefaa --- /dev/null +++ b/.maestro/enrichedInput/flows/insert_value.yaml @@ -0,0 +1,134 @@ +appId: swmansion.enriched.example +--- +# Verifies insertValue behaviour for different scenarios + +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +# ── Scenario 1: plain text into empty editor ───────────────────────────────── +- tapOn: + id: 'focus-button' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: 'Hello, world!' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_plain_into_empty' + +# ── Scenario 2: plain text inherits active inline style ─────────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- inputText: 'before' +- tapOn: + id: 'toolbar-bold' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: 'BOLD' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_inherits_inline_style' + +# ── Scenario 3: plain text into empty paragraph-styled line ─────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- tapOn: + id: 'toolbar-heading-1' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: 'Heading' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_into_empty_paragraph_style' + +# ── Scenario 4: heading HTML into empty line ────────────────────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: '

Hello

' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_heading_into_empty_line' + +# ── Scenario 5: heading HTML into line with existing content ────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- inputText: 'before' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: '

inserted

' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_heading_into_nonempty_line' + +# ── Scenario 6: bold HTML blocked inside code block ─────────────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- tapOn: + id: 'toolbar-code-block' + +- inputText: 'code' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: 'bold' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_bold_blocked_in_codeblock' + +# ── Scenario 7: multi-paragraph HTML at caret ───────────────────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- inputText: 'One' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: '

heading

normal

' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_multi_paragraph' diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_bold_blocked_in_codeblock.png b/.maestro/enrichedInput/screenshots/android/insert_value_bold_blocked_in_codeblock.png new file mode 100644 index 000000000..6e2e9f2a5 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_bold_blocked_in_codeblock.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_empty_line.png b/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_empty_line.png new file mode 100644 index 000000000..bbc8db6e7 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_empty_line.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_nonempty_line.png b/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_nonempty_line.png new file mode 100644 index 000000000..4b2bfd539 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_nonempty_line.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_inherits_inline_style.png b/.maestro/enrichedInput/screenshots/android/insert_value_inherits_inline_style.png new file mode 100644 index 000000000..775e4f288 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_inherits_inline_style.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_into_empty_paragraph_style.png b/.maestro/enrichedInput/screenshots/android/insert_value_into_empty_paragraph_style.png new file mode 100644 index 000000000..9c8f0add7 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_into_empty_paragraph_style.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_multi_paragraph.png b/.maestro/enrichedInput/screenshots/android/insert_value_multi_paragraph.png new file mode 100644 index 000000000..30b45e745 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_multi_paragraph.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_plain_into_empty.png b/.maestro/enrichedInput/screenshots/android/insert_value_plain_into_empty.png new file mode 100644 index 000000000..38ded58b8 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_plain_into_empty.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..81b973b2d 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..9cb43ad99 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..419c4545e 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/insert_value_bold_blocked_in_codeblock.png b/.maestro/enrichedInput/screenshots/ios/insert_value_bold_blocked_in_codeblock.png new file mode 100644 index 000000000..a8c341ddc Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_bold_blocked_in_codeblock.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_empty_line.png b/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_empty_line.png new file mode 100644 index 000000000..12b90e130 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_empty_line.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_nonempty_line.png b/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_nonempty_line.png new file mode 100644 index 000000000..3a88651b8 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_nonempty_line.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_inherits_inline_style.png b/.maestro/enrichedInput/screenshots/ios/insert_value_inherits_inline_style.png new file mode 100644 index 000000000..6185fc852 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_inherits_inline_style.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_into_empty_paragraph_style.png b/.maestro/enrichedInput/screenshots/ios/insert_value_into_empty_paragraph_style.png new file mode 100644 index 000000000..c9b43ede8 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_into_empty_paragraph_style.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_multi_paragraph.png b/.maestro/enrichedInput/screenshots/ios/insert_value_multi_paragraph.png new file mode 100644 index 000000000..d89f574c2 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_multi_paragraph.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_plain_into_empty.png b/.maestro/enrichedInput/screenshots/ios/insert_value_plain_into_empty.png new file mode 100644 index 000000000..ba480fd1b Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_plain_into_empty.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..15e234824 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..b12d1ed25 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..51a3213d2 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/.maestro/enrichedInput/subflows/insert_editor_value.yaml b/.maestro/enrichedInput/subflows/insert_editor_value.yaml new file mode 100644 index 000000000..c91c205f0 --- /dev/null +++ b/.maestro/enrichedInput/subflows/insert_editor_value.yaml @@ -0,0 +1,18 @@ +appId: swmansion.enriched.example +--- +- tapOn: + id: 'insert-value-button' + +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: 'value-modal-input' + timeout: 10000 + +- tapOn: + id: 'value-modal-input' + +- inputText: ${VALUE} + +- tapOn: + id: 'value-modal-submit-button' diff --git a/.playwright/helpers/insertValue.ts b/.playwright/helpers/insertValue.ts new file mode 100644 index 000000000..af8996c73 --- /dev/null +++ b/.playwright/helpers/insertValue.ts @@ -0,0 +1,114 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { toolbarButton } from './toolbar'; + +export const insertValueSelectors = { + editor: '[data-testid="insert-value-editor"]', + editorInner: '[data-testid="insert-value-editor"] .eti-editor', + htmlOutput: '[data-testid="insert-value-html-output"]', + setupHtmlInput: '[data-testid="setup-html-input"]', + setupSetValueButton: '[data-testid="setup-set-value-button"]', + selectionStart: '[data-testid="insert-value-selection-start"]', + selectionEnd: '[data-testid="insert-value-selection-end"]', + applySelectionButton: '[data-testid="insert-value-apply-selection-button"]', + insertInput: '[data-testid="insert-value-input"]', + insertSubmit: '[data-testid="insert-value-submit-button"]', + focusButton: '[data-testid="focus-button"]', + clearButton: '[data-testid="clear-button"]', + selectionEndResult: '[data-testid="insert-value-current-selection-end"]', +} as const; + +export function insertValueEditorLocator(page: Page): Locator { + return page.locator(insertValueSelectors.editorInner); +} + +export async function gotoInsertValue(page: Page): Promise { + await page.goto('/test-insert-value'); + await page.waitForSelector(insertValueSelectors.editorInner); +} + +export async function focusInsertValueEditor(page: Page): Promise { + await page.click(insertValueSelectors.focusButton); + const editor = insertValueEditorLocator(page); + await expect( + editor.locator('[contenteditable="true"]').first() + ).toBeFocused(); + return editor; +} + +export async function clearInsertValueEditor(page: Page): Promise { + await page.click(insertValueSelectors.clearButton); + await expect.poll(async () => getInsertValueHtml(page)).toBe(''); +} + +export async function getInsertValueHtml(page: Page): Promise { + return ( + (await page.locator(insertValueSelectors.htmlOutput).textContent()) ?? '' + ); +} + +export async function typeInInsertValueEditor( + page: Page, + text: string +): Promise { + const editor = await focusInsertValueEditor(page); + await editor + .locator('[contenteditable="true"]') + .first() + .pressSequentially(text); + await expect + .poll(async () => + page.locator(insertValueSelectors.selectionEndResult).textContent() + ) + .toBe(String(text.length)); +} + +export async function setInsertValueEditorHtml( + page: Page, + html: string +): Promise { + await page.fill(insertValueSelectors.setupHtmlInput, html); + await page.click(insertValueSelectors.setupSetValueButton); + await expect.poll(async () => getInsertValueHtml(page)).toMatch(/^/); +} + +export async function applyInsertValueSelection( + page: Page, + start: number, + end: number = start +): Promise { + await page.fill(insertValueSelectors.selectionStart, String(start)); + await page.fill(insertValueSelectors.selectionEnd, String(end)); + await page.click(insertValueSelectors.applySelectionButton); + await expect + .poll(async () => + page.locator(insertValueSelectors.selectionEndResult).textContent() + ) + .toBe(String(end)); +} + +export async function insertValueAt( + page: Page, + value: string, + start: number, + end: number = start +): Promise { + await applyInsertValueSelection(page, start, end); + await insertEditorValue(page, value); +} + +export async function insertEditorValue( + page: Page, + value: string +): Promise { + await page.fill(insertValueSelectors.insertInput, value); + await page.click(insertValueSelectors.insertSubmit); + await expect.poll(async () => getInsertValueHtml(page)).not.toBe(''); +} + +export async function resetInsertValueScenario(page: Page): Promise { + await clearInsertValueEditor(page); + await focusInsertValueEditor(page); +} + +export { toolbarButton }; diff --git a/.playwright/screenshots/insert-value-blockquote-inherits-alignment.png b/.playwright/screenshots/insert-value-blockquote-inherits-alignment.png new file mode 100644 index 000000000..4b0c2b173 Binary files /dev/null and b/.playwright/screenshots/insert-value-blockquote-inherits-alignment.png differ diff --git a/.playwright/screenshots/insert-value-bold-blocked-in-codeblock.png b/.playwright/screenshots/insert-value-bold-blocked-in-codeblock.png new file mode 100644 index 000000000..a40ea8719 Binary files /dev/null and b/.playwright/screenshots/insert-value-bold-blocked-in-codeblock.png differ diff --git a/.playwright/screenshots/insert-value-codeblock-into-list-item.png b/.playwright/screenshots/insert-value-codeblock-into-list-item.png new file mode 100644 index 000000000..88dc2d78d Binary files /dev/null and b/.playwright/screenshots/insert-value-codeblock-into-list-item.png differ diff --git a/.playwright/screenshots/insert-value-heading-into-empty-line.png b/.playwright/screenshots/insert-value-heading-into-empty-line.png new file mode 100644 index 000000000..e7f7c52bd Binary files /dev/null and b/.playwright/screenshots/insert-value-heading-into-empty-line.png differ diff --git a/.playwright/screenshots/insert-value-heading-into-nonempty-line.png b/.playwright/screenshots/insert-value-heading-into-nonempty-line.png new file mode 100644 index 000000000..69af26105 Binary files /dev/null and b/.playwright/screenshots/insert-value-heading-into-nonempty-line.png differ diff --git a/.playwright/screenshots/insert-value-inherits-inline-style.png b/.playwright/screenshots/insert-value-inherits-inline-style.png new file mode 100644 index 000000000..1bf7438d0 Binary files /dev/null and b/.playwright/screenshots/insert-value-inherits-inline-style.png differ diff --git a/.playwright/screenshots/insert-value-into-empty-paragraph-style.png b/.playwright/screenshots/insert-value-into-empty-paragraph-style.png new file mode 100644 index 000000000..6b8565546 Binary files /dev/null and b/.playwright/screenshots/insert-value-into-empty-paragraph-style.png differ diff --git a/.playwright/screenshots/insert-value-multi-paragraph.png b/.playwright/screenshots/insert-value-multi-paragraph.png new file mode 100644 index 000000000..06e7c0396 Binary files /dev/null and b/.playwright/screenshots/insert-value-multi-paragraph.png differ diff --git a/.playwright/screenshots/insert-value-plain-into-empty.png b/.playwright/screenshots/insert-value-plain-into-empty.png new file mode 100644 index 000000000..191a5e5e2 Binary files /dev/null and b/.playwright/screenshots/insert-value-plain-into-empty.png differ diff --git a/.playwright/tests/insertValue.spec.ts b/.playwright/tests/insertValue.spec.ts new file mode 100644 index 000000000..64f5ed027 --- /dev/null +++ b/.playwright/tests/insertValue.spec.ts @@ -0,0 +1,141 @@ +import { test, expect } from '@playwright/test'; + +import { + getInsertValueHtml, + gotoInsertValue, + insertEditorValue, + insertValueAt, + insertValueEditorLocator, + resetInsertValueScenario, + setInsertValueEditorHtml, + toolbarButton, + typeInInsertValueEditor, +} from '../helpers/insertValue'; + +test.describe('insertValue', () => { + test.beforeEach(async ({ page }) => { + await gotoInsertValue(page); + }); + + test('plain text into empty editor', async ({ page }) => { + await resetInsertValueScenario(page); + await insertEditorValue(page, 'Hello, world!'); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_plain_into_empty.png' + ); + }); + + test('plain text inherits active inline style', async ({ page }) => { + await resetInsertValueScenario(page); + await typeInInsertValueEditor(page, 'before'); + await toolbarButton(page, 'bold').click(); + await insertEditorValue(page, 'BOLD'); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_inherits_inline_style.png' + ); + }); + + test('plain text into empty paragraph-styled line', async ({ page }) => { + await resetInsertValueScenario(page); + await toolbarButton(page, 'h1').click(); + await insertEditorValue(page, 'Heading'); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_into_empty_paragraph_style.png' + ); + }); + + test('heading HTML into empty line', async ({ page }) => { + await resetInsertValueScenario(page); + await insertEditorValue(page, '

Hello

'); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_heading_into_empty_line.png' + ); + }); + + test('heading HTML into line with existing content', async ({ page }) => { + await resetInsertValueScenario(page); + await typeInInsertValueEditor(page, 'before'); + await insertEditorValue(page, '

inserted

'); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_heading_into_nonempty_line.png' + ); + }); + + test('bold HTML blocked inside code block', async ({ page }) => { + await resetInsertValueScenario(page); + await toolbarButton(page, 'codeBlock').click(); + await typeInInsertValueEditor(page, 'code'); + await insertEditorValue(page, 'bold'); + + const html = await getInsertValueHtml(page); + expect(html).toMatch(/[\s\S]*codebold[\s\S]*<\/codeblock>/i); + expect(html).not.toMatch(//i); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_bold_blocked_in_codeblock.png' + ); + }); + + test('multi-paragraph HTML at caret', async ({ page }) => { + await resetInsertValueScenario(page); + await typeInInsertValueEditor(page, 'One'); + await insertEditorValue(page, '

heading

normal

'); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_multi_paragraph.png' + ); + }); + + test('codeblock merges into a list item without extra blank lines', async ({ + page, + }) => { + await gotoInsertValue(page); + await setInsertValueEditorHtml( + page, + '
  • one
  • asdfgh
  • two
' + ); + await insertValueAt( + page, + 'World', + 4 + 3 + ); + + const html = await getInsertValueHtml(page); + expect(html).toMatch(/[\s\S]*asdWorldfgh[\s\S]*<\/codeblock>/i); + expect(html).toMatch(/
    [\s\S]*
  • one<\/li>[\s\S]*<\/ul>/i); + expect(html).toMatch(/
      [\s\S]*
    • two<\/li>[\s\S]*<\/ul>/i); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_codeblock_into_list_item.png' + ); + }); + + test('blockquote inserted into centered line inherits alignment', async ({ + page, + }) => { + await gotoInsertValue(page); + await setInsertValueEditorHtml( + page, + '

      asdfgh

      ' + ); + await insertValueAt( + page, + '

      World

      ', + 3 + ); + + const html = await getInsertValueHtml(page); + expect(html).toMatch( + /
      [\s\S]*

      [\s\S]*asdWorldfgh[\s\S]*<\/p>[\s\S]*<\/blockquote>/i + ); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_blockquote_inherits_alignment.png' + ); + }); +}); 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..b39f4e134 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -70,6 +70,7 @@ import com.swmansion.enriched.textinput.utils.EnrichedSelection import com.swmansion.enriched.textinput.utils.EnrichedSpanState import com.swmansion.enriched.textinput.utils.RichContentReceiver import com.swmansion.enriched.textinput.utils.ShortcutsHandler +import com.swmansion.enriched.textinput.utils.isStyleBlockedOnRange import com.swmansion.enriched.textinput.utils.mergeSpannables import com.swmansion.enriched.textinput.utils.setCheckboxClickListener import com.swmansion.enriched.textinput.utils.zwsCountBefore @@ -469,6 +470,68 @@ class EnrichedTextInputView : setSelection(actualStart, actualEnd) } + fun insertValue( + value: String, + start: Int, + end: Int, + ) { + val currentText = text as Editable + val textLength = currentText.length + + // Capture active inline styles before setValue, which runs as a transaction and skips the + // text watcher style pass that would otherwise materialise them. + val activeInlineStyles = EnrichedSpans.inlineSpans.keys.filter { spanState?.getStart(it) != null } + + if (textLength == 0) { + setValue(value) + applyActiveInlineStyles(activeInlineStyles, 0, text?.length ?: 0) + return + } + + // Use coerceIn to ensure indices are within [0, textLength] and that start <= end + val safeStart = getActualIndex(start).coerceIn(0, textLength) + val safeEnd = getActualIndex(end).coerceIn(safeStart, textLength) + + // Skip past ZWS anchors so inserted text lands inside paragraph spans rather than before them. + var adjustedStart = safeStart + while (adjustedStart < textLength && currentText[adjustedStart] == EnrichedConstants.ZWS) { + adjustedStart++ + } + val adjustedEnd = safeEnd.coerceAtLeast(adjustedStart) + + val newText = parseText(value) as Spannable + + val finalText = currentText.mergeSpannables(adjustedStart, adjustedEnd, newText, htmlStyle) + setValue(finalText, false) + + // replacement-safe: oldLength - removed + inserted + val insertedLength = finalText.length - (textLength - (adjustedEnd - adjustedStart)) + val insertedEnd = (adjustedStart + insertedLength).coerceIn(0, finalText.length) + setSelection(insertedEnd) + + applyActiveInlineStyles(activeInlineStyles, adjustedStart, insertedEnd) + } + + /** + * Re-applies inline styles that were active before an insertion onto the inserted range, + * mirroring what InlineStyles.afterTextChanged does for regularly typed text. + */ + private fun applyActiveInlineStyles( + styles: List, + start: Int, + end: Int, + ) { + if (styles.isEmpty() || start >= end) return + val spannable = text as? Spannable ?: return + + for (style in styles) { + if (isStyleBlockedOnRange(style, start, end, spannable, htmlStyle)) continue + inlineStyles?.applyStyleOnRange(style, start, end) + } + + selection?.validateStyles() + } + // Helper: Walks through the string skipping ZWSPs to find the Nth visible character private fun getActualIndex(visibleIndex: Int): Int { val currentText = text as Spannable 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..000b1eaf9 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -354,6 +354,15 @@ class EnrichedTextInputViewManager : view?.setCustomSelection(start, end) } + override fun insertValue( + view: EnrichedTextInputView?, + value: String, + start: Int, + end: Int, + ) { + view?.insertValue(value, start, end) + } + override fun toggleBold(view: EnrichedTextInputView?) { view?.verifyAndToggleStyle(EnrichedSpans.BOLD) } 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..abeda6eb3 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 @@ -3,11 +3,15 @@ package com.swmansion.enriched.textinput.utils import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder +import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedSpanFlags +import com.swmansion.enriched.common.spans.EnrichedImageSpan +import com.swmansion.enriched.common.spans.EnrichedMentionSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedBlockSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedParagraphSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans +import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan import com.swmansion.enriched.textinput.styles.HtmlStyle fun Spannable.getSafeSpanBoundaries( @@ -62,25 +66,25 @@ private fun getStyleForSpan(span: EnrichedSpan): String? = config.clazz.isInstance(span) }?.key -private fun Spannable.removeBlockedPasteStyles( - start: Int, - pastedSpannable: Spannable, +private fun Spannable.removeBlockedIncomingStyles( + insertStart: Int, + incomingSpannable: Spannable, htmlStyle: HtmlStyle, ): Spannable { - val pastedSpans = pastedSpannable.getSpans(0, pastedSpannable.length, EnrichedSpan::class.java) + val incomingSpans = incomingSpannable.getSpans(0, incomingSpannable.length, EnrichedSpan::class.java) - for (span in pastedSpans) { + for (span in incomingSpans) { val style = getStyleForSpan(span) ?: continue val blockingStyles = EnrichedSpans.getMergingConfigForStyle(style, htmlStyle)?.blockingStyles ?: continue if (blockingStyles.isEmpty()) continue - val spanStart = pastedSpannable.getSpanStart(span) - val spanEnd = pastedSpannable.getSpanEnd(span) + val spanStart = incomingSpannable.getSpanStart(span) + val spanEnd = incomingSpannable.getSpanEnd(span) if (spanStart == -1 || spanEnd == -1 || spanStart == spanEnd) continue - val pastedStart = start + spanStart - val pastedEnd = start + spanEnd - if (blockingStyles.any { hasStyleInRange(it, pastedStart, pastedEnd) }) { + val absoluteStart = insertStart + spanStart + val absoluteEnd = insertStart + spanEnd + if (blockingStyles.any { hasStyleInRange(it, absoluteStart, absoluteEnd) }) { removeSpan(span) } } @@ -88,50 +92,142 @@ private fun Spannable.removeBlockedPasteStyles( return this } +private fun isBlockLevel(span: Any): Boolean = span is EnrichedParagraphSpan || span is EnrichedBlockSpan + +/** + * Clears a conflicting span over [rangeStart, rangeEnd), keeping whatever falls outside it so that + * untouched text does not lose its styling. A continuous block style spanning several paragraphs + * therefore only loses the affected ones. Mentions and images carry a single entity and cannot be + * split, so they are dropped as a whole. + */ +private fun SpannableStringBuilder.clearConflictingSpan( + span: EnrichedSpan, + rangeStart: Int, + rangeEnd: Int, + htmlStyle: HtmlStyle, +) { + val spanStart = getSpanStart(span) + val spanEnd = getSpanEnd(span) + val flags = getSpanFlags(span) + removeSpan(span) + + if (span !is EnrichedInputSpan || span is EnrichedMentionSpan || span is EnrichedImageSpan) return + + // Block level styles own whole lines, so their leftovers must not swallow the separating newline + val isBlock = isBlockLevel(span) + val leftEnd = if (isBlock && rangeStart > 0 && this[rangeStart - 1] == '\n') rangeStart - 1 else rangeStart + val rightStart = if (isBlock && rangeEnd < length && this[rangeEnd] == '\n') rangeEnd + 1 else rangeEnd + + if (spanStart < leftEnd) { + val left = span.rebuildWithStyle(htmlStyle) + setSpan(left, spanStart, leftEnd, EnrichedSpanFlags.forSpan(left, flags)) + } + + if (rightStart < spanEnd) { + val right = span.rebuildWithStyle(htmlStyle) + setSpan(right, rightStart, spanEnd, EnrichedSpanFlags.forSpan(right, flags)) + } +} + +/** + * Drops the document styles that the merged in content conflicts with, mirroring how toggling a + * style clears its conflicts: block level styles resolve over the whole paragraph, inline ones only + * over the range they cover. Spans that arrived with [mergedSpannable] are left alone. + */ +private fun SpannableStringBuilder.removeConflictingStyles( + mergedSpannable: Spannable, + htmlStyle: HtmlStyle, +) { + val mergedSpans = mergedSpannable.getSpans(0, mergedSpannable.length, EnrichedSpan::class.java) + + for (span in mergedSpans) { + val spanStart = getSpanStart(span) + val spanEnd = getSpanEnd(span) + + if (spanStart == -1 || spanEnd == -1 || spanStart == spanEnd) continue + + val style = getStyleForSpan(span) ?: continue + val conflictingStyles = EnrichedSpans.getMergingConfigForStyle(style, htmlStyle)?.conflictingStyles ?: continue + if (conflictingStyles.isEmpty()) continue + + val (rangeStart, rangeEnd) = + if (isBlockLevel(span)) getParagraphBounds(spanStart, spanEnd) else Pair(spanStart, spanEnd) + + for (conflictingStyle in conflictingStyles) { + val type = EnrichedSpans.allSpans[conflictingStyle]?.clazz ?: continue + + for (existing in getSpans(rangeStart, rangeEnd, type)) { + if (existing !is EnrichedSpan || mergedSpans.any { it === existing }) { + continue + } + clearConflictingSpan(existing, rangeStart, rangeEnd, htmlStyle) + } + } + } +} + fun Spannable.mergeSpannables( start: Int, end: Int, string: String, - htmlStyle: HtmlStyle? = null, + htmlStyle: HtmlStyle, ): Spannable = this.mergeSpannables(start, end, SpannableString(string), htmlStyle) fun Spannable.mergeSpannables( start: Int, end: Int, spannable: Spannable, - htmlStyle: HtmlStyle? = null, + htmlStyle: HtmlStyle, ): Spannable { var finalStart = start var finalEnd = end - val builder = SpannableStringBuilder(this) - val startBlockSpans = spannable.getSpans(0, 0, EnrichedBlockSpan::class.java) - val startParagraphSpans = spannable.getSpans(0, 0, EnrichedParagraphSpan::class.java) - val endBlockSpans = spannable.getSpans(this.length, this.length, EnrichedBlockSpan::class.java) - val endParagraphSpans = spannable.getSpans(this.length, this.length, EnrichedParagraphSpan::class.java) val (paragraphStart, paragraphEnd) = this.getParagraphBounds(start, end) - val isNewLineStart = startBlockSpans.isNotEmpty() || startParagraphSpans.isNotEmpty() - val isNewLineEnd = endBlockSpans.isNotEmpty() || endParagraphSpans.isNotEmpty() - val pastedHasOwnStyles = + val incomingHasOwnBlockStyles = 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 - finalEnd = end + 1 - } + // ZWS anchors are not content, so a line holding nothing else still counts as empty + val hasContentBefore = (paragraphStart until start).any { this[it] != EnrichedConstants.ZWS } + val hasContentAfter = (end until paragraphEnd).any { this[it] != EnrichedConstants.ZWS } - if (isNewLineEnd && end != paragraphEnd) { - builder.insert(finalEnd, "\n") + if (incomingHasOwnBlockStyles && !hasContentBefore && !hasContentAfter) { + finalStart = paragraphStart + finalEnd = paragraphEnd } builder.replace(finalStart, finalEnd, spannable) - // Manually extend existing paragraph/block spans to cover the pasted text. - if (!pastedHasOwnStyles) { - val pasteEnd = finalStart + spannable.length + if (incomingHasOwnBlockStyles) { + // Extend each incoming block/paragraph span to cover its own paragraph so the style applies + // to existing text on the same line, matching toggle semantics + val insertEnd = finalStart + spannable.length + + val incomingBlockSpans = builder.getSpans(finalStart, insertEnd, EnrichedBlockSpan::class.java) + val incomingParagraphSpans = builder.getSpans(finalStart, insertEnd, EnrichedParagraphSpan::class.java) + val incomingSpans = incomingBlockSpans.toList() + incomingParagraphSpans.toList() + + for (span in incomingSpans) { + val spanStart = builder.getSpanStart(span) + val spanEnd = builder.getSpanEnd(span) + if (spanStart == -1) continue + + val (spanParaStart, spanParaEnd) = builder.getParagraphBounds(spanStart, spanEnd) + if (spanStart <= spanParaStart && spanEnd >= spanParaEnd) continue + + val flags = builder.getSpanFlags(span) + builder.removeSpan(span) + builder.setSpan( + span, + spanParaStart.coerceAtMost(spanStart), + spanParaEnd.coerceAtLeast(spanEnd), + EnrichedSpanFlags.forSpan(span, flags), + ) + } + } else { + // No own styles - extend existing paragraph/block spans to cover the inserted text + val insertEnd = finalStart + spannable.length val affectedParagraphSpans = builder.getSpans(finalStart, finalStart, EnrichedParagraphSpan::class.java) val affectedBlockSpans = builder.getSpans(finalStart, finalStart, EnrichedBlockSpan::class.java) @@ -140,18 +236,18 @@ fun Spannable.mergeSpannables( for (span in affectedSpans) { val spanStart = builder.getSpanStart(span) val spanEnd = builder.getSpanEnd(span) - if (spanStart == -1 || spanEnd >= pasteEnd) continue + if (spanStart == -1 || spanEnd >= insertEnd) continue - val (_, newParagraphEnd) = builder.getParagraphBounds(spanStart, pasteEnd) + val (_, newParagraphEnd) = builder.getParagraphBounds(spanStart, insertEnd) val flags = builder.getSpanFlags(span) builder.removeSpan(span) builder.setSpan(span, spanStart, newParagraphEnd, EnrichedSpanFlags.forSpan(span, flags)) } } - htmlStyle?.let { - builder.removeBlockedPasteStyles(finalStart, spannable, it) - } + // Blocking runs first so styles that never made it in are not treated as conflict winners + builder.removeBlockedIncomingStyles(finalStart, spannable, htmlStyle) + builder.removeConflictingStyles(spannable, htmlStyle) return builder } diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index e40322d84..e0e2d543a 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 { TestInsertValue } from './testScreens/TestInsertValue'; import { useEffect, useState } from 'react'; export default function RouteSelector() { @@ -50,5 +51,9 @@ export default function RouteSelector() { return ; } + if (path === '/test-insert-value') { + return ; + } + return ; } diff --git a/apps/example-web/src/testScreens/TestInsertValue.tsx b/apps/example-web/src/testScreens/TestInsertValue.tsx new file mode 100644 index 000000000..f9c80d577 --- /dev/null +++ b/apps/example-web/src/testScreens/TestInsertValue.tsx @@ -0,0 +1,235 @@ +import { useRef, useState, type ChangeEvent } from 'react'; +import { + EnrichedTextInput, + type EnrichedInputStyle, + type EnrichedTextInputInstance, + type OnChangeSelectionEvent, + type OnChangeStateEvent, +} from 'react-native-enriched-html'; +import { Toolbar } from '../components/Toolbar'; +import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; + +function toInteger(value: string): number { + const parsed = parseInt(value, 10); + return isNaN(parsed) ? 0 : parsed; +} + +export function TestInsertValue() { + const ref = useRef(null); + const [setupHtmlInput, setSetupHtmlInput] = useState(''); + const [insertInput, setInsertInput] = useState(''); + const [selectionStartInput, setSelectionStartInput] = useState('0'); + const [selectionEndInput, setSelectionEndInput] = useState('0'); + const [editorState, setEditorState] = useState( + null + ); + const [selection, setSelection] = useState( + null + ); + const [editorHtml, setEditorHtml] = useState(''); + + const handleClear = () => { + ref.current?.setValue(''); + setSetupHtmlInput(''); + setInsertInput(''); + setSelectionStartInput('0'); + setSelectionEndInput('0'); + setEditorHtml(''); + }; + + const handleSetValue = () => { + ref.current?.setValue(setupHtmlInput); + }; + + const handleApplySelection = () => { + ref.current?.setSelection( + toInteger(selectionStartInput), + toInteger(selectionEndInput) + ); + }; + + const handleInsert = () => { + ref.current?.insertValue( + insertInput, + selection?.start ?? 0, + selection?.end ?? 0 + ); + }; + + return ( +

      +
      ref.current?.focus()} + > + { + setEditorHtml(e.nativeEvent.value); + }} + onChangeState={(e) => { + setEditorState(e.nativeEvent); + }} + onChangeSelection={(e) => { + setSelection({ + start: e.nativeEvent.start, + end: e.nativeEvent.end, + text: e.nativeEvent.text, + }); + }} + /> +
      + + {}} + onOpenImageModal={() => {}} + /> + +
      + + +
      + + +