Repository: superlistapp/super_editor (dart)

Issue:
Add AttributedSpans method to get attribution about index
It's useful to get the span range that includes a given offset. For example:

"Hello, [world]!"

Imagine that you have the offset for the "o" character, and now you want the span range for the whole word that's attributed around the "o".

Add an API that provides this capability.

Pull request description:
[AttributedText] Add method to get the attributed range for a set of attributions. Resolves https://github.com/superlistapp/super_editor/issues/590

This PR adds a method to return the span range that's attributed around an offset.

For example:

`He**ll~o Wor**ld~` 

Calling this method with the offset of "W" and the attributions `bold` and `strikethrough` will return the range of "o Wor".

I made `getStartingMarkerAtOrBefore` and `getEndingMarkerAtOrAfter` public to avoid duplicating the code.

Patch:
diff --git a/attributed_text/lib/src/attributed_spans.dart b/attributed_text/lib/src/attributed_spans.dart
index cde8739b87..8c456f187c 100644
--- a/attributed_text/lib/src/attributed_spans.dart
+++ b/attributed_text/lib/src/attributed_spans.dart
@@ -5,6 +5,7 @@ import 'package:meta/meta.dart';
 
 import 'attribution.dart';
 import 'logging.dart';
+import 'span_range.dart';
 
 final _log = attributionsLog;
 
@@ -215,6 +216,43 @@ class AttributedSpans {
     return matchingAttributionSpans;
   }
 
+  /// Returns the range about [offset], which is attributed with all given [attributions].
+  ///
+  /// [attributions] must not be empty.
+  SpanRange getAttributedRange(Set<Attribution> attributions, int offset) {
+    if (attributions.isEmpty) {
+      throw Exception('getAttributedRange requires a non empty set of attributions');
+    }
+
+    int? maxStartMarkerOffset;
+    int? minEndMarkerOffset;
+
+    for (final attribution in attributions) {
+      if (!hasAttributionAt(offset, attribution: attribution)) {
+        throw Exception(
+            'Tried to get the attributed range of ($attribution) at offset "$offset" but the given attribution does not exist at that offset.');
+      }
+
+      int startMarkerOffset = _getStartingMarkerAtOrBefore(offset, attribution: attribution)!.offset;
+      int endMarkerOffset = _getEndingMarkerAtOrAfter(offset, attribution: attribution)!.offset;
+
+      if (maxStartMarkerOffset == null || startMarkerOffset > maxStartMarkerOffset) {
+        maxStartMarkerOffset = startMarkerOffset;
+      }
+
+      if (minEndMarkerOffset == null || endMarkerOffset < minEndMarkerOffset) {
+        minEndMarkerOffset = endMarkerOffset;
+      }
+    }
+
+    // Note: maxStartMarkerOffset and minEndMarkerOffset are non-null because we verified
+    // that all desired attributions are present at the given offset.
+    return SpanRange(
+      start: maxStartMarkerOffset!,
+      end: minEndMarkerOffset!,
+    );
+  }
+
   /// Finds and returns the nearest [start] marker that appears at or before the
   /// given [offset], optionally looking specifically for a marker with
   /// the given [attribution].
diff --git a/attributed_text/lib/src/attributed_text.dart b/attributed_text/lib/src/attributed_text.dart
index 029e3aebaa..9eebed3446 100644
--- a/attributed_text/lib/src/attributed_text.dart
+++ b/attributed_text/lib/src/attributed_text.dart
@@ -142,6 +142,13 @@ class AttributedText {
     );
   }
 
+  /// Returns the range about [offset], which is attributed with all given [attributions].
+  ///
+  /// [attributions] must not be empty.
+  SpanRange getAttributedRange(Set<Attribution> attributions, int offset) {
+    return spans.getAttributedRange(attributions, offset);
+  }
+
   /// Adds the given [attribution] to all characters within the given
   /// [range], inclusive.
   void addAttribution(Attribution attribution, SpanRange range) {


Test patch:
diff --git a/attributed_text/test/attributed_spans_test.dart b/attributed_text/test/attributed_spans_test.dart
index 293c3d2033..adc5ce286d 100644
--- a/attributed_text/test/attributed_spans_test.dart
+++ b/attributed_text/test/attributed_spans_test.dart
@@ -156,6 +156,115 @@ void main() {
           ),
         );
       });
+
+      group('getAttributedRange', () {
+        test('returns the range of a single attribution for an offset in the middle of a span', () {
+          final spans = AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          );
+
+          final range = spans.getAttributedRange({ExpectedSpans.bold}, 5);
+          expect(range, SpanRange(start: 4, end: 9));
+        });
+
+        test('returns the range of a single attribution for an offset at the beginning of a span', () {
+          final spans = AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          );
+
+          final range = spans.getAttributedRange({ExpectedSpans.bold}, 4);
+          expect(range, SpanRange(start: 4, end: 9));
+        });
+
+        test('returns the range of a single attribution for an offset at the end of a span', () {
+          final spans = AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          );
+
+          final range = spans.getAttributedRange({ExpectedSpans.bold}, 9);
+          expect(range, SpanRange(start: 4, end: 9));
+        });
+
+        test('returns the range for multiple attributions for an offset in the middle of the overlapping range', () {
+          final spans = AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          );
+
+          final range = spans.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 5);
+          expect(range, SpanRange(start: 4, end: 7));
+        });
+
+        test('returns the range for multiple attributions for an offset at the beginning of the overlapping range', () {
+          final spans = AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          );
+
+          final range = spans.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 4);
+          expect(range, SpanRange(start: 4, end: 7));
+        });
+
+        test('returns the range for multiple attributions for an offset at the end of the overlapping range', () {
+          final spans = AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          );
+
+          final range = spans.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 7);
+          expect(range, SpanRange(start: 4, end: 7));
+        });
+
+        test('throws when given an empty attribution set', () {
+          final spans = AttributedSpans();
+
+          expect(() => spans.getAttributedRange({}, 0), throwsException);
+        });
+
+        test('throws when any attribution is not present at the given offset', () {
+          final spans = AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          );
+
+          expect(() => spans.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 7), throwsException);
+        });
+      });
     });
 
     group('single attribution', () {
@@ -651,8 +760,7 @@ class _LinkAttribution implements Attribution {
   }
 
   @override
-  bool operator ==(Object other) =>
-      identical(this, other) || other is _LinkAttribution && runtimeType == other.runtimeType && url == other.url;
+  bool operator ==(Object other) => identical(this, other) || other is _LinkAttribution && runtimeType == other.runtimeType && url == other.url;
 
   @override
   int get hashCode => url.hashCode;
diff --git a/attributed_text/test/attributed_text_test.dart b/attributed_text/test/attributed_text_test.dart
index 55715b0e21..3fa92637d0 100644
--- a/attributed_text/test/attributed_text_test.dart
+++ b/attributed_text/test/attributed_text_test.dart
@@ -178,5 +178,60 @@ void main() {
         );
       });
     });
+
+    group('attribution queries', () {
+      test('finds all bold text around a character', () {
+        final attributedText = AttributedText(
+          text: 'Hello world',
+          spans: AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final range = attributedText.getAttributedRange({ExpectedSpans.bold}, 5);
+        expect(range, SpanRange(start: 4, end: 9));
+      });
+
+      test('finds all bold and italics text around a character', () {
+        final attributedText = AttributedText(
+          text: 'Hello world',
+          spans: AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final range = attributedText.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 5);
+        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', () {
+        final attributedText = AttributedText(
+          text: 'Hello world',
+          spans: AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 1, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 3, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final range = attributedText.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics, ExpectedSpans.strikethrough}, 2);
+        expect(range, SpanRange(start: 1, end: 3));
+      });
+    });
   });
 }