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 4dbcba776..121e85d7a 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 @@ -123,11 +123,19 @@ class SubmitComposingActionTagCommand extends EditCommand { void execute(EditContext context, CommandExecutor executor) { final document = context.document; final composer = context.find<MutableDocumentComposer>(Editor.composerKey); - if (composer.selection == null) { + final selection = composer.selection; + + if (selection == null) { + return; + } + + if (!selection.isCollapsed) { + // Action tags are composed while the user is typing. Since the + // selection is expanded, the user is not typing. return; } - final extent = composer.selection!.extent; + final extent = selection.extent; final extentPosition = extent.nodePosition; if (extentPosition is! TextNodePosition) { return; @@ -135,14 +143,12 @@ class SubmitComposingActionTagCommand extends EditCommand { final textNode = document.getNodeById(extent.nodeId) as TextNode; - final normalizedSelection = composer.selection!.normalize(document); - final tagAroundPosition = _findTag( + final tagAroundPosition = _findTagUpstream( // 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, - endPosition: normalizedSelection.end.nodePosition, + caretPosition: extentPosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); @@ -213,32 +219,25 @@ class CancelComposingActionTagCommand extends EditCommand { return; } - // Look for a composing tag at the extent, or the base. + if (!selection.isCollapsed) { + // Action tags are composed while the user is typing. Since the + // selection is expanded, the user is not typing. + return; + } + + // Look for a composing tag at the extent. final base = selection.base; final extent = selection.extent; TagAroundPosition? composingToken; TextNode? textNode; - final normalizedSelection = selection.normalize(document); - if (base.nodePosition is TextNodePosition) { - textNode = document.getNodeById(selection.base.nodeId) as TextNode; - composingToken = _findTag( - tagRule: _tagRule, - nodeId: textNode.id, - text: textNode.text, - expansionPosition: base.nodePosition as TextNodePosition, - endPosition: normalizedSelection.end.nodePosition, - isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), - ); - } - if (composingToken == null && extent.nodePosition is TextNodePosition) { + if (extent.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.extent.nodeId) as TextNode; - composingToken = _findTag( + composingToken = _findTagUpstream( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - expansionPosition: base.nodePosition as TextNodePosition, - endPosition: normalizedSelection.end.nodePosition, + caretPosition: base.nodePosition as TextNodePosition, isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), ); } @@ -293,7 +292,9 @@ class ActionTagComposingReaction extends EditReaction { _healCancelledTags(requestDispatcher, document, changeList); - if (composer.selection == null) { + if (composer.selection?.isCollapsed != true) { + // Action tags are composed while the user is typing. Since the + // selection is either null or expanded, the user is not typing. _cancelComposingTag(requestDispatcher); editorContext.composingActionTag.value = null; _onUpdateComposingActionTag(null); @@ -302,33 +303,18 @@ class ActionTagComposingReaction extends EditReaction { final selection = composer.selection!; - // Look for a composing tag at the extent, or the base. - final base = selection.base; + // Look for a composing tag at the extent. final extent = selection.extent; TagAroundPosition? tagAroundPosition; TextNode? textNode; - final normalizedSelection = selection.normalize(document); - - if (base.nodePosition is TextNodePosition) { - textNode = document.getNodeById(selection.base.nodeId) as TextNode; - tagAroundPosition = _findTag( - tagRule: _tagRule, - nodeId: textNode.id, - text: textNode.text, - expansionPosition: base.nodePosition as TextNodePosition, - endPosition: normalizedSelection.end.nodePosition, - isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), - ); - } - if (tagAroundPosition == null && extent.nodePosition is TextNodePosition) { + if (extent.nodePosition is TextNodePosition) { textNode = document.getNodeById(selection.extent.nodeId) as TextNode; - tagAroundPosition = _findTag( + tagAroundPosition = _findTagUpstream( tagRule: _tagRule, nodeId: textNode.id, text: textNode.text, - expansionPosition: extent.nodePosition as TextNodePosition, - endPosition: normalizedSelection.end.nodePosition, + caretPosition: extent.nodePosition as TextNodePosition, isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), ); } @@ -467,37 +453,35 @@ class ActionTagComposingReaction extends EditReaction { } } -/// Finds a tag that touches the given [expansionPosition], constaining it -/// to not cross the [endPosition]. +/// Finds a tag that starts upstream to the [caretPosition] and ends +/// at the [caretPosition]. /// -/// If [endPosition] is not a `TextNodePosition`, it will be ignored . -TagAroundPosition? _findTag({ +/// For example, considering the following text, where '|' represents the caret: +/// +/// "hello/wo|rld" +/// +/// This method will extract "/wo" as the tag. +TagAroundPosition? _findTagUpstream({ required TagRule tagRule, required String nodeId, required AttributedText text, - required TextNodePosition expansionPosition, - required NodePosition endPosition, + required TextNodePosition caretPosition, required bool Function(Set<Attribution> tokenAttributions) isTokenCandidate, }) { - final rawText = text.text; + final rawText = text.toPlainText(); if (rawText.isEmpty) { return null; } - int splitIndex = min(expansionPosition.offset, rawText.length); + int splitIndex = min(caretPosition.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" + // Extract the text upstream to the caret. + // For example: "hello/wor|ld" + // -> extracts the text "hello/wor" final charactersBefore = rawText.substring(0, splitIndex).characters; final iteratorUpstream = charactersBefore.iteratorAtEnd; - final charactersAfter = rawText.substring(splitIndex, endOffset).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 @@ -521,16 +505,8 @@ TagAroundPosition? _findTag({ } } - // 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 tokenRange = SpanRange(tokenStartOffset, splitIndex); final tagText = text.substringInRange(tokenRange); if (!tagText.startsWith(tagRule.trigger)) { @@ -548,7 +524,7 @@ TagAroundPosition? _findTag({ nodeId, tokenStartOffset, ), - searchOffset: expansionPosition.offset, + searchOffset: caretPosition.offset, ); } 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 e1f224f4d..61d1a178d 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 @@ -197,7 +197,7 @@ void main() { ); }); - testWidgetsOnAllPlatforms("continues when user expands the selection upstream", (tester) async { + testWidgetsOnAllPlatforms("does not continue when user expands the selection upstream", (tester) async { await _pumpTestEditor( tester, MutableDocument( @@ -220,6 +220,13 @@ void main() { // Compose an action tag. await tester.typeImeText("/header"); + // Ensure we're composing a tag. + AttributedText text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 13), + ); + // Expand the selection to "before /heade|r|" await tester.pressShiftLeftArrow(); expect( @@ -236,11 +243,14 @@ void main() { ), ); - // Ensure we're still composing - AttributedText text = SuperEditorInspector.findTextInComponent("1"); + // Ensure we're not composing anymore. + text = SuperEditorInspector.findTextInComponent("1"); expect( - text.getAttributedRange({actionTagComposingAttribution}, 7), - const SpanRange(7, 13), + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(7, 13), + ), + isEmpty, ); // Expand the selection to "before |/header|" @@ -251,26 +261,32 @@ void main() { await tester.pressShiftLeftArrow(); await tester.pressShiftLeftArrow(); - // Ensure we're still composing + // Ensure we're still not composing. text = SuperEditorInspector.findTextInComponent("1"); expect( - text.getAttributedRange({actionTagComposingAttribution}, 7), - const SpanRange(7, 13), + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(7, 13), + ), + isEmpty, ); // Expand the selection to "befor|e /header|" await tester.pressShiftLeftArrow(); await tester.pressShiftLeftArrow(); - // Ensure we're still composing + // Ensure we're still not composing. text = SuperEditorInspector.findTextInComponent("1"); expect( - text.getAttributedRange({actionTagComposingAttribution}, 7), - const SpanRange(7, 13), + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(7, 13), + ), + isEmpty, ); }); - testWidgetsOnAllPlatforms("continues when user expands the selection downstream", (tester) async { + testWidgetsOnAllPlatforms("does not continue when user expands the selection downstream", (tester) async { await _pumpTestEditor( tester, MutableDocument( @@ -293,6 +309,13 @@ void main() { // Compose an action tag. await tester.typeImeText("/header"); + // Ensure we're composing a tag. + AttributedText text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 13), + ); + // Move the caret to "before /|header". await tester.pressLeftArrow(); await tester.pressLeftArrow(); @@ -324,11 +347,14 @@ void main() { ), ); - // Ensure we're still composing - AttributedText text = SuperEditorInspector.findTextInComponent("1"); + // Ensure we're not composing anymore. + text = SuperEditorInspector.findTextInComponent("1"); expect( - text.getAttributedRange({actionTagComposingAttribution}, 7), - const SpanRange(7, 13), + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(7, 13), + ), + isEmpty, ); }); @@ -873,7 +899,7 @@ void main() { // Ensure that the action tag was removed. text = SuperEditorInspector.findTextInComponent("1"); - expect(text.text, "before after"); + expect(text.toPlainText(), "before after"); expect( text.getAttributionSpansInRange( attributionFilter: (attribution) => attribution == actionTagComposingAttribution, @@ -886,7 +912,47 @@ void main() { }); group("selections >", () { - testWidgetsOnAllPlatforms("can find tag that surrounds the extent position when the selection is expanded", + testWidgetsOnArbitraryDesktop('does not extract a tag when the selection is expanded', (tester) async { + await _pumpTestEditor( + tester, + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('A paragraph')), + // It's important that the second paragraph is longer than the first to ensure + // that we don't try to access a character in the first paragraph using an index + // from the second paragraph. + ParagraphNode(id: '2', text: AttributedText('Another paragraph with longer text')), + ]), + ); + + // Place the caret at the end of the second paragraph. + await tester.placeCaretInParagraph('2', 34); + + // Press CMD + SHIFT + ARROW UP to expand the selection to the beginning of + // the document. + await tester.pressShiftCmdUpArrow(); + + // Ensure nothing in the first paragraph is attributed. + final firstParagraphText = SuperEditorInspector.findTextInComponent("1"); + expect( + firstParagraphText.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: SpanRange(0, firstParagraphText.length), + ), + isEmpty, + ); + + // Ensure nothing in the second paragraph is attributed. + final secondParagraphText = SuperEditorInspector.findTextInComponent("1"); + expect( + secondParagraphText.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: SpanRange(0, secondParagraphText.length), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("does not extract a tag when expanding the selection from a non-text node", (tester) async { await _pumpTestEditor( tester, @@ -901,19 +967,19 @@ void main() { await tester.pressDownArrow(); await tester.pressRightArrow(); - // Select upstream towards the cancelled action tag - await expectLater( - () async { - await tester.pressShiftLeftArrow(); - await tester.pressShiftUpArrow(); - }, - returnsNormally, - ); + // Expand the selection to the first paragraph. + await tester.pressShiftLeftArrow(); + await tester.pressShiftUpArrow(); - // If we reach the end without exception, then ActionTagComposingReaction did not blow up due to the base or extent - // position, and type of content at those positions. - // - // Original bug: https://github.com/superlistapp/super_editor/pull/2201 + // Ensure nothing in the paragraph is attributed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: SpanRange(0, text.length), + ), + isEmpty, + ); }); }); }