Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SuperEditor] Limit tag expansion to caret position (Resolves #2240, #2241, #2242) #2387

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions super_editor/lib/src/default_editor/text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);

Expand Down Expand Up @@ -214,13 +215,19 @@ 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(
tagRule: _tagRule,
nodeId: textNode.id,
text: textNode.text,
expansionPosition: base.nodePosition as TextNodePosition,
endPosition: endPosition,
isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution),
);
}
Expand All @@ -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),
);
}
Expand Down Expand Up @@ -300,13 +308,19 @@ 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(
tagRule: _tagRule,
nodeId: textNode.id,
text: textNode.text,
expansionPosition: base.nodePosition as TextNodePosition,
endPosition: endPosition,
isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution),
);
}
Expand All @@ -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),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this description of endPosition. This method was intended to only look for tags that touch expansionPosition, which is a singular position. There shouldn't be a range involved. So the idea of an endPosition doesn't make sense to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So should we keep this method as it was before and modify the reaction to adjust the reported tag?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point was that I don't know what you're trying to do here because it doesn't seem to make sense. I can't answer that question until I first understand what exactly you were trying to do.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I'm trying to do is to prevent the tag to expand beyond the caret position. For example, if we have the string "helloworld" and the user types a "/" at the middle, changing it to "hello/world", the string "world" shouldn't be attributed as a tag. The user can input a tag in the middle of a word, but the tag shouldn't expand beyond the caret position.

/// 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<Attribution> tokenAttributions) isTokenCandidate,
}) {
final rawText = text.text;
Expand All @@ -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)) {
Expand Down Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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,
);
});
});
});

Expand Down
Loading