diff --git a/lib/core/controllers/colored_text_editing_controller.dart b/lib/core/controllers/colored_text_editing_controller.dart index 1b4bd54..92759ab 100644 --- a/lib/core/controllers/colored_text_editing_controller.dart +++ b/lib/core/controllers/colored_text_editing_controller.dart @@ -1,12 +1,13 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:languagetool_textfield/core/enums/mistake_type.dart'; import 'package:languagetool_textfield/domain/highlight_style.dart'; import 'package:languagetool_textfield/domain/language_check_service.dart'; import 'package:languagetool_textfield/domain/mistake.dart'; -import 'package:languagetool_textfield/domain/typedefs.dart'; +import 'package:languagetool_textfield/utils/mistake_popup.dart'; /// A TextEditingController with overrides buildTextSpan for building /// marked TextSpans with tap recognizer @@ -23,8 +24,11 @@ class ColoredTextEditingController extends TextEditingController { /// List of that is used to dispose recognizers after mistakes rebuilt final List _recognizers = []; - /// Callback that will be executed after mistake clicked - ShowPopupCallback? showPopup; + /// Reference to the popup widget + MistakePopup? popupWidget; + + /// Reference to the focus of the LanguageTool TextField + FocusNode? focusNode; Object? _fetchError; @@ -43,6 +47,9 @@ class ColoredTextEditingController extends TextEditingController { this.highlightStyle = const HighlightStyle(), }); + /// Close the popup widget + void _closePopup() => popupWidget?.popupRenderer.dismiss(); + /// Generates TextSpan from Mistake list @override TextSpan buildTextSpan({ @@ -70,9 +77,11 @@ class ColoredTextEditingController extends TextEditingController { void replaceMistake(Mistake mistake, String replacement) { text = text.replaceRange(mistake.offset, mistake.endOffset, replacement); _mistakes.remove(mistake); - selection = TextSelection.fromPosition( - TextPosition(offset: mistake.offset + replacement.length), - ); + focusNode?.requestFocus(); + Future.microtask.call(() { + final newOffset = mistake.offset + replacement.length; + selection = TextSelection.fromPosition(TextPosition(offset: newOffset)); + }); } /// Clear mistakes list when text mas modified and get a new list of mistakes @@ -82,6 +91,10 @@ class ColoredTextEditingController extends TextEditingController { ///so this check avoid cleaning Mistake list when text wasn't really changed if (newText == text) return; + // If we have a text change and we have a popup on hold + // it will close the popup + _closePopup(); + _mistakes.clear(); for (final recognizer in _recognizers) { recognizer.dispose(); @@ -120,7 +133,24 @@ class ColoredTextEditingController extends TextEditingController { /// Create a gesture recognizer for mistake final _onTap = TapGestureRecognizer() ..onTapDown = (details) { - showPopup?.call(context, mistake, details.globalPosition, this); + popupWidget?.show( + context, + mistake: mistake, + popupPosition: details.globalPosition, + controller: this, + onClose: (details) => _setCursorOnMistake( + context, + globalPosition: details.globalPosition, + style: style, + ), + ); + + // Set the cursor position on the mistake + _setCursorOnMistake( + context, + globalPosition: details.globalPosition, + style: style, + ); }; /// Adding recognizer to the list for future disposing @@ -134,7 +164,7 @@ class ColoredTextEditingController extends TextEditingController { mistake.offset, min(mistake.endOffset, text.length), ), - mouseCursor: MaterialStateMouseCursor.clickable, + mouseCursor: MaterialStateMouseCursor.textable, style: style?.copyWith( backgroundColor: mistakeColor.withOpacity( highlightStyle.backgroundOpacity, @@ -151,9 +181,11 @@ class ColoredTextEditingController extends TextEditingController { currentOffset = min(mistake.endOffset, text.length); } + final textAfterMistake = text.substring(currentOffset); + /// TextSpan after mistake yield TextSpan( - text: text.substring(currentOffset), + text: textAfterMistake, style: style, ); } @@ -177,4 +209,100 @@ class ColoredTextEditingController extends TextEditingController { return highlightStyle.otherMistakeColor; } } + + /// Sets the cursor position on a mistake within the text field based + /// on the provided [globalPosition]. + /// + /// The [context] is used to find the render object associated + /// with the text field. + /// The [style] is an optional parameter to customize the text style. + void _setCursorOnMistake( + BuildContext context, { + required Offset globalPosition, + TextStyle? style, + }) { + final offset = _getValidTextOffset( + context, + globalPosition: globalPosition, + style: style, + ); + + if (offset == null) return; + + focusNode?.requestFocus(); + Future.microtask.call( + () => selection = TextSelection.collapsed(offset: offset), + ); + + // Find the mistake within the text that corresponds to the offset + final mistake = _mistakes.firstWhereOrNull( + (e) => e.offset <= offset && offset < e.endOffset, + ); + + if (mistake == null) return; + + _closePopup(); + + // Show a popup widget with the mistake details + popupWidget?.show( + context, + mistake: mistake, + popupPosition: globalPosition, + controller: this, + onClose: (details) => _setCursorOnMistake( + context, + globalPosition: details.globalPosition, + style: style, + ), + ); + } + + /// Returns a valid text offset based on the provided [globalPosition] + /// within the text field. + /// + /// The [context] is used to find the render object associated + /// with the text field. + /// The [style] is an optional parameter to customize the text style. + /// Returns the offset within the text if it falls within the vertical bounds + /// of the text field, otherwise returns null. + int? _getValidTextOffset( + BuildContext context, { + required Offset globalPosition, + TextStyle? style, + }) { + final textFieldRenderBox = context.findRenderObject() as RenderBox?; + final localOffset = textFieldRenderBox?.globalToLocal(globalPosition); + + if (localOffset == null) return null; + + final textBoxHeight = textFieldRenderBox?.size.height ?? 0; + + // If local offset is outside the vertical bounds of the text field, + // return null + final isOffsetOutsideTextBox = + localOffset.dy < 0 || textBoxHeight < localOffset.dy; + if (isOffsetOutsideTextBox) return null; + + final textPainter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + ); + + textPainter.layout(); + + return textPainter.getPositionForOffset(localOffset).offset; + } + + /// The `onClosePopup` function is a callback method typically used + /// when a popup or overlay is closed. Its purpose is to ensure a smooth user + /// experience by handling the behavior when the popup is dismissed + void onClosePopup() { + final offset = selection.base.offset; + focusNode?.requestFocus(); + + // Delay the execution of the following code until the next microtask + Future.microtask( + () => selection = TextSelection.collapsed(offset: offset), + ); + } } diff --git a/lib/presentation/language_tool_text_field.dart b/lib/presentation/language_tool_text_field.dart index d928334..7361244 100644 --- a/lib/presentation/language_tool_text_field.dart +++ b/lib/presentation/language_tool_text_field.dart @@ -43,10 +43,13 @@ class LanguageToolTextField extends StatefulWidget { } class _LanguageToolTextFieldState extends State { + final focusNode = FocusNode(); + @override void initState() { - widget.coloredController.showPopup = widget.mistakePopup.show; super.initState(); + widget.coloredController.focusNode = focusNode; + widget.coloredController.popupWidget = widget.mistakePopup; } @override @@ -76,6 +79,7 @@ class _LanguageToolTextFieldState extends State { padding: const EdgeInsets.all(_padding), child: Center( child: TextField( + focusNode: focusNode, controller: widget.coloredController, style: widget.style, decoration: inputDecoration, diff --git a/lib/utils/mistake_popup.dart b/lib/utils/mistake_popup.dart index 5ad8f71..84b9545 100644 --- a/lib/utils/mistake_popup.dart +++ b/lib/utils/mistake_popup.dart @@ -20,17 +20,19 @@ class MistakePopup { /// Show popup at specified [popupPosition] with info about [mistake] void show( - BuildContext context, - Mistake mistake, - Offset popupPosition, - ColoredTextEditingController controller, - ) { + BuildContext context, { + required Mistake mistake, + required Offset popupPosition, + required ColoredTextEditingController controller, + ValueChanged? onClose, + }) { final MistakeBuilderCallback builder = mistakeBuilder ?? LanguageToolMistakePopup.new; popupRenderer.render( context, position: popupPosition, + onClose: onClose, popupBuilder: (context) => builder.call( popupRenderer: popupRenderer, mistake: mistake, @@ -145,7 +147,10 @@ class LanguageToolMistakePopup extends StatelessWidget { constraints: const BoxConstraints(), padding: EdgeInsets.zero, splashRadius: _dismissSplashRadius, - onPressed: _dismissDialog, + onPressed: () { + _dismissDialog(); + controller.onClosePopup(); + }, ), ], ), @@ -231,10 +236,7 @@ class LanguageToolMistakePopup extends StatelessWidget { } void _fixTheMistake(String replacement) { - controller.replaceMistake( - mistake, - replacement, - ); + controller.replaceMistake(mistake, replacement); _dismissDialog(); } } diff --git a/lib/utils/popup_overlay_renderer.dart b/lib/utils/popup_overlay_renderer.dart index 7ec71f5..26be077 100644 --- a/lib/utils/popup_overlay_renderer.dart +++ b/lib/utils/popup_overlay_renderer.dart @@ -12,13 +12,17 @@ class PopupOverlayRenderer { /// Render overlay entry on the screen with dismiss logic OverlayEntry render( BuildContext context, { + ValueChanged? onClose, required Offset position, required WidgetBuilder popupBuilder, }) { final _createdEntry = OverlayEntry( builder: (context) => GestureDetector( behavior: HitTestBehavior.opaque, - onTap: dismiss, + onTapDown: (details) { + dismiss(); + onClose?.call(details); + }, child: Material( color: Colors.transparent, type: MaterialType.canvas, @@ -43,6 +47,7 @@ class PopupOverlayRenderer { /// Remove popup void dismiss() { _overlayEntry?.remove(); + _overlayEntry = null; } } diff --git a/pubspec.yaml b/pubspec.yaml index ce683b8..1012c39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: flutter: ">=1.17.0" dependencies: + collection: ^1.17.1 flutter: sdk: flutter language_tool: ^2.1.1