From e875ec06f5e2a36f7d9cd2903dae0e5c4dca3ea0 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 13 Dec 2024 10:19:44 -0300 Subject: [PATCH 1/6] [SuperTextField] Add ability to override tap gestures (Resolves #2447) --- .../android/_user_interaction.dart | 68 ++++ .../android/android_textfield.dart | 6 + .../desktop/desktop_textfield.dart | 175 +++++++++- ..._field_gestures_interaction_overrides.dart | 51 +++ .../text_field_tap_handlers.dart | 51 +++ .../super_textfield/ios/ios_textfield.dart | 6 + .../super_textfield/ios/user_interaction.dart | 67 ++++ .../src/super_textfield/super_textfield.dart | 14 + super_editor/lib/super_text_field.dart | 3 + ...d_gestures_interaction_overrides_test.dart | 315 ++++++++++++++++++ .../super_textfield_robot.dart | 65 ++-- 11 files changed, 782 insertions(+), 39 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/lib/src/super_textfield/android/_user_interaction.dart b/super_editor/lib/src/super_textfield/android/_user_interaction.dart index 7d21131f9c..df78ab9a59 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'; @@ -43,6 +45,7 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { const AndroidTextFieldTouchInteractor({ Key? key, required this.focusNode, + this.tapHandlers, required this.textFieldLayerLink, required this.textController, required this.editingOverlayController, @@ -63,6 +66,9 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { /// [AndroidTextFieldInteractor] 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 /// [AndroidTextFieldInteractor]. /// @@ -169,6 +175,26 @@ class AndroidTextFieldTouchInteractorState extends State? tapHandlers; + /// Controller that owns the text content and text selection for /// this text field. final ImeAttributedTextEditingController? textController; @@ -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 2953ef0eec..e84d99cda3 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, List? keyboardHandlers, }) : keyboardHandlers = keyboardHandlers ?? (inputSource == TextInputSource.keyboard @@ -137,6 +141,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 +420,7 @@ class SuperDesktopTextFieldState extends State implements textScrollKey: _textScrollKey, isMultiline: isMultiline, onRightClick: widget.onRightClick, + tapHandlers: widget.tapHandlers, child: MultiListenableBuilder( listenables: { _focusNode, @@ -568,6 +576,7 @@ class SuperTextFieldGestureInteractor extends StatefulWidget { required this.textScrollKey, required this.isMultiline, this.onRightClick, + this.tapHandlers, required this.child, }) : super(key: key); @@ -592,6 +601,9 @@ class SuperTextFieldGestureInteractor extends StatefulWidget { /// Callback invoked when the user right clicks on this text field. 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,8 +629,108 @@ class _SuperTextFieldGestureInteractorState extends State widget.textScrollKey.currentState!; + final _mouseCursor = ValueNotifier(SystemMouseCursors.text); + Offset? _lastHoverOffset; + + @override + void initState() { + super.initState(); + + if (widget.tapHandlers != null) { + for (final handler in widget.tapHandlers!) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } + } + + @override + void didUpdateWidget(SuperTextFieldGestureInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + if (!const DeepCollectionEquality().equals(oldWidget.tapHandlers, widget.tapHandlers)) { + if (oldWidget.tapHandlers != null) { + for (final handler in oldWidget.tapHandlers!) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + } + + if (widget.tapHandlers != null) { + for (final handler in widget.tapHandlers!) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } + } + } + + @override + void dispose() { + super.dispose(); + if (widget.tapHandlers != null) { + for (final handler in widget.tapHandlers!) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + } + } + + void _onMouseMove(PointerHoverEvent event) { + _updateMouseCursor(event.position); + _lastHoverOffset = event.position; + } + + void _updateMouseCursorAtLatestOffset() { + if (_lastHoverOffset == null) { + return; + } + _updateMouseCursor(_lastHoverOffset!); + } + + void _updateMouseCursor(Offset globalPosition) { + final localPosition = (context.findRenderObject() as RenderBox).globalToLocal(globalPosition); + final textOffset = _getTextOffset(localPosition); + + if (widget.tapHandlers != null) { + for (final handler in widget.tapHandlers!) { + final cursorForContent = handler.mouseCursorForContentHover( + TextFieldGestureDetails( + 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'); + + if (widget.tapHandlers != null) { + final textOffset = _getTextOffset(details.localPosition); + + for (final handler in widget.tapHandlers!) { + final result = handler.onTap( + TextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + _selectionType = _SelectionType.position; final textOffset = _getTextOffset(details.localPosition); @@ -645,10 +757,30 @@ class _SuperTextFieldGestureInteractorState extends State null; + + TapHandlingInstruction onTap(TextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onDoubleTap(TextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTripleTap(TextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; +} + +/// Information about a gesture that happened within a [SuperTextField]. +class TextFieldGestureDetails { + TextFieldGestureDetails({ + 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..ab7332ed39 --- /dev/null +++ b/super_editor/lib/src/super_textfield/infrastructure/text_field_tap_handlers.dart @@ -0,0 +1,51 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/links.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.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(TextFieldGestureDetails details) { + final linkAttribution = _getLinkAttribution(details); + if (linkAttribution == null) { + return null; + } + + return SystemMouseCursors.click; + } + + @override + TapHandlingInstruction onTap(TextFieldGestureDetails 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(TextFieldGestureDetails 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..b385e88cfe 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, 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 0de3e3b016..2f91592133 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'; @@ -40,6 +42,7 @@ class IOSTextFieldTouchInteractor extends StatefulWidget { const IOSTextFieldTouchInteractor({ Key? key, required this.focusNode, + this.tapHandlers, required this.textFieldLayerLink, required this.textController, required this.editingOverlayController, @@ -60,6 +63,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]. /// @@ -184,6 +190,26 @@ 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 +374,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 +389,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 +418,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..4505b2ee61 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_gestures_interaction_overrides_test.dart @@ -0,0 +1,315 @@ +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/src/super_textfield/infrastructure/text_field_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.wasTapHandled, isTrue); + expect(handler.wasDoubleTapHandled, isFalse); + expect(handler.wasTripleTapHandled, 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.wasTapHandled, isTrue); + expect(handler.wasDoubleTapHandled, isFalse); + expect(handler.wasTripleTapHandled, 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.wasTapHandled, isTrue); + expect(handler1.wasDoubleTapHandled, isFalse); + expect(handler1.wasTripleTapHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasTapHandled, isFalse); + expect(handler2.wasDoubleTapHandled, isFalse); + expect(handler2.wasTripleTapHandled, 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.wasDoubleTapHandled, isTrue); + expect(handler.wasTripleTapHandled, 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.wasDoubleTapHandled, isTrue); + expect(handler.wasTripleTapHandled, 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.wasDoubleTapHandled, isTrue); + expect(handler1.wasTripleTapHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasDoubleTapHandled, isFalse); + expect(handler2.wasTripleTapHandled, 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.wasTripleTapHandled, 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.wasTripleTapHandled, 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.wasTripleTapHandled, isTrue); + + // Ensure the second tap handler was not called. + expect(handler2.wasTripleTapHandled, isFalse); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + }); + + 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 wasTapHandled => _wasTapHandled; + bool _wasTapHandled = false; + + bool get wasDoubleTapHandled => _wasDoubleTapHandled; + bool _wasDoubleTapHandled = false; + + bool get wasTripleTapHandled => _wasTripleTapHandled; + bool _wasTripleTapHandled = false; + + @override + MouseCursor? mouseCursorForContentHover(TextFieldGestureDetails details) { + return SystemMouseCursors.move; + } + + @override + TapHandlingInstruction onTap(TextFieldGestureDetails details) { + _wasTapHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTap(TextFieldGestureDetails details) { + _wasDoubleTapHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onTripleTap(TextFieldGestureDetails details) { + _wasTripleTapHandled = 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..3f65f57dec 100644 --- a/super_editor/test/super_textfield/super_textfield_robot.dart +++ b/super_editor/test/super_textfield/super_textfield_robot.dart @@ -310,6 +310,19 @@ extension SuperTextFieldRobot on WidgetTester { /// {@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]) async { // TODO: De-duplicate this behavior with placeCaretInSuperTextField final fieldFinder = SuperTextFieldInspector.findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); @@ -320,16 +333,12 @@ 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); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(kDoubleTapMinTime); } await pumpAndSettle(); @@ -338,17 +347,13 @@ 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); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(kDoubleTapMinTime); } await pumpAndSettle(); @@ -357,17 +362,13 @@ 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); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(kDoubleTapMinTime); } await pumpAndSettle(); From e2776a1d5dc9c9f0c73dc225f26cc4c802d08795 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 16 Dec 2024 12:02:17 -0300 Subject: [PATCH 2/6] Add right click support --- .../supertextfield/_interactive_demo.dart | 36 ++++++-- .../android/_user_interaction.dart | 14 +-- .../android/android_textfield.dart | 8 +- .../desktop/desktop_textfield.dart | 29 +++++- ..._field_gestures_interaction_overrides.dart | 19 ++-- .../text_field_tap_handlers.dart | 7 +- .../super_textfield/ios/user_interaction.dart | 6 +- .../src/super_textfield/super_textfield.dart | 2 + ...d_gestures_interaction_overrides_test.dart | 91 ++++++++++++++++--- .../super_textfield_robot.dart | 58 ++++++++++-- 10 files changed, 211 insertions(+), 59 deletions(-) diff --git a/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart b/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart index 3ed70d7ba2..94fe188549 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 onSecondaryTap(SuperTextFieldGestureDetails details) { + return rightClickHandler(details); + } +} + +typedef RightClickHandler = TapHandlingInstruction Function(SuperTextFieldGestureDetails details); 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 df78ab9a59..bb3aa92600 100644 --- a/super_editor/lib/src/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/android/_user_interaction.dart @@ -45,7 +45,6 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { const AndroidTextFieldTouchInteractor({ Key? key, required this.focusNode, - this.tapHandlers, required this.textFieldLayerLink, required this.textController, required this.editingOverlayController, @@ -54,6 +53,7 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { required this.getGlobalCaretRect, required this.isMultiline, required this.handleColor, + this.tapHandlers, this.showDebugPaint = false, required this.child, }) : super(key: key); @@ -66,9 +66,6 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { /// [AndroidTextFieldInteractor] 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 /// [AndroidTextFieldInteractor]. /// @@ -99,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; @@ -180,7 +180,7 @@ class AndroidTextFieldTouchInteractorState extends State? tapHandlers; - /// Controller that owns the text content and text selection for /// this text field. final ImeAttributedTextEditingController? textController; @@ -144,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; 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 e84d99cda3..09de87c393 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -122,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. @@ -599,6 +600,7 @@ 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} @@ -690,7 +692,7 @@ class _SuperTextFieldGestureInteractorState extends State null; + MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) => null; - TapHandlingInstruction onTap(TextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + TapHandlingInstruction onTap(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; - TapHandlingInstruction onDoubleTap(TextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + TapHandlingInstruction onDoubleTap(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; - TapHandlingInstruction onTripleTap(TextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + TapHandlingInstruction onTripleTap(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onSecondaryTap(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; } /// Information about a gesture that happened within a [SuperTextField]. -class TextFieldGestureDetails { - TextFieldGestureDetails({ +class SuperTextFieldGestureDetails { + SuperTextFieldGestureDetails({ required this.textLayout, required this.textController, required this.globalOffset, 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 index ab7332ed39..638fb37a9e 100644 --- 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 @@ -1,13 +1,12 @@ import 'package:flutter/widgets.dart'; import 'package:super_editor/src/infrastructure/links.dart'; -import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.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(TextFieldGestureDetails details) { + MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) { final linkAttribution = _getLinkAttribution(details); if (linkAttribution == null) { return null; @@ -17,7 +16,7 @@ class SuperTextFieldLaunchLinkTapHandler extends SuperTextFieldTapHandler { } @override - TapHandlingInstruction onTap(TextFieldGestureDetails details) { + TapHandlingInstruction onTap(SuperTextFieldGestureDetails details) { final linkAttribution = _getLinkAttribution(details); if (linkAttribution == null) { return TapHandlingInstruction.continueHandling; @@ -35,7 +34,7 @@ class SuperTextFieldLaunchLinkTapHandler extends SuperTextFieldTapHandler { } /// Returns the [LinkAttribution] at the given [details.textOffset], if any. - LinkAttribution? _getLinkAttribution(TextFieldGestureDetails details) { + LinkAttribution? _getLinkAttribution(SuperTextFieldGestureDetails details) { final textPosition = details.textLayout.getPositionNearestToOffset(details.textOffset); final attributions = details.textController.text // 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 2f91592133..80a8fbc7c3 100644 --- a/super_editor/lib/src/super_textfield/ios/user_interaction.dart +++ b/super_editor/lib/src/super_textfield/ios/user_interaction.dart @@ -195,7 +195,7 @@ class IOSTextFieldTouchInteractorState extends State', () { testWidgetsOnAllPlatforms('run seach handler until the gesture is handled', (tester) async { - final noopHandler = _NoopTextFieldTapHandler(); + final noOpHandler = _NoOpTextFieldTapHandler(); final handler = _SuperTextFieldTestTapHandler(); - await _pumpSingleFieldTestApp(tester, tapHandlers: [noopHandler, handler]); + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); // Tap on the text field. await tester.placeCaretInSuperTextField(0); @@ -109,10 +108,10 @@ void main() { group('multiple handlers > ', () { testWidgetsOnAllPlatforms('run each handler until the gesture is handled', (tester) async { - final noopHandler = _NoopTextFieldTapHandler(); + final noOpHandler = _NoOpTextFieldTapHandler(); final handler = _SuperTextFieldTestTapHandler(); - await _pumpSingleFieldTestApp(tester, tapHandlers: [noopHandler, handler]); + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); await tester.doubleTapAtSuperTextField(0); @@ -178,10 +177,10 @@ void main() { group('multiple handlers > ', () { testWidgetsOnAllPlatforms('run each handler until the gesture is handled', (tester) async { - final noopHandler = _NoopTextFieldTapHandler(); + final noOpHandler = _NoOpTextFieldTapHandler(); final handler = _SuperTextFieldTestTapHandler(); - await _pumpSingleFieldTestApp(tester, tapHandlers: [noopHandler, handler]); + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); await tester.tripleTapAtSuperTextField(0); @@ -220,6 +219,65 @@ void main() { }); }); + 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.wasSecondaryTapHandled, isTrue); + expect(handler.wasTapHandled, isFalse); + expect(handler.wasDoubleTapHandled, isFalse); + expect(handler.wasTripleTapHandled, 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.wasSecondaryTapHandled, isTrue); + expect(handler.wasTapHandled, isFalse); + expect(handler.wasDoubleTapHandled, isFalse); + expect(handler.wasTripleTapHandled, 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.wasSecondaryTapHandled, isTrue); + expect(handler1.wasTapHandled, isFalse); + expect(handler1.wasDoubleTapHandled, isFalse); + expect(handler1.wasTripleTapHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasSecondaryTapHandled, isFalse); + expect(handler2.wasTapHandled, isFalse); + expect(handler2.wasDoubleTapHandled, isFalse); + expect(handler2.wasTripleTapHandled, isFalse); + }); + }); + }); + testWidgetsOnDesktop('allows customizing mouse cursor', (tester) async { final handler = _SuperTextFieldTestTapHandler(); @@ -287,29 +345,38 @@ class _SuperTextFieldTestTapHandler extends SuperTextFieldTapHandler { bool get wasTripleTapHandled => _wasTripleTapHandled; bool _wasTripleTapHandled = false; + bool get wasSecondaryTapHandled => _wasSecondaryTapHandled; + bool _wasSecondaryTapHandled = false; + @override - MouseCursor? mouseCursorForContentHover(TextFieldGestureDetails details) { + MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) { return SystemMouseCursors.move; } @override - TapHandlingInstruction onTap(TextFieldGestureDetails details) { + TapHandlingInstruction onTap(SuperTextFieldGestureDetails details) { _wasTapHandled = true; return TapHandlingInstruction.halt; } @override - TapHandlingInstruction onDoubleTap(TextFieldGestureDetails details) { + TapHandlingInstruction onDoubleTap(SuperTextFieldGestureDetails details) { _wasDoubleTapHandled = true; return TapHandlingInstruction.halt; } @override - TapHandlingInstruction onTripleTap(TextFieldGestureDetails details) { + TapHandlingInstruction onTripleTap(SuperTextFieldGestureDetails details) { _wasTripleTapHandled = true; return TapHandlingInstruction.halt; } + + @override + TapHandlingInstruction onSecondaryTap(SuperTextFieldGestureDetails details) { + _wasSecondaryTapHandled = true; + return TapHandlingInstruction.halt; + } } /// A [SuperTextFieldTapHandler] that does nothing. -class _NoopTextFieldTapHandler extends SuperTextFieldTapHandler {} +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 3f65f57dec..0bcd1c87f2 100644 --- a/super_editor/test/super_textfield/super_textfield_robot.dart +++ b/super_editor/test/super_textfield/super_textfield_robot.dart @@ -305,6 +305,18 @@ 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} @@ -322,7 +334,9 @@ extension SuperTextFieldRobot on WidgetTester { } Future _tapAtSuperTextField(int offset, int tapCount, - [Finder? superTextFieldFinder, TextAffinity affinity = TextAffinity.downstream]) async { + [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)); @@ -334,11 +348,17 @@ extension SuperTextFieldRobot on WidgetTester { if (match is SuperDesktopTextField) { final superDesktopTextField = state(fieldFinder); for (int i = 1; i <= tapCount; i++) { - bool didTap = await _tapAtTextPositionOnDesktop(superDesktopTextField, offset, affinity, scrollOffset); + 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(kDoubleTapMinTime); + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); } await pumpAndSettle(); @@ -349,11 +369,16 @@ extension SuperTextFieldRobot on WidgetTester { if (match is SuperAndroidTextField) { for (int i = 1; i <= tapCount; i++) { bool didTap = await _tapAtTextPositionOnAndroid( - state(fieldFinder), offset, affinity, scrollOffset); + state(fieldFinder), + offset, + affinity, + scrollOffset, + buttons, + ); if (!didTap) { throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); } - await pump(kDoubleTapMinTime); + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); } await pumpAndSettle(); @@ -363,12 +388,17 @@ extension SuperTextFieldRobot on WidgetTester { if (match is SuperIOSTextField) { for (int i = 1; i <= tapCount; i++) { - bool didTap = - await _tapAtTextPositionOnIOS(state(fieldFinder), offset, affinity, scrollOffset); + 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(kDoubleTapMinTime); + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); } await pumpAndSettle(); @@ -384,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( @@ -393,6 +424,7 @@ extension SuperTextFieldRobot on WidgetTester { offset, textAffinity, scrollOffset, + buttons, ); } @@ -401,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( @@ -410,6 +443,7 @@ extension SuperTextFieldRobot on WidgetTester { offset, textAffinity, scrollOffset, + buttons, ); } @@ -418,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( @@ -427,6 +462,7 @@ extension SuperTextFieldRobot on WidgetTester { offset, textAffinity, scrollOffset, + buttons, ); } @@ -437,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), @@ -474,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; } From e3f4b4fb1842ecc6a3d792224e2895f68d6d6dc1 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 20 Dec 2024 09:59:41 -0300 Subject: [PATCH 3/6] Add down/up/cancel events --- .../android/_user_interaction.dart | 190 ++++++++--- .../android/android_textfield.dart | 4 +- .../desktop/desktop_textfield.dart | 309 ++++++++++++------ ..._field_gestures_interaction_overrides.dart | 27 +- .../text_field_tap_handlers.dart | 2 +- .../super_textfield/ios/ios_textfield.dart | 4 +- .../super_textfield/ios/user_interaction.dart | 189 ++++++++--- .../src/super_textfield/super_textfield.dart | 4 +- ...d_gestures_interaction_overrides_test.dart | 164 ++++++---- 9 files changed, 622 insertions(+), 271 deletions(-) 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 bb3aa92600..402601c016 100644 --- a/super_editor/lib/src/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/android/_user_interaction.dart @@ -53,7 +53,7 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { required this.getGlobalCaretRect, required this.isMultiline, required this.handleColor, - this.tapHandlers, + this.tapHandlers = const [], this.showDebugPaint = false, required this.child, }) : super(key: key); @@ -97,7 +97,7 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { final Color handleColor; /// {@macro super_text_field_tap_handlers} - final List? tapHandlers; + final List tapHandlers; /// Whether to paint debugging guides and regions. final bool showDebugPaint; @@ -172,26 +172,44 @@ 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 db9c9cac6b..8b01b8d5c2 100644 --- a/super_editor/lib/src/super_textfield/android/android_textfield.dart +++ b/super_editor/lib/src/super_textfield/android/android_textfield.dart @@ -45,7 +45,7 @@ class SuperAndroidTextField extends StatefulWidget { this.textInputAction, this.imeConfiguration, this.showComposingUnderline = true, - this.tapHandlers, + this.tapHandlers = const [], this.popoverToolbarBuilder = _defaultAndroidToolbarBuilder, this.showDebugPaint = false, this.padding, @@ -142,7 +142,7 @@ class SuperAndroidTextField extends StatefulWidget { final bool showComposingUnderline; /// {@macro super_text_field_tap_handlers} - final List? tapHandlers; + final List tapHandlers; /// Whether to paint debug guides. final bool showDebugPaint; 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 09de87c393..ac791dcf43 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -74,7 +74,7 @@ class SuperDesktopTextField extends StatefulWidget { this.imeConfiguration, this.showComposingUnderline, this.selectorHandlers, - this.tapHandlers, + this.tapHandlers = const [], List? keyboardHandlers, }) : keyboardHandlers = keyboardHandlers ?? (inputSource == TextInputSource.keyboard @@ -143,7 +143,7 @@ class SuperDesktopTextField extends StatefulWidget { final Map? selectorHandlers; /// {@macro super_text_field_tap_handlers} - final List? tapHandlers; + final List tapHandlers; /// The type of action associated with ENTER key. /// @@ -577,7 +577,7 @@ class SuperTextFieldGestureInteractor extends StatefulWidget { required this.textScrollKey, required this.isMultiline, this.onRightClick, - this.tapHandlers, + this.tapHandlers = const [], required this.child, }) : super(key: key); @@ -604,7 +604,7 @@ class SuperTextFieldGestureInteractor extends StatefulWidget { final RightClickListener? onRightClick; /// {@macro super_text_field_tap_handlers} - final List? tapHandlers; + final List tapHandlers; /// The rest of the subtree for this text field. final Widget child; @@ -638,10 +638,8 @@ class _SuperTextFieldGestureInteractorState extends State _cancelScrollMomentum(), child: GestureDetector( - onSecondaryTapUp: _onRightClick, + onSecondaryTapDown: _onRightClickDown, + onSecondaryTapUp: _onRightClickUp, + onSecondaryTapCancel: _onRightClickCancel, child: RawGestureDetector( behavior: HitTestBehavior.translucent, gestures: { @@ -1145,9 +1246,15 @@ class _SuperTextFieldGestureInteractorState extends State null; - TapHandlingInstruction onTap(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + TapHandlingInstruction onTapDown(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; - TapHandlingInstruction onDoubleTap(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + TapHandlingInstruction onTapUp(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; - TapHandlingInstruction onTripleTap(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + TapHandlingInstruction onTapCancel() => TapHandlingInstruction.continueHandling; - TapHandlingInstruction onSecondaryTap(SuperTextFieldGestureDetails details) => + 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]. 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 index 638fb37a9e..286066d7f8 100644 --- 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 @@ -16,7 +16,7 @@ class SuperTextFieldLaunchLinkTapHandler extends SuperTextFieldTapHandler { } @override - TapHandlingInstruction onTap(SuperTextFieldGestureDetails details) { + TapHandlingInstruction onTapUp(SuperTextFieldGestureDetails details) { final linkAttribution = _getLinkAttribution(details); if (linkAttribution == null) { return TapHandlingInstruction.continueHandling; 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 b385e88cfe..e6744a8907 100644 --- a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart +++ b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart @@ -38,7 +38,7 @@ class SuperIOSTextField extends StatefulWidget { Key? key, this.focusNode, this.tapRegionGroupId, - this.tapHandlers, + this.tapHandlers = const [], this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, this.textAlign = TextAlign.left, @@ -66,7 +66,7 @@ class SuperIOSTextField extends StatefulWidget { final String? tapRegionGroupId; /// {@macro super_text_field_tap_handlers} - final List? tapHandlers; + final List tapHandlers; /// Controller that owns the text content and text selection for /// this text field. 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 80a8fbc7c3..bfd66d97c0 100644 --- a/super_editor/lib/src/super_textfield/ios/user_interaction.dart +++ b/super_editor/lib/src/super_textfield/ios/user_interaction.dart @@ -42,7 +42,7 @@ class IOSTextFieldTouchInteractor extends StatefulWidget { const IOSTextFieldTouchInteractor({ Key? key, required this.focusNode, - this.tapHandlers, + this.tapHandlers = const [], required this.textFieldLayerLink, required this.textController, required this.editingOverlayController, @@ -64,7 +64,7 @@ class IOSTextFieldTouchInteractor extends StatefulWidget { final FocusNode focusNode; /// {@macro super_text_field_tap_handlers} - final List? tapHandlers; + final List tapHandlers; /// [LayerLink] that follows the text field that contains this /// [IOSExtFieldInteractor]. @@ -181,32 +181,44 @@ class IOSTextFieldTouchInteractorState extends State? tapHandlers; + final List tapHandlers; /// Padding placed around the text content of this text field, but within the /// scrollable viewport. 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 index 90b1d5381f..b17702f940 100644 --- 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 @@ -23,9 +23,10 @@ void main() { await tester.placeCaretInSuperTextField(0); // Ensure the custom tap handler was called. - expect(handler.wasTapHandled, isTrue); - expect(handler.wasDoubleTapHandled, isFalse); - expect(handler.wasTripleTapHandled, isFalse); + 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( @@ -46,9 +47,10 @@ void main() { await tester.placeCaretInSuperTextField(0); // Ensure the custom tap handler was called. - expect(handler.wasTapHandled, isTrue); - expect(handler.wasDoubleTapHandled, isFalse); - expect(handler.wasTripleTapHandled, isFalse); + 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( @@ -67,14 +69,16 @@ void main() { await tester.placeCaretInSuperTextField(0); // Ensure the first tap handler was called. - expect(handler1.wasTapHandled, isTrue); - expect(handler1.wasDoubleTapHandled, isFalse); - expect(handler1.wasTripleTapHandled, isFalse); + 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.wasTapHandled, isFalse); - expect(handler2.wasDoubleTapHandled, isFalse); - expect(handler2.wasTripleTapHandled, isFalse); + 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( @@ -95,8 +99,9 @@ void main() { await tester.doubleTapAtSuperTextField(0); // Ensure the custom tap handler was called. - expect(handler.wasDoubleTapHandled, isTrue); - expect(handler.wasTripleTapHandled, isFalse); + expect(handler.wasDoubleTapDownHandled, isTrue); + expect(handler.wasDoubleTapUpHandled, isTrue); + expect(handler.wasTripleTapDownHandled, isFalse); // Ensure the default behavior of placing the caret was not called. expect( @@ -116,8 +121,9 @@ void main() { await tester.doubleTapAtSuperTextField(0); // Ensure the custom tap handler was called. - expect(handler.wasDoubleTapHandled, isTrue); - expect(handler.wasTripleTapHandled, isFalse); + expect(handler.wasDoubleTapDownHandled, isTrue); + expect(handler.wasDoubleTapUpHandled, isTrue); + expect(handler.wasTripleTapDownHandled, isFalse); // Ensure the default behavior of placing an expanded selection // was not called. @@ -136,12 +142,14 @@ void main() { await tester.doubleTapAtSuperTextField(0); // Ensure the first tap handler was called. - expect(handler1.wasDoubleTapHandled, isTrue); - expect(handler1.wasTripleTapHandled, isFalse); + expect(handler1.wasDoubleTapDownHandled, isTrue); + expect(handler1.wasDoubleTapUpHandled, isTrue); + expect(handler1.wasTripleTapDownHandled, isFalse); // Ensure the second tap handler was not called. - expect(handler2.wasDoubleTapHandled, isFalse); - expect(handler2.wasTripleTapHandled, isFalse); + expect(handler2.wasDoubleTapDownHandled, isFalse); + expect(handler2.wasDoubleTapUpHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); // Ensure the default behavior of placing an expanded selection // was not called. @@ -164,7 +172,8 @@ void main() { await tester.tripleTapAtSuperTextField(0); // Ensure the custom tap handler was called. - expect(handler.wasTripleTapHandled, isTrue); + expect(handler.wasTripleTapDownHandled, isTrue); + expect(handler.wasTripleTapUpHandled, isTrue); // Ensure the default behavior of placing an expanded selection // was not called. @@ -185,7 +194,8 @@ void main() { await tester.tripleTapAtSuperTextField(0); // Ensure the custom tap handler was called. - expect(handler.wasTripleTapHandled, isTrue); + expect(handler.wasTripleTapDownHandled, isTrue); + expect(handler.wasTripleTapUpHandled, isTrue); // Ensure the default behavior of placing an expanded selection // was not called. @@ -204,10 +214,12 @@ void main() { await tester.tripleTapAtSuperTextField(0); // Ensure the first tap handler was called. - expect(handler1.wasTripleTapHandled, isTrue); + expect(handler1.wasTripleTapDownHandled, isTrue); + expect(handler1.wasTripleTapUpHandled, isTrue); // Ensure the second tap handler was not called. - expect(handler2.wasTripleTapHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); + expect(handler2.wasTripleTapUpHandled, isFalse); // Ensure the default behavior of placing an expanded selection // was not called. @@ -230,10 +242,11 @@ void main() { await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); // Ensure the custom tap handler was called. - expect(handler.wasSecondaryTapHandled, isTrue); - expect(handler.wasTapHandled, isFalse); - expect(handler.wasDoubleTapHandled, isFalse); - expect(handler.wasTripleTapHandled, isFalse); + expect(handler.wasSecondaryTapDownHandled, isTrue); + expect(handler.wasSecondaryTapUpHandled, isTrue); + expect(handler.wasTapUpHandled, isFalse); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); }); }); @@ -248,10 +261,11 @@ void main() { await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); // Ensure the custom tap handler was called. - expect(handler.wasSecondaryTapHandled, isTrue); - expect(handler.wasTapHandled, isFalse); - expect(handler.wasDoubleTapHandled, isFalse); - expect(handler.wasTripleTapHandled, isFalse); + 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 { @@ -264,16 +278,18 @@ void main() { await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); // Ensure the first tap handler was called. - expect(handler1.wasSecondaryTapHandled, isTrue); - expect(handler1.wasTapHandled, isFalse); - expect(handler1.wasDoubleTapHandled, isFalse); - expect(handler1.wasTripleTapHandled, isFalse); + 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.wasSecondaryTapHandled, isFalse); - expect(handler2.wasTapHandled, isFalse); - expect(handler2.wasDoubleTapHandled, isFalse); - expect(handler2.wasTripleTapHandled, isFalse); + expect(handler2.wasSecondaryTapDownHandled, isFalse); + expect(handler2.wasSecondaryTapUpHandled, isFalse); + expect(handler2.wasTapUpHandled, isFalse); + expect(handler2.wasDoubleTapDownHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); }); }); }); @@ -336,17 +352,29 @@ Future _pumpSingleFieldTestApp( /// This handler prevents any other handlers from running, because it always /// returns [TapHandlingInstruction.halt]. class _SuperTextFieldTestTapHandler extends SuperTextFieldTapHandler { - bool get wasTapHandled => _wasTapHandled; - bool _wasTapHandled = false; + bool get wasTapDownHandled => _wasTapDownHandled; + bool _wasTapDownHandled = false; - bool get wasDoubleTapHandled => _wasDoubleTapHandled; - bool _wasDoubleTapHandled = false; + bool get wasTapUpHandled => _wasTapUpHandled; + bool _wasTapUpHandled = false; - bool get wasTripleTapHandled => _wasTripleTapHandled; - bool _wasTripleTapHandled = false; + bool get wasDoubleTapDownHandled => _wasDoubleTapDownHandled; + bool _wasDoubleTapDownHandled = false; - bool get wasSecondaryTapHandled => _wasSecondaryTapHandled; - bool _wasSecondaryTapHandled = 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) { @@ -354,26 +382,50 @@ class _SuperTextFieldTestTapHandler extends SuperTextFieldTapHandler { } @override - TapHandlingInstruction onTap(SuperTextFieldGestureDetails details) { - _wasTapHandled = true; + 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 onDoubleTap(SuperTextFieldGestureDetails details) { - _wasDoubleTapHandled = true; + TapHandlingInstruction onTripleTapUp(SuperTextFieldGestureDetails details) { + _wasTripleTapUpHandled = true; return TapHandlingInstruction.halt; } @override - TapHandlingInstruction onTripleTap(SuperTextFieldGestureDetails details) { - _wasTripleTapHandled = true; + TapHandlingInstruction onSecondaryTapDown(SuperTextFieldGestureDetails details) { + _wasSecondaryTapDownHandled = true; return TapHandlingInstruction.halt; } @override - TapHandlingInstruction onSecondaryTap(SuperTextFieldGestureDetails details) { - _wasSecondaryTapHandled = true; + TapHandlingInstruction onSecondaryTapUp(SuperTextFieldGestureDetails details) { + _wasSecondaryTapUpHandled = true; return TapHandlingInstruction.halt; } } From 308a7c2d613a8339622418758ad0da70923b5e61 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 20 Dec 2024 10:06:50 -0300 Subject: [PATCH 4/6] Fix demo --- .../example/lib/demos/supertextfield/_interactive_demo.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart b/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart index 94fe188549..41cd93453b 100644 --- a/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart @@ -182,7 +182,7 @@ class _SuperTextFieldRightClickListener extends SuperTextFieldTapHandler { final RightClickHandler rightClickHandler; @override - TapHandlingInstruction onSecondaryTap(SuperTextFieldGestureDetails details) { + TapHandlingInstruction onSecondaryTapUp(SuperTextFieldGestureDetails details) { return rightClickHandler(details); } } From 6a41f6115cfec08d62b8ecc538a22741f716c999 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Jan 2025 22:46:35 -0300 Subject: [PATCH 5/6] PR updates --- .../document_gestures_interaction_overrides.dart | 11 +++++++++-- .../text_field_gestures_interaction_overrides.dart | 5 +---- 2 files changed, 10 insertions(+), 6 deletions(-) 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/infrastructure/text_field_gestures_interaction_overrides.dart b/super_editor/lib/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart index bec423b531..9afa65afdf 100644 --- a/super_editor/lib/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart +++ b/super_editor/lib/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart @@ -5,10 +5,7 @@ import 'package:super_text_layout/super_text_layout.dart'; /// Tap handler that can (optionally) respond to single, double, and triple taps, as well as dictate the cursor /// appearance on desktop. -/// -/// Listeners are notified when any time that the desired mouse cursor -/// may have changed. -abstract class SuperTextFieldTapHandler with ChangeNotifier { +abstract class SuperTextFieldTapHandler { MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) => null; TapHandlingInstruction onTapDown(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; From ece6ee695e3b772664b2450022b3ac5d0e072f97 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Jan 2025 23:17:29 -0300 Subject: [PATCH 6/6] Fix compilation errors --- .../desktop/desktop_textfield.dart | 40 ------------------- 1 file changed, 40 deletions(-) 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 ac791dcf43..1dc301d3dc 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -632,49 +632,9 @@ class _SuperTextFieldGestureInteractorState extends State widget.textScrollKey.currentState!; final _mouseCursor = ValueNotifier(SystemMouseCursors.text); - Offset? _lastHoverOffset; - - @override - void initState() { - super.initState(); - - for (final handler in widget.tapHandlers) { - handler.addListener(_updateMouseCursorAtLatestOffset); - } - } - - @override - void didUpdateWidget(SuperTextFieldGestureInteractor oldWidget) { - super.didUpdateWidget(oldWidget); - if (!const DeepCollectionEquality().equals(oldWidget.tapHandlers, widget.tapHandlers)) { - for (final handler in oldWidget.tapHandlers) { - handler.removeListener(_updateMouseCursorAtLatestOffset); - } - - for (final handler in widget.tapHandlers) { - handler.addListener(_updateMouseCursorAtLatestOffset); - } - } - } - - @override - void dispose() { - super.dispose(); - for (final handler in widget.tapHandlers) { - handler.removeListener(_updateMouseCursorAtLatestOffset); - } - } void _onMouseMove(PointerHoverEvent event) { _updateMouseCursor(event.position); - _lastHoverOffset = event.position; - } - - void _updateMouseCursorAtLatestOffset() { - if (_lastHoverOffset == null) { - return; - } - _updateMouseCursor(_lastHoverOffset!); } void _updateMouseCursor(Offset globalPosition) {