diff --git a/.playwright/tests/mentions.spec.ts b/.playwright/tests/mentions.spec.ts index d8bc60dc7..5e6d4379d 100644 --- a/.playwright/tests/mentions.spec.ts +++ b/.playwright/tests/mentions.spec.ts @@ -230,7 +230,7 @@ test('moving within the same mention does not re-fire onMentionDetected', async await expect(detectedCount(page)).toHaveText('1'); }); -test('moving out of a mention does not increment detected count', async ({ +test('moving out of a mention fires clear onMentionDetected', async ({ page, }) => { await gotoMentionTest(page); @@ -244,13 +244,15 @@ test('moving out of a mention does not increment detected count', async ({ ) .toBe(true); await editor.click(); - await editor.press('Home'); - await editor.press('ArrowRight'); - await editor.press('ArrowRight'); - await editor.press('ArrowRight'); await editor.press('End'); - await editor.press('Enter'); + await editor.press('ArrowLeft'); // skip trailing space after mention + await editor.press('ArrowLeft'); // caret inside mention text await expect(detectedCount(page)).toHaveText('1'); + await editor.press('End'); + await editor.press('Enter'); + await expect(detectedCount(page)).toHaveText('2'); + await expect(detectedText(page)).toHaveText(''); + await expect(detectedIndicator(page)).toHaveText(''); }); test('mention renders correctly', async ({ page }) => { diff --git a/.playwright/tests/testLinks.spec.ts b/.playwright/tests/testLinks.spec.ts index dd6208f15..82bef7368 100644 --- a/.playwright/tests/testLinks.spec.ts +++ b/.playwright/tests/testLinks.spec.ts @@ -347,8 +347,8 @@ test.describe('test-links onLinkDetected', () => { .toEqual({ text: '', url: '', - start: 8, - end: 8, + start: 0, + end: 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 64239d295..54216fd39 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 @@ -190,31 +190,42 @@ class EnrichedSelection( private fun getParametrizedStyleStart(type: Class): Int? { val (start, end) = getInlineSelection() val spannable = view.text as Spannable - val spans = spannable.getSpans(start, end, type) val isLinkType = type == EnrichedInputLinkSpan::class.java val isMentionType = type == EnrichedInputMentionSpan::class.java - if (isLinkType && spans.isEmpty()) { - emitLinkDetectedEvent(spannable, null, start, end) + if (isMentionType) { + val activeMention = findActiveMentionSpan(spannable, start, end) + if (activeMention != null) { + val (span, spanStart, spanEnd) = activeMention + emitMentionDetectedEvent(span, spanStart, spanEnd) + return spanStart + } + if (wasMentionPreviouslyDetected()) { + emitMentionClearedEvent() + } return null } - if (isMentionType && spans.isEmpty()) { - emitMentionDetectedEvent(spannable, null, start, end) + if (isLinkType) { + val activeLink = findActiveLinkSpan(spannable, start, end) + if (activeLink != null) { + val (span, spanStart, spanEnd) = activeLink + emitLinkDetectedEvent(span, spanStart, spanEnd) + return spanStart + } + if (wasLinkPreviouslyDetected()) { + emitLinkClearedEvent() + } return null } + val spans = spannable.getSpans(start, end, type) + for (span in spans) { val spanStart = spannable.getSpanStart(span) val spanEnd = spannable.getSpanEnd(span) if (start >= spanStart && end <= spanEnd) { - if (isLinkType && span is EnrichedInputLinkSpan) { - emitLinkDetectedEvent(spannable, span, spanStart, spanEnd) - } else if (isMentionType && span is EnrichedInputMentionSpan) { - emitMentionDetectedEvent(spannable, span, spanStart, spanEnd) - } - return spanStart } } @@ -222,6 +233,56 @@ class EnrichedSelection( return null } + private fun findActiveLinkSpan( + spannable: Spannable, + start: Int, + end: Int, + ): Triple? { + val spans = spannable.getSpans(start, end, EnrichedInputLinkSpan::class.java) + + for (span in spans) { + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + + if (start >= spanStart && end <= spanEnd) { + return Triple(span, spanStart, spanEnd) + } + } + + return null + } + + private fun findActiveMentionSpan( + spannable: Spannable, + start: Int, + end: Int, + ): Triple? { + val spans = spannable.getSpans(start, end, EnrichedInputMentionSpan::class.java) + + for (span in spans) { + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + + if (start >= spanStart && end <= spanEnd) { + return Triple(span, spanStart, spanEnd) + } + } + + return null + } + + private fun wasMentionPreviouslyDetected(): Boolean { + val previousText = previousMentionDetectedEvent["text"] ?: "" + val previousIndicator = previousMentionDetectedEvent["indicator"] ?: "" + return previousText.isNotEmpty() || previousIndicator.isNotEmpty() + } + + private fun wasLinkPreviouslyDetected(): Boolean { + val previousText = previousLinkDetectedEvent["text"] ?: "" + val previousUrl = previousLinkDetectedEvent["url"] ?: "" + return previousText.isNotEmpty() || previousUrl.isNotEmpty() + } + private fun emitSelectionChangeEvent( editable: Editable?, start: Int, @@ -249,15 +310,27 @@ class EnrichedSelection( } private fun emitLinkDetectedEvent( - spannable: Spannable, - span: EnrichedInputLinkSpan?, + span: EnrichedInputLinkSpan, + spanStart: Int, + spanEnd: Int, + ) { + val spannable = view.text as Spannable + val text = spannable.substring(spanStart, spanEnd).replace(EnrichedConstants.ZWS_STRING, "") + dispatchLinkDetectedEvent(text, span.getUrl(), spanStart, spanEnd, spannable) + } + + private fun emitLinkClearedEvent() { + val spannable = view.text as Spannable + dispatchLinkDetectedEvent("", "", 0, 0, spannable) + } + + private fun dispatchLinkDetectedEvent( + text: String, + url: String, start: Int, end: Int, + spannable: Spannable, ) { - val text = spannable.substring(start, end).replace(EnrichedConstants.ZWS_STRING, "") - val url = span?.getUrl() ?: "" - - // Prevents emitting unnecessary events if (text == previousLinkDetectedEvent["text"] && url == previousLinkDetectedEvent["url"]) return previousLinkDetectedEvent.put("text", text) @@ -283,16 +356,27 @@ class EnrichedSelection( } private fun emitMentionDetectedEvent( - spannable: Spannable, - span: EnrichedInputMentionSpan?, - start: Int, - end: Int, + span: EnrichedInputMentionSpan, + spanStart: Int, + spanEnd: Int, ) { - val text = spannable.substring(start, end) - val attributes = span?.getAttributes() ?: emptyMap() - val indicator = span?.getIndicator() ?: "" + val spannable = view.text as Spannable + val text = spannable.substring(spanStart, spanEnd) + val attributes = span.getAttributes() + val indicator = span.getIndicator() val payload = JSONObject(attributes).toString() + dispatchMentionDetectedEvent(text, indicator, payload) + } + + private fun emitMentionClearedEvent() { + dispatchMentionDetectedEvent("", "", "{}") + } + private fun dispatchMentionDetectedEvent( + text: String, + indicator: String, + payload: String, + ) { val previousText = previousMentionDetectedEvent["text"] ?: "" val previousPayload = previousMentionDetectedEvent["payload"] ?: "" val previousIndicator = previousMentionDetectedEvent["indicator"] ?: "" diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 95b8bd5c4..781f876a2 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -976,10 +976,12 @@ - (void)tryUpdatingActiveStyles { // data for onLinkDetected event LinkData *detectedLinkData; NSRange detectedLinkRange = NSMakeRange(0, 0); + BOOL shouldClearLink = NO; // data for onMentionDetected event MentionParams *detectedMentionParams = nullptr; NSRange detectedMentionRange = NSMakeRange(0, 0); + BOOL shouldClearMention = NO; for (NSNumber *type in stylesDict) { StyleBase *style = stylesDict[type]; @@ -1011,60 +1013,69 @@ - (void)tryUpdatingActiveStyles { } // onLinkDetected event - if (isActive && [type intValue] == [LinkStyle getType]) { - // get the link data - LinkData *candidateLinkData; - NSRange candidateLinkRange = NSMakeRange(0, 0); - LinkStyle *linkStyleClass = - (LinkStyle *)stylesDict[@([LinkStyle getType])]; - if (linkStyleClass != nullptr) { - candidateLinkData = - [linkStyleClass getLinkDataAt:textView.selectedRange.location]; - candidateLinkRange = - [linkStyleClass getFullLinkRangeAt:textView.selectedRange.location]; - } + if ([type intValue] == [LinkStyle getType]) { + if (isActive) { + // get the link data + LinkData *candidateLinkData; + NSRange candidateLinkRange = NSMakeRange(0, 0); + LinkStyle *linkStyleClass = + (LinkStyle *)stylesDict[@([LinkStyle getType])]; + if (linkStyleClass != nullptr) { + candidateLinkData = + [linkStyleClass getLinkDataAt:textView.selectedRange.location]; + candidateLinkRange = [linkStyleClass + getFullLinkRangeAt:textView.selectedRange.location]; + } - if (wasActive == NO) { - // we changed selection from non-link to a link - detectedLinkData = candidateLinkData; - detectedLinkRange = candidateLinkRange; - } else if (![_recentlyActiveLinkData - isEqualToLinkData:candidateLinkData] || - !NSEqualRanges(_recentlyActiveLinkRange, candidateLinkRange)) { - // we changed selection from one link to the other or modified - // current link's text - detectedLinkData = candidateLinkData; - detectedLinkRange = candidateLinkRange; + if (wasActive == NO) { + // we changed selection from non-link to a link + detectedLinkData = candidateLinkData; + detectedLinkRange = candidateLinkRange; + } else if (![_recentlyActiveLinkData + isEqualToLinkData:candidateLinkData] || + !NSEqualRanges(_recentlyActiveLinkRange, + candidateLinkRange)) { + // we changed selection from one link to the other or modified + // current link's text + detectedLinkData = candidateLinkData; + detectedLinkRange = candidateLinkRange; + } + } else if (wasActive && [self wasLinkPreviouslyDetected]) { + shouldClearLink = YES; } } // onMentionDetected event - if (isActive && [type intValue] == [MentionStyle getType]) { - // get mention data - MentionParams *candidateMentionParams; - NSRange candidateMentionRange = NSMakeRange(0, 0); - MentionStyle *mentionStyleClass = - (MentionStyle *)stylesDict[@([MentionStyle getType])]; - if (mentionStyleClass != nullptr) { - candidateMentionParams = [mentionStyleClass - getMentionParamsAt:textView.selectedRange.location]; - candidateMentionRange = [mentionStyleClass - getFullMentionRangeAt:textView.selectedRange.location]; - } + if ([type intValue] == [MentionStyle getType]) { + if (isActive) { + // get mention data + MentionParams *candidateMentionParams; + NSRange candidateMentionRange = NSMakeRange(0, 0); + MentionStyle *mentionStyleClass = + (MentionStyle *)stylesDict[@([MentionStyle getType])]; + if (mentionStyleClass != nullptr) { + candidateMentionParams = [mentionStyleClass + getMentionParamsAt:textView.selectedRange.location]; + candidateMentionRange = [mentionStyleClass + getFullMentionRangeAt:textView.selectedRange.location]; + } - if (wasActive == NO) { - // selection was changed from a non-mention to a mention - detectedMentionParams = candidateMentionParams; - detectedMentionRange = candidateMentionRange; - } else if (![_recentlyActiveMentionParams.text - isEqualToString:candidateMentionParams.text] || - ![_recentlyActiveMentionParams.attributes - isEqualToString:candidateMentionParams.attributes] || - !NSEqualRanges(_recentlyActiveMentionRange, - candidateMentionRange)) { - // selection changed from one mention to another - detectedMentionParams = candidateMentionParams; - detectedMentionRange = candidateMentionRange; + if (wasActive == NO) { + // selection was changed from a non-mention to a mention + detectedMentionParams = candidateMentionParams; + detectedMentionRange = candidateMentionRange; + } else if (![_recentlyActiveMentionParams.text + isEqualToString:candidateMentionParams.text] || + ![_recentlyActiveMentionParams.attributes + isEqualToString:candidateMentionParams.attributes] || + !NSEqualRanges(_recentlyActiveMentionRange, + candidateMentionRange)) { + // selection changed from one mention to another + detectedMentionParams = candidateMentionParams; + detectedMentionRange = candidateMentionRange; + } + } else if (wasActive && [self wasMentionPreviouslyDetected]) { + shouldClearMention = YES; } } } @@ -1111,6 +1122,11 @@ - (void)tryUpdatingActiveStyles { if (detectedLinkData != nullptr) { // emit onLinkeDetected event [self emitOnLinkDetectedEvent:detectedLinkData range:detectedLinkRange]; + } else if (shouldClearLink) { + LinkData *emptyLinkData = [[LinkData alloc] init]; + emptyLinkData.text = @""; + emptyLinkData.url = @""; + [self emitOnLinkDetectedEvent:emptyLinkData range:NSMakeRange(0, 0)]; } if (detectedMentionParams != nullptr) { @@ -1121,6 +1137,10 @@ - (void)tryUpdatingActiveStyles { _recentlyActiveMentionParams = detectedMentionParams; _recentlyActiveMentionRange = detectedMentionRange; + } else if (shouldClearMention) { + [self emitOnMentionDetectedEvent:@"" indicator:@"" attributes:@"{}"]; + _recentlyActiveMentionParams = nullptr; + _recentlyActiveMentionRange = NSMakeRange(0, 0); } // emit onChangeHtml event if needed [self tryEmittingOnChangeHtmlEvent]; @@ -1327,6 +1347,18 @@ - (void)emitOnSubmitEdittingEvent { } } +- (BOOL)wasLinkPreviouslyDetected { + return _recentlyActiveLinkData != nullptr && + (_recentlyActiveLinkData.text.length > 0 || + _recentlyActiveLinkData.url.length > 0); +} + +- (BOOL)wasMentionPreviouslyDetected { + return _recentlyActiveMentionParams != nullptr && + (_recentlyActiveMentionParams.text.length > 0 || + _recentlyActiveMentionParams.indicator.length > 0); +} + - (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range { auto emitter = [self getEventEmitter]; if (emitter != nullptr) { diff --git a/src/web/pmPlugins/mentionPlugin/subscribeMentionEvents.ts b/src/web/pmPlugins/mentionPlugin/subscribeMentionEvents.ts index 1633ce3a6..334febb91 100644 --- a/src/web/pmPlugins/mentionPlugin/subscribeMentionEvents.ts +++ b/src/web/pmPlugins/mentionPlugin/subscribeMentionEvents.ts @@ -10,6 +10,7 @@ export function subscribeMentionEvents( ): () => void { let prevTriggerState: TriggerState = { active: false }; let prevMentionKey: string | null = null; + let wasInMention = false; const handleTransaction = () => { const cb = callbacksRef.current; @@ -31,14 +32,28 @@ export function subscribeMentionEvents( } prevTriggerState = curr; - const mention = cb.onMentionDetected ? getActiveMention(editor) : null; + if (!cb.onMentionDetected) return; + + const mention = getActiveMention(editor); if (!mention) { - prevMentionKey = null; + 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?.({ + cb.onMentionDetected({ text: mention.text, indicator: mention.indicator, attributes: mention.attributes, @@ -67,15 +82,18 @@ function getActiveMention( ): (OnMentionDetected & { key: string }) | null { const { state } = editor; const mentionType = state.schema.marks.mention; - if (!mentionType || !state.selection.empty) return null; + if (!mentionType) return null; - const $pos = state.doc.resolve(state.selection.from); - const mark = mentionType.isInSet($pos.marks()); + 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($pos, mentionType); + 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}`, diff --git a/src/web/useOnLinkDetected.ts b/src/web/useOnLinkDetected.ts index c45907f51..daf1fb004 100644 --- a/src/web/useOnLinkDetected.ts +++ b/src/web/useOnLinkDetected.ts @@ -19,7 +19,6 @@ export const useOnLinkDetected = ( const linkType = state.schema.marks.link; if (!linkType) return; - const { from: selFrom, to: selTo } = state.selection; const $pos = state.selection.$from; const range = getMarkRange($pos, linkType); @@ -29,8 +28,8 @@ export const useOnLinkDetected = ( onLinkDetected({ text: '', url: '', - start: tiptapPosToNativePos(state.doc, selFrom), - end: tiptapPosToNativePos(state.doc, selTo), + start: 0, + end: 0, }); } lastEmittedRef.current = null;