diff --git a/super_editor/lib/src/default_editor/document_gestures_mouse.dart b/super_editor/lib/src/default_editor/document_gestures_mouse.dart index 06c548606..371aee1e3 100644 --- a/super_editor/lib/src/default_editor/document_gestures_mouse.dart +++ b/super_editor/lib/src/default_editor/document_gestures_mouse.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; @@ -48,7 +49,7 @@ class DocumentMouseInteractor extends StatefulWidget { required this.getDocumentLayout, required this.selectionNotifier, required this.selectionChanges, - this.contentTapHandler, + this.contentTapHandlers, required this.autoScroller, this.showDebugPaint = false, this.child, @@ -62,9 +63,9 @@ class DocumentMouseInteractor extends StatefulWidget { final Stream selectionChanges; final ValueListenable selectionNotifier; - /// Optional handler that responds to taps on content, e.g., opening + /// Optional list of handlers that respond to taps on content, e.g., opening /// a link when the user taps on text with a link attribution. - final ContentTapDelegate? contentTapHandler; + final List? contentTapHandlers; /// Auto-scrolling delegate. final AutoScrollController autoScroller; @@ -118,7 +119,11 @@ class _DocumentMouseInteractorState extends State with widget.autoScroller ..addListener(_updateDragSelection) ..addListener(_updateMouseCursorAtLatestOffset); - widget.contentTapHandler?.addListener(_updateMouseCursorAtLatestOffset); + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } } @override @@ -142,15 +147,29 @@ class _DocumentMouseInteractorState extends State with ..addListener(_updateDragSelection) ..addListener(_updateMouseCursorAtLatestOffset); } - if (widget.contentTapHandler != oldWidget.contentTapHandler) { - oldWidget.contentTapHandler?.removeListener(_updateMouseCursorAtLatestOffset); - widget.contentTapHandler?.addListener(_updateMouseCursorAtLatestOffset); + + if (!const DeepCollectionEquality().equals(oldWidget.contentTapHandlers, widget.contentTapHandlers)) { + if (oldWidget.contentTapHandlers != null) { + for (final handler in oldWidget.contentTapHandlers!) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + } + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } } } @override void dispose() { - widget.contentTapHandler?.removeListener(_updateMouseCursorAtLatestOffset); + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + } if (widget.focusNode == null) { _focusNode.dispose(); } @@ -255,12 +274,14 @@ class _DocumentMouseInteractorState extends State with return; } - if (widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTap(docPosition); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTap(docPosition); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -304,12 +325,14 @@ class _DocumentMouseInteractorState extends State with final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onDoubleTap(docPosition); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (docPosition != null && widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onDoubleTap(docPosition); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -405,12 +428,14 @@ class _DocumentMouseInteractorState extends State with final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTripleTap(docPosition); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (docPosition != null && widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTripleTap(docPosition); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -741,8 +766,17 @@ Updating drag selection: return; } - final cursorForContent = widget.contentTapHandler?.mouseCursorForContentHover(docPosition); - _mouseCursor.value = cursorForContent ?? SystemMouseCursors.text; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final cursorForContent = handler.mouseCursorForContentHover(docPosition); + if (cursorForContent != null) { + _mouseCursor.value = cursorForContent; + return; + } + } + } + + _mouseCursor.value = SystemMouseCursors.text; } @override diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart index 59adfd504..f8b8e6975 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart @@ -258,6 +258,29 @@ class SuperEditorAndroidControlsController { } } + /// {@template are_selection_handles_allowed} + /// Whether or not the selection handles are allowed to be displayed. + /// + /// Typically, whenever the selection changes the drag handles are displayed. However, + /// there are some cases where we want to select some content, but don't show the + /// drag handles. For example, when the user taps a misspelled word, we might want to select + /// the misspelled word without showing any handles. + /// + /// Defaults to `true`. + /// {@endtemplate} + ValueListenable get areSelectionHandlesAllowed => _areSelectionHandlesAllowed; + final _areSelectionHandlesAllowed = ValueNotifier(true); + + /// Temporarily prevents any selection handles from being displayed. + /// + /// Call this when you want to select some content, but don't want to show the drag handles. + /// [allowSelectionHandles] must be called to allow the drag handles to be displayed again. + void preventSelectionHandles() => _areSelectionHandlesAllowed.value = false; + + /// Allows the selection handles to be displayed after they have been temporarily + /// prevented by [preventSelectionHandles]. + void allowSelectionHandles() => _areSelectionHandlesAllowed.value = true; + /// (Optional) Builder to create the visual representation of the expanded drag handles. /// /// If [expandedHandlesBuilder] is `null`, default Android handles are displayed. @@ -406,7 +429,7 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { required this.selection, required this.openSoftwareKeyboard, required this.scrollController, - this.contentTapHandler, + this.contentTapHandlers, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), required this.dragHandleAutoScroller, this.showDebugPaint = false, @@ -423,9 +446,9 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { /// A callback that should open the software keyboard when invoked. final VoidCallback openSoftwareKeyboard; - /// Optional handler that responds to taps on content, e.g., opening + /// Optional list of handlers that responds to taps on content, e.g., opening /// a link when the user taps on text with a link attribution. - final ContentTapDelegate? contentTapHandler; + final List? contentTapHandlers; final ScrollController scrollController; @@ -784,6 +807,23 @@ class _AndroidDocumentTouchInteractorState extends State _shouldCaretBlink.value = false; + /// {@macro are_selection_handles_allowed} + ValueListenable get areSelectionHandlesAllowed => _areSelectionHandlesAllowed; + final _areSelectionHandlesAllowed = ValueNotifier(true); + + /// Temporarily prevents any selection handles from being displayed. + /// + /// Call this when you want to select some content, but don't want to show the drag handles. + /// [allowSelectionHandles] must be called to allow the drag handles to be displayed again. + void allowSelectionHandles() => _areSelectionHandlesAllowed.value = true; + + /// Allows the selection handles to be displayed after they have been temporarily + /// prevented by [preventSelectionHandles]. + void preventSelectionHandles() => _areSelectionHandlesAllowed.value = false; + /// Controls the iOS floating cursor. late final FloatingCursorController floatingCursorController; @@ -247,7 +261,7 @@ class IosDocumentTouchInteractor extends StatefulWidget { required this.openSoftwareKeyboard, required this.scrollController, required this.dragHandleAutoScroller, - this.contentTapHandler, + this.contentTapHandlers, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), this.showDebugPaint = false, this.child, @@ -263,9 +277,9 @@ class IosDocumentTouchInteractor extends StatefulWidget { /// A callback that should open the software keyboard when invoked. final VoidCallback openSoftwareKeyboard; - /// Optional handler that responds to taps on content, e.g., opening + /// Optional list of handlers that respond to taps on content, e.g., opening /// a link when the user taps on text with a link attribution. - final ContentTapDelegate? contentTapHandler; + final List? contentTapHandlers; final ScrollController scrollController; @@ -645,29 +659,31 @@ class _IosDocumentTouchInteractorState extends State return; } - final selection = widget.selection.value; - if (selection != null && - !selection.isCollapsed && - (_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) { - _controlsController!.toggleToolbar(); - return; - } - editorGesturesLog.info("Tap down on document"); final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (widget.contentTapHandler != null && docPosition != null) { - final result = widget.contentTapHandler!.onTap(docPosition); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (widget.contentTapHandlers != null && docPosition != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTap(docPosition); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } + final selection = widget.selection.value; + if (selection != null && + !selection.isCollapsed && + (_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) { + _controlsController!.toggleToolbar(); + return; + } + if (docPosition != null && selection != null && !selection.isCollapsed && @@ -760,28 +776,30 @@ class _IosDocumentTouchInteractorState extends State } void _onDoubleTapUp(TapUpDetails details) { - final selection = widget.selection.value; - if (selection != null && - !selection.isCollapsed && - (_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) { - return; - } - editorGesturesLog.info("Double tap down on document"); final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onDoubleTap(docPosition); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (docPosition != null && widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onDoubleTap(docPosition); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } + final selection = widget.selection.value; + if (selection != null && + !selection.isCollapsed && + (_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) { + return; + } + if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { @@ -852,12 +870,14 @@ class _IosDocumentTouchInteractorState extends State final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTripleTap(docPosition); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (docPosition != null && widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTripleTap(docPosition); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -1971,6 +1991,8 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild return const ContentLayerProxyWidget(child: SizedBox()); } + final controlsController = SuperEditorIosControlsScope.rootOf(context); + return IosHandlesDocumentLayer( document: editContext.document, documentLayout: editContext.documentLayout, @@ -1981,13 +2003,12 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild const ClearComposingRegionRequest(), ]); }, - handleColor: handleColor ?? - SuperEditorIosControlsScope.maybeRootOf(context)?.handleColor ?? - Theme.of(context).primaryColor, + areSelectionHandlesAllowed: controlsController.areSelectionHandlesAllowed, + handleColor: handleColor ?? controlsController.handleColor ?? Theme.of(context).primaryColor, caretWidth: caretWidth ?? 2, handleBallDiameter: handleBallDiameter ?? defaultIosHandleBallDiameter, - shouldCaretBlink: SuperEditorIosControlsScope.rootOf(context).shouldCaretBlink, - floatingCursorController: SuperEditorIosControlsScope.rootOf(context).floatingCursorController, + shouldCaretBlink: controlsController.shouldCaretBlink, + floatingCursorController: controlsController.floatingCursorController, ); } } diff --git a/super_editor/lib/src/default_editor/spelling_and_grammar/spelling_and_grammar_styler.dart b/super_editor/lib/src/default_editor/spelling_and_grammar/spelling_and_grammar_styler.dart index 13e41b919..2b6b4adab 100644 --- a/super_editor/lib/src/default_editor/spelling_and_grammar/spelling_and_grammar_styler.dart +++ b/super_editor/lib/src/default_editor/spelling_and_grammar/spelling_and_grammar_styler.dart @@ -11,6 +11,7 @@ class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase { SpellingAndGrammarStyler({ UnderlineStyle? spellingErrorUnderlineStyle, UnderlineStyle? grammarErrorUnderlineStyle, + this.selectionHighlightColor = Colors.transparent, }) : _spellingErrorUnderlineStyle = spellingErrorUnderlineStyle, _grammarErrorUnderlineStyle = grammarErrorUnderlineStyle; @@ -34,6 +35,15 @@ class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase { markDirty(); } + /// Whether or not we should to override the default selection color with [selectionHighlightColor]. + /// + /// On mobile platforms, when the suggestions popover is opened, the selected text uses a different + /// highlight color. + bool _overrideSelectionColor = false; + + /// The color to use for the selection highlight [overrideSelectionColor] is called. + final Color selectionHighlightColor; + final _errorsByNode = >{}; final _dirtyNodes = {}; @@ -59,13 +69,25 @@ class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase { markDirty(); } + /// Temporarily use the [selectionHighlightColor] to override the default selection color. + void overrideSelectionColor() { + _overrideSelectionColor = true; + markDirty(); + } + + /// Restore the default selection color. + void useDefaultSelectionColor() { + _overrideSelectionColor = false; + markDirty(); + } + @override SingleColumnLayoutViewModel style(Document document, SingleColumnLayoutViewModel viewModel) { final updatedViewModel = SingleColumnLayoutViewModel( padding: viewModel.padding, componentViewModels: [ for (final previousViewModel in viewModel.componentViewModels) // - _applyErrors(previousViewModel), + _applyErrors(previousViewModel.copy()), ], ); @@ -96,6 +118,10 @@ class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase { for (final spellingError in spellingErrors) spellingError.range, ]); + if (_overrideSelectionColor) { + viewModel.selectionColor = selectionHighlightColor; + } + final grammarErrors = _errorsByNode[viewModel.nodeId]!.where((error) => error.type == TextErrorType.grammar); if (_grammarErrorUnderlineStyle != null) { // The user explicitly requested this style be used for grammar errors. diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index bbf5f4632..87bdaad93 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -115,6 +115,7 @@ class SuperEditor extends StatefulWidget { this.documentLayoutKey, Stylesheet? stylesheet, this.customStylePhases = const [], + this.appendedStylePhases = const [], List? componentBuilders, SelectionStyles? selectionStyle, this.selectionPolicies = const SuperEditorSelectionPolicies(), @@ -235,6 +236,17 @@ class SuperEditor extends StatefulWidget { /// knows how to interpret and apply table styles for your visual table component. final List customStylePhases; + /// Custom style phases that are added to the very end of the style phases. + /// + /// Typically, apps should use [customStylePhases]. However, the selection + /// styles are always applied after [customStylePhases]. If you need to + /// change the selection color, add the style phase that changes the selection + /// color here. + /// + /// For example, a spellchecker might want to override the selection color + /// for misspelled words. + final List appendedStylePhases; + /// The `SuperEditor` input source, e.g., keyboard or Input Method Engine. final TextInputSource? inputSource; @@ -590,9 +602,11 @@ class SuperEditorState extends State { composingRegion: editContext.composer.composingRegion, showComposingUnderline: true, ), - // Selection changes are very volatile. Put that phase last + // Selection changes are very volatile. Put that phase last, + // just before the phases that the apps want to be at the end // to minimize view model recalculations. _docLayoutSelectionStyler, + ...widget.appendedStylePhases, ], ); @@ -847,7 +861,13 @@ class SuperEditorState extends State { getDocumentLayout: () => editContext.documentLayout, selectionChanges: editContext.composer.selectionChanges, selectionNotifier: editContext.composer.selectionNotifier, - contentTapHandler: _contentTapDelegate, + contentTapHandlers: [ + if (_contentTapDelegate != null) // + _contentTapDelegate!, + for (final plugin in widget.plugins) + if (plugin.contentTapDelegate != null) // + plugin.contentTapDelegate!, + ], autoScroller: _autoScrollController, showDebugPaint: widget.debugPaint.gestures, ); @@ -859,7 +879,13 @@ class SuperEditorState extends State { getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, openSoftwareKeyboard: _openSoftareKeyboard, - contentTapHandler: _contentTapDelegate, + contentTapHandlers: [ + if (_contentTapDelegate != null) // + _contentTapDelegate!, + for (final plugin in widget.plugins) + if (plugin.contentTapDelegate != null) // + plugin.contentTapDelegate!, + ], scrollController: _scrollController, dragHandleAutoScroller: _dragHandleAutoScroller, showDebugPaint: widget.debugPaint.gestures, @@ -872,7 +898,13 @@ class SuperEditorState extends State { getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, openSoftwareKeyboard: _openSoftareKeyboard, - contentTapHandler: _contentTapDelegate, + contentTapHandlers: [ + if (_contentTapDelegate != null) // + _contentTapDelegate!, + for (final plugin in widget.plugins) + if (plugin.contentTapDelegate != null) // + plugin.contentTapDelegate!, + ], scrollController: _scrollController, dragHandleAutoScroller: _dragHandleAutoScroller, showDebugPaint: widget.debugPaint.gestures, @@ -1128,6 +1160,10 @@ abstract class SuperEditorPlugin { /// Additional overlay [SuperEditorLayerBuilder]s that will be added to a given [SuperEditor]. List get documentOverlayBuilders => []; + + /// Optional handler that responds to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + ContentTapDelegate? get contentTapDelegate => null; } /// A collection of policies that dictate how a [SuperEditor]'s selection will change diff --git a/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart index bca988851..17ab07bd2 100644 --- a/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart @@ -207,11 +207,13 @@ class AndroidControlsDocumentLayerState _controlsController!.shouldCaretBlink.removeListener(_onBlinkModeChange); _controlsController!.caretJumpToOpaqueSignal.removeListener(_caretJumpToOpaque); _controlsController!.shouldShowCollapsedHandle.removeListener(_onShouldShowCollapsedHandleChange); + _controlsController!.areSelectionHandlesAllowed.removeListener(_onSelectionHandlesAllowedChange); } _controlsController = SuperEditorAndroidControlsScope.rootOf(context); _controlsController!.shouldCaretBlink.addListener(_onBlinkModeChange); _controlsController!.caretJumpToOpaqueSignal.addListener(_caretJumpToOpaque); + _controlsController!.areSelectionHandlesAllowed.addListener(_onSelectionHandlesAllowedChange); /// Listen for changes about whether we want to show the collapsed handle /// or whether we want to show expanded handles for a selection. We listen to @@ -238,6 +240,7 @@ class AndroidControlsDocumentLayerState widget.selection.removeListener(_onSelectionChange); _controlsController?.shouldCaretBlink.removeListener(_onBlinkModeChange); _controlsController!.shouldShowCollapsedHandle.removeListener(_onShouldShowCollapsedHandleChange); + _controlsController!.areSelectionHandlesAllowed.removeListener(_onSelectionHandlesAllowedChange); _caretBlinkController.dispose(); super.dispose(); @@ -311,6 +314,12 @@ class AndroidControlsDocumentLayerState }); } + void _onSelectionHandlesAllowedChange() { + setState(() { + // + }); + } + @override DocumentSelectionLayout? computeLayoutDataWithDocumentLayout( BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout) { @@ -319,6 +328,11 @@ class AndroidControlsDocumentLayerState return null; } + if (_controlsController!.areSelectionHandlesAllowed.value == false) { + // We don't want to show any selection handles. + return null; + } + if (selection.isCollapsed && !_controlsController!.shouldShowExpandedHandles.value) { Rect caretRect = documentLayout.getEdgeForPosition(selection.extent)!; diff --git a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart index 7ef8d6683..2666617c2 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart @@ -499,6 +499,7 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { required this.documentLayout, required this.selection, required this.changeSelection, + this.areSelectionHandlesAllowed, required this.handleColor, this.caretWidth = 2, this.handleBallDiameter = defaultIosHandleBallDiameter, @@ -515,6 +516,9 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { final void Function(DocumentSelection?, SelectionChangeType, String selectionReason) changeSelection; + /// {@macro are_selection_handles_allowed} + final ValueListenable? areSelectionHandlesAllowed; + /// Color the iOS-style text selection drag handles. final Color handleColor; @@ -714,7 +718,7 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState { late final Editor _editor; - final _spellingAndGrammarPlugin = SpellingAndGrammarPlugin(); + late final SpellingAndGrammarPlugin _spellingAndGrammarPlugin; + + late final SuperEditorIosControlsController _iosControlsController; + late final SuperEditorAndroidControlsController _androidControlsController; @override void initState() { super.initState(); + _iosControlsController = SuperEditorIosControlsController(); + _androidControlsController = SuperEditorAndroidControlsController(); + + _spellingAndGrammarPlugin = SpellingAndGrammarPlugin( + iosControlsController: _iosControlsController, + androidControlsController: _androidControlsController, + selectedWordHighlightColor: Colors.red.withOpacity(0.3), + ); + _editor = createDefaultDocumentEditor( document: MutableDocument.empty(), composer: MutableDocumentComposer(), @@ -79,6 +93,13 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre _insertMisspelledText(); } + @override + void dispose() { + _iosControlsController.dispose(); + _androidControlsController.dispose(); + super.dispose(); + } + void _insertMisspelledText() { WidgetsBinding.instance.addPostFrameCallback((_) { _editor.execute([ @@ -98,19 +119,26 @@ class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScre @override Widget build(BuildContext context) { return Scaffold( - body: SuperEditor( - editor: _editor, - customStylePhases: [ - _spellingAndGrammarPlugin.styler, - ], - stylesheet: defaultStylesheet.copyWith( - addRulesAfter: [ - if (Theme.of(context).brightness == Brightness.dark) ..._darkModeStyles, - ], + body: SuperEditorAndroidControlsScope( + controller: _androidControlsController, + child: SuperEditorIosControlsScope( + controller: _iosControlsController, + child: SuperEditor( + autofocus: true, + editor: _editor, + appendedStylePhases: [ + _spellingAndGrammarPlugin.styler, + ], + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + if (Theme.of(context).brightness == Brightness.dark) ..._darkModeStyles, + ], + ), + plugins: { + _spellingAndGrammarPlugin, + }, + ), ), - plugins: { - _spellingAndGrammarPlugin, - }, ), ); } diff --git a/super_editor_spellcheck/lib/src/super_editor/spell_checker_popover_controller.dart b/super_editor_spellcheck/lib/src/super_editor/spell_checker_popover_controller.dart new file mode 100644 index 000000000..2efea5807 --- /dev/null +++ b/super_editor_spellcheck/lib/src/super_editor/spell_checker_popover_controller.dart @@ -0,0 +1,118 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:super_editor/super_editor.dart'; + +/// Shows/hides a popover with spelling suggestions. +/// +/// A [SpellCheckerPopoverController] must be attached to a [SpellCheckerPopoverDelegate], +/// which will effectively show/hide the popover. +class SpellCheckerPopoverController { + SpellCheckerPopoverDelegate? _delegate; + + /// Attaches this controller to a delegate that knows how to + /// show a popover with spelling suggestions. + /// + /// A [SpellCheckerPopoverDelegate] must call this method after + /// being mounted to the widget tree. + void attach(SpellCheckerPopoverDelegate delegate) { + _delegate = delegate; + } + + /// Detaches this controller from the delegate. + /// + /// This controller can't show/hide the popover while detached from a delegate. + /// + /// A [SpellCheckerPopoverDelegate] must call this method after + /// being unmounted from the widget tree. + void detach() { + _delegate = null; + } + + /// Shows the spelling suggestions popover with the suggetions + /// provided by [SpellingErrorSuggestion.suggestions]. + /// + /// Does nothing if [spelling] doesn't have any suggestions. + void showSuggestions(SpellingErrorSuggestion spelling) { + _delegate?.showSuggestions(spelling); + } + + /// Hides the spelling suggestions popover if it's visible. + void hide() { + _delegate?.hideSuggestionsPopover(); + } + + /// Finds spelling suggestions for the word at the given [wordRange]. + /// + /// Returns `null` if no suggestions are found. + SpellingErrorSuggestion? findSuggestionsForWordAt(DocumentRange wordRange) { + return _delegate?.findSuggestionsForWordAt(wordRange); + } +} + +/// Delegate that's attached to a [SpellCheckerPopoverController], to show/hide +/// a popover with spelling suggestions. +/// +/// A [SpellCheckerPopoverDelegate] must call [SpellCheckerPopoverController.attach] +/// after being mounted to the widget tree, and [SpellCheckerPopoverController.detach] +/// after being unmounted. +/// +/// The popover should be displayed only upon a [showSuggestions] call. The delegate +/// should not display the popover on its own when selection changes. +abstract class SpellCheckerPopoverDelegate { + /// Shows the spelling suggestions popover with the suggetions + /// provided by [SpellingErrorSuggestion.suggestions]. + /// + /// If the popover is already visible, this method should update + /// the suggestions with the new ones. + /// + /// If the document changes while the popover is visible, the popover + /// should be closed. + /// + /// This method should not update the document selection. + void showSuggestions(SpellingErrorSuggestion suggestions) {} + + /// Hides the spelling suggestions popover if it's visible. + void hideSuggestionsPopover() {} + + /// Finds spelling suggestions for the word at the given [wordRange]. + /// + /// Returns `null` if no suggestions are found. + SpellingErrorSuggestion? findSuggestionsForWordAt(DocumentRange wordRange) => null; +} + +class SpellingErrorSuggestion { + const SpellingErrorSuggestion({ + required this.word, + required this.nodeId, + required this.range, + required this.suggestions, + }); + + final String word; + final String nodeId; + final TextRange range; + final List suggestions; + + DocumentRange get toDocumentRange => DocumentRange( + start: DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: range.start)), + end: DocumentPosition( + nodeId: nodeId, + nodePosition: TextNodePosition(offset: range.end - 1), + // -1 because range is exclusive and doc positions are inclusive + ), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpellingErrorSuggestion && + runtimeType == other.runtimeType && + word == other.word && + nodeId == other.nodeId && + range == other.range && + const DeepCollectionEquality().equals(suggestions, other.suggestions); + + @override + int get hashCode => word.hashCode ^ nodeId.hashCode ^ range.hashCode ^ suggestions.hashCode; +} 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 5f71a11ee..dcfb7f165 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,9 +1,12 @@ 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'; +import 'package:super_editor_spellcheck/src/super_editor/spell_checker_popover_controller.dart'; import 'package:super_editor_spellcheck/src/super_editor/spelling_error_suggestion_overlay.dart'; import 'package:super_editor_spellcheck/src/super_editor/spelling_error_suggestions.dart'; @@ -17,21 +20,44 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin { UnderlineStyle spellingErrorUnderlineStyle = defaultSpellingErrorUnderlineStyle, bool isGrammarCheckEnabled = true, UnderlineStyle grammarErrorUnderlineStyle = defaultGrammarErrorUnderlineStyle, - SpellingErrorSuggestionToolbarBuilder toolbarBuilder = desktopSpellingSuggestionToolbarBuilder, + SpellingErrorSuggestionToolbarBuilder toolbarBuilder = defaultSpellingSuggestionToolbarBuilder, + Color selectedWordHighlightColor = Colors.transparent, + SuperEditorAndroidControlsController? androidControlsController, + SuperEditorIosControlsController? iosControlsController, }) : _isSpellCheckEnabled = isSpellingCheckEnabled, _isGrammarCheckEnabled = isGrammarCheckEnabled { documentOverlayBuilders = [ SpellingErrorSuggestionOverlayBuilder( _spellingErrorSuggestions, _selectedWordLink, + popoverController: _popoverController, toolbarBuilder: toolbarBuilder, ), ]; + _styler = SpellingAndGrammarStyler( + selectionHighlightColor: selectedWordHighlightColor, + ); + + _contentTapDelegate = switch (defaultTargetPlatform) { + TargetPlatform.android => _SuperEditorAndroidSpellCheckerTapHandler( + popoverController: _popoverController, + controlsController: androidControlsController!, + styler: _styler, + ), + TargetPlatform.iOS => _SuperEditorIosSpellCheckerTapHandler( + popoverController: _popoverController, + controlsController: iosControlsController!, + styler: _styler, + ), + _ => _SuperEditorDesktopSpellCheckerTapHandler(popoverController: _popoverController), + }; } + final _popoverController = SpellCheckerPopoverController(); + final _spellingErrorSuggestions = SpellingErrorSuggestions(); - final _styler = SpellingAndGrammarStyler(); + late final SpellingAndGrammarStyler _styler; /// Leader attached to an invisible rectangle around the currently selected /// misspelled word. @@ -70,9 +96,14 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin { @override late final List documentOverlayBuilders; + @override + ContentTapDelegate? get contentTapDelegate => _contentTapDelegate; + late final _SpellCheckerContentTapDelegate? _contentTapDelegate; + @override void attach(Editor editor) { editor.context.put(spellingErrorSuggestionsKey, _spellingErrorSuggestions); + _contentTapDelegate?.editor = editor; _reaction = SpellingAndGrammarReaction(_spellingErrorSuggestions, _styler); editor.reactionPipeline.add(_reaction); @@ -82,6 +113,7 @@ class SpellingAndGrammarPlugin extends SuperEditorPlugin { void detach(Editor editor) { _styler.clearAllErrors(); editor.reactionPipeline.remove(_reaction); + _contentTapDelegate?.editor = null; editor.context.remove(spellingErrorSuggestionsKey); _spellingErrorSuggestions.clear(); @@ -128,6 +160,45 @@ extension SpellingAndGrammarEditorExtensions on Editor { textToInsert: correctSpelling, attributions: {}, ), + ChangeComposingRegionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: wordRange.start.nodeId, + nodePosition: TextNodePosition( + offset: (wordRange.start.nodePosition as TextNodePosition).offset + correctSpelling.length, + ), + ), + ), + ), + ]); + } + + 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, + ), + ), + ), + ), + const ClearComposingRegionRequest(), ]); } } @@ -158,6 +229,8 @@ class SpellingAndGrammarReaction implements EditReaction { /// of receipt. final _asyncRequestIds = {}; + final _mobileSpellChecker = DefaultSpellCheckService(); + @override void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { // No-op - spelling and grammar checks style the document, they don't alter the document. @@ -165,8 +238,9 @@ class SpellingAndGrammarReaction implements EditReaction { @override void react(EditContext editorContext, RequestDispatcher requestDispatcher, List 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; } @@ -230,6 +304,14 @@ class SpellingAndGrammarReaction implements EditReaction { } Future _findSpellingAndGrammarErrors(TextNode textNode) async { + if (defaultTargetPlatform == TargetPlatform.macOS) { + await _findSpellingAndGrammarErrorsOnMac(textNode); + } else if (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS) { + await _findSpellingAndGrammarErrorsOnMobile(textNode); + } + } + + Future _findSpellingAndGrammarErrorsOnMac(TextNode textNode) async { final spellChecker = SuperEditorSpellCheckerPlugin().macSpellChecker; // TODO: Investigate whether we can parallelize spelling and grammar checks @@ -328,4 +410,261 @@ class SpellingAndGrammarReaction implements EditReaction { // see suggestions and select them. _suggestions.putSuggestions(textNode.id, spellingSuggestions); } + + Future _findSpellingAndGrammarErrorsOnMobile(TextNode textNode) async { + final textErrors = {}; + final spellingSuggestions = {}; + + // 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); + } +} + +/// A [ContentTapDelegate] that shows the suggestions popover when the user taps on +/// a misspelled word. +/// +/// When the suggestions popover is displayed, the selection expands to the whole word +/// and the selection handles are hidden. +class _SuperEditorIosSpellCheckerTapHandler extends _SpellCheckerContentTapDelegate { + _SuperEditorIosSpellCheckerTapHandler({ + required this.popoverController, + required this.controlsController, + required this.styler, + }); + + final SpellCheckerPopoverController popoverController; + final SuperEditorIosControlsController controlsController; + final SpellingAndGrammarStyler styler; + + @override + TapHandlingInstruction onTap(DocumentPosition tapPosition) { + if (editor == null) { + return TapHandlingInstruction.continueHandling; + } + + final spelling = popoverController.findSuggestionsForWordAt(DocumentSelection.collapsed(position: tapPosition)); + if (spelling == null || spelling.suggestions.isEmpty) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + controlsController + ..hideToolbar() + ..hideMagnifier() + ..preventSelectionHandles(); + + // Select the whole word. + editor!.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: tapPosition.nodeId, + nodePosition: TextNodePosition(offset: spelling.range.start), + ), + extent: DocumentPosition( + nodeId: tapPosition.nodeId, + nodePosition: TextNodePosition(offset: spelling.range.end), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + + // Change the selection color while the suggestions popover is visible. + styler.overrideSelectionColor(); + + popoverController.showSuggestions(spelling); + + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTap(DocumentPosition tapPosition) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + void _hideSpellCheckerPopover() { + styler.useDefaultSelectionColor(); + controlsController.allowSelectionHandles(); + popoverController.hide(); + } +} + +/// A [ContentTapDelegate] that shows the suggestions popover when the user taps on +/// a misspelled word. +/// +/// When the suggestions popover is displayed, the selection and the composing region +/// expand to the whole word and the selection handles are hidden. +class _SuperEditorAndroidSpellCheckerTapHandler extends _SpellCheckerContentTapDelegate { + _SuperEditorAndroidSpellCheckerTapHandler({ + required this.popoverController, + required this.controlsController, + required this.styler, + }); + + final SpellCheckerPopoverController popoverController; + final SuperEditorAndroidControlsController controlsController; + final SpellingAndGrammarStyler styler; + + @override + TapHandlingInstruction onTap(DocumentPosition tapPosition) { + if (editor == null) { + return TapHandlingInstruction.continueHandling; + } + + final spelling = popoverController.findSuggestionsForWordAt(DocumentSelection.collapsed(position: tapPosition)); + if (spelling == null || spelling.suggestions.isEmpty) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + controlsController + ..hideToolbar() + ..hideMagnifier() + ..hideToolbar() + ..preventSelectionHandles(); + + final wordSelection = DocumentSelection( + base: DocumentPosition( + nodeId: tapPosition.nodeId, + nodePosition: TextNodePosition(offset: spelling.range.start), + ), + extent: DocumentPosition( + nodeId: tapPosition.nodeId, + nodePosition: TextNodePosition(offset: spelling.range.end), + ), + ); + + // Select the whole word and update the composing region to match + // the Android behavior of placing the whole word on the composing + // region when tapping at a word. + editor!.execute([ + ChangeSelectionRequest( + wordSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ChangeComposingRegionRequest(wordSelection), + ]); + + // Change the selection color while the suggestion popover is visible. + styler.overrideSelectionColor(); + + popoverController.showSuggestions(spelling); + + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTap(DocumentPosition tapPosition) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + void _hideSpellCheckerPopover() { + controlsController.allowSelectionHandles(); + styler.useDefaultSelectionColor(); + popoverController.hide(); + } +} + +/// A [ContentTapDelegate] that shows the suggestions popover when the user taps on +/// a misspelled word. +class _SuperEditorDesktopSpellCheckerTapHandler extends _SpellCheckerContentTapDelegate { + _SuperEditorDesktopSpellCheckerTapHandler({ + required this.popoverController, + }); + + final SpellCheckerPopoverController popoverController; + + @override + TapHandlingInstruction onTap(DocumentPosition tapPosition) { + if (editor == null) { + return TapHandlingInstruction.continueHandling; + } + + final spelling = popoverController.findSuggestionsForWordAt(DocumentSelection.collapsed(position: tapPosition)); + if (spelling == null || spelling.suggestions.isEmpty) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + editor!.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: tapPosition), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + + popoverController.showSuggestions(spelling); + + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTap(DocumentPosition tapPosition) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + void _hideSpellCheckerPopover() { + popoverController.hide(); + } +} + +/// A [ContentTapDelegate] that has access to the [editor] while the +/// plugin is attached to it. +class _SpellCheckerContentTapDelegate extends ContentTapDelegate { + _SpellCheckerContentTapDelegate(); + + Editor? editor; } diff --git a/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestion_overlay.dart b/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestion_overlay.dart index ded6e94ff..4a31a930a 100644 --- a/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestion_overlay.dart +++ b/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestion_overlay.dart @@ -1,6 +1,10 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spell_checker_popover_controller.dart'; import 'package:super_editor_spellcheck/src/super_editor/spelling_and_grammar_plugin.dart'; import 'package:super_editor_spellcheck/src/super_editor/spelling_error_suggestions.dart'; @@ -8,7 +12,8 @@ class SpellingErrorSuggestionOverlayBuilder implements SuperEditorLayerBuilder { const SpellingErrorSuggestionOverlayBuilder( this.suggestions, this.selectedWordLink, { - this.toolbarBuilder = desktopSpellingSuggestionToolbarBuilder, + this.toolbarBuilder = defaultSpellingSuggestionToolbarBuilder, + required this.popoverController, }); final SpellingErrorSuggestions suggestions; @@ -18,6 +23,8 @@ class SpellingErrorSuggestionOverlayBuilder implements SuperEditorLayerBuilder { /// the currently selected mis-spelled word. final SpellingErrorSuggestionToolbarBuilder toolbarBuilder; + final SpellCheckerPopoverController popoverController; + @override ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) { return SpellingErrorSuggestionOverlay( @@ -25,6 +32,7 @@ class SpellingErrorSuggestionOverlayBuilder implements SuperEditorLayerBuilder { editor: editContext.editor, suggestions: suggestions, selectedWordLink: selectedWordLink, + popoverController: popoverController, toolbarBuilder: toolbarBuilder, ); } @@ -37,7 +45,8 @@ class SpellingErrorSuggestionOverlay extends DocumentLayoutLayerStatefulWidget { required this.editor, required this.suggestions, required this.selectedWordLink, - this.toolbarBuilder = desktopSpellingSuggestionToolbarBuilder, + this.popoverController, + this.toolbarBuilder = defaultSpellingSuggestionToolbarBuilder, this.showDebugLeaderBounds = false, }); @@ -54,6 +63,8 @@ class SpellingErrorSuggestionOverlay extends DocumentLayoutLayerStatefulWidget { /// selected word isn't misspelled, then this link is left unattached. final LeaderLink selectedWordLink; + final SpellCheckerPopoverController? popoverController; + /// Builder that creates the spelling suggestion toolbar, which appears near /// the currently selected mis-spelled word. final SpellingErrorSuggestionToolbarBuilder toolbarBuilder; @@ -67,20 +78,28 @@ class SpellingErrorSuggestionOverlay extends DocumentLayoutLayerStatefulWidget { } class _SpellingErrorSuggestionOverlayState - extends DocumentLayoutLayerState { + extends DocumentLayoutLayerState + implements SpellCheckerPopoverDelegate { final _suggestionToolbarOverlayController = OverlayPortalController(); DocumentRange? _ignoredSpellingErrorRange; final _suggestionListenable = ValueNotifier(null); + final _boundsKey = GlobalKey(); + + SpellingErrorSuggestion? _currentSpellingSuggestions; + @override void initState() { super.initState(); + widget.editor.context.document.addListener(_onDocumentChange); widget.editor.context.composer.selectionNotifier.addListener(_onSelectionChange); widget.editor.context.spellingErrorSuggestions.addListener(_onSpellingSuggestionsChange); + widget.popoverController?.attach(this); + _suggestionToolbarOverlayController.show(); } @@ -88,6 +107,11 @@ class _SpellingErrorSuggestionOverlayState void didUpdateWidget(SpellingErrorSuggestionOverlay oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.editor.context.document != oldWidget.editor.context.document) { + oldWidget.editor.context.document.removeListener(_onDocumentChange); + widget.editor.context.document.addListener(_onDocumentChange); + } + if (widget.editor.context.composer.selectionNotifier != oldWidget.editor.context.composer.selectionNotifier) { oldWidget.editor.context.composer.selectionNotifier.removeListener(_onSelectionChange); widget.editor.context.composer.selectionNotifier.addListener(_onSelectionChange); @@ -97,6 +121,11 @@ class _SpellingErrorSuggestionOverlayState oldWidget.editor.context.spellingErrorSuggestions.removeListener(_onSpellingSuggestionsChange); widget.editor.context.spellingErrorSuggestions.addListener(_onSpellingSuggestionsChange); } + + if (widget.popoverController != oldWidget.popoverController) { + oldWidget.popoverController?.detach(); + widget.popoverController?.attach(this); + } } @override @@ -105,12 +134,46 @@ class _SpellingErrorSuggestionOverlayState _suggestionToolbarOverlayController.hide(); } + widget.editor.document.removeListener(_onDocumentChange); widget.editor.context.composer.selectionNotifier.removeListener(_onSelectionChange); widget.editor.context.spellingErrorSuggestions.removeListener(_onSpellingSuggestionsChange); + widget.popoverController?.detach(); + super.dispose(); } + @override + void showSuggestions(SpellingErrorSuggestion suggestions) { + setState(() { + _currentSpellingSuggestions = suggestions; + }); + } + + @override + void hideSuggestionsPopover() { + setState(() { + _currentSpellingSuggestions = null; + }); + } + + @override + SpellingErrorSuggestion? findSuggestionsForWordAt(DocumentRange wordRange) { + final spellingSuggestion = _findSpellingSuggestionAtRange(widget.suggestions, wordRange); + if (spellingSuggestion == null) { + // No selected mis-spelled word. Fizzle. + return null; + } + + final misspelledWordRange = spellingSuggestion.toDocumentRange; + if (misspelledWordRange == _ignoredSpellingErrorRange) { + // The user already cancelled the suggestions for this word. + return null; + } + + return spellingSuggestion; + } + void _onSelectionChange() { setState(() { // Re-compute layout data. The layout needs to be re-computed regardless @@ -154,6 +217,12 @@ class _SpellingErrorSuggestionOverlayState }); } + void _onDocumentChange(DocumentChangeLog changeLog) { + // After the document changes, the currently visible suggestions + // might not be valid anymore. Hide the popover. + hideSuggestionsPopover(); + } + void _onSpellingSuggestionsChange() { setState(() { // Re-compute layout data. @@ -182,21 +251,11 @@ class _SpellingErrorSuggestionOverlayState } }); - final documentSelection = widget.editor.context.composer.selectionNotifier.value; - if (documentSelection == null) { - // No selection upon which to base spell check suggestions. - return null; - } - if (documentSelection.base.nodeId != documentSelection.extent.nodeId) { - // Spelling error suggestions don't display when the user selects across nodes. - return null; - } - if (documentSelection.extent.nodePosition is! TextNodePosition) { - // The user isn't selecting text. Fizzle. + if (widget.editor.context.composer.selection == null) { return null; } - final spellingSuggestion = _findSpellingSuggestionAtSelection(widget.suggestions, documentSelection); + final spellingSuggestion = _currentSpellingSuggestions; if (spellingSuggestion == null) { // No selected mis-spelled word. Fizzle. return null; @@ -243,24 +302,24 @@ class _SpellingErrorSuggestionOverlayState ); } - SpellingErrorSuggestion? _findSpellingSuggestionAtSelection( + SpellingErrorSuggestion? _findSpellingSuggestionAtRange( SpellingErrorSuggestions allSuggestions, - DocumentSelection selection, + DocumentRange selection, ) { - if (selection.base.nodeId != selection.extent.nodeId) { + if (selection.start.nodeId != selection.end.nodeId) { // It doesn't make sense to correct spelling across paragraphs. Fizzle. return null; } - final textNode = widget.editor.context.document.getNodeById(selection.extent.nodeId) as TextNode; + final textNode = widget.editor.context.document.getNodeById(selection.end.nodeId) as TextNode; - final selectionBaseOffset = (selection.base.nodePosition as TextNodePosition).offset; + final selectionBaseOffset = (selection.start.nodePosition as TextNodePosition).offset; final spellingSuggestionsAtBase = allSuggestions.getSuggestionsAtTextOffset(textNode.id, selectionBaseOffset); if (spellingSuggestionsAtBase == null) { return null; } - final selectionExtentOffset = (selection.extent.nodePosition as TextNodePosition).offset; + final selectionExtentOffset = (selection.end.nodePosition as TextNodePosition).offset; final spellingSuggestionsAtExtent = allSuggestions.getSuggestionsAtTextOffset(textNode.id, selectionExtentOffset); if (spellingSuggestionsAtExtent == null) { return null; @@ -276,7 +335,7 @@ class _SpellingErrorSuggestionOverlayState // The user's selection sits somewhere within a word. Check if it's mis-spelled. final suggestions = widget.suggestions.getSuggestionsForWord( - selection.extent.nodeId, + selection.end.nodeId, TextRange(start: spellingErrorRange.start, end: spellingErrorRange.end), ); @@ -305,21 +364,14 @@ class _SpellingErrorSuggestionOverlayState // 1. Ensure the Follower is above all other content // 2. Ensure the Follower has access to the same theme as the editor return OverlayPortal( + key: _boundsKey, controller: _suggestionToolbarOverlayController, overlayChildBuilder: (overlayContext) { if (layoutData.suggestions.isEmpty) { return const SizedBox(); } - return Follower.withOffset( - link: widget.selectedWordLink, - leaderAnchor: Alignment.bottomLeft, - followerAnchor: Alignment.topLeft, - offset: const Offset(0, 16), - boundary: ScreenFollowerBoundary( - screenSize: MediaQuery.sizeOf(context), - devicePixelRatio: MediaQuery.devicePixelRatioOf(context), - ), + return _buildFollower( child: widget.toolbarBuilder( context, editorFocusNode: widget.editorFocusNode, @@ -327,6 +379,8 @@ class _SpellingErrorSuggestionOverlayState selectedWordRange: layoutData.selectedWordRange!, suggestions: layoutData.suggestions, onCancelPressed: _onCancelPressed, + closeToolbar: hideSuggestionsPopover, + selectedWordBounds: layoutData.selectedWordBounds!, ), ); }, @@ -350,6 +404,33 @@ class _SpellingErrorSuggestionOverlayState ), ); } + + Widget _buildFollower({required Widget child}) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return Follower.withAligner( + link: widget.selectedWordLink, + aligner: CupertinoPopoverToolbarAligner(_boundsKey), + boundary: ScreenFollowerBoundary( + screenSize: MediaQuery.sizeOf(context), + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ), + child: child, + ); + default: + return Follower.withOffset( + link: widget.selectedWordLink, + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + offset: const Offset(0, 16), + boundary: ScreenFollowerBoundary( + screenSize: MediaQuery.sizeOf(context), + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ), + child: child, + ); + } + } } class SpellingErrorSuggestionLayout { @@ -371,8 +452,53 @@ typedef SpellingErrorSuggestionToolbarBuilder = Widget Function( required DocumentRange selectedWordRange, required List suggestions, required VoidCallback onCancelPressed, + required VoidCallback closeToolbar, + required Rect selectedWordBounds, }); +/// Creates a spelling suggestion toolbar depending on the +/// current platform. +Widget defaultSpellingSuggestionToolbarBuilder( + BuildContext context, { + required FocusNode editorFocusNode, + required Editor editor, + required DocumentRange selectedWordRange, + required List suggestions, + required VoidCallback onCancelPressed, + required VoidCallback closeToolbar, + required Rect selectedWordBounds, +}) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return IosSpellingSuggestionToolbar( + editorFocusNode: editorFocusNode, + editor: editor, + selectedWordRange: selectedWordRange, + suggestions: suggestions, + selectedWordBounds: selectedWordBounds, + closeToolbar: closeToolbar, + ); + case TargetPlatform.android: + return AndroidSpellingSuggestionToolbar( + editorFocusNode: editorFocusNode, + editor: editor, + selectedWordRange: selectedWordRange, + suggestions: suggestions, + selectedWordBounds: selectedWordBounds, + closeToolbar: closeToolbar, + ); + default: + return DesktopSpellingSuggestionToolbar( + editorFocusNode: editorFocusNode, + editor: editor, + selectedWordRange: selectedWordRange, + suggestions: suggestions, + onCancelPressed: onCancelPressed, + closeToolbar: closeToolbar, + ); + } +} + Widget desktopSpellingSuggestionToolbarBuilder( BuildContext context, { required FocusNode editorFocusNode, @@ -380,6 +506,7 @@ Widget desktopSpellingSuggestionToolbarBuilder( required DocumentRange selectedWordRange, required List suggestions, required VoidCallback onCancelPressed, + required VoidCallback closeToolbar, }) { return DesktopSpellingSuggestionToolbar( editorFocusNode: editorFocusNode, @@ -387,6 +514,7 @@ Widget desktopSpellingSuggestionToolbarBuilder( selectedWordRange: selectedWordRange, suggestions: suggestions, onCancelPressed: onCancelPressed, + closeToolbar: closeToolbar, ); } @@ -405,6 +533,7 @@ class DesktopSpellingSuggestionToolbar extends StatefulWidget { required this.selectedWordRange, required this.suggestions, required this.onCancelPressed, + required this.closeToolbar, }); final FocusNode editorFocusNode; @@ -413,14 +542,41 @@ class DesktopSpellingSuggestionToolbar extends StatefulWidget { final DocumentRange? selectedWordRange; final List suggestions; final VoidCallback onCancelPressed; + final VoidCallback closeToolbar; @override State createState() => _DesktopSpellingSuggestionToolbarState(); } class _DesktopSpellingSuggestionToolbarState extends State { + @override + void initState() { + widget.editor.document.addListener(_onDocumentChange); + super.initState(); + } + + @override + void didUpdateWidget(covariant DesktopSpellingSuggestionToolbar oldWidget) { + if (widget.editor.document != oldWidget.editor.document) { + oldWidget.editor.document.removeListener(_onDocumentChange); + widget.editor.document.addListener(_onDocumentChange); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.editor.document.removeListener(_onDocumentChange); + super.dispose(); + } + void _applySpellingFix(String replacement) { widget.editor.fixMisspelledWord(widget.selectedWordRange!, replacement); + widget.closeToolbar(); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + widget.closeToolbar(); } @override @@ -513,3 +669,273 @@ class _DesktopSpellingSuggestionToolbarState extends State suggestions; + final Rect selectedWordBounds; + final VoidCallback closeToolbar; + + @override + State createState() => _AndroidSpellingSuggestionToolbarState(); +} + +class _AndroidSpellingSuggestionToolbarState extends State { + @override + void initState() { + super.initState(); + widget.editor.document.addListener(_onDocumentChange); + } + + @override + void didUpdateWidget(AndroidSpellingSuggestionToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.editor.document != oldWidget.editor.document) { + oldWidget.editor.document.removeListener(_onDocumentChange); + widget.editor.document.addListener(_onDocumentChange); + } + } + + @override + void dispose() { + widget.editor.document.removeListener(_onDocumentChange); + super.dispose(); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + SuperEditorAndroidControlsScope.rootOf(context).allowSelectionHandles(); + widget.closeToolbar(); + } + + void _applySpellingFix(String replacement) { + widget.editor.fixMisspelledWord(widget.selectedWordRange, replacement); + } + + void _removeWord() { + widget.editor.removeMisspelledWord(widget.selectedWordRange); + } + + Color _getTextColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.black; + case Brightness.dark: + return Colors.white; + } + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(4), + color: Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final suggestion in widget.suggestions) ...[ + _buildButton( + title: suggestion, + onPressed: () => _applySpellingFix(suggestion), + brightness: brightness, + ), + ], + _buildButton( + title: 'Delete', + onPressed: _removeWord, + brightness: brightness, + ), + ], + ), + ); + } + + Widget _buildButton({ + required String title, + required VoidCallback onPressed, + required Brightness brightness, + }) { + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension), + padding: EdgeInsets.zero, + foregroundColor: _getTextColor(brightness), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + title, + style: const TextStyle(fontSize: 14), + ), + ), + ); + } +} + +/// A spelling suggestion toolbar, designed for the iOS platform, +/// which displays a horizontal list alternative spellings for a given +/// mis-spelled word. +/// +/// When the user taps on a suggested spelling, the mis-spelled word +/// is replaced by selected word. +class IosSpellingSuggestionToolbar extends StatefulWidget { + const IosSpellingSuggestionToolbar({ + super.key, + required this.editorFocusNode, + this.tapRegionId, + required this.editor, + required this.selectedWordRange, + required this.suggestions, + required this.selectedWordBounds, + required this.closeToolbar, + }); + + final FocusNode editorFocusNode; + final Object? tapRegionId; + final Editor editor; + final DocumentRange selectedWordRange; + final List suggestions; + final Rect selectedWordBounds; + final VoidCallback closeToolbar; + + @override + State createState() => _IosSpellingSuggestionToolbarState(); +} + +class _IosSpellingSuggestionToolbarState extends State { + @override + void initState() { + super.initState(); + widget.editor.document.addListener(_onDocumentChange); + } + + @override + void didUpdateWidget(covariant IosSpellingSuggestionToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.editor.document != oldWidget.editor.document) { + oldWidget.editor.document.removeListener(_onDocumentChange); + widget.editor.document.addListener(_onDocumentChange); + } + } + + @override + void dispose() { + widget.editor.document.removeListener(_onDocumentChange); + super.dispose(); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + SuperEditorIosControlsScope.rootOf(context).allowSelectionHandles(); + widget.closeToolbar(); + } + + void _applySpellingFix(String replacement) { + widget.editor.fixMisspelledWord(widget.selectedWordRange, replacement); + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + + return Focus( + parentNode: widget.editorFocusNode, + child: TapRegion( + groupId: widget.tapRegionId, + child: CupertinoPopoverToolbar( + focalPoint: StationaryMenuFocalPoint(widget.selectedWordBounds.center), + backgroundColor: _getBackgroundColor(brightness), + activeButtonTextColor: brightness == Brightness.dark // + ? iOSToolbarDarkArrowActiveColor + : iOSToolbarLightArrowActiveColor, + inactiveButtonTextColor: brightness == Brightness.dark // + ? iOSToolbarDarkArrowInactiveColor + : iOSToolbarLightArrowInactiveColor, + elevation: 8.0, + children: [ + for (final suggestion in widget.suggestions) ...[ + _buildButton( + title: suggestion, + onPressed: () => _applySpellingFix(suggestion), + brightness: brightness, + ), + ], + ], + ), + ), + ); + } + + Color _getBackgroundColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.white; + case Brightness.dark: + return Colors.grey.shade900; + } + } + + Color _getTextColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.black; + case Brightness.dark: + return Colors.white; + } + } + + Widget _buildButton({ + required String title, + required VoidCallback onPressed, + required Brightness brightness, + }) { + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: const Size(kMinInteractiveDimension, 0), + padding: EdgeInsets.zero, + splashFactory: NoSplash.splashFactory, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: _getTextColor(brightness), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + title, + style: const TextStyle(fontSize: 12), + ), + ), + ); + } +} + +const iOSToolbarDarkBackgroundColor = Color(0xFF333333); +const iOSToolbarLightBackgroundColor = Colors.white; + +const iOSToolbarLightArrowActiveColor = Color(0xFF000000); +const iOSToolbarDarkArrowActiveColor = Color(0xFFFFFFFF); + +const iOSToolbarLightArrowInactiveColor = Color(0xFF999999); +const iOSToolbarDarkArrowInactiveColor = Color(0xFF757575); diff --git a/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestions.dart b/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestions.dart index a4b679694..f3361858f 100644 --- a/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestions.dart +++ b/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestions.dart @@ -1,8 +1,8 @@ import 'dart:ui'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spell_checker_popover_controller.dart'; /// Spelling error correction suggestions for all mis-spelled words within /// a [Document]. @@ -96,39 +96,3 @@ class SpellingErrorSuggestions with ChangeNotifier implements Editable { clear(); } } - -class SpellingErrorSuggestion { - const SpellingErrorSuggestion({ - required this.word, - required this.nodeId, - required this.range, - required this.suggestions, - }); - - final String word; - final String nodeId; - final TextRange range; - final List suggestions; - - DocumentRange get toDocumentRange => DocumentRange( - start: DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: range.start)), - end: DocumentPosition( - nodeId: nodeId, - nodePosition: TextNodePosition(offset: range.end - 1), - // -1 because range is exclusive and doc positions are inclusive - ), - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SpellingErrorSuggestion && - runtimeType == other.runtimeType && - word == other.word && - nodeId == other.nodeId && - range == other.range && - const DeepCollectionEquality().equals(suggestions, other.suggestions); - - @override - int get hashCode => word.hashCode ^ nodeId.hashCode ^ range.hashCode ^ suggestions.hashCode; -} diff --git a/super_editor_spellcheck/pubspec.yaml b/super_editor_spellcheck/pubspec.yaml index cd8349c54..d76eca5e0 100644 --- a/super_editor_spellcheck/pubspec.yaml +++ b/super_editor_spellcheck/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: flutter: sdk: flutter follow_the_leader: ^0.0.4+8 + overlord: ^0.0.3+5 plugin_platform_interface: ^2.0.2 # collection: ^1.18.0