Repository: superlistapp/super_editor (dart)

Issue:
[SuperEditor] Add Valued Attribution support in SuperEditor (ColorAttribution, SizeAttribution, etc...)
Hello, I write this issue as Feature, tell me if It's supported or not because I'm not able to find this behavior in Demo.

**User Behavior**
As a User, I use DemoEditor
I would like to change some text color from Selection or just from Cursor.

I use `ToggleTextAttributionsRequest`.
In some case, the Command cannot be applied du to 'canMerge' of ColorAttribute

Ex:
`[color A]All my text is colored into A color[/color A]`
If I decide to color any word between beacons: ERROR


**Feature Value**
When an attribution is associated with value Editor cannot split attribution or manage them to apply the new one if already the same Attribution is applied.

With this feature, editor will be able to use Attribution with value by applying attribution on some TextNode portions an not only paragraph.

Ex 1:
`[color A]All my text is colored into A color[/color A]`
If I decide to color any word between beacons: `[color A]All my text is [/color A][color B]colored[/color B] [color A]into A color[/color A]`

Ex 2:
`Only the word [color A]color[/color A] is colored in A color`
If I decide to color all the sentence with B color: `[color B]Only the word color is colored in A color[/color B]`

The behavior targeted is the same as a classic editor

This behavior can be the same with SizeAttribution for text size, Hightlight, FontFamily etc...


**Additional context**
If you need more, code example or else don't hesitate to ask for.

Pull request description:
[SuperEditor] Split incompatible attributions. Resolves #1983

Valued attributions, like `ColorAttribution` can't be applied in a range where another `ColorAttribution` exists.

Although it doesn't make sense to have two `ColorAttribution`s in the same range, users should be able to change the color of an already attributed range.

For example, consider the following range, fully attributed with blue:

```
   blue green blue
b [xxxxxxxxxxxxxxx]
```

Users should be able to apply green color to the word "green", causing the blue attributed range to be split:

```
   blue green blue
b [xxxxx-----xxxxx]
g [-----xxxxx-----]
```

This PR changes `AddTextAttributionsCommand` and `ToggleTextAttributionsCommand` to find and remove any conflicting attributions in the range before trying to apply the desired attribution.

The following changes were made:

- Introduce `AttributedSpans.findConflictingAttributions` to return a list of conflicting attributions.
- Change `AddTextAttributionsCommand` and `ToggleTextAttributionsCommand` to remove conflicting attributions
- Update the tests that previously ensured we couldn't apply conflicting attributions

Also, I found a bug in `AttributedSpans.getMatchingAttributionsWithin`, where we always tried to get the attributions from the same index. This PR fixes this bug.

We'll need to release a new version of `attributed_text` because of this new method.

Patch:
diff --git a/attributed_text/lib/src/attributed_spans.dart b/attributed_text/lib/src/attributed_spans.dart
index 9bc6af3e36..3e121011c0 100644
--- a/attributed_text/lib/src/attributed_spans.dart
+++ b/attributed_text/lib/src/attributed_spans.dart
@@ -96,7 +96,7 @@ class AttributedSpans {
     final matchingAttributions = <Attribution>{};
     for (int i = start; i <= end; ++i) {
       for (final attribution in attributions) {
-        final otherAttributions = getAllAttributionsAt(start);
+        final otherAttributions = getAllAttributionsAt(i);
         for (final otherAttribution in otherAttributions) {
           if (otherAttribution.id == attribution.id) {
             matchingAttributions.add(otherAttribution);
@@ -280,18 +280,48 @@ class AttributedSpans {
 
   /// Applies the [newAttribution] from [start] to [end], inclusive.
   ///
-  /// When [autoMerge] is `true`, the new attribution is merged with any
-  /// preceding or following attribution whose [Attribution.canMergeWith] returns
-  /// `true`.
+  /// If [start] is less than `0`, nothing happens.
   ///
-  /// It [newAttribution] overlaps a conflicting span, or if [newAttribution]
-  /// overlaps a merge-able span but [autoMerge] is `false`, a
-  /// [IncompatibleOverlappingAttributionsException] is thrown.
+  /// [AttributedSpans] doesn't have any knowledge about content length, so [end] can
+  /// take any value that's desired. However, users of [AttributedSpans] should take
+  /// care to avoid values for [end] that exceed the content length.
+  ///
+  /// The effect of adding an attribution is straight forward when the text doesn't
+  /// contain any other attributions with the same ID. However, there are various
+  /// situations where [newAttribution] can't necessarily co-exist with other
+  /// attribution spans that already exist in the text.
+  ///
+  /// Attribution overlaps can take one of two forms: mergeable or conflicting.
+  ///
+  /// ## Mergeable Attribution Spans
+  /// An example of a mergeable overlap is where two bold spans overlap each
+  /// other. All bold attributions are interchangeable, so when two bold spans
+  /// overlap, those spans can be merged together into a single span.
+  ///
+  /// However, mergeable overlapping spans are not automatically merged. Instead,
+  /// this decision is left to the user of this class. If you want [AttributedSpans] to
+  /// merge overlapping mergeable spans, pass `true` for [autoMerge]. Otherwise,
+  /// if [autoMerge] is `false`, an exception is thrown when two mergeable spans
+  /// overlap each other.
+  ///
+  ///
+  /// ## Conflicting Attribution Spans
+  /// An example of a conflicting overlap is where a black text color overlaps a red
+  /// text color. Text is either black, OR red, but never both. Therefore, the black
+  /// attribution cannot co-exist with the red attribution. Something must be done
+  /// to resolve this.
+  ///
+  /// There are two possible ways to handle conflicting overlaps. The new attribution
+  /// can overwrite the existing attribution where they overlap. Or, an exception can be
+  /// thrown. To overwrite the existing attribution with the new attribution, pass `true`
+  /// for [overwriteConflictingSpans]. Otherwise, if [overwriteConflictingSpans]
+  /// is `false`, an exception is thrown.
   void addAttribution({
     required Attribution newAttribution,
     required int start,
     required int end,
     bool autoMerge = true,
+    bool overwriteConflictingSpans = true,
   }) {
     if (start < 0 || start > end) {
       _log.warning("Tried to add an attribution ($newAttribution) at an invalid start/end: $start -> $end");
@@ -301,32 +331,94 @@ class AttributedSpans {
     _log.info("Adding attribution ($newAttribution) from $start to $end");
     _log.finer("Has ${_markers.length} markers before addition");
 
-    // Ensure that no conflicting attribution overlaps the new attribution.
-    // If a conflict exists, throw an exception.
+    final conflicts = <_AttributionConflict>[];
+
+    // Check if conflicting attributions overlap the new attribution.
     final matchingAttributions = getMatchingAttributionsWithin(attributions: {newAttribution}, start: start, end: end);
     if (matchingAttributions.isNotEmpty) {
       for (final matchingAttribution in matchingAttributions) {
-        if (!newAttribution.canMergeWith(matchingAttribution) || !autoMerge) {
-          late int conflictStart;
+        bool areAttributionsMergeable = newAttribution.canMergeWith(matchingAttribution);
+        if (!areAttributionsMergeable || !autoMerge) {
+          int? conflictStart;
+          int? conflictEnd;
+
           for (int i = start; i <= end; ++i) {
             if (hasAttributionAt(i, attribution: matchingAttribution)) {
-              conflictStart = i;
-              break;
+              conflictStart ??= i;
+              conflictEnd = i;
+
+              if (areAttributionsMergeable) {
+                // Both attributions are mergeable, but the caller doesn't want to merge them.
+                throw IncompatibleOverlappingAttributionsException(
+                  existingAttribution: matchingAttribution,
+                  newAttribution: newAttribution,
+                  conflictStart: conflictStart,
+                );
+              }
+            } else if (conflictStart != null) {
+              // We found the end of the conflict.
+              conflicts.add(_AttributionConflict(
+                newAttribution: newAttribution,
+                existingAttribution: matchingAttribution,
+                conflictStart: conflictStart,
+                conflictEnd: conflictEnd!,
+              ));
+
+              // Reset so we can find the next conflict.
+              conflictStart = null;
+              conflictEnd = null;
             }
           }
 
-          throw IncompatibleOverlappingAttributionsException(
-            existingAttribution: matchingAttribution,
-            newAttribution: newAttribution,
-            conflictStart: conflictStart,
-          );
+          if (conflictStart != null && conflictEnd != null) {
+            // We found a conflict that extends to the end of the range.
+            conflicts.add(_AttributionConflict(
+              newAttribution: newAttribution,
+              existingAttribution: matchingAttribution,
+              conflictStart: conflictStart,
+              conflictEnd: conflictEnd,
+            ));
+          }
         }
       }
+
+      if (conflicts.isNotEmpty && !overwriteConflictingSpans) {
+        // We found conflicting attributions and we are configured not to overwrite them.
+        // For example, the user tried to apply a blue color attribution to a range of text
+        // that already has another color attribution.
+        throw IncompatibleOverlappingAttributionsException(
+          existingAttribution: conflicts.first.existingAttribution,
+          newAttribution: newAttribution,
+          conflictStart: conflicts.first.conflictStart,
+        );
+      }
+    }
+
+    // Removes any conflicting attributions. For example, consider the following text,
+    // with a blue color attribution that spans the entire text:
+    //
+    //    one two three
+    //   |bbbbbbbbbbbbb|
+    //
+    // We can't apply a green color attribution to the word "two", because it's already
+    // attributed with blue. So, we need to remove the blue attribution from the word "two",
+    // which results in the following text:
+    //
+    //    one two three
+    //   |bbbb---bbbbbb|
+    //
+    // After that, we can apply the desired attribution, because there isn't a conflicting attribution
+    // in this range anymore.
+    for (final conflict in conflicts) {
+      removeAttribution(
+        attributionToRemove: conflict.existingAttribution,
+        start: conflict.conflictStart,
+        end: conflict.conflictEnd,
+      );
     }
 
     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.
+      // 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(
@@ -1197,3 +1289,45 @@ class IncompatibleOverlappingAttributionsException implements Exception {
     return 'Tried to insert attribution ($newAttribution) over a conflicting existing attribution ($existingAttribution). The overlap began at index $conflictStart';
   }
 }
+
+/// A conflict between the [newAttribution] and [existingAttribution] between [conflictStart] and [conflictEnd] (inclusive).
+///
+/// This means [newAttribution] and [existingAttribution] have the same id, but they can't be merged.
+class _AttributionConflict {
+  _AttributionConflict({
+    required this.newAttribution,
+    required this.existingAttribution,
+    required this.conflictStart,
+    required this.conflictEnd,
+  });
+
+  /// The new attribution that conflicts with the existing attribution.
+  final Attribution newAttribution;
+
+  /// The conflicting attribution.
+  final Attribution existingAttribution;
+
+  /// The first conflicting index.
+  final int conflictStart;
+
+  /// The last conflicting index (inclusive).
+  final int conflictEnd;
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is _AttributionConflict &&
+          runtimeType == other.runtimeType &&
+          newAttribution == other.newAttribution &&
+          existingAttribution == other.existingAttribution &&
+          conflictStart == other.conflictStart &&
+          conflictEnd == other.conflictEnd;
+
+  @override
+  int get hashCode =>
+      newAttribution.hashCode ^ existingAttribution.hashCode ^ conflictStart.hashCode ^ conflictEnd.hashCode;
+
+  @override
+  String toString() =>
+      '[AttributionConflict] - newAttribution: $newAttribution existingAttribution: $existingAttribution, conflictStart: $conflictStart, conflictEnd: $conflictEnd';
+}
diff --git a/attributed_text/lib/src/attributed_text.dart b/attributed_text/lib/src/attributed_text.dart
index 8912128b51..d449505d52 100644
--- a/attributed_text/lib/src/attributed_text.dart
+++ b/attributed_text/lib/src/attributed_text.dart
@@ -179,7 +179,13 @@ class AttributedText {
     SpanRange range, {
     bool autoMerge = true,
   }) {
-    spans.addAttribution(newAttribution: attribution, start: range.start, end: range.end, autoMerge: autoMerge);
+    spans.addAttribution(
+      newAttribution: attribution,
+      start: range.start,
+      end: range.end,
+      autoMerge: autoMerge,
+      overwriteConflictingSpans: false,
+    );
     _notifyListeners();
   }
 
diff --git a/attributed_text/pubspec.yaml b/attributed_text/pubspec.yaml
index cb28a855a7..4520a26013 100644
--- a/attributed_text/pubspec.yaml
+++ b/attributed_text/pubspec.yaml
@@ -4,7 +4,7 @@ version: 0.3.0
 homepage: https://github.com/superlistapp/super_editor
 
 environment:
-  sdk: ">=2.12.0 <4.0.0"
+  sdk: ">=3.0.0 <4.0.0"
 
 dependencies:
   characters: ^1.2.0
diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart
index 735c3d5e7e..bce79c8aae 100644
--- a/super_editor/lib/src/default_editor/text.dart
+++ b/super_editor/lib/src/default_editor/text.dart
@@ -1552,11 +1552,14 @@ class ToggleTextAttributionsCommand implements EditCommand {
           // see that we made a change, and re-renders the text in the document.
           node.text = AttributedText(
             node.text.text,
-            node.text.spans.copy(),
-          )..addAttribution(
-              attribution,
-              range,
-            );
+            node.text.spans.copy()
+              ..addAttribution(
+                newAttribution: attribution,
+                start: range.start,
+                end: range.end,
+                autoMerge: true,
+              ),
+          );
         }
 
         final wasAttributionAdded = node.text.hasAttributionAt(range.start, attribution: attribution);
diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml
index 1b4e04d035..26e49f26dd 100644
--- a/super_editor/pubspec.yaml
+++ b/super_editor/pubspec.yaml
@@ -41,8 +41,8 @@ dependencies:
 dependency_overrides:
   #  # Override to local mono-repo path so devs can test this repo
   #  # against changes that they're making to other mono-repo packages
-#  attributed_text:
-#    path: ../attributed_text
+  attributed_text:
+    path: ../attributed_text
 #  super_text_layout:
 #    path: ../super_text_layout
 


Test patch:
diff --git a/attributed_text/test/attributed_spans_test.dart b/attributed_text/test/attributed_spans_test.dart
index d359805987..3a8de3f95b 100644
--- a/attributed_text/test/attributed_spans_test.dart
+++ b/attributed_text/test/attributed_spans_test.dart
@@ -601,10 +601,211 @@ void main() {
             newAttribution: _LinkAttribution(url: 'https://pub.dev'),
             start: 4,
             end: 12,
+            overwriteConflictingSpans: false,
           );
         }, throwsA(isA<IncompatibleOverlappingAttributionsException>()));
       });
 
+      test('overwrites incompatible attributions at the beginning of the span', () {
+        // Starting value:
+        // |aaaaaaa|
+        //
+        // Ending value:
+        // |-----aa|
+        // |bbbbb--|
+
+        final spans = AttributedSpans(
+          attributions: _createSpanMarkersForAttribution(
+            attribution: _LinkAttribution(url: 'https://flutter.dev'),
+            startOffset: 0,
+            endOffset: 6,
+          ),
+        );
+
+        // Add an overlapping link at the beginning.
+        spans.addAttribution(
+          newAttribution: _LinkAttribution(url: 'https://pub.dev'),
+          start: 0,
+          end: 4,
+        );
+
+        expect(
+          spans,
+          AttributedSpans(
+            attributions: [
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://pub.dev'),
+                startOffset: 0,
+                endOffset: 4,
+              ),
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://flutter.dev'),
+                startOffset: 5,
+                endOffset: 6,
+              ),
+            ],
+          ),
+        );
+      });
+
+      test('splits incompatible attributions at the middle of the text', () {
+        // Starting value:
+        // |aaaaaaa----------|
+        // |----------bbbbbbb|
+        //
+        // Ending value:
+        // |aaaa-------------|
+        // |-------------bbbb|
+        // |----ccccccccc----|
+
+        final spans = AttributedSpans(attributions: [
+          ..._createSpanMarkersForAttribution(
+            attribution: _LinkAttribution(url: 'https://flutter.dev'),
+            startOffset: 0,
+            endOffset: 6,
+          ),
+          ..._createSpanMarkersForAttribution(
+            attribution: _LinkAttribution(url: 'https://pub.dev'),
+            startOffset: 10,
+            endOffset: 16,
+          ),
+        ]);
+
+        // Add an overlapping at the middle.
+        spans.addAttribution(
+          newAttribution: _LinkAttribution(url: 'https://google.com'),
+          start: 4,
+          end: 12,
+        );
+
+        expect(
+          spans,
+          AttributedSpans(
+            attributions: [
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://flutter.dev'),
+                startOffset: 0,
+                endOffset: 3,
+              ),
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://google.com'),
+                startOffset: 4,
+                endOffset: 12,
+              ),
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://pub.dev'),
+                startOffset: 13,
+                endOffset: 16,
+              ),
+            ],
+          ),
+        );
+      });
+
+      test('overwrites incompatible attributions at the end of the span', () {
+        // Starting value:
+        // |aaaaaaa------|
+        //
+        // Ending value:
+        // |aaaa---------|
+        // |----bbbbbbbbb|
+
+        final spans = AttributedSpans(
+          attributions: _createSpanMarkersForAttribution(
+            attribution: _LinkAttribution(url: 'https://flutter.dev'),
+            startOffset: 0,
+            endOffset: 6,
+          ),
+        );
+
+        // Add an overlapping link at the end.
+        spans.addAttribution(
+          newAttribution: _LinkAttribution(url: 'https://pub.dev'),
+          start: 4,
+          end: 12,
+        );
+
+        expect(
+          spans,
+          AttributedSpans(
+            attributions: [
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://flutter.dev'),
+                startOffset: 0,
+                endOffset: 3,
+              ),
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://pub.dev'),
+                startOffset: 4,
+                endOffset: 12,
+              ),
+            ],
+          ),
+        );
+      });
+
+      test('overwrites multiple incompatible attributions at the midle of the text', () {
+        // Starting value:
+        // |aaaaaa--------------------|
+        // |----------bbbbbb----------|
+        // |--------------------cccccc|
+        //
+        // Ending value:
+        // |aaaa----------------------|
+        // |-----------------------ccc|
+        // |----ddddddddddddddddddd---|
+
+        final spans = AttributedSpans(
+          attributions: [
+            ..._createSpanMarkersForAttribution(
+              attribution: _LinkAttribution(url: 'https://flutter.dev'),
+              startOffset: 0,
+              endOffset: 5,
+            ),
+            ..._createSpanMarkersForAttribution(
+              attribution: _LinkAttribution(url: 'https://pub.dev'),
+              startOffset: 10,
+              endOffset: 15,
+            ),
+            ..._createSpanMarkersForAttribution(
+              attribution: _LinkAttribution(url: 'https://google.com'),
+              startOffset: 20,
+              endOffset: 25,
+            ),
+          ],
+        );
+
+        // Add a link overlapping with all existing spans.
+        spans.addAttribution(
+          newAttribution: _LinkAttribution(url: 'https://youtube.com'),
+          start: 4,
+          end: 22,
+        );
+
+        expect(
+          spans,
+          AttributedSpans(
+            attributions: [
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://flutter.dev'),
+                startOffset: 0,
+                endOffset: 3,
+              ),
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://youtube.com'),
+                startOffset: 4,
+                endOffset: 22,
+              ),
+              ..._createSpanMarkersForAttribution(
+                attribution: _LinkAttribution(url: 'https://google.com'),
+                startOffset: 23,
+                endOffset: 25,
+              )
+            ],
+          ),
+        );
+      });
+
       test('compatible attributions are merged', () {
         final spans = AttributedSpans();
 
@@ -813,3 +1014,24 @@ class _LinkAttribution implements Attribution {
   @override
   int get hashCode => url.hashCode;
 }
+
+/// Creates start and end markers for the [attribution], starting at [startOffset]
+/// and ending at [endOffset].
+List<SpanMarker> _createSpanMarkersForAttribution({
+  required Attribution attribution,
+  required int startOffset,
+  required int endOffset,
+}) {
+  return [
+    SpanMarker(
+      attribution: attribution,
+      offset: startOffset,
+      markerType: SpanMarkerType.start,
+    ),
+    SpanMarker(
+      attribution: attribution,
+      offset: endOffset,
+      markerType: SpanMarkerType.end,
+    ),
+  ];
+}
diff --git a/super_editor/test/super_editor/supereditor_attributions_test.dart b/super_editor/test/super_editor/supereditor_attributions_test.dart
index dc5c60a5c6..381a69d9ff 100644
--- a/super_editor/test/super_editor/supereditor_attributions_test.dart
+++ b/super_editor/test/super_editor/supereditor_attributions_test.dart
@@ -1276,108 +1276,201 @@ void main() {
       });
     });
 
-    testWidgetsOnArbitraryDesktop('does not merge different colors', (tester) async {
-      final context = await tester //
+    testWidgetsOnArbitraryDesktop('overwrites spans with different colors', (tester) async {
+      // Pump an editor with a single paragraph with blue color across the entire paragraph.
+      final testContext = await tester //
           .createDocument()
-          .withSingleParagraph()
+          .withCustomContent(
+            MutableDocument(
+              nodes: [
+                ParagraphNode(
+                  id: '1',
+                  text: AttributedText(
+                    'blue orange pink',
+                    _createAttributedSpansForAttribution(
+                      attribution: const ColorAttribution(Colors.blue),
+                      startOffset: 0,
+                      endOffset: 15,
+                    ),
+                  ),
+                )
+              ],
+            ),
+          )
           .pump();
 
-      final editor = context.editor;
-
-      // Apply a yellow color to the word "Lorem".
-      editor.execute([
+      // Apply orange color to the word "orange".
+      testContext.editor.execute([
         AddTextAttributionsRequest(
-          documentRange: const DocumentRange(
-            start: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)),
-            end: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)),
-          ),
-          attributions: {const ColorAttribution(Colors.yellow)},
+          documentRange: _creatSingleNodeTextRange('1', 5, 11),
+          attributions: {const ColorAttribution(Colors.orange)},
+        ),
+      ]);
+      await tester.pump();
+
+      // Apply pink color to the word "pink" by toggling the attribution.
+      testContext.editor.execute([
+        ToggleTextAttributionsRequest(
+          documentRange: _creatSingleNodeTextRange('1', 11, 16),
+          attributions: {const ColorAttribution(Colors.pink)},
         ),
       ]);
+      await tester.pump();
 
-      // Try to apply a red color to the range "L|ore|m". This should fail
-      // because we can't apply two different colors to the same range.
+      // Ensure the spans were overwritten.
       expect(
-        () => editor.execute([
-          AddTextAttributionsRequest(
-            documentRange: const DocumentRange(
-              start: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 1)),
-              end: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 3)),
+        SuperEditorInspector.findTextInComponent('1').spans,
+        AttributedSpans(
+          attributions: [
+            ..._createSpanMarkersForAttribution(
+              attribution: const ColorAttribution(Colors.blue),
+              startOffset: 0,
+              endOffset: 4,
             ),
-            attributions: {const ColorAttribution(Colors.red)},
-          ),
-        ]),
-        throwsException,
+            ..._createSpanMarkersForAttribution(
+              attribution: const ColorAttribution(Colors.orange),
+              startOffset: 5,
+              endOffset: 10,
+            ),
+            ..._createSpanMarkersForAttribution(
+              attribution: const ColorAttribution(Colors.pink),
+              startOffset: 11,
+              endOffset: 15,
+            ),
+          ],
+        ),
       );
     });
 
-    testWidgetsOnArbitraryDesktop('does not merge different background colors', (tester) async {
-      final context = await tester //
+    testWidgetsOnArbitraryDesktop('overwrites spans with different background colors', (tester) async {
+      // Pump an editor with a single paragraph with blue background color across the entire paragraph.
+      final testContext = await tester //
           .createDocument()
-          .withSingleParagraph()
+          .withCustomContent(
+            MutableDocument(
+              nodes: [
+                ParagraphNode(
+                  id: '1',
+                  text: AttributedText(
+                    'blue orange pink',
+                    _createAttributedSpansForAttribution(
+                      attribution: const BackgroundColorAttribution(Colors.blue),
+                      startOffset: 0,
+                      endOffset: 15,
+                    ),
+                  ),
+                )
+              ],
+            ),
+          )
           .pump();
 
-      final editor = context.editor;
-
-      // Apply a yellow background to the word "Lorem".
-      editor.execute([
+      // Apply orange color to the word "orange".
+      testContext.editor.execute([
         AddTextAttributionsRequest(
-          documentRange: const DocumentRange(
-            start: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)),
-            end: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)),
-          ),
-          attributions: {const BackgroundColorAttribution(Colors.yellow)},
+          documentRange: _creatSingleNodeTextRange('1', 5, 11),
+          attributions: {const BackgroundColorAttribution(Colors.orange)},
+        ),
+      ]);
+      await tester.pump();
+
+      // Apply pink color to the word "pink" by toggling the attribution.
+      testContext.editor.execute([
+        ToggleTextAttributionsRequest(
+          documentRange: _creatSingleNodeTextRange('1', 11, 16),
+          attributions: {const BackgroundColorAttribution(Colors.pink)},
         ),
       ]);
+      await tester.pump();
 
-      // Try to apply a red background to the range "L|ore|m". This should fail
-      // because we can't apply two different background colors to the same range.
+      // Ensure the spans were overwritten.
       expect(
-        () => editor.execute([
-          AddTextAttributionsRequest(
-            documentRange: const DocumentRange(
-              start: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 1)),
-              end: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 3)),
+        SuperEditorInspector.findTextInComponent('1').spans,
+        AttributedSpans(
+          attributions: [
+            ..._createSpanMarkersForAttribution(
+              attribution: const BackgroundColorAttribution(Colors.blue),
+              startOffset: 0,
+              endOffset: 4,
             ),
-            attributions: {const BackgroundColorAttribution(Colors.red)},
-          ),
-        ]),
-        throwsException,
+            ..._createSpanMarkersForAttribution(
+              attribution: const BackgroundColorAttribution(Colors.orange),
+              startOffset: 5,
+              endOffset: 10,
+            ),
+            ..._createSpanMarkersForAttribution(
+              attribution: const BackgroundColorAttribution(Colors.pink),
+              startOffset: 11,
+              endOffset: 15,
+            ),
+          ],
+        ),
       );
     });
 
-    testWidgetsOnArbitraryDesktop('does not merge different font sizes', (tester) async {
-      final context = await tester //
+    testWidgetsOnArbitraryDesktop('overwrites spans with different font sizes', (tester) async {
+      // Pump an editor with a single paragraph with 16px font size across the entire paragraph.
+      final testContext = await tester //
           .createDocument()
-          .withSingleParagraph()
+          .withCustomContent(
+            MutableDocument(
+              nodes: [
+                ParagraphNode(
+                  id: '1',
+                  text: AttributedText(
+                    '16px 18px 14px',
+                    _createAttributedSpansForAttribution(
+                      attribution: const FontSizeAttribution(16),
+                      startOffset: 0,
+                      endOffset: 13,
+                    ),
+                  ),
+                )
+              ],
+            ),
+          )
           .pump();
 
-      final editor = context.editor;
-
-      // Apply a font size of 14 to the word "Lorem".
-      editor.execute([
+      // Apply 18px font size to the text "18px".
+      testContext.editor.execute([
         AddTextAttributionsRequest(
-          documentRange: const DocumentRange(
-            start: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)),
-            end: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)),
-          ),
+          documentRange: _creatSingleNodeTextRange('1', 5, 9),
+          attributions: {const FontSizeAttribution(18)},
+        ),
+      ]);
+      await tester.pump();
+
+      // Apply 14px color to the text "14px" by toggling the attribution.
+      testContext.editor.execute([
+        ToggleTextAttributionsRequest(
+          documentRange: _creatSingleNodeTextRange('1', 9, 14),
           attributions: {const FontSizeAttribution(14)},
         ),
       ]);
+      await tester.pump();
 
-      // Try to apply a font size of 16 to the range "L|ore|m". This should fail
-      // because we can't apply two different font sizes to the same range.
+      // Ensure the spans were overwritten.
       expect(
-        () => editor.execute([
-          AddTextAttributionsRequest(
-            documentRange: const DocumentRange(
-              start: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 1)),
-              end: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 3)),
+        SuperEditorInspector.findTextInComponent('1').spans,
+        AttributedSpans(
+          attributions: [
+            ..._createSpanMarkersForAttribution(
+              attribution: const FontSizeAttribution(16),
+              startOffset: 0,
+              endOffset: 4,
             ),
-            attributions: {const FontSizeAttribution(16)},
-          ),
-        ]),
-        throwsException,
+            ..._createSpanMarkersForAttribution(
+              attribution: const FontSizeAttribution(18),
+              startOffset: 5,
+              endOffset: 8,
+            ),
+            ..._createSpanMarkersForAttribution(
+              attribution: const FontSizeAttribution(14),
+              startOffset: 9,
+              endOffset: 13,
+            ),
+          ],
+        ),
       );
     });
   });
@@ -1515,7 +1608,7 @@ extension _ToggleAttributions on Editor {
 /// Creates an [AttributedSpans] for the [attribution] starting at [startOffset]
 /// and ending at [endOffset].
 AttributedSpans _createAttributedSpansForAttribution({
-  required NamedAttribution attribution,
+  required Attribution attribution,
   required int startOffset,
   required int endOffset,
 }) {
@@ -1534,3 +1627,39 @@ AttributedSpans _createAttributedSpansForAttribution({
     ],
   );
 }
+
+/// Creates start and end markers for the [attribution], starting at [startOffset]
+/// and ending at [endOffset].
+List<SpanMarker> _createSpanMarkersForAttribution({
+  required Attribution attribution,
+  required int startOffset,
+  required int endOffset,
+}) {
+  return [
+    SpanMarker(
+      attribution: attribution,
+      offset: startOffset,
+      markerType: SpanMarkerType.start,
+    ),
+    SpanMarker(
+      attribution: attribution,
+      offset: endOffset,
+      markerType: SpanMarkerType.end,
+    ),
+  ];
+}
+
+/// Creates a [DocumentRange] that starts at node [nodeId] at [start] and
+/// ends at [nodeId] at [end].
+DocumentRange _creatSingleNodeTextRange(String nodeId, int start, int end) {
+  return DocumentRange(
+    start: DocumentPosition(
+      nodeId: nodeId,
+      nodePosition: TextNodePosition(offset: start),
+    ),
+    end: DocumentPosition(
+      nodeId: nodeId,
+      nodePosition: TextNodePosition(offset: end),
+    ),
+  );
+}