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 00000000..c3cf07e7 --- /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 00000000..74947bcf 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 00000000..7a053e6c 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 00000000..d80b9a75 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 00000000..933b17d2 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 00000000..a32731ec 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 00000000..b0c1a44c 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 00000000..f51d04b5 --- /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 5e6d4379..56e202b1 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 cdcad4e2..163f2ac9 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -91,6 +91,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 d2dbbf8a..35214ad5 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 5d36f949..ae65b0ac 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 be6265b7..a63a34ef 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 @@ -91,7 +91,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( @@ -239,7 +255,7 @@ class ParametrizedStyles( detectLinksInRange(spannable, affectedRange.first, affectedRange.last) } - private fun afterTextChangedMentions( + private fun detectActiveMention( s: CharSequence, endCursorPosition: Int, ) { 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 002e1954..5b41bc23 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 bcc41294..6e5bc144 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 5c8ff863..eb63a708 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -109,10 +109,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 7a1e7180..f6b458d7 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 bb9d3c2a..0d2371b5 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 4327f4b6..2d7d6975 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 11959d91..b6e4938a 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 ( - <> +