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 (
- <>
+
- >
+
);
}
@@ -173,11 +174,14 @@ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
+ paddingTop: 100,
+ },
+ scrollContainer: {
+ flex: 1,
},
- content: {
+ scrollContent: {
flexGrow: 1,
padding: 16,
- paddingTop: 100,
alignItems: 'center',
},
editor: {
diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm
index 19e819f2..e31f10ed 100644
--- a/ios/EnrichedTextInputView.mm
+++ b/ios/EnrichedTextInputView.mm
@@ -1722,12 +1722,11 @@ - (void)anyTextMayHaveBeenModified {
}
if (![textView.textStorage.string isEqualToString:_recentInputString]) {
+ _recentInputString = [textView.textStorage.string copy];
+
// emit onChangeText event
auto emitter = [self getEventEmitter];
if (emitter != nullptr && _emitTextChange) {
- // set the recent input string only if the emitter is defined
- _recentInputString = [textView.textStorage.string copy];
-
// emit string without zero width spaces
NSString *stringToBeEmitted = [[textView.textStorage.string
stringByReplacingOccurrencesOfString:@"\u200B"
diff --git a/ios/styles/MentionStyle.mm b/ios/styles/MentionStyle.mm
index feec78cd..8ce4accf 100644
--- a/ios/styles/MentionStyle.mm
+++ b/ios/styles/MentionStyle.mm
@@ -13,6 +13,8 @@ @implementation MentionStyle {
NSValue *_activeMentionRange;
NSString *_activeMentionIndicator;
BOOL _blockMentionEditing;
+ NSString *_lastEmittedMentionIndicator;
+ NSString *_lastEmittedMentionText;
}
+ (StyleType)getType {
@@ -156,9 +158,25 @@ - (void)addMention:(NSString *)indicator
params.indicator = indicator;
params.attributes = attributes;
- // add a single space after the mention
- NSString *newText = [NSString stringWithFormat:@"%@ ", text];
NSRange rangeToBeReplaced = [_activeMentionRange rangeValue];
+
+ // add a single space after the mention if there isn't one already
+ BOOL hasSpaceAfter = NO;
+ NSUInteger nextCharIndex =
+ rangeToBeReplaced.location + rangeToBeReplaced.length;
+
+ if (nextCharIndex < self.host.textView.textStorage.string.length) {
+ unichar nextChar =
+ [self.host.textView.textStorage.string characterAtIndex:nextCharIndex];
+ if ([[NSCharacterSet whitespaceAndNewlineCharacterSet]
+ characterIsMember:nextChar]) {
+ hasSpaceAfter = YES;
+ }
+ }
+
+ NSString *newText =
+ hasSpaceAfter ? text : [NSString stringWithFormat:@"%@ ", text];
+
[TextInsertionUtils replaceText:newText
at:rangeToBeReplaced
additionalAttributes:nullptr
@@ -522,9 +540,24 @@ - (void)setActiveMentionRange:(NSRange)range text:(NSString *)text {
[NSString stringWithFormat:@"%C", [text characterAtIndex:0]];
NSString *textString =
[text substringWithRange:NSMakeRange(1, text.length - 1)];
+
+ BOOL startMention = NO;
+
+ // switching directly to an active mention
+ if (![_activeMentionIndicator isEqualToString:indicatorString]) {
+ startMention = YES;
+ [self removeActiveMentionRange];
+ }
+
+ // explicit startMention event before changeMention event
+ if (startMention && textString.length > 0) {
+ [self emitOnMentionEvent:indicatorString text:@""];
+ }
+
+ [self emitOnMentionEvent:indicatorString text:textString];
+
_activeMentionIndicator = indicatorString;
_activeMentionRange = [NSValue valueWithRange:range];
- [self.host emitOnMentionEvent:indicatorString text:textString];
}
// removes stored mention range + indicator, which means that we no longer edit
@@ -534,7 +567,18 @@ - (void)removeActiveMentionRange {
NSString *indicatorCopy = [_activeMentionIndicator copy];
_activeMentionIndicator = nullptr;
_activeMentionRange = nullptr;
- [self.host emitOnMentionEvent:indicatorCopy text:nullptr];
+ [self emitOnMentionEvent:indicatorCopy text:nullptr];
+ }
+}
+
+- (void)emitOnMentionEvent:(NSString *)indicator text:(NSString *)text {
+ BOOL sameText = (_lastEmittedMentionText == text) ||
+ [_lastEmittedMentionText isEqualToString:text];
+
+ if (![_lastEmittedMentionIndicator isEqualToString:indicator] || !sameText) {
+ [self.host emitOnMentionEvent:indicator text:text];
+ _lastEmittedMentionIndicator = indicator;
+ _lastEmittedMentionText = text;
}
}
diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx
index 4ea1742c..b9a98f37 100644
--- a/src/web/EnrichedTextInput.tsx
+++ b/src/web/EnrichedTextInput.tsx
@@ -1,4 +1,5 @@
import {
+ useCallback,
useEffect,
useImperativeHandle,
useMemo,
@@ -69,7 +70,7 @@ import {
MentionPlugin,
setMention,
startMention,
- subscribeMentionEvents,
+ useMentionEvents,
} from './pmPlugins/MentionPlugin';
import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin';
import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin';
@@ -150,6 +151,11 @@ export const EnrichedTextInput = ({
};
}, [onStartMention, onChangeMention, onEndMention, onMentionDetected]);
+ const getMentionCallbacks = useCallback(
+ () => mentionCallbacksRef.current,
+ []
+ );
+
const submitBehaviorRef = useRef(submitBehavior);
const onSubmitEditingRef = useRef(onSubmitEditing);
const onKeyPressRef = useRef(onKeyPress);
@@ -291,11 +297,7 @@ export const EnrichedTextInput = ({
editor?.commands.normalizeBoldInStyledHeadings();
}, [editor, resolvedHtmlStyle]);
- useEffect(() => {
- if (!editor) return;
- return subscribeMentionEvents(editor, () => mentionCallbacksRef.current);
- }, [editor]);
-
+ useMentionEvents(editor, getMentionCallbacks);
useOnChangeHtml(editor, onChangeHtml);
useOnChangeText(editor, onChangeText);
useOnChangeState(editor, resolvedHtmlStyle, onChangeState);
diff --git a/src/web/pmPlugins/MentionPlugin/index.ts b/src/web/pmPlugins/MentionPlugin/index.ts
index 4b3d3c97..d6b07bbf 100644
--- a/src/web/pmPlugins/MentionPlugin/index.ts
+++ b/src/web/pmPlugins/MentionPlugin/index.ts
@@ -11,7 +11,7 @@ export type { MentionPluginOptions, TriggerState } from './types';
export { mentionPluginKey } from './mentionPluginKey';
export { setMention } from './setMention';
export { startMention } from './startMention';
-export { subscribeMentionEvents } from './subscribeMentionEvents';
+export { useMentionEvents } from './useMentionEvents';
export const MentionPlugin = Extension.create({
name: 'mentionTrigger',
diff --git a/src/web/pmPlugins/MentionPlugin/makeMentionPluginState.ts b/src/web/pmPlugins/MentionPlugin/makeMentionPluginState.ts
index f98ede55..fcfe65ad 100644
--- a/src/web/pmPlugins/MentionPlugin/makeMentionPluginState.ts
+++ b/src/web/pmPlugins/MentionPlugin/makeMentionPluginState.ts
@@ -1,6 +1,7 @@
-import type { StateField } from '@tiptap/pm/state';
+import type { EditorState, StateField } from '@tiptap/pm/state';
import { isCaretInBlockedContext } from './isCaretInBlockedContext';
import type { MentionPluginOptions, TriggerState } from './types';
+import type { ResolvedPos } from '@tiptap/pm/model';
export function makeMentionPluginState(
getIndicators: MentionPluginOptions['getIndicators']
@@ -19,9 +20,11 @@ export function makeMentionPluginState(
return { active: false };
const blockStart = $from.start();
+ const wordEndPos = getCurrentWordEndPosition(newEditorState, $from);
+
const text = newEditorState.doc.textBetween(
blockStart,
- $from.pos,
+ wordEndPos,
'\n',
'\n'
);
@@ -45,7 +48,7 @@ export function makeMentionPluginState(
active: true,
indicator: found.indicator,
from: blockStart + found.indexInText,
- to: $from.pos,
+ to: wordEndPos,
query,
};
},
@@ -65,11 +68,35 @@ function findLastValidMentionIndicator(
const isAfterSpace = idx > 0 && text[idx - 1] === ' ';
if (!isAtStart && !isAfterSpace) continue;
- // Skip indicators inside a finalized mention
- if (isIndicatorInsideFinalizedMention(idx)) continue;
+ // Stops inside a finalized mention
+ if (isIndicatorInsideFinalizedMention(idx)) return null;
return { indexInText: idx, indicator: ch };
}
return null;
}
+
+function getCurrentWordEndPosition(state: EditorState, $from: ResolvedPos) {
+ const blockEnd = $from.end();
+
+ let wordEndPos = $from.pos;
+
+ while (wordEndPos < blockEnd) {
+ const char = state.doc.textBetween(wordEndPos, wordEndPos + 1);
+
+ if (/\s/.test(char)) {
+ break;
+ }
+
+ // Break if advancing enters a blocked context
+ const $nextPos = state.doc.resolve(wordEndPos + 1);
+ if (isCaretInBlockedContext($nextPos, state.schema)) {
+ break;
+ }
+
+ wordEndPos++;
+ }
+
+ return wordEndPos;
+}
diff --git a/src/web/pmPlugins/MentionPlugin/setMention.ts b/src/web/pmPlugins/MentionPlugin/setMention.ts
index 92e2701d..b558a5ce 100644
--- a/src/web/pmPlugins/MentionPlugin/setMention.ts
+++ b/src/web/pmPlugins/MentionPlugin/setMention.ts
@@ -39,6 +39,14 @@ export function setMention(
exclusiveEndThroughMatchingMentionTail(state, from, text)
);
+ // avoid inserting a space if there already is one
+ const parentEnd = state.doc.resolve(extendedTo).end();
+ let hasSpaceAfter = false;
+ if (extendedTo < parentEnd) {
+ const charAfter = state.doc.textBetween(extendedTo, extendedTo + 1, '');
+ hasSpaceAfter = /\s/.test(charAfter);
+ }
+
const mentionMark = mentionType.create({
indicator,
text,
@@ -48,10 +56,12 @@ export function setMention(
.resolve(from)
.marks()
.filter((m) => m.type.name !== 'mention');
- const fragment = Fragment.fromArray([
- state.schema.text(text, mentionMark.addToSet(baseMarks)),
- state.schema.text(' ', baseMarks),
- ]);
+
+ const nodes = [state.schema.text(text, mentionMark.addToSet(baseMarks))];
+ if (!hasSpaceAfter) {
+ nodes.push(state.schema.text(' ', baseMarks));
+ }
+ const fragment = Fragment.fromArray(nodes);
editor
.chain()
diff --git a/src/web/pmPlugins/MentionPlugin/subscribeMentionEvents.ts b/src/web/pmPlugins/MentionPlugin/subscribeMentionEvents.ts
deleted file mode 100644
index 36fbdba5..00000000
--- a/src/web/pmPlugins/MentionPlugin/subscribeMentionEvents.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { getMarkRange } from '@tiptap/core';
-import type { Editor } from '@tiptap/react';
-import type { OnMentionDetected } from '../../../types';
-import { mentionPluginKey } from './mentionPluginKey';
-import type { MentionCallbacks, TriggerState } from './types';
-
-export function subscribeMentionEvents(
- editor: Editor,
- getCallbacks: () => MentionCallbacks
-): () => void {
- let prevTriggerState: TriggerState = { active: false };
- let prevMentionKey: string | null = null;
- let wasInMention = false;
-
- const handleTransaction = () => {
- const cb = getCallbacks();
- const curr = mentionPluginKey.getState(editor.state);
- if (!curr) return;
-
- if (!prevTriggerState.active && curr.active) {
- cb.onStartMention?.(curr.indicator);
- if (curr.query !== '')
- cb.onChangeMention?.({ indicator: curr.indicator, text: curr.query });
- } else if (
- prevTriggerState.active &&
- curr.active &&
- curr.query !== prevTriggerState.query
- ) {
- cb.onChangeMention?.({ indicator: curr.indicator, text: curr.query });
- } else if (prevTriggerState.active && !curr.active) {
- cb.onEndMention?.(prevTriggerState.indicator);
- }
- prevTriggerState = curr;
-
- if (!cb.onMentionDetected) return;
-
- const mention = getActiveMention(editor);
- if (!mention) {
- if (wasInMention) {
- wasInMention = false;
- prevMentionKey = null;
- cb.onMentionDetected({
- text: '',
- indicator: '',
- attributes: {},
- });
- } else {
- prevMentionKey = null;
- }
- return;
- }
-
- wasInMention = true;
- if (mention.key === prevMentionKey) return;
- prevMentionKey = mention.key;
- cb.onMentionDetected({
- text: mention.text,
- indicator: mention.indicator,
- attributes: mention.attributes,
- });
- };
-
- const handleBlur = () => {
- const cb = getCallbacks();
- if (prevTriggerState.active) {
- cb.onEndMention?.(prevTriggerState.indicator);
- prevTriggerState = { active: false };
- }
- prevMentionKey = null;
- };
-
- editor.on('transaction', handleTransaction);
- editor.on('blur', handleBlur);
-
- return () => {
- editor.off('transaction', handleTransaction);
- editor.off('blur', handleBlur);
- };
-}
-
-function getActiveMention(
- editor: Editor
-): (OnMentionDetected & { key: string }) | null {
- const { state } = editor;
- const mentionType = state.schema.marks.mention;
- if (!mentionType) return null;
-
- const { from: selFrom, to: selTo } = state.selection;
- const $from = state.doc.resolve(selFrom);
- const mark = mentionType.isInSet($from.marks());
- if (!mark) return null;
-
- const range = getMarkRange($from, mentionType);
- if (!range) return null;
-
- if (selFrom < range.from || selTo > range.to) return null;
-
- const { text, indicator, attributes } = mark.attrs;
- return {
- key: `${range.from}:${range.to}:${text}:${indicator}`,
- text: text as string,
- indicator: indicator as string,
- attributes: (attributes ?? {}) as Record,
- };
-}
diff --git a/src/web/pmPlugins/MentionPlugin/useMentionEvents.ts b/src/web/pmPlugins/MentionPlugin/useMentionEvents.ts
new file mode 100644
index 00000000..ee354291
--- /dev/null
+++ b/src/web/pmPlugins/MentionPlugin/useMentionEvents.ts
@@ -0,0 +1,114 @@
+import { getMarkRange } from '@tiptap/core';
+import type { Editor } from '@tiptap/react';
+import type { OnMentionDetected } from '../../../types';
+import { mentionPluginKey } from './mentionPluginKey';
+import type { MentionCallbacks, TriggerState } from './types';
+import { useEffect } from 'react';
+
+export function useMentionEvents(
+ editor: Editor,
+ getCallbacks: () => MentionCallbacks
+) {
+ useEffect(() => {
+ let prevTriggerState: TriggerState = { active: false };
+ let prevMentionKey: string | null = null;
+ let wasInMention = false;
+
+ const handleUpdate = () => {
+ const cb = getCallbacks();
+ const curr = mentionPluginKey.getState(editor.state);
+ if (!curr) return;
+
+ if (!prevTriggerState.active && curr.active) {
+ cb.onStartMention?.(curr.indicator);
+ if (curr.query !== '')
+ cb.onChangeMention?.({ indicator: curr.indicator, text: curr.query });
+ } else if (
+ prevTriggerState.active &&
+ curr.active &&
+ prevTriggerState.indicator !== curr.indicator
+ ) {
+ cb.onEndMention?.(prevTriggerState.indicator);
+ cb.onStartMention?.(curr.indicator);
+ if (curr.query !== '')
+ cb.onChangeMention?.({ indicator: curr.indicator, text: curr.query });
+ } else if (
+ prevTriggerState.active &&
+ curr.active &&
+ curr.query !== prevTriggerState.query
+ ) {
+ cb.onChangeMention?.({ indicator: curr.indicator, text: curr.query });
+ } else if (prevTriggerState.active && !curr.active) {
+ cb.onEndMention?.(prevTriggerState.indicator);
+ }
+ prevTriggerState = curr;
+
+ if (!cb.onMentionDetected) return;
+
+ const mention = getMentionInCurrentSelection(editor);
+ if (!mention) {
+ if (wasInMention) {
+ cb.onMentionDetected({
+ text: '',
+ indicator: '',
+ attributes: {},
+ });
+ wasInMention = false;
+ prevMentionKey = null;
+ }
+ } else {
+ wasInMention = true;
+ if (mention.key === prevMentionKey) return;
+ prevMentionKey = mention.key;
+ cb.onMentionDetected({
+ text: mention.text,
+ indicator: mention.indicator,
+ attributes: mention.attributes,
+ });
+ }
+ };
+
+ const handleBlur = () => {
+ const cb = getCallbacks();
+ if (prevTriggerState.active) {
+ cb.onEndMention?.(prevTriggerState.indicator);
+ prevTriggerState = { active: false };
+ }
+ prevMentionKey = null;
+ };
+
+ editor.on('transaction', handleUpdate);
+ editor.on('blur', handleBlur);
+
+ return () => {
+ editor.off('transaction', handleUpdate);
+ editor.off('blur', handleBlur);
+ };
+ }, [editor, getCallbacks]);
+}
+
+function getMentionInCurrentSelection(
+ editor: Editor
+): (OnMentionDetected & { key: string }) | null {
+ const { state } = editor;
+ const mentionType = state.schema.marks.mention;
+ if (!mentionType) return null;
+
+ const { from: selFrom, to: selTo } = state.selection;
+ const $from = state.doc.resolve(selFrom);
+ const mark = mentionType.isInSet($from.marks());
+ if (!mark) return null;
+
+ const range = getMarkRange($from, mentionType);
+ if (!range) return null;
+
+ if (selFrom < range.from || selTo > range.to) return null;
+
+ const { text, indicator, attributes } = mark.attrs;
+ return {
+ key: `${range.from}:${range.to}:${text}:${indicator}`,
+ text: text as string,
+ indicator: indicator as string,
+ attributes: (attributes ?? {}) as Record,
+ };
+}