Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SuperEditor][mobile] Implement spellchecking support (Resolves #2353) #2378

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:super_editor/src/core/document_layout.dart';
import 'package:super_editor/src/core/document_selection.dart';
import 'package:super_editor/src/core/edit_context.dart';
import 'package:super_editor/src/core/editor.dart';
import 'package:super_editor/src/default_editor/spelling_and_grammar/spell_checker_popover_controller.dart';
import 'package:super_editor/src/default_editor/super_editor.dart';
import 'package:super_editor/src/default_editor/text_tools.dart';
import 'package:super_editor/src/document_operations/selection_operations.dart';
Expand Down Expand Up @@ -407,6 +408,7 @@ class AndroidDocumentTouchInteractor extends StatefulWidget {
required this.openSoftwareKeyboard,
required this.scrollController,
this.contentTapHandler,
this.spellCheckerPopoverController,
this.dragAutoScrollBoundary = const AxisOffset.symmetric(54),
required this.dragHandleAutoScroller,
this.showDebugPaint = false,
Expand All @@ -427,6 +429,8 @@ class AndroidDocumentTouchInteractor extends StatefulWidget {
/// a link when the user taps on text with a link attribution.
final ContentTapDelegate? contentTapHandler;

final SpellCheckerPopoverController? spellCheckerPopoverController;
angelosilvestre marked this conversation as resolved.
Show resolved Hide resolved

final ScrollController scrollController;

/// The closest that the user's selection drag gesture can get to the
Expand Down Expand Up @@ -976,6 +980,7 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
void _showAndHideEditingControlsAfterTapSelection({
required bool didTapOnExistingSelection,
}) {
widget.spellCheckerPopoverController?.hide();
if (widget.selection.value == null) {
// There's no selection. Hide all controls.
_controlsController!
Expand Down Expand Up @@ -1009,6 +1014,7 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
} else {
// The user tapped somewhere else in the document. Hide the toolbar.
_controlsController!.hideToolbar();
widget.spellCheckerPopoverController?.show(widget.selection.value!);
angelosilvestre marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:super_editor/src/core/document_layout.dart';
import 'package:super_editor/src/core/document_selection.dart';
import 'package:super_editor/src/core/edit_context.dart';
import 'package:super_editor/src/core/editor.dart';
import 'package:super_editor/src/default_editor/spelling_and_grammar/spell_checker_popover_controller.dart';
import 'package:super_editor/src/default_editor/super_editor.dart';
import 'package:super_editor/src/default_editor/text.dart';
import 'package:super_editor/src/default_editor/text_tools.dart';
Expand Down Expand Up @@ -249,6 +250,7 @@ class IosDocumentTouchInteractor extends StatefulWidget {
required this.dragHandleAutoScroller,
this.contentTapHandler,
this.dragAutoScrollBoundary = const AxisOffset.symmetric(54),
this.spellCheckerPopoverController,
this.showDebugPaint = false,
this.child,
}) : super(key: key);
Expand Down Expand Up @@ -278,6 +280,8 @@ class IosDocumentTouchInteractor extends StatefulWidget {
/// edges.
final AxisOffset dragAutoScrollBoundary;

final SpellCheckerPopoverController? spellCheckerPopoverController;

final bool showDebugPaint;

final Widget? child;
Expand Down Expand Up @@ -638,6 +642,8 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
..hideMagnifier()
..blinkCaret();

widget.spellCheckerPopoverController?.hide();

if (_wasScrollingOnTapDown) {
// The scrollable was scrolling when the user touched down. We expect that the
// touch down stopped the scrolling momentum. We don't want to take any further
Expand Down Expand Up @@ -704,6 +710,10 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
_controlsController!.hideToolbar();
}

if (!didTapOnExistingSelection) {
widget.spellCheckerPopoverController?.show(DocumentSelection.collapsed(position: adjustedSelectionPosition));
}

final tappedComponent = _docLayout.getComponentByNodeId(adjustedSelectionPosition.nodeId)!;
if (!tappedComponent.isVisualSelectionSupported()) {
// The user tapped a non-selectable component.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'package:super_editor/src/core/document_selection.dart';

/// Shows/hides a popover with spelling suggestions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be a lot more information in this Dart Doc. Imagine reading this as someone who needs to use this - there's no guiding information about where/when/how.

class SpellCheckerPopoverController {
SpellCheckerPopoverDelegate? _delegate;

/// Attaches this controller to a delegate that knows how to
/// show a popover with spelling suggestions.
void attach(SpellCheckerPopoverDelegate delegate) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we use an attach/detach approach for the existing mobile editing toolbar, too?

_delegate = delegate;
}

/// Detaches this controller from the delegate.
void detach() {
_delegate = null;
}

/// Shows spelling suggestions for the word at [wordRange].
void show(DocumentRange wordRange) {
_delegate?.showSuggestionsForWordAt(wordRange);
}

/// Hides the spelling suggestions popover.
void hide() {
_delegate?.hideSuggestionsPopover();
}
}

abstract class SpellCheckerPopoverDelegate {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Dart Doc. This should have a lot of info about who is supposed to implement this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some docs, can you take a look to see what else should be detailed?

/// Shows spelling suggestions for the word at [wordRange].
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the document changes? What if the user's selection changes? Please fully describe expected behaviors.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some docs, can you take a look to see what else should be detailed?

void showSuggestionsForWordAt(DocumentRange wordRange) {}

/// Hides the spelling suggestions popover.
void hideSuggestionsPopover() {}
}
13 changes: 13 additions & 0 deletions super_editor/lib/src/default_editor/super_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart
import 'package:super_editor/src/default_editor/document_scrollable.dart';
import 'package:super_editor/src/default_editor/layout_single_column/_styler_composing_region.dart';
import 'package:super_editor/src/default_editor/list_items.dart';
import 'package:super_editor/src/default_editor/spelling_and_grammar/spell_checker_popover_controller.dart';
import 'package:super_editor/src/default_editor/tasks.dart';
import 'package:super_editor/src/infrastructure/_logging.dart';
import 'package:super_editor/src/infrastructure/content_layers.dart';
Expand Down Expand Up @@ -127,6 +128,7 @@ class SuperEditor extends StatefulWidget {
this.selectorHandlers,
this.gestureMode,
this.contentTapDelegateFactory = superEditorLaunchLinkTapHandlerFactory,
this.spellCheckerPopoverController,
angelosilvestre marked this conversation as resolved.
Show resolved Hide resolved
this.selectionLayerLinks,
this.documentUnderlayBuilders = const [],
this.documentOverlayBuilders = defaultSuperEditorDocumentOverlayBuilders,
Expand Down Expand Up @@ -277,6 +279,15 @@ class SuperEditor extends StatefulWidget {
/// when a user taps on a link.
final SuperEditorContentTapDelegateFactory? contentTapDelegateFactory;

/// Shows/hides a popover with spelling suggestions.
///
/// A [SpellCheckerPopoverDelegate] must be attached to this controller
/// before it can be used.
///
/// The `SpellingAndGrammarPlugin` provides a default implementation for
/// a [SpellCheckerPopoverDelegate].
final SpellCheckerPopoverController? spellCheckerPopoverController;

/// Leader links that connect leader widgets near the user's selection
/// to carets, handles, and other things that want to follow the selection.
///
Expand Down Expand Up @@ -860,6 +871,7 @@ class SuperEditorState extends State<SuperEditor> {
selection: editContext.composer.selectionNotifier,
openSoftwareKeyboard: _openSoftareKeyboard,
contentTapHandler: _contentTapDelegate,
spellCheckerPopoverController: widget.spellCheckerPopoverController,
scrollController: _scrollController,
dragHandleAutoScroller: _dragHandleAutoScroller,
showDebugPaint: widget.debugPaint.gestures,
Expand All @@ -875,6 +887,7 @@ class SuperEditorState extends State<SuperEditor> {
contentTapHandler: _contentTapDelegate,
scrollController: _scrollController,
dragHandleAutoScroller: _dragHandleAutoScroller,
spellCheckerPopoverController: widget.spellCheckerPopoverController,
showDebugPaint: widget.debugPaint.gestures,
);
}
Expand Down
3 changes: 3 additions & 0 deletions super_editor/lib/super_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ export 'src/super_reader/read_only_document_mouse_interactor.dart';
export 'src/super_reader/reader_context.dart';
export 'src/super_reader/super_reader.dart';

// SpellChecker
export 'src/default_editor/spelling_and_grammar/spell_checker_popover_controller.dart';

// Export from super_text_layout so that downstream clients don't
// have to add this package to get access to these classes.
export 'package:super_text_layout/super_text_layout.dart'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,18 @@ class _SuperEditorSpellcheckPluginAppState extends State<_SuperEditorSpellcheckP
theme: ThemeData(
brightness: _brightness,
),
home: Stack(
children: [
const _SuperEditorSpellcheckScreen(),
Positioned(
top: 0,
bottom: 0,
right: 0,
child: _buildToolbar(),
),
],
home: SafeArea(
child: Stack(
children: [
const _SuperEditorSpellcheckScreen(),
Positioned(
top: 0,
bottom: 0,
right: 0,
child: _buildToolbar(),
),
],
),
),
);
}
Expand Down Expand Up @@ -65,12 +67,17 @@ class _SuperEditorSpellcheckScreen extends StatefulWidget {

class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScreen> {
late final Editor _editor;
final _spellingAndGrammarPlugin = SpellingAndGrammarPlugin();
final _popoverController = SpellCheckerPopoverController();
late final SpellingAndGrammarPlugin _spellingAndGrammarPlugin;

@override
void initState() {
super.initState();

_spellingAndGrammarPlugin = SpellingAndGrammarPlugin(
popoverController: _popoverController,
);

_editor = createDefaultDocumentEditor(
document: MutableDocument.empty(),
composer: MutableDocumentComposer(),
Expand Down Expand Up @@ -100,6 +107,7 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre
return Scaffold(
body: SuperEditor(
editor: _editor,
spellCheckerPopoverController: _popoverController,
customStylePhases: [
_spellingAndGrammarPlugin.styler,
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:ui';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:follow_the_leader/follow_the_leader.dart';
import 'package:super_editor/super_editor.dart';
import 'package:super_editor_spellcheck/src/platform/spell_checker.dart';
Expand All @@ -17,13 +19,15 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin {
UnderlineStyle spellingErrorUnderlineStyle = defaultSpellingErrorUnderlineStyle,
bool isGrammarCheckEnabled = true,
UnderlineStyle grammarErrorUnderlineStyle = defaultGrammarErrorUnderlineStyle,
SpellingErrorSuggestionToolbarBuilder toolbarBuilder = desktopSpellingSuggestionToolbarBuilder,
SpellingErrorSuggestionToolbarBuilder toolbarBuilder = defaultSpellingSuggestionToolbarBuilder,
SpellCheckerPopoverController? popoverController,
}) : _isSpellCheckEnabled = isSpellingCheckEnabled,
_isGrammarCheckEnabled = isGrammarCheckEnabled {
documentOverlayBuilders = <SuperEditorLayerBuilder>[
SpellingErrorSuggestionOverlayBuilder(
_spellingErrorSuggestions,
_selectedWordLink,
popoverController: popoverController,
toolbarBuilder: toolbarBuilder,
),
];
Expand Down Expand Up @@ -130,6 +134,34 @@ extension SpellingAndGrammarEditorExtensions on Editor {
),
]);
}

void removeMisspelledWord(DocumentRange wordRange) {
execute([
// Move caret to start of mis-spelled word so that we ensure the
// caret location is legitimate after deleting the word. E.g.,
// consider what would happen if the mis-spelled word is the last
// word in the given paragraph.
ChangeSelectionRequest(
DocumentSelection.collapsed(
position: wordRange.start,
),
SelectionChangeType.alteredContent,
SelectionReason.contentChange,
),
// Delete the mis-spelled word.
DeleteContentRequest(
documentRange: DocumentRange(
start: wordRange.start,
end: wordRange.end.copyWith(
nodePosition: TextNodePosition(
// +1 to make end of range exclusive.
offset: (wordRange.end.nodePosition as TextNodePosition).offset + 1,
),
),
),
),
]);
}
}

/// An [EditReaction] that runs spelling and grammar checks on all [TextNode]s
Expand Down Expand Up @@ -158,15 +190,18 @@ class SpellingAndGrammarReaction implements EditReaction {
/// of receipt.
final _asyncRequestIds = <String, int>{};

final _mobileSpellChecker = DefaultSpellCheckService();

@override
void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List<EditEvent> changeList) {
// No-op - spelling and grammar checks style the document, they don't alter the document.
}

@override
void react(EditContext editorContext, RequestDispatcher requestDispatcher, List<EditEvent> changeList) {
if (defaultTargetPlatform != TargetPlatform.macOS || kIsWeb) {
// We currently only support spell check when running on Mac desktop.
if (kIsWeb ||
!const [TargetPlatform.macOS, TargetPlatform.android, TargetPlatform.iOS].contains(defaultTargetPlatform)) {
// We currently only support spell check when running on Mac desktop or mobile platforms.
return;
}

Expand Down Expand Up @@ -230,6 +265,14 @@ class SpellingAndGrammarReaction implements EditReaction {
}

Future<void> _findSpellingAndGrammarErrors(TextNode textNode) async {
if (defaultTargetPlatform == TargetPlatform.macOS) {
await _findSpellingAndGrammarErrorsOnMac(textNode);
} else if (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS) {
await _findSpellingAndGrammarErrorsOnMobile(textNode);
}
}

Future<void> _findSpellingAndGrammarErrorsOnMac(TextNode textNode) async {
final spellChecker = SuperEditorSpellCheckerPlugin().macSpellChecker;

// TODO: Investigate whether we can parallelize spelling and grammar checks
Expand Down Expand Up @@ -328,4 +371,58 @@ class SpellingAndGrammarReaction implements EditReaction {
// see suggestions and select them.
_suggestions.putSuggestions(textNode.id, spellingSuggestions);
}

Future<void> _findSpellingAndGrammarErrorsOnMobile(TextNode textNode) async {
final textErrors = <TextError>{};
final spellingSuggestions = <TextRange, SpellingErrorSuggestion>{};

// Track this spelling and grammar request to make sure we don't process
// the response out of order with other requests.
_asyncRequestIds[textNode.id] ??= 0;
final requestId = _asyncRequestIds[textNode.id]! + 1;
_asyncRequestIds[textNode.id] = requestId;

final suggestions = await _mobileSpellChecker.fetchSpellCheckSuggestions(
PlatformDispatcher.instance.locale,
textNode.text.text,
);
if (suggestions == null) {
return;
}

for (final suggestion in suggestions) {
final misspelledWord = textNode.text.substring(suggestion.range.start, suggestion.range.end);
spellingSuggestions[suggestion.range] = SpellingErrorSuggestion(
word: misspelledWord,
nodeId: textNode.id,
range: suggestion.range,
suggestions: suggestion.suggestions,
);
textErrors.add(
TextError.spelling(
nodeId: textNode.id,
range: suggestion.range,
value: misspelledWord,
suggestions: suggestion.suggestions,
),
);
}

if (requestId != _asyncRequestIds[textNode.id]) {
// Another request was started for this node while we were running our
// request. Fizzle.
return;
}
// Reset the request ID counter to zero so that we avoid increasing infinitely.
_asyncRequestIds[textNode.id] = 0;

// Display underlines on spelling and grammar errors.
_styler
..clearErrorsForNode(textNode.id)
..addErrors(textNode.id, textErrors);

// Update the shared repository of spelling suggestions so that the user can
// see suggestions and select them.
_suggestions.putSuggestions(textNode.id, spellingSuggestions);
}
}
Loading
Loading