Skip to content

Commit 34fcc8d

Browse files
fix: unify detection events between platforms (#613)
# Summary Fixes: #571 Unify `onMentionDeteted` and `onLinkDetected` events emitting between platforms. ## Test Plan Verify that `onMentionDetected` and `onLinkDetected` are triggered correctly in the following scenarios: - moving the cursor into or selecting a mention/link emits the detection event - moving the cursor outside a mention/link emits the clear event ## Screenshots / Videos https://github.com/user-attachments/assets/53b8ce07-803c-4f77-b0e4-77fc3cd734d0 Web: https://github.com/user-attachments/assets/a25f217a-1245-4068-bfe4-f9b4b23926b5 ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ✅ | | Android | ✅ | | Web | ✅ | ## Checklist - [ ] E2E tests are passing - [ ] Required E2E tests have been added (if applicable)
1 parent f26ffbd commit 34fcc8d

6 files changed

Lines changed: 123 additions & 68 deletions

File tree

.playwright/tests/mentions.spec.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ test('moving within the same mention does not re-fire onMentionDetected', async
230230
await expect(detectedCount(page)).toHaveText('1');
231231
});
232232

233-
test('moving out of a mention does not increment detected count', async ({
233+
test('moving out of a mention fires clear onMentionDetected', async ({
234234
page,
235235
}) => {
236236
await gotoMentionTest(page);
@@ -244,13 +244,15 @@ test('moving out of a mention does not increment detected count', async ({
244244
)
245245
.toBe(true);
246246
await editor.click();
247-
await editor.press('Home');
248-
await editor.press('ArrowRight');
249-
await editor.press('ArrowRight');
250-
await editor.press('ArrowRight');
251247
await editor.press('End');
252-
await editor.press('Enter');
248+
await editor.press('ArrowLeft'); // skip trailing space after mention
249+
await editor.press('ArrowLeft'); // caret inside mention text
253250
await expect(detectedCount(page)).toHaveText('1');
251+
await editor.press('End');
252+
await editor.press('Enter');
253+
await expect(detectedCount(page)).toHaveText('2');
254+
await expect(detectedText(page)).toHaveText('');
255+
await expect(detectedIndicator(page)).toHaveText('');
254256
});
255257

256258
test('mention renders correctly', async ({ page }) => {

.playwright/tests/testLinks.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,8 @@ test.describe('test-links onLinkDetected', () => {
347347
.toEqual({
348348
text: '',
349349
url: '',
350-
start: 8,
351-
end: 8,
350+
start: 0,
351+
end: 0,
352352
});
353353
});
354354
});

android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,16 @@ class EnrichedSelection(
195195
val isMentionType = type == EnrichedInputMentionSpan::class.java
196196

197197
if (isLinkType && spans.isEmpty()) {
198-
emitLinkDetectedEvent(spannable, null, start, end)
198+
if (wasLinkPreviouslyDetected()) {
199+
emitLinkDetectedEvent(spannable, null, 0, 0)
200+
}
199201
return null
200202
}
201203

202204
if (isMentionType && spans.isEmpty()) {
203-
emitMentionDetectedEvent(spannable, null, start, end)
205+
if (wasMentionPreviouslyDetected()) {
206+
emitMentionDetectedEvent(spannable, null, start, end)
207+
}
204208
return null
205209
}
206210

@@ -248,6 +252,18 @@ class EnrichedSelection(
248252
)
249253
}
250254

255+
private fun wasMentionPreviouslyDetected(): Boolean {
256+
val previousText = previousMentionDetectedEvent["text"] ?: ""
257+
val previousIndicator = previousMentionDetectedEvent["indicator"] ?: ""
258+
return previousText.isNotEmpty() || previousIndicator.isNotEmpty()
259+
}
260+
261+
private fun wasLinkPreviouslyDetected(): Boolean {
262+
val previousText = previousLinkDetectedEvent["text"] ?: ""
263+
val previousUrl = previousLinkDetectedEvent["url"] ?: ""
264+
return previousText.isNotEmpty() || previousUrl.isNotEmpty()
265+
}
266+
251267
private fun emitLinkDetectedEvent(
252268
spannable: Spannable,
253269
span: EnrichedInputLinkSpan?,

ios/EnrichedTextInputView.mm

Lines changed: 68 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -976,10 +976,12 @@ - (void)tryUpdatingActiveStyles {
976976
// data for onLinkDetected event
977977
LinkData *detectedLinkData;
978978
NSRange detectedLinkRange = NSMakeRange(0, 0);
979+
BOOL shouldClearLink = NO;
979980

980981
// data for onMentionDetected event
981982
MentionParams *detectedMentionParams = nullptr;
982983
NSRange detectedMentionRange = NSMakeRange(0, 0);
984+
BOOL shouldClearMention = NO;
983985

984986
for (NSNumber *type in stylesDict) {
985987
StyleBase *style = stylesDict[type];
@@ -1011,60 +1013,69 @@ - (void)tryUpdatingActiveStyles {
10111013
}
10121014

10131015
// onLinkDetected event
1014-
if (isActive && [type intValue] == [LinkStyle getType]) {
1015-
// get the link data
1016-
LinkData *candidateLinkData;
1017-
NSRange candidateLinkRange = NSMakeRange(0, 0);
1018-
LinkStyle *linkStyleClass =
1019-
(LinkStyle *)stylesDict[@([LinkStyle getType])];
1020-
if (linkStyleClass != nullptr) {
1021-
candidateLinkData =
1022-
[linkStyleClass getLinkDataAt:textView.selectedRange.location];
1023-
candidateLinkRange =
1024-
[linkStyleClass getFullLinkRangeAt:textView.selectedRange.location];
1025-
}
1016+
if ([type intValue] == [LinkStyle getType]) {
1017+
if (isActive) {
1018+
// get the link data
1019+
LinkData *candidateLinkData;
1020+
NSRange candidateLinkRange = NSMakeRange(0, 0);
1021+
LinkStyle *linkStyleClass =
1022+
(LinkStyle *)stylesDict[@([LinkStyle getType])];
1023+
if (linkStyleClass != nullptr) {
1024+
candidateLinkData =
1025+
[linkStyleClass getLinkDataAt:textView.selectedRange.location];
1026+
candidateLinkRange = [linkStyleClass
1027+
getFullLinkRangeAt:textView.selectedRange.location];
1028+
}
10261029

1027-
if (wasActive == NO) {
1028-
// we changed selection from non-link to a link
1029-
detectedLinkData = candidateLinkData;
1030-
detectedLinkRange = candidateLinkRange;
1031-
} else if (![_recentlyActiveLinkData
1032-
isEqualToLinkData:candidateLinkData] ||
1033-
!NSEqualRanges(_recentlyActiveLinkRange, candidateLinkRange)) {
1034-
// we changed selection from one link to the other or modified
1035-
// current link's text
1036-
detectedLinkData = candidateLinkData;
1037-
detectedLinkRange = candidateLinkRange;
1030+
if (wasActive == NO) {
1031+
// we changed selection from non-link to a link
1032+
detectedLinkData = candidateLinkData;
1033+
detectedLinkRange = candidateLinkRange;
1034+
} else if (![_recentlyActiveLinkData
1035+
isEqualToLinkData:candidateLinkData] ||
1036+
!NSEqualRanges(_recentlyActiveLinkRange,
1037+
candidateLinkRange)) {
1038+
// we changed selection from one link to the other or modified
1039+
// current link's text
1040+
detectedLinkData = candidateLinkData;
1041+
detectedLinkRange = candidateLinkRange;
1042+
}
1043+
} else if (wasActive) {
1044+
shouldClearLink = YES;
10381045
}
10391046
}
10401047

10411048
// onMentionDetected event
1042-
if (isActive && [type intValue] == [MentionStyle getType]) {
1043-
// get mention data
1044-
MentionParams *candidateMentionParams;
1045-
NSRange candidateMentionRange = NSMakeRange(0, 0);
1046-
MentionStyle *mentionStyleClass =
1047-
(MentionStyle *)stylesDict[@([MentionStyle getType])];
1048-
if (mentionStyleClass != nullptr) {
1049-
candidateMentionParams = [mentionStyleClass
1050-
getMentionParamsAt:textView.selectedRange.location];
1051-
candidateMentionRange = [mentionStyleClass
1052-
getFullMentionRangeAt:textView.selectedRange.location];
1053-
}
1049+
if ([type intValue] == [MentionStyle getType]) {
1050+
if (isActive) {
1051+
// get mention data
1052+
MentionParams *candidateMentionParams;
1053+
NSRange candidateMentionRange = NSMakeRange(0, 0);
1054+
MentionStyle *mentionStyleClass =
1055+
(MentionStyle *)stylesDict[@([MentionStyle getType])];
1056+
if (mentionStyleClass != nullptr) {
1057+
candidateMentionParams = [mentionStyleClass
1058+
getMentionParamsAt:textView.selectedRange.location];
1059+
candidateMentionRange = [mentionStyleClass
1060+
getFullMentionRangeAt:textView.selectedRange.location];
1061+
}
10541062

1055-
if (wasActive == NO) {
1056-
// selection was changed from a non-mention to a mention
1057-
detectedMentionParams = candidateMentionParams;
1058-
detectedMentionRange = candidateMentionRange;
1059-
} else if (![_recentlyActiveMentionParams.text
1060-
isEqualToString:candidateMentionParams.text] ||
1061-
![_recentlyActiveMentionParams.attributes
1062-
isEqualToString:candidateMentionParams.attributes] ||
1063-
!NSEqualRanges(_recentlyActiveMentionRange,
1064-
candidateMentionRange)) {
1065-
// selection changed from one mention to another
1066-
detectedMentionParams = candidateMentionParams;
1067-
detectedMentionRange = candidateMentionRange;
1063+
if (wasActive == NO) {
1064+
// selection was changed from a non-mention to a mention
1065+
detectedMentionParams = candidateMentionParams;
1066+
detectedMentionRange = candidateMentionRange;
1067+
} else if (![_recentlyActiveMentionParams.text
1068+
isEqualToString:candidateMentionParams.text] ||
1069+
![_recentlyActiveMentionParams.attributes
1070+
isEqualToString:candidateMentionParams.attributes] ||
1071+
!NSEqualRanges(_recentlyActiveMentionRange,
1072+
candidateMentionRange)) {
1073+
// selection changed from one mention to another
1074+
detectedMentionParams = candidateMentionParams;
1075+
detectedMentionRange = candidateMentionRange;
1076+
}
1077+
} else if (wasActive) {
1078+
shouldClearMention = YES;
10681079
}
10691080
}
10701081
}
@@ -1111,6 +1122,11 @@ - (void)tryUpdatingActiveStyles {
11111122
if (detectedLinkData != nullptr) {
11121123
// emit onLinkeDetected event
11131124
[self emitOnLinkDetectedEvent:detectedLinkData range:detectedLinkRange];
1125+
} else if (shouldClearLink) {
1126+
LinkData *emptyLinkData = [[LinkData alloc] init];
1127+
emptyLinkData.text = @"";
1128+
emptyLinkData.url = @"";
1129+
[self emitOnLinkDetectedEvent:emptyLinkData range:NSMakeRange(0, 0)];
11141130
}
11151131

11161132
if (detectedMentionParams != nullptr) {
@@ -1121,6 +1137,10 @@ - (void)tryUpdatingActiveStyles {
11211137

11221138
_recentlyActiveMentionParams = detectedMentionParams;
11231139
_recentlyActiveMentionRange = detectedMentionRange;
1140+
} else if (shouldClearMention) {
1141+
[self emitOnMentionDetectedEvent:@"" indicator:@"" attributes:@"{}"];
1142+
_recentlyActiveMentionParams = nullptr;
1143+
_recentlyActiveMentionRange = NSMakeRange(0, 0);
11241144
}
11251145
// emit onChangeHtml event if needed
11261146
[self tryEmittingOnChangeHtmlEvent];

src/web/pmPlugins/MentionPlugin/subscribeMentionEvents.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export function subscribeMentionEvents(
1010
): () => void {
1111
let prevTriggerState: TriggerState = { active: false };
1212
let prevMentionKey: string | null = null;
13+
let wasInMention = false;
1314

1415
const handleTransaction = () => {
1516
const cb = getCallbacks();
@@ -31,14 +32,28 @@ export function subscribeMentionEvents(
3132
}
3233
prevTriggerState = curr;
3334

34-
const mention = cb.onMentionDetected ? getActiveMention(editor) : null;
35+
if (!cb.onMentionDetected) return;
36+
37+
const mention = getActiveMention(editor);
3538
if (!mention) {
36-
prevMentionKey = null;
39+
if (wasInMention) {
40+
wasInMention = false;
41+
prevMentionKey = null;
42+
cb.onMentionDetected({
43+
text: '',
44+
indicator: '',
45+
attributes: {},
46+
});
47+
} else {
48+
prevMentionKey = null;
49+
}
3750
return;
3851
}
52+
53+
wasInMention = true;
3954
if (mention.key === prevMentionKey) return;
4055
prevMentionKey = mention.key;
41-
cb.onMentionDetected?.({
56+
cb.onMentionDetected({
4257
text: mention.text,
4358
indicator: mention.indicator,
4459
attributes: mention.attributes,
@@ -68,15 +83,18 @@ function getActiveMention(
6883
): (OnMentionDetected & { key: string }) | null {
6984
const { state } = editor;
7085
const mentionType = state.schema.marks.mention;
71-
if (!mentionType || !state.selection.empty) return null;
86+
if (!mentionType) return null;
7287

73-
const $pos = state.doc.resolve(state.selection.from);
74-
const mark = mentionType.isInSet($pos.marks());
88+
const { from: selFrom, to: selTo } = state.selection;
89+
const $from = state.doc.resolve(selFrom);
90+
const mark = mentionType.isInSet($from.marks());
7591
if (!mark) return null;
7692

77-
const range = getMarkRange($pos, mentionType);
93+
const range = getMarkRange($from, mentionType);
7894
if (!range) return null;
7995

96+
if (selFrom < range.from || selTo > range.to) return null;
97+
8098
const { text, indicator, attributes } = mark.attrs;
8199
return {
82100
key: `${range.from}:${range.to}:${text}:${indicator}`,

src/web/useOnLinkDetected.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export const useOnLinkDetected = (
1919
const linkType = state.schema.marks.link;
2020
if (!linkType) return;
2121

22-
const { from: selFrom, to: selTo } = state.selection;
2322
const $pos = state.selection.$from;
2423
const range = getMarkRange($pos, linkType);
2524

@@ -29,8 +28,8 @@ export const useOnLinkDetected = (
2928
onLinkDetected({
3029
text: '',
3130
url: '',
32-
start: tiptapPosToNativePos(state.doc, selFrom),
33-
end: tiptapPosToNativePos(state.doc, selTo),
31+
start: 0,
32+
end: 0,
3433
});
3534
}
3635
lastEmittedRef.current = null;

0 commit comments

Comments
 (0)