From eacfcf8990c2067ab4ba96d5fdb002c7056ee2cc Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 29 Oct 2024 20:13:01 -0300 Subject: [PATCH 1/7] [SuperEditor] Limit tag expansion to caret position (Resolves #2240) --- .../text_tokenizing/action_tags.dart | 31 ++- .../default_editor/text_tokenizing/tags.dart | 9 +- .../text_entry/tagging/action_tags_test.dart | 198 ++++++++++++++++++ 3 files changed, 230 insertions(+), 8 deletions(-) diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index 360d24480a..bf6c7bbca6 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -138,6 +138,7 @@ class SubmitComposingActionTagCommand extends EditCommand { nodeId: composer.selection!.extent.nodeId, text: textNode.text, expansionPosition: extentPosition, + endPosition: extentPosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); @@ -214,6 +215,11 @@ class CancelComposingActionTagCommand extends EditCommand { TagAroundPosition? composingToken; TextNode? textNode; + final normalizedSelection = selection.normalize(document); + final endPosition = normalizedSelection.end.nodePosition is TextNodePosition + ? normalizedSelection.end.nodePosition as TextNodePosition + : null; + if (base.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.base.nodeId) as TextNode; composingToken = TagFinder.findTagAroundPosition( @@ -221,6 +227,7 @@ class CancelComposingActionTagCommand extends EditCommand { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, + endPosition: endPosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -231,6 +238,7 @@ class CancelComposingActionTagCommand extends EditCommand { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, + endPosition: endPosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -300,6 +308,11 @@ class ActionTagComposingReaction extends EditReaction { TagAroundPosition? tagAroundPosition; TextNode? textNode; + final normalizedSelection = selection.normalize(document); + final endPosition = normalizedSelection.end.nodePosition is TextNodePosition + ? normalizedSelection.end.nodePosition as TextNodePosition + : null; + if (base.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.base.nodeId) as TextNode; tagAroundPosition = TagFinder.findTagAroundPosition( @@ -307,6 +320,7 @@ class ActionTagComposingReaction extends EditReaction { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, + endPosition: endPosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -317,6 +331,7 @@ class ActionTagComposingReaction extends EditReaction { nodeId: textNode.id, text: textNode.text, expansionPosition: extent.nodePosition as TextNodePosition, + endPosition: endPosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -442,15 +457,17 @@ class ActionTagComposingReaction extends EditReaction { ), attributions: {actionTagComposingAttribution}, ), - AddTextAttributionsRequest( - documentRange: DocumentSelection( - base: composingTag.start, - extent: composingTag.start.copyWith( - nodePosition: TextNodePosition(offset: composingTag.startOffset + 1), + // Only cancel the attribution if the tag is longer than just the trigger. + if (composingTag.length > 1) + AddTextAttributionsRequest( + documentRange: DocumentSelection( + base: composingTag.start, + extent: composingTag.start.copyWith( + nodePosition: TextNodePosition(offset: composingTag.startOffset + 1), + ), ), + attributions: {actionTagCancelledAttribution}, ), - attributions: {actionTagCancelledAttribution}, - ), ]); } } diff --git a/super_editor/lib/src/default_editor/text_tokenizing/tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart index 7122ce72fe..7ba3cacacc 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart @@ -10,11 +10,15 @@ import 'package:super_editor/src/default_editor/text.dart'; class TagFinder { /// Finds a tag that touches the given [expansionPosition] and returns that tag, /// indexed within the document, along with the [expansionPosition]. + /// + /// If [endPosition] is provided, the search will be limited to the range between + /// the [expansionPosition] and the [endPosition]. static TagAroundPosition? findTagAroundPosition({ required TagRule tagRule, required String nodeId, required AttributedText text, required TextNodePosition expansionPosition, + TextNodePosition? endPosition, required bool Function(Set tokenAttributions) isTokenCandidate, }) { final rawText = text.text; @@ -31,7 +35,7 @@ class TagFinder { final charactersBefore = rawText.substring(0, splitIndex).characters; final iteratorUpstream = charactersBefore.iteratorAtEnd; - final charactersAfter = rawText.substring(splitIndex).characters; + final charactersAfter = rawText.substring(splitIndex, endPosition?.offset).characters; final iteratorDownstream = charactersAfter.iterator; if (charactersBefore.isNotEmpty && tagRule.excludedCharacters.contains(charactersBefore.last)) { @@ -283,6 +287,9 @@ class IndexedTag { /// The [DocumentRange] from [start] to [end]. DocumentRange get range => DocumentRange(start: start, end: end); + /// The length of the [tag]'s text. + int get length => tag.raw.length; + /// Collects and returns all attributions in this tag's [TextNode], between the /// [start] of the tag and the [end] of the tag. AttributedSpans computeTagSpans(Document document) => diff --git a/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart b/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart index 756ad02f4e..702169d105 100644 --- a/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart +++ b/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart @@ -58,6 +58,41 @@ void main() { ); }); + testWidgetsOnAllPlatforms("can start at the beginning of a word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |after" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag, typing at "|after". + await tester.typeImeText("/header"); + + // Ensure that "/header" was attributed but "after" was left unnattributed. + final spans = SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 19), + ); + expect(spans.length, 1); + expect( + spans.first, + const AttributionSpan( + attribution: actionTagComposingAttribution, + start: 7, + end: 13, + ), + ); + }); + testWidgetsOnAllPlatforms("by default does not continue after a space", (tester) async { await _pumpTestEditor( tester, @@ -500,6 +535,119 @@ void main() { ); }); + testWidgetsOnDesktop("cancels composing when deleting the trigger character", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |after" + await tester.placeCaretInParagraph("1", 7); + + // Start composing a tag. + await tester.typeImeText("/"); + + // Press backspace to delete the tag. + await tester.pressBackspace(); + + // Ensure nothing is attributed, because we didn't type any characters + // after the initial "/". + expect( + SuperEditorInspector.findTextInComponent("1").getAllAttributionsThroughout( + const SpanRange(0, 13), + ), + isEmpty, + ); + + // Start composing the tag again. + await tester.typeImeText("/header"); + + // Ensure that "/header" is attributed. + final spans = SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 19), + ); + expect(spans.length, 1); + expect( + spans.first, + const AttributionSpan( + attribution: actionTagComposingAttribution, + start: 7, + end: 13, + ), + ); + }); + + testWidgetsOnMobile("cancels composing when deleting the trigger character with software keyboard", + (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |after" + await tester.placeCaretInParagraph("1", 7); + + // Start composing a tag. + await tester.typeImeText("/"); + + // Simulate the user pressing backspace on the software keyboard. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. before /after', + selection: TextSelection(baseOffset: 9, extentOffset: 9), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: '. before /after', + deletedRange: TextSelection(baseOffset: 9, extentOffset: 10), + selection: TextSelection(baseOffset: 9, extentOffset: 9), + composing: TextRange.empty, + ), + ], getter: imeClientGetter); + + // Ensure nothing is attributed, because we didn't type any characters + // after the initial "/". + expect( + SuperEditorInspector.findTextInComponent("1").getAllAttributionsThroughout( + const SpanRange(0, 13), + ), + isEmpty, + ); + + // Start composing the tag again. + await tester.typeImeText("/header"); + + // Ensure that "/header" is attributed. + final spans = SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 19), + ); + expect(spans.length, 1); + expect( + spans.first, + const AttributionSpan( + attribution: actionTagComposingAttribution, + start: 7, + end: 13, + ), + ); + }); + testWidgetsOnAllPlatforms("only notifies tag index listeners when tags change", (tester) async { final actionTagPlugin = ActionTagsPlugin(); @@ -631,6 +779,56 @@ void main() { isEmpty, ); }); + + testWidgetsOnAllPlatforms("at the beginning of a word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |after". + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Ensure only "/header" is attributed. + AttributedText? text = SuperEditorInspector.findTextInComponent("1"); + final spans = text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 19), + ); + expect(spans.length, 1); + expect( + spans.first, + const AttributionSpan( + attribution: actionTagComposingAttribution, + start: 7, + end: 13, + ), + ); + + // Submit the tag. + await tester.pressEnter(); + + // Ensure that the action tag was removed. + text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "before after"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 12), + ), + isEmpty, + ); + }); }); }); From 23d2527b5839ba7ab520bda7bf5c15cabdaa9b1f Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Wed, 30 Oct 2024 20:10:31 -0300 Subject: [PATCH 2/7] Fix tag being re-applied after being canceled --- super_editor/lib/src/default_editor/text.dart | 9 ++- .../text_tokenizing/action_tags.dart | 16 +++-- .../text_entry/tagging/action_tags_test.dart | 61 +++++++++++++++++-- 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index d28a361089..bbfa3c29ff 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -1396,9 +1396,12 @@ class RemoveTextAttributionsCommand extends EditCommand { startOffset = (normalizedRange.start.nodePosition as TextPosition).offset; - // -1 because TextPosition's offset indexes the character after the - // selection, not the final character in the selection. - endOffset = (normalizedRange.end.nodePosition as TextPosition).offset - 1; + endOffset = normalizedRange.start != normalizedRange.end + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + ? (normalizedRange.end.nodePosition as TextPosition).offset - 1 + // The selection is collapsed. Don't decrement the offset. + : startOffset; } else if (textNode == nodes.first) { // Handle partial node selection in first node. editorDocLog.info(' - selecting part of the first node: ${textNode.id}'); diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index bf6c7bbca6..bf218988c1 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -457,17 +457,15 @@ class ActionTagComposingReaction extends EditReaction { ), attributions: {actionTagComposingAttribution}, ), - // Only cancel the attribution if the tag is longer than just the trigger. - if (composingTag.length > 1) - AddTextAttributionsRequest( - documentRange: DocumentSelection( - base: composingTag.start, - extent: composingTag.start.copyWith( - nodePosition: TextNodePosition(offset: composingTag.startOffset + 1), - ), + AddTextAttributionsRequest( + documentRange: DocumentSelection( + base: composingTag.start, + extent: composingTag.start.copyWith( + nodePosition: TextNodePosition(offset: composingTag.startOffset + 1), ), - attributions: {actionTagCancelledAttribution}, ), + attributions: {actionTagCancelledAttribution}, + ), ]); } } diff --git a/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart b/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart index 702169d105..74a4d13a2c 100644 --- a/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart +++ b/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart @@ -560,8 +560,9 @@ void main() { // Ensure nothing is attributed, because we didn't type any characters // after the initial "/". expect( - SuperEditorInspector.findTextInComponent("1").getAllAttributionsThroughout( - const SpanRange(0, 13), + SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: const SpanRange(0, 13), ), isEmpty, ); @@ -623,8 +624,9 @@ void main() { // Ensure nothing is attributed, because we didn't type any characters // after the initial "/". expect( - SuperEditorInspector.findTextInComponent("1").getAllAttributionsThroughout( - const SpanRange(0, 13), + SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: const SpanRange(0, 13), ), isEmpty, ); @@ -648,6 +650,57 @@ void main() { ); }); + testWidgetsOnAllPlatforms("does not re-apply a canceled tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before | after" + await tester.placeCaretInParagraph("1", 7); + + // Start composing a tag. + await tester.typeImeText("/"); + + // Ensure that we're composing. + var text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 7), + ); + + // Move the caret to "before |/ after" + await tester.pressLeftArrow(); + + // Ensure we are not composing anymore. + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: const SpanRange(0, 14), + ), + isEmpty, + ); + + // Move the caret to "before /| after" + await tester.pressRightArrow(); + + // Ensure we are still not composing. + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: const SpanRange(0, 14), + ), + isEmpty, + ); + }); + testWidgetsOnAllPlatforms("only notifies tag index listeners when tags change", (tester) async { final actionTagPlugin = ActionTagsPlugin(); From aea32bea4d4bee5dd5f75ccd5f04d402850dea32 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 17 Dec 2024 10:43:10 -0300 Subject: [PATCH 3/7] PR updates --- .../text_tokenizing/action_tags.dart | 59 ++++++++++++++----- .../default_editor/text_tokenizing/tags.dart | 6 +- .../text_entry/tagging/pattern_tags_test.dart | 28 +++++++++ .../text_entry/tagging/stable_tags_test.dart | 59 +++++++++++++++++++ 4 files changed, 131 insertions(+), 21 deletions(-) diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index bf218988c1..397c53e35e 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -138,7 +138,6 @@ class SubmitComposingActionTagCommand extends EditCommand { nodeId: composer.selection!.extent.nodeId, text: textNode.text, expansionPosition: extentPosition, - endPosition: extentPosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); @@ -148,17 +147,19 @@ class SubmitComposingActionTagCommand extends EditCommand { context.composingActionTag.value = null; + final indexedTag = _constrainTagToCaret(tagAroundPosition, document, composer.selection!); + executor.executeCommand( DeleteContentCommand( documentRange: DocumentSelection( - base: tagAroundPosition.indexedTag.start, - extent: tagAroundPosition.indexedTag.end, + base: indexedTag.start, + extent: indexedTag.end, ), ), ); executor.executeCommand( ChangeSelectionCommand( - DocumentSelection.collapsed(position: tagAroundPosition.indexedTag.start), + DocumentSelection.collapsed(position: indexedTag.start), SelectionChangeType.deleteContent, SelectionReason.userInteraction, ), @@ -227,7 +228,6 @@ class CancelComposingActionTagCommand extends EditCommand { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, - endPosition: endPosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -238,7 +238,6 @@ class CancelComposingActionTagCommand extends EditCommand { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, - endPosition: endPosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -308,11 +307,6 @@ class ActionTagComposingReaction extends EditReaction { TagAroundPosition? tagAroundPosition; TextNode? textNode; - final normalizedSelection = selection.normalize(document); - final endPosition = normalizedSelection.end.nodePosition is TextNodePosition - ? normalizedSelection.end.nodePosition as TextNodePosition - : null; - if (base.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.base.nodeId) as TextNode; tagAroundPosition = TagFinder.findTagAroundPosition( @@ -320,7 +314,6 @@ class ActionTagComposingReaction extends EditReaction { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, - endPosition: endPosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -331,7 +324,6 @@ class ActionTagComposingReaction extends EditReaction { nodeId: textNode.id, text: textNode.text, expansionPosition: extent.nodePosition as TextNodePosition, - endPosition: endPosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -343,9 +335,11 @@ class ActionTagComposingReaction extends EditReaction { return; } - _updateComposingTag(requestDispatcher, tagAroundPosition.indexedTag); - editorContext.composingActionTag.value = tagAroundPosition.indexedTag; - _onUpdateComposingActionTag(tagAroundPosition.indexedTag); + final indexedTag = _constrainTagToCaret(tagAroundPosition, document, selection); + + _updateComposingTag(requestDispatcher, indexedTag); + editorContext.composingActionTag.value = indexedTag; + _onUpdateComposingActionTag(indexedTag); } /// Finds all cancelled action tags across all changed text nodes in [changeList] and corrects @@ -470,6 +464,39 @@ class ActionTagComposingReaction extends EditReaction { } } +/// Given a [tagAroundPosition], returns a new [IndexedTag] which ends before the caret position. +/// +/// For example, consider "hello|world", where "|" represents the caret position. If the user +/// types "/" to start composing a tag, we don't want "world" to be included in the tag. If the +/// user types "/header", we will have the text "hello/headerworld", and only "/header" should be +/// included in the tag. +IndexedTag _constrainTagToCaret( + TagAroundPosition tagAroundPosition, MutableDocument document, DocumentSelection selection) { + final indexedTag = tagAroundPosition.indexedTag; + + final normalizedSelection = selection.normalize(document); + final endPosition = normalizedSelection.end.nodePosition is TextNodePosition + ? normalizedSelection.end.nodePosition as TextNodePosition + : null; + + if (endPosition == null) { + // It shouldn't be possible to have a tag without a `TextNodePosition`. Fizzle. + return indexedTag; + } + + if (indexedTag.endOffset < endPosition.offset) { + // The tag is already constrained to the caret. + return indexedTag; + } + + // Constrain the tag to the caret. + return IndexedTag( + Tag(indexedTag.tag.trigger, indexedTag.tag.token.substring(0, endPosition.offset - indexedTag.startOffset - 1)), + indexedTag.nodeId, + indexedTag.startOffset, + ); +} + const _composingActionTagKey = "composing_action_tag"; extension on EditContext { diff --git a/super_editor/lib/src/default_editor/text_tokenizing/tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart index 7ba3cacacc..60d6f084af 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart @@ -10,15 +10,11 @@ import 'package:super_editor/src/default_editor/text.dart'; class TagFinder { /// Finds a tag that touches the given [expansionPosition] and returns that tag, /// indexed within the document, along with the [expansionPosition]. - /// - /// If [endPosition] is provided, the search will be limited to the range between - /// the [expansionPosition] and the [endPosition]. static TagAroundPosition? findTagAroundPosition({ required TagRule tagRule, required String nodeId, required AttributedText text, required TextNodePosition expansionPosition, - TextNodePosition? endPosition, required bool Function(Set tokenAttributions) isTokenCandidate, }) { final rawText = text.text; @@ -35,7 +31,7 @@ class TagFinder { final charactersBefore = rawText.substring(0, splitIndex).characters; final iteratorUpstream = charactersBefore.iteratorAtEnd; - final charactersAfter = rawText.substring(splitIndex, endPosition?.offset).characters; + final charactersAfter = rawText.substring(splitIndex).characters; final iteratorDownstream = charactersAfter.iterator; if (charactersBefore.isNotEmpty && tagRule.excludedCharacters.contains(charactersBefore.last)) { diff --git a/super_editor/test/super_editor/text_entry/tagging/pattern_tags_test.dart b/super_editor/test/super_editor/text_entry/tagging/pattern_tags_test.dart index c1071fa4c3..4eed6f70ad 100644 --- a/super_editor/test/super_editor/text_entry/tagging/pattern_tags_test.dart +++ b/super_editor/test/super_editor/text_entry/tagging/pattern_tags_test.dart @@ -76,6 +76,34 @@ void main() { ); }); + testWidgetsOnAllPlatforms("can start at the beginning of an existing word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before flutter after"), + ), + ], + ), + ); + + // Place the caret at "before |flutter". + await tester.placeCaretInParagraph("1", 7); + + // Type the trigger to start composing a tag. + await tester.typeImeText("#"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "before #flutter after"); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 7), + const SpanRange(7, 14), + ); + }); + testWidgetsOnAllPlatforms("removes tag when deleting back to the #", (tester) async { await _pumpTestEditor( tester, diff --git a/super_editor/test/super_editor/text_entry/tagging/stable_tags_test.dart b/super_editor/test/super_editor/text_entry/tagging/stable_tags_test.dart index 73f69c9ffa..67ce3aff8b 100644 --- a/super_editor/test/super_editor/text_entry/tagging/stable_tags_test.dart +++ b/super_editor/test/super_editor/text_entry/tagging/stable_tags_test.dart @@ -76,6 +76,34 @@ void main() { ); }); + testWidgetsOnAllPlatforms("can start at the beginning of an existing word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before john after"), + ), + ], + ), + ); + + // Place the caret at "before |john" + await tester.placeCaretInParagraph("1", 7); + + // Type the trigger to start composing a tag. + await tester.typeImeText("@"); + + // Ensure that "@john" was attributed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "before @john after"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + }); + testWidgetsOnAllPlatforms("by default does not continue after a space", (tester) async { await _pumpTestEditor( tester, @@ -439,6 +467,37 @@ void main() { ); }); + testWidgetsOnAllPlatforms("at the beginning of an existing word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before john after"), + ), + ], + ), + ); + + // Place the caret at "before |john" + await tester.placeCaretInParagraph("1", 7); + + // Type the trigger to start composing a tag. + await tester.typeImeText("@"); + + // Press left arrow to move away and commit the tag. + await tester.pressLeftArrow(); + + // Ensure that "@john" was attributed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "before @john after"); + expect( + text.getAttributedRange({const CommittedStableTagAttribution("john")}, 7), + const SpanRange(7, 11), + ); + }); + testWidgetsOnAllPlatforms("after existing text", (tester) async { await _pumpTestEditor( tester, From f9e58195eadfd0be2cb2d5d8d0a32dfba3ccc369 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 17 Dec 2024 10:46:01 -0300 Subject: [PATCH 4/7] PR updates --- .../text_tokenizing/action_tags.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index 397c53e35e..5f2e1c5753 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -216,11 +216,6 @@ class CancelComposingActionTagCommand extends EditCommand { TagAroundPosition? composingToken; TextNode? textNode; - final normalizedSelection = selection.normalize(document); - final endPosition = normalizedSelection.end.nodePosition is TextNodePosition - ? normalizedSelection.end.nodePosition as TextNodePosition - : null; - if (base.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.base.nodeId) as TextNode; composingToken = TagFinder.findTagAroundPosition( @@ -249,12 +244,14 @@ class CancelComposingActionTagCommand extends EditCommand { return; } + final indexedTag = _constrainTagToCaret(composingToken, document, selection); + // Remove the composing attribution. executor.executeCommand( RemoveTextAttributionsCommand( documentRange: textNode!.selectionBetween( - composingToken.indexedTag.startOffset, - composingToken.indexedTag.endOffset, + indexedTag.startOffset, + indexedTag.endOffset, ), attributions: {actionTagComposingAttribution}, ), @@ -262,8 +259,8 @@ class CancelComposingActionTagCommand extends EditCommand { executor.executeCommand( AddTextAttributionsCommand( documentRange: textNode.selectionBetween( - composingToken.indexedTag.startOffset, - composingToken.indexedTag.startOffset + 1, + indexedTag.startOffset, + indexedTag.startOffset + 1, ), attributions: {actionTagCancelledAttribution}, ), From bacfd11154cd92f83143ee637e8a1520b06b79b9 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 17 Dec 2024 11:33:56 -0300 Subject: [PATCH 5/7] Extract findTagAroundPosition --- .../text_tokenizing/action_tags.dart | 131 +++++++++++------- 1 file changed, 79 insertions(+), 52 deletions(-) diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index 5f2e1c5753..d8b96c3255 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -1,4 +1,5 @@ import 'package:attributed_text/attributed_text.dart'; +import 'package:characters/characters.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; @@ -132,12 +133,12 @@ class SubmitComposingActionTagCommand extends EditCommand { final textNode = document.getNodeById(extent.nodeId) as TextNode; - final tagAroundPosition = TagFinder.findTagAroundPosition( + final tagAroundPosition = _findTagBeforeCaret( // TODO: deal with these tag rules in requests and commands, should the user really pass them? tagRule: defaultActionTagRule, nodeId: composer.selection!.extent.nodeId, text: textNode.text, - expansionPosition: extentPosition, + caretPosition: extentPosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); @@ -147,19 +148,17 @@ class SubmitComposingActionTagCommand extends EditCommand { context.composingActionTag.value = null; - final indexedTag = _constrainTagToCaret(tagAroundPosition, document, composer.selection!); - executor.executeCommand( DeleteContentCommand( documentRange: DocumentSelection( - base: indexedTag.start, - extent: indexedTag.end, + base: tagAroundPosition.indexedTag.start, + extent: tagAroundPosition.indexedTag.end, ), ), ); executor.executeCommand( ChangeSelectionCommand( - DocumentSelection.collapsed(position: indexedTag.start), + DocumentSelection.collapsed(position: tagAroundPosition.indexedTag.start), SelectionChangeType.deleteContent, SelectionReason.userInteraction, ), @@ -218,21 +217,21 @@ class CancelComposingActionTagCommand extends EditCommand { if (base.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.base.nodeId) as TextNode; - composingToken = TagFinder.findTagAroundPosition( + composingToken = _findTagBeforeCaret( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - expansionPosition: base.nodePosition as TextNodePosition, + caretPosition: base.nodePosition as TextNodePosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } if (composingToken == null && extent.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.extent.nodeId) as TextNode; - composingToken = TagFinder.findTagAroundPosition( + composingToken = _findTagBeforeCaret( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - expansionPosition: base.nodePosition as TextNodePosition, + caretPosition: base.nodePosition as TextNodePosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -244,14 +243,12 @@ class CancelComposingActionTagCommand extends EditCommand { return; } - final indexedTag = _constrainTagToCaret(composingToken, document, selection); - // Remove the composing attribution. executor.executeCommand( RemoveTextAttributionsCommand( documentRange: textNode!.selectionBetween( - indexedTag.startOffset, - indexedTag.endOffset, + composingToken.indexedTag.startOffset, + composingToken.indexedTag.endOffset, ), attributions: {actionTagComposingAttribution}, ), @@ -259,8 +256,8 @@ class CancelComposingActionTagCommand extends EditCommand { executor.executeCommand( AddTextAttributionsCommand( documentRange: textNode.selectionBetween( - indexedTag.startOffset, - indexedTag.startOffset + 1, + composingToken.indexedTag.startOffset, + composingToken.indexedTag.startOffset + 1, ), attributions: {actionTagCancelledAttribution}, ), @@ -306,21 +303,21 @@ class ActionTagComposingReaction extends EditReaction { if (base.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.base.nodeId) as TextNode; - tagAroundPosition = TagFinder.findTagAroundPosition( + tagAroundPosition = _findTagBeforeCaret( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - expansionPosition: base.nodePosition as TextNodePosition, + caretPosition: base.nodePosition as TextNodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } if (tagAroundPosition == null && extent.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.extent.nodeId) as TextNode; - tagAroundPosition = TagFinder.findTagAroundPosition( + tagAroundPosition = _findTagBeforeCaret( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - expansionPosition: extent.nodePosition as TextNodePosition, + caretPosition: extent.nodePosition as TextNodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -332,11 +329,9 @@ class ActionTagComposingReaction extends EditReaction { return; } - final indexedTag = _constrainTagToCaret(tagAroundPosition, document, selection); - - _updateComposingTag(requestDispatcher, indexedTag); - editorContext.composingActionTag.value = indexedTag; - _onUpdateComposingActionTag(indexedTag); + _updateComposingTag(requestDispatcher, tagAroundPosition.indexedTag); + editorContext.composingActionTag.value = tagAroundPosition.indexedTag; + _onUpdateComposingActionTag(tagAroundPosition.indexedTag); } /// Finds all cancelled action tags across all changed text nodes in [changeList] and corrects @@ -461,36 +456,68 @@ class ActionTagComposingReaction extends EditReaction { } } -/// Given a [tagAroundPosition], returns a new [IndexedTag] which ends before the caret position. -/// -/// For example, consider "hello|world", where "|" represents the caret position. If the user -/// types "/" to start composing a tag, we don't want "world" to be included in the tag. If the -/// user types "/header", we will have the text "hello/headerworld", and only "/header" should be -/// included in the tag. -IndexedTag _constrainTagToCaret( - TagAroundPosition tagAroundPosition, MutableDocument document, DocumentSelection selection) { - final indexedTag = tagAroundPosition.indexedTag; - - final normalizedSelection = selection.normalize(document); - final endPosition = normalizedSelection.end.nodePosition is TextNodePosition - ? normalizedSelection.end.nodePosition as TextNodePosition - : null; - - if (endPosition == null) { - // It shouldn't be possible to have a tag without a `TextNodePosition`. Fizzle. - return indexedTag; +/// Finds a tag that starts at [caretPosition]. +TagAroundPosition? _findTagBeforeCaret({ + required TagRule tagRule, + required String nodeId, + required AttributedText text, + required TextNodePosition caretPosition, + required bool Function(Set tokenAttributions) isTokenCandidate, +}) { + final rawText = text.text; + if (rawText.isEmpty) { + return null; + } + + final caretOffset = caretPosition.offset; + + // Extract the text before the caret. + final charactersBefore = rawText.substring(0, caretOffset).characters; + final iteratorUpstream = charactersBefore.iteratorAtEnd; + + if (charactersBefore.isNotEmpty && tagRule.excludedCharacters.contains(charactersBefore.last)) { + // The character where we're supposed to begin our expansion is a + // character that's not allowed in a tag. Therefore, no tag exists + // around the search offset. + return null; + } + + // Move upstream until we find the trigger character or an excluded character. + while (iteratorUpstream.moveBack()) { + final currentCharacter = iteratorUpstream.current; + if (tagRule.excludedCharacters.contains(currentCharacter)) { + // The upstream character isn't allowed to appear in a tag. end the search. + return null; + } + + if (currentCharacter == tagRule.trigger) { + // The character we are reading is the trigger. + // We move the iteratorUpstream one last time to include the trigger in the tokenRange and stop looking any further upstream + iteratorUpstream.moveBack(); + break; + } + } + + final tokenStartOffset = caretOffset - iteratorUpstream.stringAfterLength; + final tokenRange = SpanRange(tokenStartOffset, caretOffset); + + final tagText = text.substringInRange(tokenRange); + if (!tagText.startsWith(tagRule.trigger)) { + return null; } - if (indexedTag.endOffset < endPosition.offset) { - // The tag is already constrained to the caret. - return indexedTag; + final tokenAttributions = text.getAttributionSpansInRange(attributionFilter: (a) => true, range: tokenRange); + if (!isTokenCandidate(tokenAttributions.map((span) => span.attribution).toSet())) { + return null; } - // Constrain the tag to the caret. - return IndexedTag( - Tag(indexedTag.tag.trigger, indexedTag.tag.token.substring(0, endPosition.offset - indexedTag.startOffset - 1)), - indexedTag.nodeId, - indexedTag.startOffset, + return TagAroundPosition( + indexedTag: IndexedTag( + Tag(tagRule.trigger, tagText.substring(1)), + nodeId, + tokenStartOffset, + ), + searchOffset: caretPosition.offset, ); } From 07e0e216d5d75f204daf555da6acdb239e36db91 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 17 Dec 2024 12:02:22 -0300 Subject: [PATCH 6/7] PR updates --- .../text_tokenizing/action_tags.dart | 65 +++++++++++++------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index d8b96c3255..d5b5e444b7 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:attributed_text/attributed_text.dart'; import 'package:characters/characters.dart'; import 'package:flutter/foundation.dart'; @@ -133,12 +135,14 @@ class SubmitComposingActionTagCommand extends EditCommand { final textNode = document.getNodeById(extent.nodeId) as TextNode; - final tagAroundPosition = _findTagBeforeCaret( + final normalizedSelection = composer.selection!.normalize(document); + final tagAroundPosition = _findTag( // TODO: deal with these tag rules in requests and commands, should the user really pass them? tagRule: defaultActionTagRule, nodeId: composer.selection!.extent.nodeId, text: textNode.text, - caretPosition: extentPosition, + expansionPosition: extentPosition, + endPosition: normalizedSelection.end.nodePosition as TextNodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); @@ -215,23 +219,26 @@ class CancelComposingActionTagCommand extends EditCommand { TagAroundPosition? composingToken; TextNode? textNode; + final normalizedSelection = selection.normalize(document); if (base.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.base.nodeId) as TextNode; - composingToken = _findTagBeforeCaret( + composingToken = _findTag( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - caretPosition: base.nodePosition as TextNodePosition, + expansionPosition: base.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition as TextNodePosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } if (composingToken == null && extent.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.extent.nodeId) as TextNode; - composingToken = _findTagBeforeCaret( + composingToken = _findTag( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - caretPosition: base.nodePosition as TextNodePosition, + expansionPosition: base.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition as TextNodePosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -301,23 +308,27 @@ class ActionTagComposingReaction extends EditReaction { TagAroundPosition? tagAroundPosition; TextNode? textNode; + final normalizedSelection = selection.normalize(document); + if (base.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.base.nodeId) as TextNode; - tagAroundPosition = _findTagBeforeCaret( + tagAroundPosition = _findTag( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - caretPosition: base.nodePosition as TextNodePosition, + expansionPosition: base.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition as TextNodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } if (tagAroundPosition == null && extent.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.extent.nodeId) as TextNode; - tagAroundPosition = _findTagBeforeCaret( + tagAroundPosition = _findTag( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - caretPosition: extent.nodePosition as TextNodePosition, + expansionPosition: extent.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition as TextNodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -456,12 +467,14 @@ class ActionTagComposingReaction extends EditReaction { } } -/// Finds a tag that starts at [caretPosition]. -TagAroundPosition? _findTagBeforeCaret({ +/// Finds a tag that touches the given [expansionPosition], constaining it +/// to not cross the [endPosition]. +TagAroundPosition? _findTag({ required TagRule tagRule, required String nodeId, required AttributedText text, - required TextNodePosition caretPosition, + required TextNodePosition expansionPosition, + required TextNodePosition endPosition, required bool Function(Set tokenAttributions) isTokenCandidate, }) { final rawText = text.text; @@ -469,12 +482,18 @@ TagAroundPosition? _findTagBeforeCaret({ return null; } - final caretOffset = caretPosition.offset; + int splitIndex = min(expansionPosition.offset, rawText.length); + splitIndex = max(splitIndex, 0); - // Extract the text before the caret. - final charactersBefore = rawText.substring(0, caretOffset).characters; + // Create 2 splits of characters to navigate upstream and downstream the caret position. + // ex: "this is a very|long string" + // -> split around the caret into charactersBefore="this is a very" and charactersAfter="long string" + final charactersBefore = rawText.substring(0, splitIndex).characters; final iteratorUpstream = charactersBefore.iteratorAtEnd; + final charactersAfter = rawText.substring(splitIndex, endPosition.offset).characters; + final iteratorDownstream = charactersAfter.iterator; + if (charactersBefore.isNotEmpty && tagRule.excludedCharacters.contains(charactersBefore.last)) { // The character where we're supposed to begin our expansion is a // character that's not allowed in a tag. Therefore, no tag exists @@ -498,8 +517,16 @@ TagAroundPosition? _findTagBeforeCaret({ } } - final tokenStartOffset = caretOffset - iteratorUpstream.stringAfterLength; - final tokenRange = SpanRange(tokenStartOffset, caretOffset); + // Move downstream the caret position until we find excluded character or reach the end of the text. + while (iteratorDownstream.moveNext()) { + final current = iteratorDownstream.current; + if (tagRule.excludedCharacters.contains(current)) { + break; + } + } + + final tokenStartOffset = splitIndex - iteratorUpstream.stringAfterLength; + final tokenRange = SpanRange(tokenStartOffset, splitIndex + iteratorDownstream.stringBeforeLength); final tagText = text.substringInRange(tokenRange); if (!tagText.startsWith(tagRule.trigger)) { @@ -517,7 +544,7 @@ TagAroundPosition? _findTagBeforeCaret({ nodeId, tokenStartOffset, ), - searchOffset: caretPosition.offset, + searchOffset: expansionPosition.offset, ); } From d9278a20a949ba545e2c6c2958980851ae7ff92b Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 17 Dec 2024 12:25:28 -0300 Subject: [PATCH 7/7] PR updates --- .../text_tokenizing/action_tags.dart | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index d5b5e444b7..4dbcba7769 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -142,7 +142,7 @@ class SubmitComposingActionTagCommand extends EditCommand { nodeId: composer.selection!.extent.nodeId, text: textNode.text, expansionPosition: extentPosition, - endPosition: normalizedSelection.end.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); @@ -227,7 +227,7 @@ class CancelComposingActionTagCommand extends EditCommand { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, - endPosition: normalizedSelection.end.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -238,7 +238,7 @@ class CancelComposingActionTagCommand extends EditCommand { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, - endPosition: normalizedSelection.end.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -317,7 +317,7 @@ class ActionTagComposingReaction extends EditReaction { nodeId: textNode.id, text: textNode.text, expansionPosition: base.nodePosition as TextNodePosition, - endPosition: normalizedSelection.end.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -328,7 +328,7 @@ class ActionTagComposingReaction extends EditReaction { nodeId: textNode.id, text: textNode.text, expansionPosition: extent.nodePosition as TextNodePosition, - endPosition: normalizedSelection.end.nodePosition as TextNodePosition, + endPosition: normalizedSelection.end.nodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -469,12 +469,14 @@ class ActionTagComposingReaction extends EditReaction { /// Finds a tag that touches the given [expansionPosition], constaining it /// to not cross the [endPosition]. +/// +/// If [endPosition] is not a `TextNodePosition`, it will be ignored . TagAroundPosition? _findTag({ required TagRule tagRule, required String nodeId, required AttributedText text, required TextNodePosition expansionPosition, - required TextNodePosition endPosition, + required NodePosition endPosition, required bool Function(Set tokenAttributions) isTokenCandidate, }) { final rawText = text.text; @@ -485,13 +487,15 @@ TagAroundPosition? _findTag({ int splitIndex = min(expansionPosition.offset, rawText.length); splitIndex = max(splitIndex, 0); + final endOffset = endPosition is TextNodePosition ? endPosition.offset : null; + // Create 2 splits of characters to navigate upstream and downstream the caret position. // ex: "this is a very|long string" // -> split around the caret into charactersBefore="this is a very" and charactersAfter="long string" final charactersBefore = rawText.substring(0, splitIndex).characters; final iteratorUpstream = charactersBefore.iteratorAtEnd; - final charactersAfter = rawText.substring(splitIndex, endPosition.offset).characters; + final charactersAfter = rawText.substring(splitIndex, endOffset).characters; final iteratorDownstream = charactersAfter.iterator; if (charactersBefore.isNotEmpty && tagRule.excludedCharacters.contains(charactersBefore.last)) {