diff --git a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart index 8dd408227..3a250c1af 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart @@ -1094,6 +1094,9 @@ class ComposingStableTag { @override int get hashCode => contentBounds.hashCode ^ token.hashCode; + + @override + String toString() => 'ComposingStableTag{contentBounds: $contentBounds, token: $token}'; } /// An [EditReaction] that prevents partial selection of a stable user tag. 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 201c42b0e..7122ce72f 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart @@ -5,7 +5,6 @@ import 'package:characters/characters.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/default_editor/text.dart'; -import 'package:super_editor/src/infrastructure/strings.dart'; /// A set of tools for finding tags within document text. class TagFinder { @@ -23,55 +22,51 @@ class TagFinder { return null; } - int tokenStartOffset = min(expansionPosition.offset - 1, rawText.length - 1); - tokenStartOffset = max(tokenStartOffset, 0); - if (tagRule.excludedCharacters.contains(rawText[tokenStartOffset])) { + int splitIndex = min(expansionPosition.offset, rawText.length); + splitIndex = max(splitIndex, 0); + + // 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).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 // around the search offset. return null; } - int tokenEndOffset = min(expansionPosition.offset - 1, rawText.length - 1); - tokenEndOffset = max(tokenEndOffset, 0); - - if (rawText[tokenStartOffset] != tagRule.trigger) { - while (tokenStartOffset > 0) { - final upstreamCharacterIndex = rawText.moveOffsetUpstreamByCharacter(tokenStartOffset)!; - final upstreamCharacter = rawText[upstreamCharacterIndex]; - if (tagRule.excludedCharacters.contains(upstreamCharacter)) { - // The upstream character isn't allowed to appear in a tag. Break before moving - // the starting character index any further upstream. - break; - } - - // Move the starting character index upstream. - tokenStartOffset = upstreamCharacterIndex; + // 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 (upstreamCharacter == tagRule.trigger) { - // The character we just added to the token bounds is the trigger. - // We don't want to move the start any further upstream. - break; - } + 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; } } - while (tokenEndOffset < rawText.length - 1) { - final downstreamCharacterIndex = rawText.moveOffsetDownstreamByCharacter(tokenEndOffset)!; - final downstreamCharacter = rawText[downstreamCharacterIndex]; - if (downstreamCharacter != tagRule.trigger && tagRule.excludedCharacters.contains(downstreamCharacter)) { + // 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; } - - tokenEndOffset = downstreamCharacterIndex; } - // Make end off exclusive. - tokenEndOffset += 1; - final tokenRange = SpanRange(tokenStartOffset, tokenEndOffset); - if (tokenRange.end - tokenRange.start <= 0) { - return null; - } + final tokenStartOffset = splitIndex - iteratorUpstream.stringAfterLength; + final tokenRange = SpanRange(tokenStartOffset, splitIndex + iteratorDownstream.stringBeforeLength); final tagText = text.substringInRange(tokenRange); if (!tagText.startsWith(tagRule.trigger)) { @@ -83,7 +78,7 @@ class TagFinder { return null; } - final tagAroundPosition = TagAroundPosition( + return TagAroundPosition( indexedTag: IndexedTag( Tag(tagRule.trigger, tagText.substring(1)), nodeId, @@ -91,8 +86,6 @@ class TagFinder { ), searchOffset: expansionPosition.offset, ); - - return tagAroundPosition; } /// Finds and returns all tags in the given [textNode], which meet the given [rule]. 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 8c008c9ad..e86629f4a 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 @@ -29,6 +29,25 @@ void main() { ); }); + testWidgetsOnAllPlatforms("can start with a single character", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose a stable tag. + await tester.typeImeText("@j"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "@j"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 0), + const SpanRange(0, 1), + ); + }); + testWidgetsOnAllPlatforms("can start between words", (tester) async { await _pumpTestEditor( tester, @@ -100,7 +119,9 @@ void main() { ), ], ), - const TagRule(trigger: "@"), + plugin: StableTagPlugin( + tagRule: const TagRule(trigger: '@'), + ), ); // Place the caret at "before |" @@ -1042,19 +1063,259 @@ void main() { ); }); }); + + group("emoji >", () { + testWidgetsOnAllPlatforms("can be typed as first character of a paragraph without crashing the editor", + (tester) async { + // Ensure we can type an emoji as first character without crashing + // https://github.com/superlistapp/super_editor/issues/1863 + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(""), + ), + ], + ), + ); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type an emoji as first charactet ๐Ÿ’™ + await tester.typeImeText("๐Ÿ’™"); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 2), + ), + ), + ); + + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "๐Ÿ’™"); + }); + + testWidgetsOnAllPlatforms("caret can move around emoji without breaking editor", (tester) async { + // We are doing this to ensure the plugin doesn't make the editor crash when moving caret around emoji. + + final plugin = StableTagPlugin(); + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("๐Ÿ’™"), + ), + ], + ), + plugin: plugin, + ); + + // Place the caret before the emoji: |๐Ÿ’™ + await tester.placeCaretInParagraph("1", 0); + + // Place the caret after the emoji: ๐Ÿ’™| + await tester.pressRightArrow(); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 2), + ), + ), + ); + + // Move the caret back to initial position, before the emoji: |๐Ÿ’™. + await tester.pressLeftArrow(); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + // Ensure the paragraph string is well formed: ๐Ÿ’™ + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "๐Ÿ’™"); + }); + + testWidgetsOnAllPlatforms("can be captured with trigger", (tester) async { + final plugin = StableTagPlugin(); + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(""), + ), + ], + ), + plugin: plugin, + ); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type @ to trigger a composing tag, followed by an emoji ๐Ÿ’™ + await tester.typeImeText("@๐Ÿ’™"); + + // Ensure the paragraph string is well formed: @๐Ÿ’™ + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "@๐Ÿ’™"); + + // Ensure the composing tag is wrapping the emoji + final composingStableTag = plugin.tagIndex.composingStableTag.value!; + expect( + composingStableTag, + const ComposingStableTag( + DocumentRange( + start: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 1), + ), + end: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 3), + ), + ), + '๐Ÿ’™', + ), + ); + + // Type a white space to commit the tag: @๐Ÿ’™ | + await tester.typeImeText(" "); + + final commitedTags = plugin.tagIndex.getCommittedTagsInTextNode('1'); + + expect(commitedTags.length, 1); + + final commitTag = commitedTags.first; + + // Ensure the committed tag is the emoji and the composing tag is removed + expect(commitTag, const IndexedTag(Tag('@', '๐Ÿ’™'), '1', 0)); + expect(plugin.tagIndex.composingStableTag.value, isNull); + }); + + testWidgetsOnAllPlatforms("can be used before a trigger", (tester) async { + final plugin = StableTagPlugin(); + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("๐Ÿ’™"), + ), + ], + ), + plugin: plugin, + ); + + // Place the caret after the emoji: ๐Ÿ’™| + await tester.placeCaretInParagraph("1", 2); + + // Type @ to trigger a composing tag and ensure the string is well formed: ๐Ÿ’™@ + await tester.typeImeText("@"); + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "๐Ÿ’™@"); + + // Ensure the composing tag is not null and empty + expect( + plugin.tagIndex.composingStableTag.value, + const ComposingStableTag( + DocumentRange( + start: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 3), + ), + end: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 3), + ), + ), + '', + ), + ); + }); + + testWidgetsOnAllPlatforms("can be used in the middle of a tag", (tester) async { + final plugin = StableTagPlugin(); + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(""), + ), + ], + ), + plugin: plugin, + ); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Start compsing a tag with an emoji in the middle + await tester.typeImeText("@Flutter๐Ÿ’™SuperEditor"); + + // Ensure the composing tag is valid + final composingStableTag = plugin.tagIndex.composingStableTag.value!; + expect( + composingStableTag, + const ComposingStableTag( + DocumentRange( + start: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 1), + ), + end: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 21), + ), + ), + 'Flutter๐Ÿ’™SuperEditor', + ), + ); + + // Commit the tag by typing a white space + await tester.typeImeText(" "); + + // Ensure the tag is commited and the composing tag is reset + final commitedTags = plugin.tagIndex.getCommittedTagsInTextNode('1'); + expect(commitedTags.length, 1); + expect(commitedTags.first, const IndexedTag(Tag('@', 'Flutter๐Ÿ’™SuperEditor'), '1', 0)); + expect(plugin.tagIndex.composingStableTag.value, isNull); + }); + }); }); } Future _pumpTestEditor( WidgetTester tester, - MutableDocument document, [ - TagRule tagRule = userTagRule, -]) async { + MutableDocument document, { + SuperEditorPlugin? plugin, +}) async { return await tester .createDocument() .withCustomContent(document) - .withPlugin(StableTagPlugin( - tagRule: tagRule, - )) + .withPlugin(plugin ?? + StableTagPlugin( + tagRule: userTagRule, + )) .pump(); }