diff --git a/super_editor_spellcheck/example/lib/main_super_editor_spellcheck.dart b/super_editor_spellcheck/example/lib/main_super_editor_spellcheck.dart index a63c2fec4..09d239422 100644 --- a/super_editor_spellcheck/example/lib/main_super_editor_spellcheck.dart +++ b/super_editor_spellcheck/example/lib/main_super_editor_spellcheck.dart @@ -82,6 +82,11 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre _spellingAndGrammarPlugin = SpellingAndGrammarPlugin( iosControlsController: _iosControlsController, androidControlsController: _androidControlsController, + ignoreRules: [ + SpellingIgnoreRules.byAttribution(boldAttribution), + SpellingIgnoreRules.byAttributionFilter((attr) => attr is LinkAttribution), + SpellingIgnoreRules.byPattern(RegExp(r'#\w+')), + ], ); _editor = createDefaultDocumentEditor( @@ -119,6 +124,58 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre attributions: {}, ), ]); + _editor.execute([ + InsertNodeAfterNodeRequest( + existingNodeId: _editor.context.document.last.id, + newNode: ParagraphNode(id: Editor.createNodeId(), text: AttributedText('')), + ) + ]); + _editor.execute([ + InsertAttributedTextRequest( + DocumentPosition( + nodeId: _editor.context.document.last.id, + nodePosition: _editor.context.document.last.endPosition, + ), + AttributedText( + 'The spellchecking can be configured to ignore spelling errors for some situation, like links: https://www.populr.com, ' + 'tags: #framwork, or text with specific attributions, like bold attbution.', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: LinkAttribution('https://www.populr.com'), + offset: 94, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: LinkAttribution('https://www.populr.com'), + offset: 115, + markerType: SpanMarkerType.end, + ), + const SpanMarker( + attribution: PatternTagAttribution(), + offset: 124, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: PatternTagAttribution(), + offset: 132, + markerType: SpanMarkerType.end, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 176, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 189, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ) + ]); }); } @@ -133,6 +190,17 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre autofocus: true, editor: _editor, stylesheet: defaultStylesheet.copyWith( + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.whereType().isNotEmpty) { + style = style.copyWith( + color: Colors.orange, + ); + } + + return style; + }, addRulesAfter: [ if (Theme.of(context).brightness == Brightness.dark) ..._darkModeStyles, ], diff --git a/super_editor_spellcheck/lib/src/super_editor/spelling_and_grammar_plugin.dart b/super_editor_spellcheck/lib/src/super_editor/spelling_and_grammar_plugin.dart index ef8b581bb..51417226c 100644 --- a/super_editor_spellcheck/lib/src/super_editor/spelling_and_grammar_plugin.dart +++ b/super_editor_spellcheck/lib/src/super_editor/spelling_and_grammar_plugin.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:ui'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -33,6 +34,9 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin { /// is required when running on Android. /// - [iosControlsController]: the controls controller to use when running on iOS. This is /// required when running on iOS. + /// - [ignoreRules]: a list of rules that determine ranges that should be ignored from spellchecking. + /// It can be used, for example, to ignore links or text with specific attributions. See [SpellingIgnoreRules] + /// for a list of built-in rules. SpellingAndGrammarPlugin({ bool isSpellingCheckEnabled = true, UnderlineStyle spellingErrorUnderlineStyle = defaultSpellingErrorUnderlineStyle, @@ -42,6 +46,7 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin { Color? selectedWordHighlightColor, SuperEditorAndroidControlsController? androidControlsController, SuperEditorIosControlsController? iosControlsController, + List ignoreRules = const [], }) : _isSpellCheckEnabled = isSpellingCheckEnabled, _isGrammarCheckEnabled = isGrammarCheckEnabled { assert(defaultTargetPlatform != TargetPlatform.android || androidControlsController != null, @@ -66,6 +71,8 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin { : null), ); + _ignoreRules = ignoreRules; + _contentTapHandler = switch (defaultTargetPlatform) { TargetPlatform.android => SuperEditorAndroidSpellCheckerTapHandler( popoverController: _popoverController, @@ -89,6 +96,8 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin { /// misspelled word. final _selectedWordLink = LeaderLink(); + late final List _ignoreRules; + late final SpellingAndGrammarReaction _reaction; /// Whether this reaction checks spelling in the document. @@ -138,7 +147,7 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin { editor.context.put(spellingErrorSuggestionsKey, _spellingErrorSuggestions); _contentTapHandler?.editor = editor; - _reaction = SpellingAndGrammarReaction(_spellingErrorSuggestions, _styler); + _reaction = SpellingAndGrammarReaction(_spellingErrorSuggestions, _styler, _ignoreRules); editor.reactionPipeline.add(_reaction); // Do initial spelling and grammar analysis, in case the document already @@ -245,12 +254,14 @@ extension SpellingAndGrammarEditorExtensions on Editor { /// An [EditReaction] that runs spelling and grammar checks on all [TextNode]s /// in a given [Document]. class SpellingAndGrammarReaction implements EditReaction { - SpellingAndGrammarReaction(this._suggestions, this._styler); + SpellingAndGrammarReaction(this._suggestions, this._styler, this._ignoreRules); final SpellingErrorSuggestions _suggestions; final SpellingAndGrammarStyler _styler; + final List _ignoreRules; + bool isSpellCheckEnabled = true; set spellingErrorUnderlineStyle(UnderlineStyle style) => _styler.spellingErrorUnderlineStyle = style; @@ -374,6 +385,8 @@ class SpellingAndGrammarReaction implements EditReaction { final requestId = _asyncRequestIds[textNode.id]! + 1; _asyncRequestIds[textNode.id] = requestId; + final plainText = _filterIgnoredRanges(textNode); + int startingOffset = 0; TextRange prevError = TextRange.empty; final locale = PlatformDispatcher.instance.locale; @@ -382,16 +395,16 @@ class SpellingAndGrammarReaction implements EditReaction { if (isSpellCheckEnabled) { do { prevError = await _macSpellChecker.checkSpelling( - stringToCheck: textNode.text.text, + stringToCheck: plainText, startingOffset: startingOffset, language: language, ); if (prevError.isValid) { - final word = textNode.text.text.substring(prevError.start, prevError.end); + final word = plainText.substring(prevError.start, prevError.end); // Ask platform for spelling correction guesses. - final guesses = await _macSpellChecker.guesses(range: prevError, text: textNode.text.text); + final guesses = await _macSpellChecker.guesses(range: prevError, text: plainText); textErrors.add( TextError.spelling( @@ -419,7 +432,7 @@ class SpellingAndGrammarReaction implements EditReaction { prevError = TextRange.empty; do { final result = await _macSpellChecker.checkGrammar( - stringToCheck: textNode.text.text, + stringToCheck: plainText, startingOffset: startingOffset, language: language, ); @@ -428,7 +441,7 @@ class SpellingAndGrammarReaction implements EditReaction { if (prevError.isValid) { for (final grammarError in result.details) { final errorRange = grammarError.range; - final text = textNode.text.text.substring(errorRange.start, errorRange.end); + final text = plainText.substring(errorRange.start, errorRange.end); textErrors.add( TextError.grammar( nodeId: textNode.id, @@ -471,16 +484,18 @@ class SpellingAndGrammarReaction implements EditReaction { final requestId = _asyncRequestIds[textNode.id]! + 1; _asyncRequestIds[textNode.id] = requestId; + final plainText = _filterIgnoredRanges(textNode); + final suggestions = await _mobileSpellChecker.fetchSpellCheckSuggestions( PlatformDispatcher.instance.locale, - textNode.text.toPlainText(), + plainText, ); if (suggestions == null) { return; } for (final suggestion in suggestions) { - final misspelledWord = textNode.text.substring(suggestion.range.start, suggestion.range.end); + final misspelledWord = plainText.substring(suggestion.range.start, suggestion.range.end); spellingSuggestions[suggestion.range] = SpellingError( word: misspelledWord, nodeId: textNode.id, @@ -514,6 +529,83 @@ class SpellingAndGrammarReaction implements EditReaction { // see suggestions and select them. _suggestions.putSuggestions(textNode.id, spellingSuggestions); } + + /// Filters out ranges that should be ignored from spellchecking. + /// + /// This method replaces the ignored ranges with whitespaces so that the spellchecker + /// doesn't see them. + String _filterIgnoredRanges(TextNode node) { + final ranges = _ignoreRules // + .map((rule) => rule(node)) + .expand((listOfRanges) => listOfRanges) + .toList(); + + final text = node.text.toPlainText(); + + if (ranges.isEmpty) { + // We don't have any ranges to remove, short circuit. + return text; + } + + final buffer = StringBuffer(); + + final mergedRanges = _mergeOverlappingRanges(ranges); + int currentOffset = 0; + for (final range in mergedRanges) { + if (range.start > currentOffset) { + // We have text before the ignored range. Add it. + buffer.write(text.substring(currentOffset, range.start)); + } + + // Fill the ignored range with whitespaces. + buffer.write(' ' * (range.end - range.start)); + + currentOffset = range.end; + } + + // Add the remaining text, after the last ignored range, if any. + if (currentOffset < text.length) { + buffer.write(text.substring(currentOffset)); + } + + return buffer.toString(); + } + + /// Merges overlapping ranges in the given list of [ranges]. + /// + /// Returns a new sorted list of ranges where overlapping ranges are merged. + List _mergeOverlappingRanges(List ranges) { + final sortedRanges = ranges.sorted((a, b) { + if (a.start < b.start) { + return -1; + } else if (a.start > b.start) { + return 1; + } + + return a.end - b.end; + }); + + TextRange currentRange = sortedRanges.first; + + final mergedRanges = []; + for (int i = 1; i < sortedRanges.length; i++) { + final nextRange = sortedRanges[i]; + if (currentRange.end >= nextRange.start) { + // The ranges overlap, merge them. + currentRange = TextRange( + start: currentRange.start, + end: nextRange.end, + ); + } else { + // The ranges don't overlap. + mergedRanges.add(currentRange); + currentRange = nextRange; + } + } + mergedRanges.add(currentRange); + + return mergedRanges; + } } /// A [ContentTapDelegate] that shows the suggestions popover when the user taps on @@ -785,3 +877,38 @@ class _SpellCheckerContentTapDelegate extends ContentTapDelegate { Editor? editor; } + +/// A function that determines ranges to be ignored from spellchecking. +typedef SpellingIgnoreRule = List Function(TextNode node); + +/// A collection of built-in rules for ignoring spans of text from spellchecking. +class SpellingIgnoreRules { + /// Creates a rule that ignores text spans that match the given [pattern]. + static SpellingIgnoreRule byPattern(Pattern pattern) { + return (TextNode node) { + return pattern + .allMatches(node.text.toPlainText()) + .map((match) => TextRange(start: match.start, end: match.end)) + .toList(); + }; + } + + /// Creates a rule that ignores text spans that have the given [attribution]. + static SpellingIgnoreRule byAttribution(Attribution attribution) { + return byAttributionFilter((candidate) => candidate == attribution); + } + + /// Creates a rule that ignore text spans that have at least one atribution that matches the given [filter]. + static SpellingIgnoreRule byAttributionFilter(AttributionFilter filter) { + return (TextNode node) { + return node.text.spans + .getAttributionSpansInRange( + attributionFilter: filter, + start: 0, + end: node.text.toPlainText().length - 1, // -1 to make end of range inclusive. + ) + .map((span) => TextRange(start: span.start, end: span.end + 1)) // +1 to make the end exclusive. + .toList(); + }; + } +}