Skip to content

Commit 1604d4b

Browse files
[SuperEditorSpellCheck] Add API to ignore text during spellcheck (Resolves #2564) (#2567)
1 parent fd7730d commit 1604d4b

File tree

2 files changed

+204
-9
lines changed

2 files changed

+204
-9
lines changed

super_editor_spellcheck/example/lib/main_super_editor_spellcheck.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre
8282
_spellingAndGrammarPlugin = SpellingAndGrammarPlugin(
8383
iosControlsController: _iosControlsController,
8484
androidControlsController: _androidControlsController,
85+
ignoreRules: [
86+
SpellingIgnoreRules.byAttribution(boldAttribution),
87+
SpellingIgnoreRules.byAttributionFilter((attr) => attr is LinkAttribution),
88+
SpellingIgnoreRules.byPattern(RegExp(r'#\w+')),
89+
],
8590
);
8691

8792
_editor = createDefaultDocumentEditor(
@@ -119,6 +124,58 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre
119124
attributions: {},
120125
),
121126
]);
127+
_editor.execute([
128+
InsertNodeAfterNodeRequest(
129+
existingNodeId: _editor.context.document.last.id,
130+
newNode: ParagraphNode(id: Editor.createNodeId(), text: AttributedText('')),
131+
)
132+
]);
133+
_editor.execute([
134+
InsertAttributedTextRequest(
135+
DocumentPosition(
136+
nodeId: _editor.context.document.last.id,
137+
nodePosition: _editor.context.document.last.endPosition,
138+
),
139+
AttributedText(
140+
'The spellchecking can be configured to ignore spelling errors for some situation, like links: https://www.populr.com, '
141+
'tags: #framwork, or text with specific attributions, like bold attbution.',
142+
AttributedSpans(
143+
attributions: [
144+
const SpanMarker(
145+
attribution: LinkAttribution('https://www.populr.com'),
146+
offset: 94,
147+
markerType: SpanMarkerType.start,
148+
),
149+
const SpanMarker(
150+
attribution: LinkAttribution('https://www.populr.com'),
151+
offset: 115,
152+
markerType: SpanMarkerType.end,
153+
),
154+
const SpanMarker(
155+
attribution: PatternTagAttribution(),
156+
offset: 124,
157+
markerType: SpanMarkerType.start,
158+
),
159+
const SpanMarker(
160+
attribution: PatternTagAttribution(),
161+
offset: 132,
162+
markerType: SpanMarkerType.end,
163+
),
164+
const SpanMarker(
165+
attribution: boldAttribution,
166+
offset: 176,
167+
markerType: SpanMarkerType.start,
168+
),
169+
const SpanMarker(
170+
attribution: boldAttribution,
171+
offset: 189,
172+
markerType: SpanMarkerType.end,
173+
),
174+
],
175+
),
176+
),
177+
)
178+
]);
122179
});
123180
}
124181

@@ -133,6 +190,17 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre
133190
autofocus: true,
134191
editor: _editor,
135192
stylesheet: defaultStylesheet.copyWith(
193+
inlineTextStyler: (attributions, existingStyle) {
194+
TextStyle style = defaultInlineTextStyler(attributions, existingStyle);
195+
196+
if (attributions.whereType<PatternTagAttribution>().isNotEmpty) {
197+
style = style.copyWith(
198+
color: Colors.orange,
199+
);
200+
}
201+
202+
return style;
203+
},
136204
addRulesAfter: [
137205
if (Theme.of(context).brightness == Brightness.dark) ..._darkModeStyles,
138206
],

super_editor_spellcheck/lib/src/super_editor/spelling_and_grammar_plugin.dart

Lines changed: 136 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22
import 'dart:ui';
33

4+
import 'package:collection/collection.dart';
45
import 'package:flutter/foundation.dart';
56
import 'package:flutter/material.dart';
67
import 'package:flutter/services.dart';
@@ -33,6 +34,9 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
3334
/// is required when running on Android.
3435
/// - [iosControlsController]: the controls controller to use when running on iOS. This is
3536
/// 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.
3640
SpellingAndGrammarPlugin({
3741
bool isSpellingCheckEnabled = true,
3842
UnderlineStyle spellingErrorUnderlineStyle = defaultSpellingErrorUnderlineStyle,
@@ -42,6 +46,7 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
4246
Color? selectedWordHighlightColor,
4347
SuperEditorAndroidControlsController? androidControlsController,
4448
SuperEditorIosControlsController? iosControlsController,
49+
List<SpellingIgnoreRule> ignoreRules = const [],
4550
}) : _isSpellCheckEnabled = isSpellingCheckEnabled,
4651
_isGrammarCheckEnabled = isGrammarCheckEnabled {
4752
assert(defaultTargetPlatform != TargetPlatform.android || androidControlsController != null,
@@ -66,6 +71,8 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
6671
: null),
6772
);
6873

74+
_ignoreRules = ignoreRules;
75+
6976
_contentTapHandler = switch (defaultTargetPlatform) {
7077
TargetPlatform.android => SuperEditorAndroidSpellCheckerTapHandler(
7178
popoverController: _popoverController,
@@ -89,6 +96,8 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
8996
/// misspelled word.
9097
final _selectedWordLink = LeaderLink();
9198

99+
late final List<SpellingIgnoreRule> _ignoreRules;
100+
92101
late final SpellingAndGrammarReaction _reaction;
93102

94103
/// Whether this reaction checks spelling in the document.
@@ -138,7 +147,7 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
138147
editor.context.put(spellingErrorSuggestionsKey, _spellingErrorSuggestions);
139148
_contentTapHandler?.editor = editor;
140149

141-
_reaction = SpellingAndGrammarReaction(_spellingErrorSuggestions, _styler);
150+
_reaction = SpellingAndGrammarReaction(_spellingErrorSuggestions, _styler, _ignoreRules);
142151
editor.reactionPipeline.add(_reaction);
143152

144153
// Do initial spelling and grammar analysis, in case the document already
@@ -245,12 +254,14 @@ extension SpellingAndGrammarEditorExtensions on Editor {
245254
/// An [EditReaction] that runs spelling and grammar checks on all [TextNode]s
246255
/// in a given [Document].
247256
class SpellingAndGrammarReaction implements EditReaction {
248-
SpellingAndGrammarReaction(this._suggestions, this._styler);
257+
SpellingAndGrammarReaction(this._suggestions, this._styler, this._ignoreRules);
249258

250259
final SpellingErrorSuggestions _suggestions;
251260

252261
final SpellingAndGrammarStyler _styler;
253262

263+
final List<SpellingIgnoreRule> _ignoreRules;
264+
254265
bool isSpellCheckEnabled = true;
255266

256267
set spellingErrorUnderlineStyle(UnderlineStyle style) => _styler.spellingErrorUnderlineStyle = style;
@@ -374,6 +385,8 @@ class SpellingAndGrammarReaction implements EditReaction {
374385
final requestId = _asyncRequestIds[textNode.id]! + 1;
375386
_asyncRequestIds[textNode.id] = requestId;
376387

388+
final plainText = _filterIgnoredRanges(textNode);
389+
377390
int startingOffset = 0;
378391
TextRange prevError = TextRange.empty;
379392
final locale = PlatformDispatcher.instance.locale;
@@ -382,16 +395,16 @@ class SpellingAndGrammarReaction implements EditReaction {
382395
if (isSpellCheckEnabled) {
383396
do {
384397
prevError = await _macSpellChecker.checkSpelling(
385-
stringToCheck: textNode.text.text,
398+
stringToCheck: plainText,
386399
startingOffset: startingOffset,
387400
language: language,
388401
);
389402

390403
if (prevError.isValid) {
391-
final word = textNode.text.text.substring(prevError.start, prevError.end);
404+
final word = plainText.substring(prevError.start, prevError.end);
392405

393406
// 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);
395408

396409
textErrors.add(
397410
TextError.spelling(
@@ -419,7 +432,7 @@ class SpellingAndGrammarReaction implements EditReaction {
419432
prevError = TextRange.empty;
420433
do {
421434
final result = await _macSpellChecker.checkGrammar(
422-
stringToCheck: textNode.text.text,
435+
stringToCheck: plainText,
423436
startingOffset: startingOffset,
424437
language: language,
425438
);
@@ -428,7 +441,7 @@ class SpellingAndGrammarReaction implements EditReaction {
428441
if (prevError.isValid) {
429442
for (final grammarError in result.details) {
430443
final errorRange = grammarError.range;
431-
final text = textNode.text.text.substring(errorRange.start, errorRange.end);
444+
final text = plainText.substring(errorRange.start, errorRange.end);
432445
textErrors.add(
433446
TextError.grammar(
434447
nodeId: textNode.id,
@@ -471,16 +484,18 @@ class SpellingAndGrammarReaction implements EditReaction {
471484
final requestId = _asyncRequestIds[textNode.id]! + 1;
472485
_asyncRequestIds[textNode.id] = requestId;
473486

487+
final plainText = _filterIgnoredRanges(textNode);
488+
474489
final suggestions = await _mobileSpellChecker.fetchSpellCheckSuggestions(
475490
PlatformDispatcher.instance.locale,
476-
textNode.text.toPlainText(),
491+
plainText,
477492
);
478493
if (suggestions == null) {
479494
return;
480495
}
481496

482497
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);
484499
spellingSuggestions[suggestion.range] = SpellingError(
485500
word: misspelledWord,
486501
nodeId: textNode.id,
@@ -514,6 +529,83 @@ class SpellingAndGrammarReaction implements EditReaction {
514529
// see suggestions and select them.
515530
_suggestions.putSuggestions(textNode.id, spellingSuggestions);
516531
}
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+
}
517609
}
518610

519611
/// A [ContentTapDelegate] that shows the suggestions popover when the user taps on
@@ -785,3 +877,38 @@ class _SpellCheckerContentTapDelegate extends ContentTapDelegate {
785877

786878
Editor? editor;
787879
}
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

Comments
 (0)