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

Fixed the clickable zone of the text field #47

Merged
merged 20 commits into from
Jul 4, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
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
146 changes: 137 additions & 9 deletions lib/core/controllers/colored_text_editing_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ 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/extensions/iterable_extension.dart';
import 'package:languagetool_textfield/utils/mistake_popup.dart';

/// A TextEditingController with overrides buildTextSpan for building
/// marked TextSpans with tap recognizer
Expand All @@ -23,8 +24,11 @@ class ColoredTextEditingController extends TextEditingController {
/// List of that is used to dispose recognizers after mistakes rebuilt
final List<TapGestureRecognizer> _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;

Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
);
}
Expand All @@ -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),
);
}
}
6 changes: 5 additions & 1 deletion lib/presentation/language_tool_text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,13 @@ class LanguageToolTextField extends StatefulWidget {
}

class _LanguageToolTextFieldState extends State<LanguageToolTextField> {
final focusNode = FocusNode();

@override
void initState() {
widget.coloredController.showPopup = widget.mistakePopup.show;
super.initState();
widget.coloredController.focusNode = focusNode;
widget.coloredController.popupWidget = widget.mistakePopup;
}

@override
Expand Down Expand Up @@ -76,6 +79,7 @@ class _LanguageToolTextFieldState extends State<LanguageToolTextField> {
padding: const EdgeInsets.all(_padding),
child: Center(
child: TextField(
focusNode: focusNode,
controller: widget.coloredController,
style: widget.style,
decoration: inputDecoration,
Expand Down
14 changes: 14 additions & 0 deletions lib/utils/extensions/iterable_extension.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// Extension on Iterable
extension IterableExtension<T> on Iterable<T> {
/// Returns the first element in the iterable that satisfies the given [test]
/// function, or `null` if no element satisfies the condition.
T? firstWhereOrNull(bool Function(T) test) {
for (final element in this) {
if (test(element)) {
return element;
}
}

return null;
}
}
solid-danylokhvan marked this conversation as resolved.
Show resolved Hide resolved
22 changes: 12 additions & 10 deletions lib/utils/mistake_popup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<TapDownDetails>? onClose,
}) {
final MistakeBuilderCallback builder =
mistakeBuilder ?? LanguageToolMistakePopup.new;

popupRenderer.render(
context,
position: popupPosition,
onClose: onClose,
popupBuilder: (context) => builder.call(
popupRenderer: popupRenderer,
mistake: mistake,
Expand Down Expand Up @@ -145,7 +147,10 @@ class LanguageToolMistakePopup extends StatelessWidget {
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
splashRadius: _dismissSplashRadius,
onPressed: _dismissDialog,
onPressed: () {
_dismissDialog();
controller.onClosePopup();
},
),
],
),
Expand Down Expand Up @@ -231,10 +236,7 @@ class LanguageToolMistakePopup extends StatelessWidget {
}

void _fixTheMistake(String replacement) {
controller.replaceMistake(
mistake,
replacement,
);
controller.replaceMistake(mistake, replacement);
_dismissDialog();
}
}
7 changes: 6 additions & 1 deletion lib/utils/popup_overlay_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ class PopupOverlayRenderer {
/// Render overlay entry on the screen with dismiss logic
OverlayEntry render(
BuildContext context, {
ValueChanged<TapDownDetails>? 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,
Expand All @@ -43,6 +47,7 @@ class PopupOverlayRenderer {
/// Remove popup
void dismiss() {
_overlayEntry?.remove();
_overlayEntry = null;
}
}

Expand Down