diff --git a/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart b/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart index 7e44e787b3..749914209d 100644 --- a/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart @@ -44,7 +44,7 @@ class _MobileSuperTextFieldDemoState extends State { _textController = ImeAttributedTextEditingController( controller: AttributedTextEditingController( - text: widget.initialText, + text: widget.initialText.copyText(0), ), ); } @@ -66,15 +66,23 @@ class _MobileSuperTextFieldDemoState extends State { int? maxLines; switch (_sizeMode) { case _TextFieldSizeMode.singleLine: + _textController.text = widget.initialText.copyText(0); minLines = 1; maxLines = 1; break; case _TextFieldSizeMode.short: + _textController.text = widget.initialText.copyText(0); maxLines = 5; break; case _TextFieldSizeMode.tall: + _textController.text = widget.initialText.copyText(0); // no-op break; + case _TextFieldSizeMode.empty: + _textController.text = AttributedText(); + minLines = 1; + maxLines = 1; + break; } return MobileTextFieldDemoConfig( @@ -117,11 +125,14 @@ class _MobileSuperTextFieldDemoState extends State { child: const Icon(Icons.bug_report), ), bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, currentIndex: _sizeMode == _TextFieldSizeMode.singleLine ? 0 : _sizeMode == _TextFieldSizeMode.short ? 1 - : 2, + : _sizeMode == _TextFieldSizeMode.tall + ? 2 + : 3, items: const [ BottomNavigationBarItem( icon: Icon(Icons.short_text), @@ -135,6 +146,10 @@ class _MobileSuperTextFieldDemoState extends State { icon: Icon(Icons.wrap_text_rounded), label: 'Tall', ), + BottomNavigationBarItem( + icon: Icon(Icons.short_text), + label: 'Empty', + ), ], onTap: (int newIndex) { setState(() { @@ -144,6 +159,8 @@ class _MobileSuperTextFieldDemoState extends State { _sizeMode = _TextFieldSizeMode.short; } else if (newIndex == 2) { _sizeMode = _TextFieldSizeMode.tall; + } else if (newIndex == 3) { + _sizeMode = _TextFieldSizeMode.empty; } }); }, @@ -180,6 +197,7 @@ enum _TextFieldSizeMode { singleLine, short, tall, + empty, } class MobileTextFieldDemoConfig { diff --git a/super_editor/lib/src/infrastructure/flutter/text_selection.dart b/super_editor/lib/src/infrastructure/flutter/text_selection.dart new file mode 100644 index 0000000000..93a51d4990 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/text_selection.dart @@ -0,0 +1,10 @@ +import 'package:flutter/painting.dart'; + +extension Bounds on TextSelection { + /// Returns `true` if this [TextSelection] has the same bounds as the + /// [other] [TextSelection], regardless of selection direction, i.e., + /// affinity. + bool hasSameBoundsAs(TextSelection other) { + return start == other.start && end == other.end; + } +} diff --git a/super_editor/lib/src/super_textfield/android/_user_interaction.dart b/super_editor/lib/src/super_textfield/android/_user_interaction.dart index 2177b223ef..91fe67a852 100644 --- a/super_editor/lib/src/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/android/_user_interaction.dart @@ -19,6 +19,8 @@ final _log = androidTextFieldLog; /// /// * Tap: Place a collapsed text selection at the tapped location /// in text. +/// * Long-Press (over text): select surrounding word. +/// * Long-Press (in empty space with a selection): show the toolbar. /// * Double-Tap: Select the word surrounding the tapped location /// * Triple-Tap: Select the paragraph surrounding the tapped location /// * Drag: Move a collapsed selection wherever the user drags, while @@ -210,6 +212,16 @@ class AndroidTextFieldTouchInteractorState extends State( + () => LongPressGestureRecognizer(), + (LongPressGestureRecognizer recognizer) { + recognizer + ..onLongPress = _onLongPress + ..gestureSettings = gestureSettings; + }, + ), PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => PanGestureRecognizer(), (PanGestureRecognizer recognizer) { diff --git a/super_editor/lib/src/super_textfield/ios/_user_interaction.dart b/super_editor/lib/src/super_textfield/ios/_user_interaction.dart index cc342e859b..134e6d99fd 100644 --- a/super_editor/lib/src/super_textfield/ios/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/ios/_user_interaction.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/text_selection.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -17,9 +18,10 @@ final _log = iosTextFieldLog; /// /// This widget recognizes and acts upon various user interactions: /// -/// * Tap: Place a collapsed text selection at the tapped location -/// in text. -/// * Double-Tap: Select the word surrounding the tapped location +/// * Tap: Place a collapsed text selection at the text location that's +/// nearest to the tap offset. +/// * Tap (in a location that doesn't move the caret): Toggle the toolbar. +/// * Double-Tap: Select the word surrounding the tapped location. /// * Triple-Tap: Select the paragraph surrounding the tapped location /// * Drag: Move a collapsed selection wherever the user drags, while /// displaying a magnifying glass. @@ -101,6 +103,8 @@ class IOSTextFieldTouchInteractorState extends State= _selectionBeforeTap!.start && + exactTapTextPosition.offset <= _selectionBeforeTap!.end); - final previousSelection = widget.textController.selection; - final didTapOnExistingSelection = previousSelection.isCollapsed - ? tapTextPosition == previousSelection.extent - : tapTextPosition.offset >= previousSelection.start && tapTextPosition.offset <= previousSelection.end; + // Select the text that's nearest to where the user tapped. + _selectAtOffset(details.localPosition); - if (didTapOnExistingSelection) { - // Toggle the toolbar display when the user taps on the collapsed caret, - // or on top of an existing selection. + final didCaretStayInSamePlace = _selectionBeforeTap != null && + _selectionBeforeTap?.hasSameBoundsAs(widget.textController.selection) == true && + _selectionBeforeTap!.isCollapsed; + if (didCaretStayInSamePlace || didTapOnExistingSelection) { + // The user either tapped directly on the caret, or on an expanded selection, + // or the user tapped in empty space but didn't move the caret, for example + // the user tapped in empty space after the text and the caret was already + // at the end of the text. + // + // Toggle the toolbar. widget.editingOverlayController.toggleToolbar(); - } else { + } else if (!didCaretStayInSamePlace && !didTapOnExistingSelection) { // The user tapped somewhere in the text outside any existing selection. // Hide the toolbar. widget.editingOverlayController.hideToolbar(); - - // Place the caret based on the tap offset. - _selectAtOffset(details.localPosition); } + + _selectionBeforeTap = null; } /// Places the caret in the field's text based on the given [localOffset], /// and displays the drag handle. void _selectAtOffset(Offset localOffset) { - final tapTextPosition = _getTextPositionAtOffset(localOffset); - if (tapTextPosition == null) { + final tapTextPosition = _getTextPositionNearestToOffset(localOffset); + if (tapTextPosition == null || tapTextPosition.offset < 0) { // This situation indicates the user tapped in empty space widget.textController.selection = TextSelection.collapsed(offset: widget.textController.text.text.length); return; @@ -208,7 +210,7 @@ class IOSTextFieldTouchInteractorState extends State PanGestureRecognizer(), (PanGestureRecognizer recognizer) { recognizer - ..onStart = widget.focusNode.hasFocus ? _onTextPanStart : null + ..onStart = widget.focusNode.hasFocus ? _onPanStart : null ..onUpdate = widget.focusNode.hasFocus ? _onPanUpdate : null ..onEnd = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanEnd : null ..onCancel = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanCancel : null diff --git a/super_editor/test/super_textfield/android/super_textfield_android_selection_test.dart b/super_editor/test/super_textfield/android/super_textfield_android_selection_test.dart new file mode 100644 index 0000000000..8e1c41edb4 --- /dev/null +++ b/super_editor/test/super_textfield/android/super_textfield_android_selection_test.dart @@ -0,0 +1,107 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; +import 'package:super_editor/super_text_field.dart'; + +import '../super_textfield_inspector.dart'; + +void main() { + group("SuperTextField Android selection >", () { + testWidgetsOnAndroid("long-pressing in empty space shows the toolbar", (tester) async { + await _pumpTestApp(tester); + + // Ensure there's no selection to begin with, and no toolbar is displayed. + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: -1)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Place the caret at the end of the text by tapping in empty space at the center + // of the text field. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 3)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Long-press in empty space at the center of the text field. + await tester.longPress(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that the text field toolbar is visible. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOneWidget); + + // Tap again to hide the toolbar. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that the text field toolbar disappeared. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + }); + + testWidgetsOnAndroid("long-pressing in empty space when there is NO selection does NOT show the toolbar", + (tester) async { + await _pumpTestApp(tester); + + // Ensure there's no selection to begin with, and no toolbar is displayed. + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: -1)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Long-press in empty space at the center of the text field. + await tester.longPress(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + }); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + AttributedTextEditingController? controller, + EdgeInsets? padding, + TextAlign? textAlign, +}) async { + final textFieldFocusNode = FocusNode(); + const tapRegionGroupdId = "test_super_text_field"; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TapRegion( + groupId: tapRegionGroupdId, + onTapOutside: (_) { + // Unfocus on tap outside so that we're sure that all gesture tests + // pass when using TapRegion's for focus, because apps should be able + // to do that. + textFieldFocusNode.unfocus(); + }, + child: SizedBox.expand( + child: Align( + alignment: Alignment.center, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + ), + child: SuperTextField( + focusNode: textFieldFocusNode, + tapRegionGroupId: tapRegionGroupdId, + padding: padding, + textAlign: textAlign ?? TextAlign.left, + textController: controller ?? + AttributedTextEditingController( + text: AttributedText('abc'), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/super_editor/test/super_textfield/ios/super_textfield_ios_selection_test.dart b/super_editor/test/super_textfield/ios/super_textfield_ios_selection_test.dart new file mode 100644 index 0000000000..fa0115ec28 --- /dev/null +++ b/super_editor/test/super_textfield/ios/super_textfield_ios_selection_test.dart @@ -0,0 +1,90 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; +import 'package:super_editor/super_text_field.dart'; + +import '../super_textfield_inspector.dart'; + +void main() { + group("SuperTextField iOS selection >", () { + testWidgetsOnIos("tapping in empty space shows the toolbar", (tester) async { + await _pumpTestApp(tester); + + // Ensure there's no selection to begin with, and no toolbar is displayed. + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: -1)); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + + // Place the caret at the end of the text by tapping in empty space at the center + // of the text field. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 3)); + + // Tap again in the empty space by tapping in the center of the text field. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that the text field toolbar is visible. + expect(find.byType(IOSTextEditingFloatingToolbar), findsOneWidget); + + // Tap a third time in the empty space by tapping in the center of the text field. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that the text field toolbar disappeared. + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + }); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + AttributedTextEditingController? controller, + EdgeInsets? padding, + TextAlign? textAlign, +}) async { + final textFieldFocusNode = FocusNode(); + const tapRegionGroupdId = "test_super_text_field"; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TapRegion( + groupId: tapRegionGroupdId, + onTapOutside: (_) { + // Unfocus on tap outside so that we're sure that all gesture tests + // pass when using TapRegion's for focus, because apps should be able + // to do that. + textFieldFocusNode.unfocus(); + }, + child: SizedBox.expand( + child: Align( + alignment: Alignment.center, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + ), + child: SuperTextField( + focusNode: textFieldFocusNode, + tapRegionGroupId: tapRegionGroupdId, + padding: padding, + textAlign: textAlign ?? TextAlign.left, + textController: controller ?? + AttributedTextEditingController( + text: AttributedText('abc'), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); +}