Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5c5c5cb
feat: mock users
hejsztynx May 29, 2026
0f27040
fix: considering finalized mention in new mentions
hejsztynx Jun 11, 2026
e527a44
refactor: consistent mention event hook with other event hooks
hejsztynx Jun 11, 2026
a876871
fix(web): endMention event call when switching mentions
hejsztynx Jun 11, 2026
43c2670
fix(ios): call end mention event when switching between mentions
hejsztynx Jun 11, 2026
1dc29cd
fix(android): mention events emitting
hejsztynx Jun 11, 2026
cf482ac
fix(ios): mentions not remanaged when no onChangeText in props
hejsztynx Jun 12, 2026
8c8503d
fix(ios): mention event emission dedup
hejsztynx Jun 12, 2026
e369d0c
feat: updated example apps with correct mention event emittion behavior
hejsztynx Jun 12, 2026
4226044
fix(web): query changing on cursor travel inside candidate mention
hejsztynx Jun 12, 2026
5a6ccad
feat(web): avoid double space when inserting a mention
hejsztynx Jun 12, 2026
fdc310f
feat(ios): avoid double space when inserting a mention
hejsztynx Jun 13, 2026
71fbb85
refactor: testing code cleanup
hejsztynx Jun 13, 2026
e4b050d
fix(web): double space edge case
hejsztynx Jun 14, 2026
59411bc
fix(ios): last emitted mention event comparison
hejsztynx Jun 14, 2026
c285576
test: e2e tests for switching between mentions
hejsztynx Jun 15, 2026
e631d40
Merge branch 'main' into @ksienkiewicz/fix-mention-events
kacperzolkiewski Jun 25, 2026
21b9028
fix(web): move cursor when setMention before the indicator
hejsztynx Jun 25, 2026
ba2c9b9
fix(ios): updating _recentInputString only when the emitter is defined
hejsztynx Jul 1, 2026
ea20849
fix(ios): always move the selection after adding mention to the avail…
hejsztynx Jul 1, 2026
b5bb257
refactor(ios): text equality check
hejsztynx Jul 1, 2026
21b7319
fix: Update build CI xcode version to latest stable
szydlovsky Jul 1, 2026
4d5dbbc
refactor(ios): additional info about _recentInputString 'if' branches
hejsztynx Jul 1, 2026
4c48aaf
Revert "fix: Update build CI xcode version to latest stable"
hejsztynx Jul 1, 2026
eb64438
fix: setMention on Android replacing surrounding text (#669)
hejsztynx Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .playwright/tests/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]',
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -263,3 +267,27 @@ 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'); // 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('#');
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,30 @@ 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(
indicator: String,
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class OnMentionEvent(
) : Event<OnMentionEvent>(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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -239,7 +255,7 @@ class ParametrizedStyles(
detectLinksInRange(spannable, affectedRange.first, affectedRange.last)
}

private fun afterTextChangedMentions(
private fun detectActiveMention(
s: CharSequence,
endCursorPosition: Int,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 0 additions & 2 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};

Expand Down
9 changes: 7 additions & 2 deletions apps/example-web/src/testScreens/TestMentions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>) => {
e.preventDefault();
Expand All @@ -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);
Expand All @@ -65,6 +67,9 @@ export function TestMentions() {
<Field label="event text">
<span data-testid="mention-event-text">{eventText}</span>
</Field>
<Field label="last end event">
<span data-testid="mention-last-end-event">{lastEndEvent}</span>
</Field>
<Field label="detected count">
<span data-testid="mention-detected-count">{detectedCount}</span>
</Field>
Expand Down
6 changes: 3 additions & 3 deletions apps/example/src/hooks/useEditorState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function useEditorState() {
};

const handleStartMention = (indicator: string) => {
console.log('Start Mention', indicator);
if (indicator === '@') {
userMention.onMentionChange('');
openUserMentionPopup();
Expand All @@ -115,6 +116,7 @@ export function useEditorState() {
};

const handleEndMention = (indicator: string) => {
console.log('End Mention', indicator);
if (indicator === '@') {
closeUserMentionPopup();
userMention.onMentionChange('');
Expand All @@ -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) => {
Expand Down
5 changes: 2 additions & 3 deletions ios/EnrichedTextInputView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
hejsztynx marked this conversation as resolved.
_recentInputString = [textView.textStorage.string copy];

Comment thread
hejsztynx marked this conversation as resolved.
Outdated
// emit string without zero width spaces
NSString *stringToBeEmitted = [[textView.textStorage.string
stringByReplacingOccurrencesOfString:@"\u200B"
Expand Down
50 changes: 46 additions & 4 deletions ios/styles/MentionStyle.mm
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ @implementation MentionStyle {
NSValue *_activeMentionRange;
NSString *_activeMentionIndicator;
BOOL _blockMentionEditing;
NSString *_lastEmittedMentionIndicator;
NSString *_lastEmittedMentionText;
}

+ (StyleType)getType {
Expand Down Expand Up @@ -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];

Comment thread
hejsztynx marked this conversation as resolved.
[TextInsertionUtils replaceText:newText
at:rangeToBeReplaced
additionalAttributes:nullptr
Expand Down Expand Up @@ -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
Expand All @@ -534,7 +567,16 @@ - (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 {
if (![_lastEmittedMentionIndicator isEqualToString:indicator] ||
![_lastEmittedMentionText isEqualToString:text]) {
[self.host emitOnMentionEvent:indicator text:text];
_lastEmittedMentionIndicator = indicator;
_lastEmittedMentionText = text;
}
}
Comment thread
hejsztynx marked this conversation as resolved.

Expand Down
14 changes: 8 additions & 6 deletions src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
Expand Down Expand Up @@ -69,7 +70,7 @@ import {
MentionPlugin,
setMention,
startMention,
subscribeMentionEvents,
useMentionEvents,
} from './pmPlugins/MentionPlugin';
import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin';
import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/web/pmPlugins/MentionPlugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
hejsztynx marked this conversation as resolved.

export const MentionPlugin = Extension.create<MentionPluginOptions>({
name: 'mentionTrigger',
Expand Down
Loading
Loading