From b5afa67b173ff98baa6e97649122e37d53791b30 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Jan 2025 23:34:20 -0300 Subject: [PATCH] [SuperTextField] Add ability to override tap gestures (Resolves #2447) (#2455) --- .../supertextfield/_interactive_demo.dart | 36 +- ...cument_gestures_interaction_overrides.dart | 11 +- .../android/_user_interaction.dart | 158 +++++++ .../android/android_textfield.dart | 6 + .../desktop/desktop_textfield.dart | 269 ++++++++++- ..._field_gestures_interaction_overrides.dart | 70 +++ .../text_field_tap_handlers.dart | 50 ++ .../super_textfield/ios/ios_textfield.dart | 6 + .../super_textfield/ios/user_interaction.dart | 156 ++++++- .../src/super_textfield/super_textfield.dart | 16 + super_editor/lib/super_text_field.dart | 3 + ...d_gestures_interaction_overrides_test.dart | 434 ++++++++++++++++++ .../super_textfield_robot.dart | 107 +++-- 13 files changed, 1266 insertions(+), 56 deletions(-) create mode 100644 super_editor/lib/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart create mode 100644 super_editor/lib/src/super_textfield/infrastructure/text_field_tap_handlers.dart create mode 100644 super_editor/test/super_textfield/super_textfield_gestures_interaction_overrides_test.dart diff --git a/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart b/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart index 3ed70d7ba2..41cd93453b 100644 --- a/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart @@ -43,19 +43,20 @@ class _InteractiveTextFieldDemoState extends State { super.dispose(); } - void _onRightClick( - BuildContext textFieldContext, AttributedTextEditingController textController, Offset localOffset) { + TapHandlingInstruction _onRightClick(SuperTextFieldGestureDetails details) { // Only show menu if some text is selected - if (textController.selection.isCollapsed) { - return; + if (details.textController.selection.isCollapsed) { + return TapHandlingInstruction.continueHandling; } final overlay = Overlay.of(context); - final overlayBox = overlay.context.findRenderObject() as RenderBox?; - final textFieldBox = textFieldContext.findRenderObject() as RenderBox; - _popupOffset = textFieldBox.localToGlobal(localOffset, ancestor: overlayBox); + final overlayBox = overlay.context.findRenderObject() as RenderBox; + + _popupOffset = overlayBox.globalToLocal(details.globalOffset); _popupOverlayController.show(); + + return TapHandlingInstruction.halt; } void _closePopup() { @@ -86,6 +87,9 @@ class _InteractiveTextFieldDemoState extends State { textStyleBuilder: demoTextStyleBuilder, blinkTimingMode: BlinkTimingMode.timer, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + tapHandlers: [ + _SuperTextFieldRightClickListener(rightClickHandler: _onRightClick), + ], decorationBuilder: (context, child) { return Container( decoration: BoxDecoration( @@ -109,7 +113,6 @@ class _InteractiveTextFieldDemoState extends State { hintBehavior: HintBehavior.displayHintUntilTextEntered, minLines: 5, maxLines: 5, - onRightClick: _onRightClick, ), ), ), @@ -168,3 +171,20 @@ class _InteractiveTextFieldDemoState extends State { ); } } + +/// A [SuperTextFieldTapHandler] that listens for right clicks and invokes the +/// [rightClickHandler] when a right click happens. +class _SuperTextFieldRightClickListener extends SuperTextFieldTapHandler { + _SuperTextFieldRightClickListener({ + required this.rightClickHandler, + }); + + final RightClickHandler rightClickHandler; + + @override + TapHandlingInstruction onSecondaryTapUp(SuperTextFieldGestureDetails details) { + return rightClickHandler(details); + } +} + +typedef RightClickHandler = TapHandlingInstruction Function(SuperTextFieldGestureDetails details); diff --git a/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart b/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart index 1b06841a0c..7c035b07c7 100644 --- a/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart +++ b/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart @@ -6,8 +6,15 @@ import 'package:super_editor/src/core/document_layout.dart'; /// Delegate for mouse status and clicking on special types of content, /// e.g., tapping on a link open the URL. /// -/// Listeners are notified when any time that the desired mouse cursor -/// may have changed. +/// Each [ContentTapDelegate] notifies its listeners whenever an +/// internal policy changes, which might impact the mouse cursor +/// style. For example, a handler in a desktop app, when hovering +/// over a link, might initially show a text cursor, but when the +/// user pressed CMD (or CTL), the mouse cursor would change to a +/// click cursor. Only the individual handlers know when or if such +/// a change should occur. When such a change does occur, the +/// handler notifies its listeners, and the handler expects that +/// someone will ask it for the desired mouse cursor style. abstract class ContentTapDelegate with ChangeNotifier { MouseCursor? mouseCursorForContentHover(DocumentPosition hoverPosition) { return null; 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 7d21131f9c..402601c016 100644 --- a/super_editor/lib/src/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/android/_user_interaction.dart @@ -2,8 +2,10 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -51,6 +53,7 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { required this.getGlobalCaretRect, required this.isMultiline, required this.handleColor, + this.tapHandlers = const [], this.showDebugPaint = false, required this.child, }) : super(key: key); @@ -93,6 +96,9 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { /// The color of expanded selection drag handles. final Color handleColor; + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + /// Whether to paint debugging guides and regions. final bool showDebugPaint; @@ -166,9 +172,47 @@ class AndroidTextFieldTouchInteractorState extends State TapSequenceGestureRecognizer(), (TapSequenceGestureRecognizer recognizer) { recognizer + ..onTapDown = _onTapDown ..onTapUp = _onTapUp + ..onTapCancel = _onTapCancel ..onDoubleTapDown = _onDoubleTapDown + ..onDoubleTapUp = _onDoubleTapUp + ..onDoubleTapCancel = _onDoubleTapCancel ..onTripleTapDown = _onTripleTapDown + ..onTripleTapUp = _onTripleTapUp + ..onTripleTapCancel = _onTripleTapCancel ..gestureSettings = gestureSettings; }, ), diff --git a/super_editor/lib/src/super_textfield/android/android_textfield.dart b/super_editor/lib/src/super_textfield/android/android_textfield.dart index 5cdad5349d..8b01b8d5c2 100644 --- a/super_editor/lib/src/super_textfield/android/android_textfield.dart +++ b/super_editor/lib/src/super_textfield/android/android_textfield.dart @@ -12,6 +12,7 @@ import 'package:super_editor/src/super_textfield/android/_editing_controls.dart' import 'package:super_editor/src/super_textfield/android/_user_interaction.dart'; import 'package:super_editor/src/super_textfield/infrastructure/fill_width_if_constrained.dart'; import 'package:super_editor/src/super_textfield/infrastructure/hint_text.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; import 'package:super_editor/src/super_textfield/infrastructure/text_scrollview.dart'; import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -44,6 +45,7 @@ class SuperAndroidTextField extends StatefulWidget { this.textInputAction, this.imeConfiguration, this.showComposingUnderline = true, + this.tapHandlers = const [], this.popoverToolbarBuilder = _defaultAndroidToolbarBuilder, this.showDebugPaint = false, this.padding, @@ -139,6 +141,9 @@ class SuperAndroidTextField extends StatefulWidget { /// Whether to show an underline beneath the text in the composing region. final bool showComposingUnderline; + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + /// Whether to paint debug guides. final bool showDebugPaint; @@ -555,6 +560,7 @@ class SuperAndroidTextFieldState extends State link: _textFieldLayerLink, child: AndroidTextFieldTouchInteractor( focusNode: _focusNode, + tapHandlers: widget.tapHandlers, textKey: _textContentKey, getGlobalCaretRect: _getGlobalCaretRect, textFieldLayerLink: _textFieldLayerLink, diff --git a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart index 81c81070fc..c61db35ea7 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:ui' as ui; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide SelectableText; @@ -10,6 +11,7 @@ import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/actions.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/flutter/material_scrollbar.dart'; @@ -21,6 +23,7 @@ import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; import 'package:super_editor/src/infrastructure/platforms/platform.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; import 'package:super_editor/src/super_textfield/infrastructure/text_field_scroller.dart'; import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -71,6 +74,7 @@ class SuperDesktopTextField extends StatefulWidget { this.imeConfiguration, this.showComposingUnderline, this.selectorHandlers, + this.tapHandlers = const [], List? keyboardHandlers, }) : keyboardHandlers = keyboardHandlers ?? (inputSource == TextInputSource.keyboard @@ -118,6 +122,7 @@ class SuperDesktopTextField extends StatefulWidget { final DecorationBuilder? decorationBuilder; + @Deprecated('Use tapHandlers instead') final RightClickListener? onRightClick; /// The [SuperDesktopTextField] input source, e.g., keyboard or Input Method Engine. @@ -137,6 +142,9 @@ class SuperDesktopTextField extends StatefulWidget { /// defined as a mapping from selector names to handler functions. final Map? selectorHandlers; + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + /// The type of action associated with ENTER key. /// /// This property is ignored when an [imeConfiguration] is provided. @@ -413,6 +421,7 @@ class SuperDesktopTextFieldState extends State implements textScrollKey: _textScrollKey, isMultiline: isMultiline, onRightClick: widget.onRightClick, + tapHandlers: widget.tapHandlers, child: MultiListenableBuilder( listenables: { _focusNode, @@ -568,6 +577,7 @@ class SuperTextFieldGestureInteractor extends StatefulWidget { required this.textScrollKey, required this.isMultiline, this.onRightClick, + this.tapHandlers = const [], required this.child, }) : super(key: key); @@ -590,8 +600,12 @@ class SuperTextFieldGestureInteractor extends StatefulWidget { final bool isMultiline; /// Callback invoked when the user right clicks on this text field. + @Deprecated('Use tapHandlers instead') final RightClickListener? onRightClick; + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + /// The rest of the subtree for this text field. final Widget child; @@ -617,11 +631,58 @@ class _SuperTextFieldGestureInteractorState extends State widget.textScrollKey.currentState!; + final _mouseCursor = ValueNotifier(SystemMouseCursors.text); + + void _onMouseMove(PointerHoverEvent event) { + _updateMouseCursor(event.position); + } + + void _updateMouseCursor(Offset globalPosition) { + final localPosition = (context.findRenderObject() as RenderBox).globalToLocal(globalPosition); + final textOffset = _getTextOffset(localPosition); + + for (final handler in widget.tapHandlers) { + final cursorForContent = handler.mouseCursorForContentHover( + SuperTextFieldGestureDetails( + textController: widget.textController, + textLayout: _textLayout, + globalOffset: globalPosition, + layoutOffset: localPosition, + textOffset: textOffset, + ), + ); + if (cursorForContent != null) { + _mouseCursor.value = cursorForContent; + return; + } + } + + _mouseCursor.value = SystemMouseCursors.text; + } + void _onTapDown(TapDownDetails details) { _log.fine('Tap down on SuperTextField'); - _selectionType = _SelectionType.position; final textOffset = _getTextOffset(details.localPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + + _selectionType = _SelectionType.position; + final tapTextPosition = _getPositionNearestToTextOffset(textOffset); _log.finer("Tap text position: $tapTextPosition"); @@ -644,11 +705,58 @@ class _SuperTextFieldGestureInteractorState extends State _cancelScrollMomentum(), child: GestureDetector( - onSecondaryTapUp: _onRightClick, + onSecondaryTapDown: _onRightClickDown, + onSecondaryTapUp: _onRightClickUp, + onSecondaryTapCancel: _onRightClickCancel, child: RawGestureDetector( behavior: HitTestBehavior.translucent, gestures: { @@ -972,9 +1206,15 @@ class _SuperTextFieldGestureInteractorState extends State null; + + TapHandlingInstruction onTapDown(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTapUp(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTapCancel() => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onDoubleTapDown(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onDoubleTapUp(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onDoubleTapCancel() => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTripleTapDown(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTripleTapUp(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTripleTapCancel() => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onSecondaryTapDown(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onSecondaryTapUp(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onSecondaryTapCancel() => TapHandlingInstruction.continueHandling; +} + +/// Information about a gesture that happened within a [SuperTextField]. +class SuperTextFieldGestureDetails { + SuperTextFieldGestureDetails({ + required this.textLayout, + required this.textController, + required this.globalOffset, + required this.layoutOffset, + required this.textOffset, + }); + + /// The text layout of the text field. + /// + /// It can be used to pull information about the logical position + /// where the tap occurred. For example, to find the [TextPosition] + /// that is nearest to the tap. + final ProseTextLayout textLayout; + + /// The controller that holds the current text and selection of the text field. + /// It can be used to pull information about the text and its attributions. + final AttributedTextEditingController textController; + + /// The position of the gesture in global coordinates. + final Offset globalOffset; + + /// The position of the gesture in [SuperTextField]'s coordinate space. This + /// coordinate space contains the text layout and the padding around the text. + final Offset layoutOffset; + + /// The position of the gesture in the text coordinate space. + final Offset textOffset; +} diff --git a/super_editor/lib/src/super_textfield/infrastructure/text_field_tap_handlers.dart b/super_editor/lib/src/super_textfield/infrastructure/text_field_tap_handlers.dart new file mode 100644 index 0000000000..286066d7f8 --- /dev/null +++ b/super_editor/lib/src/super_textfield/infrastructure/text_field_tap_handlers.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/links.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A [SuperTextFieldTapHandler] that opens links when the user taps text with +/// a [LinkAttribution]. +class SuperTextFieldLaunchLinkTapHandler extends SuperTextFieldTapHandler { + @override + MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) { + final linkAttribution = _getLinkAttribution(details); + if (linkAttribution == null) { + return null; + } + + return SystemMouseCursors.click; + } + + @override + TapHandlingInstruction onTapUp(SuperTextFieldGestureDetails details) { + final linkAttribution = _getLinkAttribution(details); + if (linkAttribution == null) { + return TapHandlingInstruction.continueHandling; + } + + final uri = Uri.tryParse(linkAttribution.url); + if (uri == null) { + // The link is not a valid URI. We can't open it. + return TapHandlingInstruction.continueHandling; + } + + UrlLauncher.instance.launchUrl(uri); + + return TapHandlingInstruction.halt; + } + + /// Returns the [LinkAttribution] at the given [details.textOffset], if any. + LinkAttribution? _getLinkAttribution(SuperTextFieldGestureDetails details) { + final textPosition = details.textLayout.getPositionNearestToOffset(details.textOffset); + + final attributions = details.textController.text // + .getAllAttributionsAt(textPosition.offset) + .whereType(); + + if (attributions.isEmpty) { + return null; + } + + return attributions.first; + } +} diff --git a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart index cc4bbfa072..e6744a8907 100644 --- a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart +++ b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart @@ -13,6 +13,7 @@ import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart' import 'package:super_editor/src/infrastructure/signal_notifier.dart'; import 'package:super_editor/src/super_textfield/infrastructure/fill_width_if_constrained.dart'; import 'package:super_editor/src/super_textfield/infrastructure/hint_text.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; import 'package:super_editor/src/super_textfield/infrastructure/text_scrollview.dart'; import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; import 'package:super_editor/src/super_textfield/ios/editing_controls.dart'; @@ -37,6 +38,7 @@ class SuperIOSTextField extends StatefulWidget { Key? key, this.focusNode, this.tapRegionGroupId, + this.tapHandlers = const [], this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, this.textAlign = TextAlign.left, @@ -63,6 +65,9 @@ class SuperIOSTextField extends StatefulWidget { /// {@macro super_text_field_tap_region_group_id} final String? tapRegionGroupId; + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + /// Controller that owns the text content and text selection for /// this text field. final ImeAttributedTextEditingController? textController; @@ -555,6 +560,7 @@ class SuperIOSTextFieldState extends State link: _textFieldLayerLink, child: IOSTextFieldTouchInteractor( focusNode: _focusNode, + tapHandlers: widget.tapHandlers, selectableTextKey: _textContentKey, getGlobalCaretRect: _getGlobalCaretRect, textFieldLayerLink: _textFieldLayerLink, 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 2c82147557..f778914464 100644 --- a/super_editor/lib/src/super_textfield/ios/user_interaction.dart +++ b/super_editor/lib/src/super_textfield/ios/user_interaction.dart @@ -2,10 +2,12 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.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/infrastructure/platforms/ios/selection_heuristics.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_editor/src/test/test_globals.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -44,6 +46,7 @@ class IOSTextFieldTouchInteractor extends StatefulWidget { const IOSTextFieldTouchInteractor({ Key? key, required this.focusNode, + this.tapHandlers = const [], required this.textFieldLayerLink, required this.textController, required this.editingOverlayController, @@ -64,6 +67,9 @@ class IOSTextFieldTouchInteractor extends StatefulWidget { /// [IOSTextFieldInteractor] requests focus when the user taps on it. final FocusNode focusNode; + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + /// [LayerLink] that follows the text field that contains this /// [IOSExtFieldInteractor]. /// @@ -179,15 +185,47 @@ class IOSTextFieldTouchInteractorState extends State? selectorHandlers; + /// {@template super_text_field_tap_handlers} + /// 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. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + /// {@endtemplate} + final List tapHandlers; + /// Padding placed around the text content of this text field, but within the /// scrollable viewport. final EdgeInsets? padding; @@ -363,6 +376,7 @@ class SuperTextFieldState extends State implements ImeInputOwner maxLines: widget.maxLines, keyboardHandlers: widget.keyboardHandlers, selectorHandlers: widget.selectorHandlers, + tapHandlers: widget.tapHandlers, padding: widget.padding ?? EdgeInsets.zero, inputSource: _inputSource, textInputAction: _textInputAction, @@ -377,6 +391,7 @@ class SuperTextFieldState extends State implements ImeInputOwner key: _platformFieldKey, focusNode: _focusNode, tapRegionGroupId: widget.tapRegionGroupId, + tapHandlers: widget.tapHandlers, textController: _controller, textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, @@ -405,6 +420,7 @@ class SuperTextFieldState extends State implements ImeInputOwner key: _platformFieldKey, focusNode: _focusNode, tapRegionGroupId: widget.tapRegionGroupId, + tapHandlers: widget.tapHandlers, textController: _controller, textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, diff --git a/super_editor/lib/super_text_field.dart b/super_editor/lib/super_text_field.dart index e753901601..d3dea4f61c 100644 --- a/super_editor/lib/super_text_field.dart +++ b/super_editor/lib/super_text_field.dart @@ -3,5 +3,8 @@ library super_text_field; // The whole text field. export 'src/super_textfield/super_textfield.dart'; +// Tap handlers. +export 'src/super_textfield/infrastructure/text_field_tap_handlers.dart'; + // Tools for building new text fields. export 'src/super_textfield/infrastructure/text_field_border.dart'; diff --git a/super_editor/test/super_textfield/super_textfield_gestures_interaction_overrides_test.dart b/super_editor/test/super_textfield/super_textfield_gestures_interaction_overrides_test.dart new file mode 100644 index 0000000000..b17702f940 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_gestures_interaction_overrides_test.dart @@ -0,0 +1,434 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/super_text_field.dart'; + +import 'super_textfield_inspector.dart'; +import 'super_textfield_robot.dart'; + +void main() { + group('SuperTextField gesture interaction overrides > ', () { + group('single tap >', () { + group('single handler >', () { + testWidgetsOnAllPlatforms('can be customized', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + // Tap on the text field. + await tester.placeCaretInSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasTapDownHandled, isTrue); + expect(handler.wasTapUpHandled, isTrue); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing the caret was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + + group('multiple handlers >', () { + testWidgetsOnAllPlatforms('run seach handler until the gesture is handled', (tester) async { + final noOpHandler = _NoOpTextFieldTapHandler(); + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); + + // Tap on the text field. + await tester.placeCaretInSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasTapDownHandled, isTrue); + expect(handler.wasTapUpHandled, isTrue); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing the caret was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + + testWidgetsOnAllPlatforms('stops when a handler handles the gesture', (tester) async { + final handler1 = _SuperTextFieldTestTapHandler(); + final handler2 = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler1, handler2]); + + // Tap on the text field. + await tester.placeCaretInSuperTextField(0); + + // Ensure the first tap handler was called. + expect(handler1.wasTapDownHandled, isTrue); + expect(handler1.wasTapUpHandled, isTrue); + expect(handler1.wasDoubleTapDownHandled, isFalse); + expect(handler1.wasTripleTapDownHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasTapDownHandled, isFalse); + expect(handler2.wasTapUpHandled, isFalse); + expect(handler2.wasDoubleTapDownHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing the caret was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + }); + + group('double tap >', () { + group('single handler >', () { + testWidgetsOnAllPlatforms('can be customized', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + await tester.doubleTapAtSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasDoubleTapDownHandled, isTrue); + expect(handler.wasDoubleTapUpHandled, isTrue); + expect(handler.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing the caret was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + + group('multiple handlers > ', () { + testWidgetsOnAllPlatforms('run each handler until the gesture is handled', (tester) async { + final noOpHandler = _NoOpTextFieldTapHandler(); + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); + + await tester.doubleTapAtSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasDoubleTapDownHandled, isTrue); + expect(handler.wasDoubleTapUpHandled, isTrue); + expect(handler.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + + testWidgetsOnAllPlatforms('stops when a handler handles the gesture', (tester) async { + final handler1 = _SuperTextFieldTestTapHandler(); + final handler2 = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler1, handler2]); + + await tester.doubleTapAtSuperTextField(0); + + // Ensure the first tap handler was called. + expect(handler1.wasDoubleTapDownHandled, isTrue); + expect(handler1.wasDoubleTapUpHandled, isTrue); + expect(handler1.wasTripleTapDownHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasDoubleTapDownHandled, isFalse); + expect(handler2.wasDoubleTapUpHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + }); + + group('triple tap', () { + group('single handler > ', () { + testWidgetsOnAllPlatforms('can be customized', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + // Triple tap on the text field. + await tester.tripleTapAtSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasTripleTapDownHandled, isTrue); + expect(handler.wasTripleTapUpHandled, isTrue); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + + group('multiple handlers > ', () { + testWidgetsOnAllPlatforms('run each handler until the gesture is handled', (tester) async { + final noOpHandler = _NoOpTextFieldTapHandler(); + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); + + await tester.tripleTapAtSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasTripleTapDownHandled, isTrue); + expect(handler.wasTripleTapUpHandled, isTrue); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + + testWidgetsOnAllPlatforms('stops when a handler handles the gesture', (tester) async { + final handler1 = _SuperTextFieldTestTapHandler(); + final handler2 = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler1, handler2]); + + await tester.tripleTapAtSuperTextField(0); + + // Ensure the first tap handler was called. + expect(handler1.wasTripleTapDownHandled, isTrue); + expect(handler1.wasTripleTapUpHandled, isTrue); + + // Ensure the second tap handler was not called. + expect(handler2.wasTripleTapDownHandled, isFalse); + expect(handler2.wasTripleTapUpHandled, isFalse); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + }); + + group('secondary tap >', () { + group('single handler >', () { + testWidgetsOnDesktop('can be customized', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + // Tap on the text field. + await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); + + // Ensure the custom tap handler was called. + expect(handler.wasSecondaryTapDownHandled, isTrue); + expect(handler.wasSecondaryTapUpHandled, isTrue); + expect(handler.wasTapUpHandled, isFalse); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); + }); + }); + + group('multiple handlers >', () { + testWidgetsOnDesktop('run seach handler until the gesture is handled', (tester) async { + final noOpHandler = _NoOpTextFieldTapHandler(); + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); + + // Tap on the text field. + await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); + + // Ensure the custom tap handler was called. + expect(handler.wasSecondaryTapDownHandled, isTrue); + expect(handler.wasSecondaryTapUpHandled, isTrue); + expect(handler.wasTapUpHandled, isFalse); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); + }); + + testWidgetsOnDesktop('stops when a handler handles the gesture', (tester) async { + final handler1 = _SuperTextFieldTestTapHandler(); + final handler2 = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler1, handler2]); + + // Tap on the text field. + await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); + + // Ensure the first tap handler was called. + expect(handler1.wasSecondaryTapDownHandled, isTrue); + expect(handler1.wasSecondaryTapUpHandled, isTrue); + expect(handler1.wasTapUpHandled, isFalse); + expect(handler1.wasDoubleTapDownHandled, isFalse); + expect(handler1.wasTripleTapDownHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasSecondaryTapDownHandled, isFalse); + expect(handler2.wasSecondaryTapUpHandled, isFalse); + expect(handler2.wasTapUpHandled, isFalse); + expect(handler2.wasDoubleTapDownHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); + }); + }); + }); + + testWidgetsOnDesktop('allows customizing mouse cursor', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + // Start a gesture outside SuperTextField bounds. + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + + // Ensure the cursor type is 'basic' when not hovering SuperTextField. + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); + + // Hover over the text field. + await gesture.moveTo(tester.getCenter(find.byType(SuperTextField))); + await tester.pump(); + + // Ensure the cursor type was configured by the custom handler. + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.move); + }); + }); +} + +/// Pump a test app with a single [SuperTextField] that has the given [tapHandlers]. +Future _pumpSingleFieldTestApp( + WidgetTester tester, { + required List tapHandlers, +}) async { + final textController = AttributedTextEditingController( + text: AttributedText('This is a text field'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(20.0), + child: SizedBox( + width: 300, + child: SuperTextField( + textController: textController, + lineHeight: 16, + tapHandlers: tapHandlers, + ), + ), + ), + ), + ), + ); +} + +/// A [SuperTextFieldTapHandler] that records whether each tap was handled and +/// always specifies [SystemMouseCursors.move] as the mouse cursor. +/// +/// This handler prevents any other handlers from running, because it always +/// returns [TapHandlingInstruction.halt]. +class _SuperTextFieldTestTapHandler extends SuperTextFieldTapHandler { + bool get wasTapDownHandled => _wasTapDownHandled; + bool _wasTapDownHandled = false; + + bool get wasTapUpHandled => _wasTapUpHandled; + bool _wasTapUpHandled = false; + + bool get wasDoubleTapDownHandled => _wasDoubleTapDownHandled; + bool _wasDoubleTapDownHandled = false; + + bool get wasDoubleTapUpHandled => _wasDoubleTapUpHandled; + bool _wasDoubleTapUpHandled = false; + + bool get wasTripleTapDownHandled => _wasTripleTapDownHandled; + bool _wasTripleTapDownHandled = false; + + bool get wasTripleTapUpHandled => _wasTripleTapUpHandled; + bool _wasTripleTapUpHandled = false; + + bool get wasSecondaryTapDownHandled => _wasSecondaryTapDownHandled; + bool _wasSecondaryTapDownHandled = false; + + bool get wasSecondaryTapUpHandled => _wasSecondaryTapUpHandled; + bool _wasSecondaryTapUpHandled = false; + + @override + MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) { + return SystemMouseCursors.move; + } + + @override + TapHandlingInstruction onTapDown(SuperTextFieldGestureDetails details) { + _wasTapDownHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onTapUp(SuperTextFieldGestureDetails details) { + _wasTapUpHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTapDown(SuperTextFieldGestureDetails details) { + _wasDoubleTapDownHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTapUp(SuperTextFieldGestureDetails details) { + _wasDoubleTapUpHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onTripleTapDown(SuperTextFieldGestureDetails details) { + _wasTripleTapDownHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onTripleTapUp(SuperTextFieldGestureDetails details) { + _wasTripleTapUpHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onSecondaryTapDown(SuperTextFieldGestureDetails details) { + _wasSecondaryTapDownHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onSecondaryTapUp(SuperTextFieldGestureDetails details) { + _wasSecondaryTapUpHandled = true; + return TapHandlingInstruction.halt; + } +} + +/// A [SuperTextFieldTapHandler] that does nothing. +class _NoOpTextFieldTapHandler extends SuperTextFieldTapHandler {} diff --git a/super_editor/test/super_textfield/super_textfield_robot.dart b/super_editor/test/super_textfield/super_textfield_robot.dart index a358ae171f..0bcd1c87f2 100644 --- a/super_editor/test/super_textfield/super_textfield_robot.dart +++ b/super_editor/test/super_textfield/super_textfield_robot.dart @@ -305,11 +305,38 @@ extension SuperTextFieldRobot on WidgetTester { await tapAt(handleCenter); } + /// Taps in a [SuperTextField] at the given [offset] + /// + /// {@macro supertextfield_finder} + Future tapAtSuperTextField( + int offset, { + Finder? superTextFieldFinder, + TextAffinity affinity = TextAffinity.downstream, + int buttons = kPrimaryButton, + }) async { + await _tapAtSuperTextField(offset, 1, superTextFieldFinder, affinity, buttons); + } + /// Double taps in a [SuperTextField] at the given [offset] /// /// {@macro supertextfield_finder} Future doubleTapAtSuperTextField(int offset, [Finder? superTextFieldFinder, TextAffinity affinity = TextAffinity.downstream]) async { + await _tapAtSuperTextField(offset, 2, superTextFieldFinder, affinity); + } + + /// Triple taps in a [SuperTextField] at the given [offset] + /// + /// {@macro supertextfield_finder} + Future tripleTapAtSuperTextField(int offset, + [Finder? superTextFieldFinder, TextAffinity affinity = TextAffinity.downstream]) async { + await _tapAtSuperTextField(offset, 3, superTextFieldFinder, affinity); + } + + Future _tapAtSuperTextField(int offset, int tapCount, + [Finder? superTextFieldFinder, + TextAffinity affinity = TextAffinity.downstream, + int buttons = kPrimaryButton]) async { // TODO: De-duplicate this behavior with placeCaretInSuperTextField final fieldFinder = SuperTextFieldInspector.findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); @@ -320,16 +347,18 @@ extension SuperTextFieldRobot on WidgetTester { if (match is SuperDesktopTextField) { final superDesktopTextField = state(fieldFinder); - - bool didTap = await _tapAtTextPositionOnDesktop(superDesktopTextField, offset, affinity, scrollOffset); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); - } - await pump(kDoubleTapMinTime); - - didTap = await _tapAtTextPositionOnDesktop(superDesktopTextField, offset, affinity, scrollOffset); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + for (int i = 1; i <= tapCount; i++) { + bool didTap = await _tapAtTextPositionOnDesktop( + superDesktopTextField, + offset, + affinity, + scrollOffset, + buttons, + ); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); } await pumpAndSettle(); @@ -338,17 +367,18 @@ extension SuperTextFieldRobot on WidgetTester { } if (match is SuperAndroidTextField) { - bool didTap = await _tapAtTextPositionOnAndroid( - state(fieldFinder), offset, affinity, scrollOffset); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); - } - await pump(kDoubleTapMinTime); - - didTap = await _tapAtTextPositionOnAndroid( - state(fieldFinder), offset, affinity, scrollOffset); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + for (int i = 1; i <= tapCount; i++) { + bool didTap = await _tapAtTextPositionOnAndroid( + state(fieldFinder), + offset, + affinity, + scrollOffset, + buttons, + ); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); } await pumpAndSettle(); @@ -357,17 +387,18 @@ extension SuperTextFieldRobot on WidgetTester { } if (match is SuperIOSTextField) { - bool didTap = - await _tapAtTextPositionOnIOS(state(fieldFinder), offset, affinity, scrollOffset); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); - } - await pump(kDoubleTapMinTime); - - didTap = - await _tapAtTextPositionOnIOS(state(fieldFinder), offset, affinity, scrollOffset); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + for (int i = 1; i <= tapCount; i++) { + bool didTap = await _tapAtTextPositionOnIOS( + state(fieldFinder), + offset, + affinity, + scrollOffset, + buttons, + ); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); } await pumpAndSettle(); @@ -383,6 +414,7 @@ extension SuperTextFieldRobot on WidgetTester { int offset, [ TextAffinity textAffinity = TextAffinity.downstream, Offset scrollOffset = Offset.zero, + int buttons = kPrimaryButton, ]) async { final textFieldBox = textField.context.findRenderObject() as RenderBox; return await _tapAtTextPositionInTextLayout( @@ -392,6 +424,7 @@ extension SuperTextFieldRobot on WidgetTester { offset, textAffinity, scrollOffset, + buttons, ); } @@ -400,6 +433,7 @@ extension SuperTextFieldRobot on WidgetTester { int offset, [ TextAffinity textAffinity = TextAffinity.downstream, Offset scrollOffset = Offset.zero, + int buttons = kPrimaryButton, ]) async { final textFieldBox = textField.context.findRenderObject() as RenderBox; return await _tapAtTextPositionInTextLayout( @@ -409,6 +443,7 @@ extension SuperTextFieldRobot on WidgetTester { offset, textAffinity, scrollOffset, + buttons, ); } @@ -417,6 +452,7 @@ extension SuperTextFieldRobot on WidgetTester { int offset, [ TextAffinity textAffinity = TextAffinity.downstream, Offset scrollOffset = Offset.zero, + int buttons = kPrimaryButton, ]) async { final textFieldBox = textField.context.findRenderObject() as RenderBox; return await _tapAtTextPositionInTextLayout( @@ -426,6 +462,7 @@ extension SuperTextFieldRobot on WidgetTester { offset, textAffinity, scrollOffset, + buttons, ); } @@ -436,6 +473,7 @@ extension SuperTextFieldRobot on WidgetTester { int offset, [ TextAffinity textAffinity = TextAffinity.downstream, Offset scrollOffset = Offset.zero, + int buttons = kPrimaryButton, ]) async { final textPositionOffset = textLayout.getOffsetForCaret( TextPosition(offset: offset, affinity: textAffinity), @@ -473,7 +511,10 @@ extension SuperTextFieldRobot on WidgetTester { } final globalTapOffset = textOffsetInField + adjustedOffset + textFieldBox.localToGlobal(Offset.zero); - await tapAt(globalTapOffset); + await tapAt( + globalTapOffset, + buttons: buttons, + ); return true; }