Repository: superlistapp/super_editor (dart)

Issue:
[attributed_text] - Make it possible to add attributions without expanding existing attributions
Currently, whenever an attribution is added to an `AttributedText`, if the new attribution is immediately preceded or followed by another attribution, whose "can merge with" query returns true, those attributions are combined into one.

This is useful for styles like bold, italics, etc, which have no reason to subdivide themselves when characters appear in sequence.

However, there are other attributions, such as hash tags, which should sometimes merge, and sometimes shouldn't. For example:

```
// Should merge
#
#j
#jo
#joh
#john

// Shouldn't merge
#john
#john#

// Should merge again
#john#
#john#s
```

Make auto-merging optional when adding attributions to an `AttributedText`.

Pull request:
Let the user add attributions without auto-merging with nearby attributions (Resolves #1198)

This change supports use-cases like hash tags where sometimes we want the attribution to expand, and other times we don't.

Example:
```
// We want to expand
#jo
#joh

// We don't want to expand
#john
#john#
```

Patch:
diff --git a/attributed_text/CHANGELOG.md b/attributed_text/CHANGELOG.md
index 7ea1f5da48..4f2c6eff96 100644
--- a/attributed_text/CHANGELOG.md
+++ b/attributed_text/CHANGELOG.md
@@ -1,3 +1,6 @@
+## [X.Y.Z] - ???
+ * `AttributedText` now allows you to `addAttribution()` without auto-merging with preceding and following attributions (#1198)
+
 ## [0.2.2] - May, 2023
 Upgrade Dart constraints to explicitly include Dart 3. Make `markers` public on `AttributedSpans`.
 
diff --git a/attributed_text/lib/src/attributed_spans.dart b/attributed_text/lib/src/attributed_spans.dart
index 6f915b1fe5..24edb308c3 100644
--- a/attributed_text/lib/src/attributed_spans.dart
+++ b/attributed_text/lib/src/attributed_spans.dart
@@ -279,16 +279,18 @@ class AttributedSpans {
 
   /// Applies the [newAttribution] from [start] to [end], inclusive.
   ///
-  /// If [newAttribution] spans already exist at [start] or [end], and those
-  /// spans are compatible, the spans are expanded to include the new region
-  /// between [start] and [end].
+  /// When [autoMerge] is `true`, the new attribution is merged with any
+  /// preceding or following attribution whose [Attribution.canMergeWith] returns
+  /// `true`.
   ///
-  /// It [newAttribution] overlaps a conflicting span, a
+  /// It [newAttribution] overlaps a conflicting span, or if [newAttribution]
+  /// overlaps a merge-able span but [autoMerge] is `false`, a
   /// [IncompatibleOverlappingAttributionsException] is thrown.
   void addAttribution({
     required Attribution newAttribution,
     required int start,
     required int end,
+    bool autoMerge = true,
   }) {
     if (start < 0 || start > end) {
       _log.warning("Tried to add an attribution ($newAttribution) at an invalid start/end: $start -> $end");
@@ -303,7 +305,7 @@ class AttributedSpans {
     final matchingAttributions = getMatchingAttributionsWithin(attributions: {newAttribution}, start: start, end: end);
     if (matchingAttributions.isNotEmpty) {
       for (final matchingAttribution in matchingAttributions) {
-        if (!newAttribution.canMergeWith(matchingAttribution)) {
+        if (!newAttribution.canMergeWith(matchingAttribution) || !autoMerge) {
           late int conflictStart;
           for (int i = start; i <= end; ++i) {
             if (hasAttributionAt(i, attribution: matchingAttribution)) {
@@ -321,6 +323,25 @@ class AttributedSpans {
       }
     }
 
+    if (!autoMerge) {
+      // There are not conflicting attributions in the desired range, and we don't
+      // want to merge this new attribution with any other nearby attribution.
+      // Therefore, we can blindly create the new attribution range without any
+      // further adjustments, and then be done.
+      _insertMarker(SpanMarker(
+        attribution: newAttribution,
+        offset: start,
+        markerType: SpanMarkerType.start,
+      ));
+      _insertMarker(SpanMarker(
+        attribution: newAttribution,
+        offset: end,
+        markerType: SpanMarkerType.end,
+      ));
+
+      return;
+    }
+
     // Start the new span, either by expanding an existing span, or by
     // inserting a new start marker for the new span.
     final endMarkerJustBefore =
diff --git a/attributed_text/lib/src/attributed_text.dart b/attributed_text/lib/src/attributed_text.dart
index 9eebed3446..7965c4bce8 100644
--- a/attributed_text/lib/src/attributed_text.dart
+++ b/attributed_text/lib/src/attributed_text.dart
@@ -151,8 +151,16 @@ class AttributedText {
 
   /// Adds the given [attribution] to all characters within the given
   /// [range], inclusive.
-  void addAttribution(Attribution attribution, SpanRange range) {
-    spans.addAttribution(newAttribution: attribution, start: range.start, end: range.end);
+  ///
+  /// When [autoMerge] is `true`, the new attribution is merged with any
+  /// preceding or following attribution whose [Attribution.canMergeWith] returns
+  /// `true`.
+  void addAttribution(
+    Attribution attribution,
+    SpanRange range, {
+    bool autoMerge = true,
+  }) {
+    spans.addAttribution(newAttribution: attribution, start: range.start, end: range.end, autoMerge: autoMerge);
     _notifyListeners();
   }
 
@@ -422,7 +430,12 @@ class CallbackAttributionVisitor implements AttributionVisitor {
   }
 
   @override
-  void visitAttributions(AttributedText fullText, int index, Set<Attribution> startingAttributions, Set<Attribution> endingAttributions) {
+  void visitAttributions(
+    AttributedText fullText,
+    int index,
+    Set<Attribution> startingAttributions,
+    Set<Attribution> endingAttributions,
+  ) {
     _onVisitAttributions(fullText, index, startingAttributions, endingAttributions);
   }
 


Test patch:
diff --git a/attributed_text/lib/src/test_tools.dart b/attributed_text/lib/src/test_tools.dart
index b65de393a8..856e2f9155 100644
--- a/attributed_text/lib/src/test_tools.dart
+++ b/attributed_text/lib/src/test_tools.dart
@@ -7,6 +7,7 @@ class ExpectedSpans {
   static const bold = NamedAttribution('bold');
   static const italics = NamedAttribution('italics');
   static const strikethrough = NamedAttribution('strikethrough');
+  static const hashTag = NamedAttribution('hashTag');
 
   ExpectedSpans(
     List<String> spanTemplates,
diff --git a/attributed_text/test/attributed_text_test.dart b/attributed_text/test/attributed_text_test.dart
index 3fa92637d0..4b74aac544 100644
--- a/attributed_text/test/attributed_text_test.dart
+++ b/attributed_text/test/attributed_text_test.dart
@@ -63,7 +63,7 @@ void main() {
         );
       });
 
-      test('combines back-to-back spans after addition', () {
+      test('automatically combines back-to-back spans after addition', () {
         final text = AttributedText(text: 'ABCD');
         text.addAttribution(ExpectedSpans.bold, const SpanRange(start: 0, end: 1));
         text.addAttribution(ExpectedSpans.bold, const SpanRange(start: 2, end: 3));
@@ -79,6 +79,47 @@ void main() {
           SpanMarker(attribution: ExpectedSpans.bold, offset: 3, markerType: SpanMarkerType.end),
         );
       });
+
+      test('keeps back-to-back spans separate when requested', () {
+        final text = AttributedText(text: '#john#sally');
+        text.addAttribution(ExpectedSpans.hashTag, const SpanRange(start: 0, end: 4));
+        text.addAttribution(ExpectedSpans.hashTag, const SpanRange(start: 5, end: 10), autoMerge: false);
+
+        // Ensure that the hash tag spans were kept separate
+        expect(text.spans.markers.length, 2 * 2);
+
+        final markers = text.spans.markers.toList();
+
+        // #john
+        expect(
+          markers[0],
+          SpanMarker(attribution: ExpectedSpans.hashTag, offset: 0, markerType: SpanMarkerType.start),
+        );
+        expect(
+          markers[1],
+          SpanMarker(attribution: ExpectedSpans.hashTag, offset: 4, markerType: SpanMarkerType.end),
+        );
+
+        // #sally
+        expect(
+          markers[2],
+          SpanMarker(attribution: ExpectedSpans.hashTag, offset: 5, markerType: SpanMarkerType.start),
+        );
+        expect(
+          markers[3],
+          SpanMarker(attribution: ExpectedSpans.hashTag, offset: 10, markerType: SpanMarkerType.end),
+        );
+      });
+
+      test('throws exception when compatible attributions overlap but auto-merge is false', () {
+        final text = AttributedText(text: '#john#sally');
+        text.addAttribution(ExpectedSpans.hashTag, const SpanRange(start: 0, end: 4));
+
+        expect(
+          () => text.addAttribution(ExpectedSpans.hashTag, const SpanRange(start: 0, end: 10), autoMerge: false),
+          throwsA(isA<IncompatibleOverlappingAttributionsException>()),
+        );
+      });
     });
 
     test('notifies listeners when style changes', () {
@@ -214,7 +255,9 @@ void main() {
         expect(range, SpanRange(start: 4, end: 7));
       });
 
-      test('finds all bold, italic and strikethrough text within a word that also includes a span with only bold and italics', () {
+      test(
+          'finds all bold, italic and strikethrough text within a word that also includes a span with only bold and italics',
+          () {
         final attributedText = AttributedText(
           text: 'Hello world',
           spans: AttributedSpans(
@@ -229,7 +272,8 @@ void main() {
           ),
         );
 
-        final range = attributedText.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics, ExpectedSpans.strikethrough}, 2);
+        final range = attributedText
+            .getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics, ExpectedSpans.strikethrough}, 2);
         expect(range, SpanRange(start: 1, end: 3));
       });
     });