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

Merged
merged 7 commits into from
Dec 23, 2024
Merged
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
107 changes: 102 additions & 5 deletions super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import 'dart:math';

import 'package:attributed_text/attributed_text.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:super_editor/src/core/document.dart';
Expand Down Expand Up @@ -132,12 +135,14 @@ class SubmitComposingActionTagCommand extends EditCommand {

final textNode = document.getNodeById(extent.nodeId) as TextNode;

final tagAroundPosition = TagFinder.findTagAroundPosition(
final normalizedSelection = composer.selection!.normalize(document);
final tagAroundPosition = _findTag(
// 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,
isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution),
);

Expand Down Expand Up @@ -214,23 +219,26 @@ class CancelComposingActionTagCommand extends EditCommand {
TagAroundPosition? composingToken;
TextNode? textNode;

final normalizedSelection = selection.normalize(document);
if (base.nodePosition is TextNodePosition) {
textNode = document.getNodeById(selection.base.nodeId) as TextNode;
composingToken = TagFinder.findTagAroundPosition(
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) {
textNode = document.getNodeById(selection.extent.nodeId) as TextNode;
composingToken = TagFinder.findTagAroundPosition(
composingToken = _findTag(
tagRule: _tagRule,
nodeId: textNode.id,
text: textNode.text,
expansionPosition: base.nodePosition as TextNodePosition,
endPosition: normalizedSelection.end.nodePosition,
isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution),
);
}
Expand Down Expand Up @@ -300,23 +308,27 @@ class ActionTagComposingReaction extends EditReaction {
TagAroundPosition? tagAroundPosition;
TextNode? textNode;

final normalizedSelection = selection.normalize(document);

if (base.nodePosition is TextNodePosition) {
textNode = document.getNodeById(selection.base.nodeId) as TextNode;
tagAroundPosition = TagFinder.findTagAroundPosition(
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) {
textNode = document.getNodeById(selection.extent.nodeId) as TextNode;
tagAroundPosition = TagFinder.findTagAroundPosition(
tagAroundPosition = _findTag(
tagRule: _tagRule,
nodeId: textNode.id,
text: textNode.text,
expansionPosition: extent.nodePosition as TextNodePosition,
endPosition: normalizedSelection.end.nodePosition,
isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution),
);
}
Expand Down Expand Up @@ -455,6 +467,91 @@ class ActionTagComposingReaction extends EditReaction {
}
}

/// Finds a tag that touches the given [expansionPosition], constaining it
/// to not cross the [endPosition].
///
/// If [endPosition] is not a `TextNodePosition`, it will be ignored .
TagAroundPosition? _findTag({
required TagRule tagRule,
required String nodeId,
required AttributedText text,
required TextNodePosition expansionPosition,
required NodePosition endPosition,
required bool Function(Set<Attribution> tokenAttributions) isTokenCandidate,
}) {
final rawText = text.text;
if (rawText.isEmpty) {
return null;
}

int splitIndex = min(expansionPosition.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"
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
// around the search offset.
return null;
}

// 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 (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;
}
}

// 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 tagText = text.substringInRange(tokenRange);
if (!tagText.startsWith(tagRule.trigger)) {
return null;
}

final tokenAttributions = text.getAttributionSpansInRange(attributionFilter: (a) => true, range: tokenRange);
if (!isTokenCandidate(tokenAttributions.map((span) => span.attribution).toSet())) {
return null;
}

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

const _composingActionTagKey = "composing_action_tag";

extension on EditContext {
Expand Down
3 changes: 3 additions & 0 deletions super_editor/lib/src/default_editor/text_tokenizing/tags.dart
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,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
Loading
Loading