Skip to content

Commit

Permalink
Allow emojis at beginning of paragraph with tagging enabled (Resolves #…
Browse files Browse the repository at this point in the history
…1882) (#2046)

Co-authored-by: Corentin Bazin <crntnbzn@gmail.com>
  • Loading branch information
2 people authored and web-flow committed May 28, 2024
1 parent 728689f commit bb61952
Show file tree
Hide file tree
Showing 3 changed files with 260 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,9 @@ class ComposingStableTag {

@override
int get hashCode => contentBounds.hashCode ^ token.hashCode;

@override
String toString() => "[ComposingStableTag] - '$token', bounds: $contentBounds";
}

/// An [EditReaction] that prevents partial selection of a stable user tag.
Expand Down
71 changes: 32 additions & 39 deletions super_editor/lib/src/default_editor/text_tokenizing/tags.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)) {
Expand All @@ -83,16 +78,14 @@ class TagFinder {
return null;
}

final tagAroundPosition = TagAroundPosition(
return TagAroundPosition(
indexedTag: IndexedTag(
Tag(tagRule.trigger, tagText.substring(1)),
nodeId,
tokenStartOffset,
),
searchOffset: expansionPosition.offset,
);

return tagAroundPosition;
}

/// Finds and returns all tags in the given [textNode], which meet the given [rule].
Expand Down
238 changes: 225 additions & 13 deletions super_editor/test/super_editor/text_entry/tagging/stable_tags_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ import '../../test_documents.dart';
void main() {
group("SuperEditor stable tags >", () {
group("composing >", () {
testWidgetsOnAllPlatforms("starts with a trigger", (tester) async {
await _pumpTestEditor(
tester,
singleParagraphEmptyDoc(),
);
await tester.placeCaretInParagraph("1", 0);

// Compose a stable tag.
await tester.typeImeText("@");

// Ensure that the tag has a composing attribution.
final text = SuperEditorInspector.findTextInComponent("1");
expect(text.text, "@");
expect(
text.getAttributedRange({stableTagComposingAttribution}, 0),
const SpanRange(0, 0),
);
});

testWidgetsOnAllPlatforms("can start at the beginning of a paragraph", (tester) async {
await _pumpTestEditor(
tester,
Expand Down Expand Up @@ -91,17 +110,16 @@ void main() {

testWidgetsOnAllPlatforms("can be configured to continue after a space", (tester) async {
await _pumpTestEditor(
tester,
MutableDocument(
nodes: [
ParagraphNode(
id: "1",
text: AttributedText("before "),
),
],
),
const TagRule(trigger: "@"),
);
tester,
MutableDocument(
nodes: [
ParagraphNode(
id: "1",
text: AttributedText("before "),
),
],
),
const TagRule(trigger: "@"));

// Place the caret at "before |"
await tester.placeCaretInParagraph("1", 7);
Expand All @@ -123,8 +141,14 @@ void main() {
text = SuperEditorInspector.findTextInComponent("1");
expect(text.text, "before @john after");
expect(
text.getAttributedRange({stableTagComposingAttribution}, 7),
const SpanRange(7, 17),
text.getAttributionSpansByFilter((a) => a == stableTagComposingAttribution),
{
const AttributionSpan(
attribution: stableTagComposingAttribution,
start: 7,
end: 17,
),
},
);
});

Expand Down Expand Up @@ -1042,6 +1066,194 @@ 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,
singleParagraphEmptyDoc(),
);

// Place the caret at the beginning of the paragraph.
await tester.placeCaretInParagraph("1", 0);

// Type an emoji as first character 💙
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.
await _pumpTestEditor(
tester,
MutableDocument(
nodes: [
ParagraphNode(
id: "1",
text: AttributedText("💙"),
),
],
),
);

// 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 {
await _pumpTestEditor(
tester,
singleParagraphEmptyDoc(),
);

// 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 emoji is in the tag, and nothing went wrong with string formation.
final text = SuperEditorInspector.findTextInComponent("1");
expect(text.text, "@💙");

// Ensure the composing tag includes the emoji.
expect(
SuperEditorInspector.findTextInComponent("1")
.getAttributionSpansByFilter((a) => a == stableTagComposingAttribution),
{
const AttributionSpan(
attribution: stableTagComposingAttribution,
start: 0,
end: 2,
),
},
);

// Commit the tag.
await tester.typeImeText(" ");

// Ensure the committed tag is the emoji and the composing tag is removed
expect(
SuperEditorInspector.findTextInComponent("1")
.getAttributionSpansByFilter((a) => a is CommittedStableTagAttribution),
{
const AttributionSpan(
attribution: CommittedStableTagAttribution("💙"),
start: 0,
end: 2,
),
},
);
});

testWidgetsOnAllPlatforms("can be used before a trigger", (tester) async {
await _pumpTestEditor(
tester,
MutableDocument(
nodes: [
ParagraphNode(
id: "1",
text: AttributedText("💙"),
),
],
),
);

// Place the caret after the emoji: 💙|
await tester.placeCaretInParagraph("1", 2);

// Type @ to trigger a composing tag: 💙@
await tester.typeImeText("@");

// Ensure nothing went wrong with the string construction.
final text = SuperEditorInspector.findTextInComponent("1");
expect(text.text, "💙@");

// Ensure the tag was committed with the emoji.
expect(
SuperEditorInspector.findTextInComponent("1")
.getAttributionSpansByFilter((a) => a == stableTagComposingAttribution),
{
const AttributionSpan(
attribution: stableTagComposingAttribution,
start: 2,
end: 2,
),
},
);
});

testWidgetsOnAllPlatforms("can be used in the middle of a tag", (tester) async {
await _pumpTestEditor(
tester,
singleParagraphEmptyDoc(),
);

// Place the caret at the beginning of the paragraph.
await tester.placeCaretInParagraph("1", 0);

// Start composing a tag with an emoji in the middle
await tester.typeImeText("@Flutter💙SuperEditor ");

// Ensure the tag was committed with the emoji.
expect(
SuperEditorInspector.findTextInComponent("1")
.getAttributionSpansByFilter((a) => a is CommittedStableTagAttribution),
{
const AttributionSpan(
attribution: CommittedStableTagAttribution("Flutter💙SuperEditor"),
start: 0,
end: 20,
),
},
);
});
});
});
}

Expand Down

0 comments on commit bb61952

Please sign in to comment.