Skip to content

Commit

Permalink
[SuperEditor] Limit tag expansion to caret position (Resolves #2240, #…
Browse files Browse the repository at this point in the history
  • Loading branch information
angelosilvestre authored and web-flow committed Dec 23, 2024
1 parent 4247200 commit a5fd02d
Show file tree
Hide file tree
Showing 6 changed files with 449 additions and 8 deletions.
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 @@ -1562,9 +1562,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

0 comments on commit a5fd02d

Please sign in to comment.