diff --git a/.maestro/enrichedInput/flows/mention_popup_closing_on_cursor_travel.yaml b/.maestro/enrichedInput/flows/mention_popup_closing_on_cursor_travel.yaml new file mode 100644 index 000000000..c3cf07e74 --- /dev/null +++ b/.maestro/enrichedInput/flows/mention_popup_closing_on_cursor_travel.yaml @@ -0,0 +1,45 @@ +appId: swmansion.enriched.example +--- +# fix PR #637 - mention popups not closing when switching between mentions + +- launchApp + +- tapOn: + id: "toggle-screen-button" + +- tapOn: + id: "editor-input" + +- inputText: "mentions #gen @J" + +# user popup visible +- runFlow: + file: "../subflows/capture_or_assert_fullscreen_screenshot.yaml" + env: + SCREENSHOT_NAME: "mention_popup_closing_on_cursor_travel_1" + +- tapOn: + id: "focus-button" + +- tapOn: + id: "editor-input" + point: "30%, 50%" + +# channel popup visible +- runFlow: + file: "../subflows/capture_or_assert_fullscreen_screenshot.yaml" + env: + SCREENSHOT_NAME: "mention_popup_closing_on_cursor_travel_2" + +- tapOn: + id: "focus-button" + +- tapOn: + id: "editor-input" + point: "10%, 50%" + +# no popup visible +- runFlow: + file: "../subflows/capture_or_assert_fullscreen_screenshot.yaml" + env: + SCREENSHOT_NAME: "mention_popup_closing_on_cursor_travel_3" 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 new file mode 100644 index 000000000..74947bcfd Binary files /dev/null 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 new file mode 100644 index 000000000..7a053e6c5 Binary files /dev/null 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 new file mode 100644 index 000000000..d80b9a756 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.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 new file mode 100644 index 000000000..933b17d23 Binary files /dev/null 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 new file mode 100644 index 000000000..a32731ece Binary files /dev/null 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 new file mode 100644 index 000000000..b0c1a44ca Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedInput/subflows/capture_or_assert_fullscreen_screenshot.yaml b/.maestro/enrichedInput/subflows/capture_or_assert_fullscreen_screenshot.yaml new file mode 100644 index 000000000..f51d04b5a --- /dev/null +++ b/.maestro/enrichedInput/subflows/capture_or_assert_fullscreen_screenshot.yaml @@ -0,0 +1,10 @@ +appId: swmansion.enriched.example +--- +- tapOn: + id: 'blur-button' + +- runFlow: + file: '../../subflows/capture_or_assert_screenshot.yaml' + env: + ELEMENT_ID: 'full-screen' + SCREENSHOT_PREFIX: 'enrichedInput' diff --git a/.playwright/tests/mentions.spec.ts b/.playwright/tests/mentions.spec.ts index 5e6d4379d..56e202b19 100644 --- a/.playwright/tests/mentions.spec.ts +++ b/.playwright/tests/mentions.spec.ts @@ -12,6 +12,7 @@ const sel = { eventType: '[data-testid="mention-event-type"]', eventIndicator: '[data-testid="mention-event-indicator"]', eventText: '[data-testid="mention-event-text"]', + lastEndEvent: '[data-testid="mention-last-end-event"]', htmlOutput: '[data-testid="mention-html-output"]', detectedCount: '[data-testid="mention-detected-count"]', detectedText: '[data-testid="mention-detected-text"]', @@ -41,6 +42,9 @@ function eventIndicator(page: Page) { function eventText(page: Page) { return page.locator(sel.eventText); } +function lastEndEvent(page: Page) { + return page.locator(sel.lastEndEvent); +} function htmlOutput(page: Page) { return page.locator(sel.htmlOutput); } @@ -263,3 +267,49 @@ test('mention renders correctly', async ({ page }) => { ); await expect(editorLocator(page)).toHaveScreenshot('mention-visual.png'); }); + +test('switching to a different mention starts it and ends the previous one', async ({ + page, +}) => { + await gotoMentionTest(page); + const editor = mentionEditor(page); + await editor.click(); + await editor.pressSequentially('foo #g ', { delay: 80 }); + await expect(eventType(page)).toHaveText('change'); + await expect(eventIndicator(page)).toHaveText('#'); + await editor.pressSequentially('@', { delay: 80 }); + await expect(eventType(page)).toHaveText('start'); + await expect(eventIndicator(page)).toHaveText('@'); + await expect(lastEndEvent(page)).toHaveText('#'); + await editor.press('ArrowLeft'); + await editor.press('ArrowLeft'); // back to the '#' mention + await expect(eventType(page)).toHaveText('change'); + await expect(eventIndicator(page)).toHaveText('#'); + await expect(lastEndEvent(page)).toHaveText('@'); + await editor.press('ArrowLeft'); + await editor.press('ArrowLeft'); + await editor.press('ArrowLeft'); // leaving the '#' mention + await expect(eventType(page)).toHaveText('end'); + await expect(eventIndicator(page)).toHaveText('#'); +}); + +test("inserting a mention between text doesn't produce a double space", async ({ + page, +}) => { + await gotoMentionTest(page); + const editor = mentionEditor(page); + await editor.click(); + await editor.pressSequentially('example ', { delay: 80 }); + await editor.pressSequentially(' test', { delay: 80 }); + for (let i = 0; i < 5; i++) { + await editor.press('ArrowLeft'); + } + await editor.press('@'); + await page.locator(sel.setUserButton).click(); + await page.waitForTimeout(2000); + await expect + .poll(async () => await htmlOutput(page).textContent()) + .toEqual( + '

example Jane test

' + ); +}); 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 b2a0c16c4..c72164488 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -92,6 +92,7 @@ class EnrichedTextInputView : val alignmentStyles: AlignmentStyles? = AlignmentStyles(this) var isDuringTransaction: Boolean = false var isRemovingMany: Boolean = false + var recentInputString: String = "" var scrollEnabled: Boolean = true var allowFontScaling: Boolean = EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT set(value) { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/events/MentionHandler.kt b/android/src/main/java/com/swmansion/enriched/textinput/events/MentionHandler.kt index d2dbbf8ac..35214ad5f 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/events/MentionHandler.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/events/MentionHandler.kt @@ -27,8 +27,20 @@ class MentionHandler( indicator: String, text: String?, ) { + var startMention = false + + // switching directly to an active mention + if (previousIndicator != indicator) { + startMention = true + endMention() + } + + // explicit startMention event before changeMention event + if (startMention && !text.isNullOrEmpty()) { + emitEvent(indicator, "") + } + emitEvent(indicator, text) - previousIndicator = indicator } private fun emitEvent( @@ -36,8 +48,9 @@ class MentionHandler( text: String?, ) { // Do not emit events too often - if (previousText == text) return + if (previousIndicator == indicator && previousText == text) return + previousIndicator = indicator previousText = text val context = view.context as ReactContext val surfaceId = UIManagerHelper.getSurfaceId(context) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/events/OnMentionEvent.kt b/android/src/main/java/com/swmansion/enriched/textinput/events/OnMentionEvent.kt index 5d36f9496..ae65b0ace 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/events/OnMentionEvent.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/events/OnMentionEvent.kt @@ -13,6 +13,12 @@ class OnMentionEvent( ) : Event(surfaceId, viewId) { override fun getEventName(): String = EVENT_NAME + // start/change/end can be emitted as a burst within a single frame + // (e.g. when switching mentions: end -> start -> change). + // The default coalescing would merge them in the batch and drop the + // intermediate ones, so it must be disabled to deliver every event in order. + override fun canCoalesce(): Boolean = false + override fun getEventData(): WritableMap? { val eventData: WritableMap = Arguments.createMap() eventData.putString("indicator", indicator) 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 c441224ba..1bdce75cb 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 @@ -1,6 +1,7 @@ package com.swmansion.enriched.textinput.styles import android.text.Editable +import android.text.Selection import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned @@ -18,6 +19,7 @@ class ParametrizedStyles( private val view: EnrichedTextInputView, ) { private var mentionStart: Int? = null + private var mentionEnd: Int? = null private var isSettingLinkSpan = false var mentionIndicators: Array = emptyArray() @@ -92,7 +94,23 @@ class ParametrizedStyles( endCursorPosition: Int, ) { afterTextChangedLinks(startCursorPosition, endCursorPosition) - afterTextChangedMentions(s, startCursorPosition) + detectActiveMention(s, startCursorPosition) + } + + // Re-runs in-progress mention detection on a pure caret move (no text change), + fun afterSelectionChangedMentions( + start: Int, + end: Int, + ) { + val s = view.text ?: return + + // A non-collapsed selection can't be editing a single mention. + if (start != end) { + view.mentionHandler?.endMention() + return + } + + detectActiveMention(s, end) } fun onStyleToggled( @@ -240,7 +258,7 @@ class ParametrizedStyles( detectLinksInRange(spannable, affectedRange.first, affectedRange.last) } - private fun afterTextChangedMentions( + private fun detectActiveMention( s: CharSequence, endCursorPosition: Int, ) { @@ -262,12 +280,16 @@ class ParametrizedStyles( // No previous word -> no mention to be detected if (previousWord == null) { + mentionStart = null + mentionEnd = null mentionHandler.endMention() return } // Previous word is not a mention -> end mention if (!mentionRegex.matches(previousWord.text)) { + mentionStart = null + mentionEnd = null mentionHandler.endMention() return } @@ -281,6 +303,9 @@ class ParametrizedStyles( indicator = mentionIndicatorRegex.find(currentWord.text)?.value ?: "" } + mentionStart = finalStart + mentionEnd = finalEnd + // Mirror iOS conflicting-styles behaviour: check the full candidate range for // a finalized mention span. If the span's stored text still matches what is in // the buffer the mention is intact — block the event (covers HTML-loaded @@ -293,11 +318,14 @@ class ParametrizedStyles( val spanEnd = spannable.getSpanEnd(span) val currentSpanText = spannable.subSequence(spanStart, spanEnd).toString() if (currentSpanText == span.getText()) { + mentionStart = null + mentionEnd = null mentionHandler.endMention() return } spannable.removeSpan(span) mentionStart = spanStart + mentionEnd = spanEnd } // Extract text without indicator @@ -306,6 +334,7 @@ class ParametrizedStyles( // Means we are starting mention if (text.isEmpty()) { mentionStart = finalStart + mentionEnd = finalEnd } mentionHandler.onMention(indicator, text) @@ -367,9 +396,10 @@ class ParametrizedStyles( } val start = mentionStart ?: selectionStart + val end = mentionEnd ?: selectionEnd view.runAsATransaction { - spannable.replace(start, selectionEnd, text) + spannable.replace(start, end, text) val span = EnrichedInputMentionSpan(text, indicator, attributes, view.htmlStyle) val spanEnd = start + text.length @@ -380,11 +410,13 @@ class ParametrizedStyles( if (!hasSpaceAtTheEnd) { spannable.insert(safeEnd, " ") } + Selection.setSelection(spannable, (safeEnd + 1).coerceAtMost(spannable.length)) } view.mentionHandler?.reset() view.selection.validateStyles() mentionStart = null + mentionEnd = null } fun getStyleRange(): Pair = view.selection?.getInlineSelection() ?: Pair(0, 0) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt index 002e19545..5b41bc233 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt @@ -55,6 +55,11 @@ class EnrichedSelection( start = finalStart end = finalEnd validateStyles() + + if (view.text?.toString() == view.recentInputString) { + view.parametrizedStyles?.afterSelectionChangedMentions(finalStart, finalEnd) + } + emitSelectionChangeEvent(view.text, finalStart, finalEnd) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index bcc41294e..6e5bc1443 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt @@ -68,6 +68,8 @@ class EnrichedTextWatcher( if (s == null) return emitEvents(s) + view.recentInputString = s.toString() + if (view.isDuringTransaction) return applyStyles(s) view.layoutManager.invalidateLayout() diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index c973a7c94..6970f2ffc 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -111,10 +111,8 @@ function App() { console.log('[EnrichedTextInput] Change mention', indicator, text); if (indicator === '@') { userMention.onMentionChange(text); - if (!isUserPopupOpen) setIsUserPopupOpen(true); } else { channelMention.onMentionChange(text); - if (!isChannelPopupOpen) setIsChannelPopupOpen(true); } }; diff --git a/apps/example-web/src/testScreens/TestMentions.tsx b/apps/example-web/src/testScreens/TestMentions.tsx index 703512593..7cf6dd199 100644 --- a/apps/example-web/src/testScreens/TestMentions.tsx +++ b/apps/example-web/src/testScreens/TestMentions.tsx @@ -15,6 +15,7 @@ export function TestMentions() { const [detectedCount, setDetectedCount] = useState(0); const [detectedText, setDetectedText] = useState(''); const [detectedIndicator, setDetectedIndicator] = useState(''); + const [lastEndEvent, setLastEndEvent] = useState(''); const preventDefault = (e: React.MouseEvent) => { e.preventDefault(); @@ -40,10 +41,11 @@ export function TestMentions() { setEventIndicator(indicator); setEventText(text); }} - onEndMention={() => { + onEndMention={(indicator) => { setEventType('end'); - setEventIndicator(''); + setEventIndicator(indicator); setEventText(''); + setLastEndEvent(indicator); }} onMentionDetected={({ text, indicator }) => { setDetectedCount((c) => c + 1); @@ -65,6 +67,9 @@ export function TestMentions() { {eventText} + + {lastEndEvent} + {detectedCount} diff --git a/apps/example/metro.config.js b/apps/example/metro.config.js index bb9d3c2a9..0d2371b52 100644 --- a/apps/example/metro.config.js +++ b/apps/example/metro.config.js @@ -10,7 +10,21 @@ const root = path.resolve(__dirname, '../..'); * * @type {import('metro-config').MetroConfig} */ -module.exports = withMetroConfig(getDefaultConfig(__dirname), { +const config = withMetroConfig(getDefaultConfig(__dirname), { root, dirname: __dirname, }); + +config.resolver = { + ...config.resolver, + blockList: [ + ...(Array.isArray(config.resolver?.blockList) + ? config.resolver.blockList + : config.resolver?.blockList + ? [config.resolver.blockList] + : []), + /.*\/\.maestro\/.*/, + ], +}; + +module.exports = config; diff --git a/apps/example/src/hooks/useEditorState.ts b/apps/example/src/hooks/useEditorState.ts index 61038b393..69dcc3048 100644 --- a/apps/example/src/hooks/useEditorState.ts +++ b/apps/example/src/hooks/useEditorState.ts @@ -105,6 +105,7 @@ export function useEditorState() { }; const handleStartMention = (indicator: string) => { + console.log('Start Mention', indicator); if (indicator === '@') { userMention.onMentionChange(''); openUserMentionPopup(); @@ -115,6 +116,7 @@ export function useEditorState() { }; const handleEndMention = (indicator: string) => { + console.log('End Mention', indicator); if (indicator === '@') { closeUserMentionPopup(); userMention.onMentionChange(''); @@ -125,12 +127,10 @@ export function useEditorState() { }; const handleChangeMention = ({ indicator, text }: OnChangeMentionEvent) => { + console.log('Change Mention', indicator, text); indicator === '@' ? userMention.onMentionChange(text) : channelMention.onMentionChange(text); - indicator === '@' - ? !isUserPopupOpen && setIsUserPopupOpen(true) - : !isChannelPopupOpen && setIsChannelPopupOpen(true); }; const handleUserMentionSelected = (item: MentionItem) => { diff --git a/apps/example/src/screens/TestScreen.tsx b/apps/example/src/screens/TestScreen.tsx index 4b9565cd2..4e98dadf7 100644 --- a/apps/example/src/screens/TestScreen.tsx +++ b/apps/example/src/screens/TestScreen.tsx @@ -27,10 +27,11 @@ export function TestScreen({ const [sizeMode, setSizeMode] = useState<'base' | 'max'>('base'); return ( - <> +