diff --git a/.playwright/tests/mentions.spec.ts b/.playwright/tests/mentions.spec.ts index 56e202b19..49a165bbf 100644 --- a/.playwright/tests/mentions.spec.ts +++ b/.playwright/tests/mentions.spec.ts @@ -22,6 +22,7 @@ const sel = { setChannelButton: '[data-testid="mention-set-channel-button"]', startUserButton: '[data-testid="mention-start-user-button"]', startChannelButton: '[data-testid="mention-start-channel-button"]', + removeInvalidToggle: '[data-testid="mention-remove-invalid-toggle"]', } as const; async function gotoMentionTest(page: Page): Promise { @@ -313,3 +314,72 @@ test("inserting a mention between text doesn't produce a double space", async ({ '

example Jane test

' ); }); + +// Inserts "Jane" plus the space setMention adds, then moves the caret +// `steps` characters left from the end: 1 is right after the mention, +// 3 is between "Ja" and "ne" +async function insertMentionAndMoveLeft( + page: Page, + steps: number +): Promise { + const editor = mentionEditor(page); + await editor.click(); + await editor.pressSequentially('@', { delay: 80 }); + await page.locator(sel.setUserButton).click(); + await expect + .poll(async () => + (await htmlOutput(page).textContent())?.includes(' { + await gotoMentionTest(page); + await insertMentionAndMoveLeft(page, 3); + await mentionEditor(page).press('x'); + await expect + .poll(async () => await htmlOutput(page).textContent()) + .toEqual('

Jaxne

'); +}); + +test('removeInvalidMentionContent: typing inside a mention removes it', async ({ + page, +}) => { + await gotoMentionTest(page); + await page.locator(sel.removeInvalidToggle).click(); + await insertMentionAndMoveLeft(page, 3); + await mentionEditor(page).press('x'); + await expect + .poll(async () => await htmlOutput(page).textContent()) + .toEqual('

'); +}); + +test('removeInvalidMentionContent: backspace inside a mention removes it', async ({ + page, +}) => { + await gotoMentionTest(page); + await page.locator(sel.removeInvalidToggle).click(); + await insertMentionAndMoveLeft(page, 3); + await mentionEditor(page).press('Backspace'); + await expect + .poll(async () => await htmlOutput(page).textContent()) + .toEqual('

'); +}); + +test('removeInvalidMentionContent: typing right after a mention keeps it', async ({ + page, +}) => { + await gotoMentionTest(page); + await page.locator(sel.removeInvalidToggle).click(); + await insertMentionAndMoveLeft(page, 1); + await mentionEditor(page).press('x'); + await expect + .poll(async () => await htmlOutput(page).textContent()) + .toEqual( + '

Janex

' + ); +}); 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 9cf0293da..202e8e555 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -177,6 +177,14 @@ class EnrichedTextInputViewManager : view?.parametrizedStyles?.mentionIndicators = indicatorsArray } + @ReactProp(name = "removeInvalidMentionContent", defaultBoolean = false) + override fun setRemoveInvalidMentionContent( + view: EnrichedTextInputView?, + removeInvalidMentionContent: Boolean, + ) { + view?.parametrizedStyles?.removeInvalidMentionContent = removeInvalidMentionContent + } + @ReactProp(name = "htmlStyle") override fun setHtmlStyle( 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..d9812a925 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 @@ -23,6 +23,7 @@ class ParametrizedStyles( private var isSettingLinkSpan = false var mentionIndicators: Array = emptyArray() + var removeInvalidMentionContent = false fun removeSpansForRange( spannable: Spannable, @@ -94,9 +95,27 @@ class ParametrizedStyles( endCursorPosition: Int, ) { afterTextChangedLinks(startCursorPosition, endCursorPosition) + // the deletion re-runs this watcher on the final text with fresh positions + if (removeInvalidMentionContent && removeInvalidMentions(s)) return detectActiveMention(s, startCursorPosition) } + private fun removeInvalidMentions(s: Editable): Boolean { + var removed = false + for (span in s.getSpans(0, s.length, EnrichedInputMentionSpan::class.java)) { + val start = s.getSpanStart(span) + val end = s.getSpanEnd(span) + // the nested watcher pass may have removed it already + if (start == -1) continue + if (s.subSequence(start, end).toString() == span.getText()) continue + + s.removeSpan(span) + s.delete(start, end) + removed = true + } + return removed + } + // Re-runs in-progress mention detection on a pure caret move (no text change), fun afterSelectionChangedMentions( start: Int, diff --git a/apps/example-web/src/testScreens/TestMentions.tsx b/apps/example-web/src/testScreens/TestMentions.tsx index 7cf6dd199..89d792d25 100644 --- a/apps/example-web/src/testScreens/TestMentions.tsx +++ b/apps/example-web/src/testScreens/TestMentions.tsx @@ -16,6 +16,8 @@ export function TestMentions() { const [detectedText, setDetectedText] = useState(''); const [detectedIndicator, setDetectedIndicator] = useState(''); const [lastEndEvent, setLastEndEvent] = useState(''); + const [removeInvalidMentionContent, setRemoveInvalidMentionContent] = + useState(false); const preventDefault = (e: React.MouseEvent) => { e.preventDefault(); @@ -31,6 +33,7 @@ export function TestMentions() { style={editorStyle} htmlStyle={WEB_DEFAULT_HTML_STYLE} mentionIndicators={['@', '#']} + removeInvalidMentionContent={removeInvalidMentionContent} onStartMention={(indicator) => { setEventType('start'); setEventIndicator(indicator); @@ -112,6 +115,13 @@ export function TestMentions() { Start mention (channel) + ); } diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index d5b4ba3f1..4dcf99d96 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -23,6 +23,8 @@ interface DevScreenProps { export function DevScreen({ onSwitch }: DevScreenProps) { const editor = useEditorState(); const [textNodes, setTextNodes] = useState>([]); + const [removeInvalidMentionContent, setRemoveInvalidMentionContent] = + useState(false); const handlePushTextNode = async () => { const currentText = await editor.ref.current?.getHTML(); @@ -44,6 +46,7 @@ export function DevScreen({ onSwitch }: DevScreenProps) { +