diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index d28a36108..bbfa3c29f 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 360d24480..bf218988c 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), ); } 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 7122ce72f..7ba3cacac 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 756ad02f4..74a4d13a2 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,172 @@ 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").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: 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").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: 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("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(); @@ -631,6 +832,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, + ); + }); }); });