1
1
import 'dart:async' ;
2
2
import 'dart:ui' ;
3
3
4
+ import 'package:collection/collection.dart' ;
4
5
import 'package:flutter/foundation.dart' ;
5
6
import 'package:flutter/material.dart' ;
6
7
import 'package:flutter/services.dart' ;
@@ -33,6 +34,9 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
33
34
/// is required when running on Android.
34
35
/// - [iosControlsController] : the controls controller to use when running on iOS. This is
35
36
/// required when running on iOS.
37
+ /// - [ignoreRules] : a list of rules that determine ranges that should be ignored from spellchecking.
38
+ /// It can be used, for example, to ignore links or text with specific attributions. See [SpellingIgnoreRules]
39
+ /// for a list of built-in rules.
36
40
SpellingAndGrammarPlugin ({
37
41
bool isSpellingCheckEnabled = true ,
38
42
UnderlineStyle spellingErrorUnderlineStyle = defaultSpellingErrorUnderlineStyle,
@@ -42,6 +46,7 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
42
46
Color ? selectedWordHighlightColor,
43
47
SuperEditorAndroidControlsController ? androidControlsController,
44
48
SuperEditorIosControlsController ? iosControlsController,
49
+ List <SpellingIgnoreRule > ignoreRules = const [],
45
50
}) : _isSpellCheckEnabled = isSpellingCheckEnabled,
46
51
_isGrammarCheckEnabled = isGrammarCheckEnabled {
47
52
assert (defaultTargetPlatform != TargetPlatform .android || androidControlsController != null ,
@@ -66,6 +71,8 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
66
71
: null ),
67
72
);
68
73
74
+ _ignoreRules = ignoreRules;
75
+
69
76
_contentTapHandler = switch (defaultTargetPlatform) {
70
77
TargetPlatform .android => SuperEditorAndroidSpellCheckerTapHandler (
71
78
popoverController: _popoverController,
@@ -89,6 +96,8 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
89
96
/// misspelled word.
90
97
final _selectedWordLink = LeaderLink ();
91
98
99
+ late final List <SpellingIgnoreRule > _ignoreRules;
100
+
92
101
late final SpellingAndGrammarReaction _reaction;
93
102
94
103
/// Whether this reaction checks spelling in the document.
@@ -138,7 +147,7 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
138
147
editor.context.put (spellingErrorSuggestionsKey, _spellingErrorSuggestions);
139
148
_contentTapHandler? .editor = editor;
140
149
141
- _reaction = SpellingAndGrammarReaction (_spellingErrorSuggestions, _styler);
150
+ _reaction = SpellingAndGrammarReaction (_spellingErrorSuggestions, _styler, _ignoreRules );
142
151
editor.reactionPipeline.add (_reaction);
143
152
144
153
// Do initial spelling and grammar analysis, in case the document already
@@ -245,12 +254,14 @@ extension SpellingAndGrammarEditorExtensions on Editor {
245
254
/// An [EditReaction] that runs spelling and grammar checks on all [TextNode] s
246
255
/// in a given [Document] .
247
256
class SpellingAndGrammarReaction implements EditReaction {
248
- SpellingAndGrammarReaction (this ._suggestions, this ._styler);
257
+ SpellingAndGrammarReaction (this ._suggestions, this ._styler, this ._ignoreRules );
249
258
250
259
final SpellingErrorSuggestions _suggestions;
251
260
252
261
final SpellingAndGrammarStyler _styler;
253
262
263
+ final List <SpellingIgnoreRule > _ignoreRules;
264
+
254
265
bool isSpellCheckEnabled = true ;
255
266
256
267
set spellingErrorUnderlineStyle (UnderlineStyle style) => _styler.spellingErrorUnderlineStyle = style;
@@ -374,6 +385,8 @@ class SpellingAndGrammarReaction implements EditReaction {
374
385
final requestId = _asyncRequestIds[textNode.id]! + 1 ;
375
386
_asyncRequestIds[textNode.id] = requestId;
376
387
388
+ final plainText = _filterIgnoredRanges (textNode);
389
+
377
390
int startingOffset = 0 ;
378
391
TextRange prevError = TextRange .empty;
379
392
final locale = PlatformDispatcher .instance.locale;
@@ -382,16 +395,16 @@ class SpellingAndGrammarReaction implements EditReaction {
382
395
if (isSpellCheckEnabled) {
383
396
do {
384
397
prevError = await _macSpellChecker.checkSpelling (
385
- stringToCheck: textNode.text.text ,
398
+ stringToCheck: plainText ,
386
399
startingOffset: startingOffset,
387
400
language: language,
388
401
);
389
402
390
403
if (prevError.isValid) {
391
- final word = textNode.text.text .substring (prevError.start, prevError.end);
404
+ final word = plainText .substring (prevError.start, prevError.end);
392
405
393
406
// Ask platform for spelling correction guesses.
394
- final guesses = await _macSpellChecker.guesses (range: prevError, text: textNode.text.text );
407
+ final guesses = await _macSpellChecker.guesses (range: prevError, text: plainText );
395
408
396
409
textErrors.add (
397
410
TextError .spelling (
@@ -419,7 +432,7 @@ class SpellingAndGrammarReaction implements EditReaction {
419
432
prevError = TextRange .empty;
420
433
do {
421
434
final result = await _macSpellChecker.checkGrammar (
422
- stringToCheck: textNode.text.text ,
435
+ stringToCheck: plainText ,
423
436
startingOffset: startingOffset,
424
437
language: language,
425
438
);
@@ -428,7 +441,7 @@ class SpellingAndGrammarReaction implements EditReaction {
428
441
if (prevError.isValid) {
429
442
for (final grammarError in result.details) {
430
443
final errorRange = grammarError.range;
431
- final text = textNode.text.text .substring (errorRange.start, errorRange.end);
444
+ final text = plainText .substring (errorRange.start, errorRange.end);
432
445
textErrors.add (
433
446
TextError .grammar (
434
447
nodeId: textNode.id,
@@ -471,16 +484,18 @@ class SpellingAndGrammarReaction implements EditReaction {
471
484
final requestId = _asyncRequestIds[textNode.id]! + 1 ;
472
485
_asyncRequestIds[textNode.id] = requestId;
473
486
487
+ final plainText = _filterIgnoredRanges (textNode);
488
+
474
489
final suggestions = await _mobileSpellChecker.fetchSpellCheckSuggestions (
475
490
PlatformDispatcher .instance.locale,
476
- textNode.text. toPlainText () ,
491
+ plainText ,
477
492
);
478
493
if (suggestions == null ) {
479
494
return ;
480
495
}
481
496
482
497
for (final suggestion in suggestions) {
483
- final misspelledWord = textNode.text .substring (suggestion.range.start, suggestion.range.end);
498
+ final misspelledWord = plainText .substring (suggestion.range.start, suggestion.range.end);
484
499
spellingSuggestions[suggestion.range] = SpellingError (
485
500
word: misspelledWord,
486
501
nodeId: textNode.id,
@@ -514,6 +529,83 @@ class SpellingAndGrammarReaction implements EditReaction {
514
529
// see suggestions and select them.
515
530
_suggestions.putSuggestions (textNode.id, spellingSuggestions);
516
531
}
532
+
533
+ /// Filters out ranges that should be ignored from spellchecking.
534
+ ///
535
+ /// This method replaces the ignored ranges with whitespaces so that the spellchecker
536
+ /// doesn't see them.
537
+ String _filterIgnoredRanges (TextNode node) {
538
+ final ranges = _ignoreRules //
539
+ .map ((rule) => rule (node))
540
+ .expand ((listOfRanges) => listOfRanges)
541
+ .toList ();
542
+
543
+ final text = node.text.toPlainText ();
544
+
545
+ if (ranges.isEmpty) {
546
+ // We don't have any ranges to remove, short circuit.
547
+ return text;
548
+ }
549
+
550
+ final buffer = StringBuffer ();
551
+
552
+ final mergedRanges = _mergeOverlappingRanges (ranges);
553
+ int currentOffset = 0 ;
554
+ for (final range in mergedRanges) {
555
+ if (range.start > currentOffset) {
556
+ // We have text before the ignored range. Add it.
557
+ buffer.write (text.substring (currentOffset, range.start));
558
+ }
559
+
560
+ // Fill the ignored range with whitespaces.
561
+ buffer.write (' ' * (range.end - range.start));
562
+
563
+ currentOffset = range.end;
564
+ }
565
+
566
+ // Add the remaining text, after the last ignored range, if any.
567
+ if (currentOffset < text.length) {
568
+ buffer.write (text.substring (currentOffset));
569
+ }
570
+
571
+ return buffer.toString ();
572
+ }
573
+
574
+ /// Merges overlapping ranges in the given list of [ranges] .
575
+ ///
576
+ /// Returns a new sorted list of ranges where overlapping ranges are merged.
577
+ List <TextRange > _mergeOverlappingRanges (List <TextRange > ranges) {
578
+ final sortedRanges = ranges.sorted ((a, b) {
579
+ if (a.start < b.start) {
580
+ return - 1 ;
581
+ } else if (a.start > b.start) {
582
+ return 1 ;
583
+ }
584
+
585
+ return a.end - b.end;
586
+ });
587
+
588
+ TextRange currentRange = sortedRanges.first;
589
+
590
+ final mergedRanges = < TextRange > [];
591
+ for (int i = 1 ; i < sortedRanges.length; i++ ) {
592
+ final nextRange = sortedRanges[i];
593
+ if (currentRange.end >= nextRange.start) {
594
+ // The ranges overlap, merge them.
595
+ currentRange = TextRange (
596
+ start: currentRange.start,
597
+ end: nextRange.end,
598
+ );
599
+ } else {
600
+ // The ranges don't overlap.
601
+ mergedRanges.add (currentRange);
602
+ currentRange = nextRange;
603
+ }
604
+ }
605
+ mergedRanges.add (currentRange);
606
+
607
+ return mergedRanges;
608
+ }
517
609
}
518
610
519
611
/// A [ContentTapDelegate] that shows the suggestions popover when the user taps on
@@ -785,3 +877,38 @@ class _SpellCheckerContentTapDelegate extends ContentTapDelegate {
785
877
786
878
Editor ? editor;
787
879
}
880
+
881
+ /// A function that determines ranges to be ignored from spellchecking.
882
+ typedef SpellingIgnoreRule = List <TextRange > Function (TextNode node);
883
+
884
+ /// A collection of built-in rules for ignoring spans of text from spellchecking.
885
+ class SpellingIgnoreRules {
886
+ /// Creates a rule that ignores text spans that match the given [pattern] .
887
+ static SpellingIgnoreRule byPattern (Pattern pattern) {
888
+ return (TextNode node) {
889
+ return pattern
890
+ .allMatches (node.text.toPlainText ())
891
+ .map ((match) => TextRange (start: match.start, end: match.end))
892
+ .toList ();
893
+ };
894
+ }
895
+
896
+ /// Creates a rule that ignores text spans that have the given [attribution] .
897
+ static SpellingIgnoreRule byAttribution (Attribution attribution) {
898
+ return byAttributionFilter ((candidate) => candidate == attribution);
899
+ }
900
+
901
+ /// Creates a rule that ignore text spans that have at least one atribution that matches the given [filter] .
902
+ static SpellingIgnoreRule byAttributionFilter (AttributionFilter filter) {
903
+ return (TextNode node) {
904
+ return node.text.spans
905
+ .getAttributionSpansInRange (
906
+ attributionFilter: filter,
907
+ start: 0 ,
908
+ end: node.text.toPlainText ().length - 1 , // -1 to make end of range inclusive.
909
+ )
910
+ .map ((span) => TextRange (start: span.start, end: span.end + 1 )) // +1 to make the end exclusive.
911
+ .toList ();
912
+ };
913
+ }
914
+ }
0 commit comments