diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart index f5e3513de9..253f2ed385 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart @@ -1336,11 +1336,39 @@ class SuperEditorAndroidControlsOverlayManagerState extends State(null); final _magnifierFocalPoint = ValueNotifier(null); + late final DocumentHandleGestureDelegate _collapsedHandleGestureDelegate; + late final DocumentHandleGestureDelegate _upstreamHandleGesturesDelegate; + late final DocumentHandleGestureDelegate _downstreamHandleGesturesDelegate; + @override void initState() { super.initState(); _overlayController.show(); widget.selection.addListener(_onSelectionChange); + _collapsedHandleGestureDelegate = DocumentHandleGestureDelegate( + onTap: _toggleToolbarOnCollapsedHandleTap, + onPanStart: (details) => _onHandlePanStart(details, HandleType.collapsed), + onPanUpdate: _onHandlePanUpdate, + onPanEnd: (details) => _onHandlePanEnd(details, HandleType.collapsed), + ); + _upstreamHandleGesturesDelegate = DocumentHandleGestureDelegate( + onTap: () { + // Register tap down to win gesture arena ASAP. + }, + onPanStart: (details) => _onHandlePanStart(details, HandleType.upstream), + onPanUpdate: _onHandlePanUpdate, + onPanEnd: (details) => _onHandlePanEnd(details, HandleType.upstream), + onPanCancel: () => _onHandlePanCancel(HandleType.upstream), + ); + _downstreamHandleGesturesDelegate = DocumentHandleGestureDelegate( + onTap: () { + // Register tap down to win gesture arena ASAP. + }, + onPanStart: (details) => _onHandlePanStart(details, HandleType.downstream), + onPanUpdate: _onHandlePanUpdate, + onPanEnd: (details) => _onHandlePanEnd(details, HandleType.downstream), + onPanCancel: () => _onHandlePanCancel(HandleType.downstream), + ); } @override @@ -1675,6 +1703,16 @@ class SuperEditorAndroidControlsOverlayManagerState extends State _onHandlePanStart(details, HandleType.collapsed), - onPanUpdate: _onHandlePanUpdate, - onPanEnd: (details) => _onHandlePanEnd(details, HandleType.collapsed), - onPanCancel: () => _onHandlePanCancel(HandleType.collapsed), + onTap: _collapsedHandleGestureDelegate.onTap, + onPanStart: _collapsedHandleGestureDelegate.onPanStart, + onPanUpdate: _collapsedHandleGestureDelegate.onPanUpdate, + onPanEnd: _collapsedHandleGestureDelegate.onPanEnd, + onPanCancel: _collapsedHandleGestureDelegate.onPanCancel, dragStartBehavior: DragStartBehavior.down, child: AndroidSelectionHandle( key: DocumentKeys.androidCaretHandle, @@ -1719,6 +1757,26 @@ class SuperEditorAndroidControlsOverlayManagerState extends State _buildExpandedHandles() { + if (_controlsController!.expandedHandlesBuilder != null) { + return [ + ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowExpandedHandles, + builder: (context, shouldShow, child) { + return _controlsController!.expandedHandlesBuilder!( + context, + upstreamHandleKey: DocumentKeys.upstreamHandle, + upstreamFocalPoint: _controlsController!.upstreamHandleFocalPoint, + upstreamGestureDelegate: _upstreamHandleGesturesDelegate, + downstreamHandleKey: DocumentKeys.downstreamHandle, + downstreamFocalPoint: _controlsController!.downstreamHandleFocalPoint, + downstreamGestureDelegate: _downstreamHandleGesturesDelegate, + shouldShow: shouldShow, + ); + }, + ) + ]; + } + return [ ValueListenableBuilder( valueListenable: _controlsController!.shouldShowExpandedHandles, @@ -1735,13 +1793,11 @@ class SuperEditorAndroidControlsOverlayManagerState extends State _onHandlePanStart(details, HandleType.upstream), - onPanUpdate: _onHandlePanUpdate, - onPanEnd: (details) => _onHandlePanEnd(details, HandleType.upstream), - onPanCancel: () => _onHandlePanCancel(HandleType.upstream), + onTapDown: _upstreamHandleGesturesDelegate.onTapDown, + onPanStart: _upstreamHandleGesturesDelegate.onPanStart, + onPanUpdate: _upstreamHandleGesturesDelegate.onPanUpdate, + onPanEnd: _upstreamHandleGesturesDelegate.onPanEnd, + onPanCancel: _upstreamHandleGesturesDelegate.onPanCancel, dragStartBehavior: DragStartBehavior.down, child: AndroidSelectionHandle( key: DocumentKeys.upstreamHandle, @@ -1767,13 +1823,11 @@ class SuperEditorAndroidControlsOverlayManagerState extends State _onHandlePanStart(details, HandleType.downstream), - onPanUpdate: _onHandlePanUpdate, - onPanEnd: (details) => _onHandlePanEnd(details, HandleType.downstream), - onPanCancel: () => _onHandlePanCancel(HandleType.downstream), + onTapDown: _downstreamHandleGesturesDelegate.onTapDown, + onPanStart: _downstreamHandleGesturesDelegate.onPanStart, + onPanUpdate: _downstreamHandleGesturesDelegate.onPanUpdate, + onPanEnd: _downstreamHandleGesturesDelegate.onPanEnd, + onPanCancel: _downstreamHandleGesturesDelegate.onPanCancel, dragStartBehavior: DragStartBehavior.down, child: AndroidSelectionHandle( key: DocumentKeys.downstreamHandle, diff --git a/super_editor/lib/src/infrastructure/platforms/android/drag_handle_selection.dart b/super_editor/lib/src/infrastructure/platforms/android/drag_handle_selection.dart index f83014f575..f1ba919a51 100644 --- a/super_editor/lib/src/infrastructure/platforms/android/drag_handle_selection.dart +++ b/super_editor/lib/src/infrastructure/platforms/android/drag_handle_selection.dart @@ -137,10 +137,12 @@ class AndroidTextFieldDragHandleSelectionStrategy { final didFocalPointStayInSameNode = nearestPositionNodeIndex == previousNearestPositionNodeIndex; final didFocalPointMoveDownstream = didFocalPointMoveToDownstreamNode || - (didFocalPointStayInSameNode && nearestPositionTextOffset > previousNearestPositionTextOffset); + (didFocalPointStayInSameNode && nearestPositionTextOffset > previousNearestPositionTextOffset) || + (didFocalPointStayInSameNode && details.delta.dx > 0); final didFocalPointMoveUpstream = didFocalPointMoveToUpstreamNode || - (didFocalPointStayInSameNode && nearestPositionTextOffset < previousNearestPositionTextOffset); + (didFocalPointStayInSameNode && nearestPositionTextOffset < previousNearestPositionTextOffset) || + (didFocalPointStayInSameNode && details.delta.dx < 0); _lastFocalPosition = nearestPosition; diff --git a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart index c890a5d96d..95fafe93d4 100644 --- a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart +++ b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart @@ -20,24 +20,45 @@ class DocumentKeys { /// Builds a full-screen collapsed drag handle display, with the handle positioned near the [focalPoint], /// and with the handle attached to the given [handleKey]. /// -/// The [handleKey] is used to find the handle in the widget tree for various purposes, -/// e.g., within tests to verify the presence or absence of the handle. -/// -/// The [handleKey] must be attached to the handle, not the top-level widget returned -/// from this builder, because the [handleKey] might be used to verify the size and location -/// of the handle. For example: +/// Implementers of this builder have the following responsibilities: +/// * Attach the [handleKey] to the widget that renders the handle. +/// * Wrap the handle widget with a `Follower` and attach the `focalPoint` to the `Follower`. +/// * Wrap the handle widget with a `GestureDetector` and attach the provided [gestureDelegate] callbacks to the `GestureDetector`. +/// * When [shouldShow] is `false`, hide the handle and ensure that no gestures are handled. /// /// ```dart -/// Widget buildCollapsedHandle(context, handleKey, focalPoint) { -/// return Follower( +/// Widget buildCollapsedHandle(BuildContext context, { +/// required LeaderLink focalPoint, +/// required DocumentHandleGestureDelegate gestureDelegate, +/// required Key handleKey, +/// required bool shouldShow, +/// }) { +/// if (!shouldShow) { +/// return const SizedBox(); +/// } +/// return Follower.withOffset( +/// offset: Offset.zero, /// link: focalPoint, -/// child: CollapsedHandle( -/// key: handleKey, +/// child: GestureDetector( +/// onTap: gestureDelegate.onTap, +/// onPanStart: gestureDelegate.onPanStart, +/// onPanUpdate: gestureDelegate.onPanUpdate, +/// onPanEnd: gestureDelegate.onPanEnd, +/// onPanCancel: gestureDelegate.onPanCancel, +/// child: CollapsedHandle( +/// key: handleKey, +/// ), /// ), /// ); /// } /// ``` -typedef DocumentCollapsedHandleBuilder = Widget Function(BuildContext, Key handleKey, LeaderLink focalPoint); +typedef DocumentCollapsedHandleBuilder = Widget Function( + BuildContext, { + required Key handleKey, + required LeaderLink focalPoint, + required DocumentHandleGestureDelegate gestureDelegate, + required bool shouldShow, +}); /// Builds a full-screen display of a set of expanded drag handles, with the handles positioned near the /// [upstreamFocalPoint] and [downstreamFocalPoint], respectively, and with the handles attached to the @@ -46,33 +67,119 @@ typedef DocumentCollapsedHandleBuilder = Widget Function(BuildContext, Key handl /// The [upstreamHandleKey] and [downstreamHandleKey] are used to find the handles in the widget tree for /// various purposes, e.g., within tests to verify the presence or absence of the handles. /// +/// Implementers of this builder have the following responsibilities: +/// * Attach the [upstreamHandleKey] to the widget that renders the upstream handle and [downstreamHandleKey] +/// to the downstream handle. +/// * Wrap each handle widget with a `Follower`, attaching the [downstreamFocalPoint] to the downstream handle `Follower` +/// and [upstreamFocalPoint] to the upstream handle `Follower`. +/// * Wrap each handle widget with a `GestureDetector`, attaching the provided [upstreamGestureDelegate] callbacks to +/// the upstream handle `GestureDetector` and the [downstreamGestureDelegate] callbacks to the downstream +/// handle `GestureDetector`. +/// * When [shouldShow] is `false`, hide the handle and ensure that no gestures are handled. +/// /// The handle keys must be attached to the handles, not the top-level widget returned /// from this builder, because the handle keys might be used to verify the size and location /// of the handles. For example: /// /// ```dart -/// Widget buildCollapsedHandle(context, upstreamHandleKey, upstreamFocalPoint, downstreamHandleKey, downstreamFocalPoint) { +/// Widget buildExpandedHandles(BuildContext context, { +/// required LeaderLink downstreamFocalPoint, +/// required DocumentHandleGestureDelegate downstreamGestureDelegate, +/// required Key downstreamHandleKey, +/// required LeaderLink upstreamFocalPoint, +/// required DocumentHandleGestureDelegate upstreamGestureDelegate, +/// required Key upstreamHandleKey, +/// required bool shouldShow, +/// }) { +/// if (!shouldShow) { +/// return const SizedBox(); +/// } /// return Stack( /// children: [ -/// Follower( -/// link: upstreamFocalPoint, -/// child: UpstreamHandle(key: upstreamHandleKey), +/// Follower.withOffset( +/// offset: Offset.zero, +/// link: upstreamFocalPoint, +/// child: GestureDetector( +/// onTapDown: upstreamGestureDelegate.onTapDown, +/// onPanStart: upstreamGestureDelegate.onPanStart, +/// onPanUpdate: upstreamGestureDelegate.onPanUpdate, +/// onPanEnd: upstreamGestureDelegate.onPanEnd, +/// onPanCancel: upstreamGestureDelegate.onPanCancel, +/// child: UpstreamHandle(key: upstreamHandleKey), +/// ), /// ), -/// Follower( -/// link: downstreamFocalPoint, -/// child: DownstreamHandle(key: downstreamHandleKey), +/// Follower.withOffset( +/// offset: Offset.zero, +/// link: downstreamFocalPoint, +/// child: GestureDetector( +/// onTapDown: downstreamGestureDelegate.onTapDown, +/// onPanStart: downstreamGestureDelegate.onPanStart, +/// onPanUpdate: downstreamGestureDelegate.onPanUpdate, +/// onPanEnd: downstreamGestureDelegate.onPanEnd, +/// onPanCancel: downstreamGestureDelegate.onPanCancel, +/// child: DownstreamHandle(key: downstreamHandleKey), +/// ), /// ), /// ], /// ); /// } /// ``` typedef DocumentExpandedHandlesBuilder = Widget Function( - BuildContext, - Key upstreamHandleKey, - LeaderLink upstreamFocalPoint, - Key downstreamHandleKey, - LeaderLink downstreamFocalPoint, -); + BuildContext, { + required Key upstreamHandleKey, + required LeaderLink upstreamFocalPoint, + required DocumentHandleGestureDelegate upstreamGestureDelegate, + required Key downstreamHandleKey, + required LeaderLink downstreamFocalPoint, + required DocumentHandleGestureDelegate downstreamGestureDelegate, + required bool shouldShow, +}); + +/// Delegate for handling gestures on a document handle. +/// +/// These callbacks are intended to make it easier for developers to customize +/// the drag handles, without having to re-implement the gesture logic. For +/// example, implementers can wrap the handle in a `GestureDetector`: +/// +/// ```dart +/// Widget buildCollapsedHandle(BuildContext context, { +/// required LeaderLink focalPoint, +/// required DocumentHandleGestureDelegate gestureDelegate, +/// required Key handleKey, +/// required bool shouldShow, +/// }) { +/// return Follower( +/// link: focalPoint, +/// child: GestureDetector( +/// onTap: gestureDelegate.onTap, +/// onPanStart: gestureDelegate.onPanStart, +/// onPanUpdate: gestureDelegate.onPanUpdate, +/// onPanEnd: gestureDelegate.onPanEnd, +/// onPanCancel: gestureDelegate.onPanCancel, +/// child: CollapsedHandle( +/// key: handleKey, +/// ), +/// ), +/// ); +/// } +/// ``` +class DocumentHandleGestureDelegate { + DocumentHandleGestureDelegate({ + this.onTapDown, + this.onTap, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd, + this.onPanCancel, + }); + + final GestureTapDownCallback? onTapDown; + final GestureTapCallback? onTap; + final GestureDragStartCallback? onPanStart; + final GestureDragUpdateCallback? onPanUpdate; + final GestureDragEndCallback? onPanEnd; + final GestureDragCancelCallback? onPanCancel; +} /// Builds a full-screen floating toolbar display, with the toolbar positioned near the /// [focalPoint], and with the toolbar attached to the given [mobileToolbarKey]. @@ -422,8 +529,8 @@ class DragHandleAutoScroller { // at the top edge of the scrollable, so we can't scroll further up. if (currentScrollOffset > 0.0) { // Jump to the position where the offset sits at the leading boundary. - scrollPosition.jumpTo(( - currentScrollOffset + (offsetInViewport.dy - _dragAutoScrollBoundary.leading).clamp(min, max)), + scrollPosition.jumpTo( + (currentScrollOffset + (offsetInViewport.dy - _dragAutoScrollBoundary.leading).clamp(min, max)), ); } } else if (offsetInViewport.dy > _getViewportBox().size.height - _dragAutoScrollBoundary.trailing) { diff --git a/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart b/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart index 3b532b7b56..19f01956c6 100644 --- a/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart +++ b/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; @@ -304,6 +305,91 @@ void main() { expect(SuperEditorInspector.isCaretVisible(), true); }); + testWidgetsOnAndroid("allows customizing the collapsed handle", (tester) async { + // Use a key different from the provided by the builder to make sure our handle + // is used instead of the default one. + final collapsedFinderKey = GlobalKey(); + + await tester // + .createDocument() + .withSingleParagraph() + .withAndroidCollapsedHandleBuilder( + ( + BuildContext context, { + required Key handleKey, + required LeaderLink focalPoint, + required DocumentHandleGestureDelegate gestureDelegate, + required bool shouldShow, + }) { + return SizedBox( + key: collapsedFinderKey, + width: 20, + height: 20, + child: Container( + key: handleKey, + ), + ); + }, + ).pump(); + + // Place the caret at the beginning of the document to show the collapsed handle. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the custom handle is used. + expect(find.byKey(collapsedFinderKey), findsOneWidget); + }); + + testWidgetsOnAndroid("allows customizing the expanded handles", (tester) async { + // Use keys different from the provided by the builder to make sure our handles + // are used instead of the default ones. + final upstreamFinderKey = GlobalKey(); + final downstreamFinderKey = GlobalKey(); + + await tester // + .createDocument() + .withSingleParagraph() + .withAndroidExpandedHandlesBuilder( + ( + BuildContext context, { + required Key upstreamHandleKey, + required LeaderLink upstreamFocalPoint, + required DocumentHandleGestureDelegate upstreamGestureDelegate, + required Key downstreamHandleKey, + required LeaderLink downstreamFocalPoint, + required DocumentHandleGestureDelegate downstreamGestureDelegate, + required bool shouldShow, + }) { + return Stack( + children: [ + SizedBox( + key: upstreamFinderKey, + width: 20, + height: 20, + child: Container( + key: upstreamHandleKey, + ), + ), + SizedBox( + key: downstreamFinderKey, + width: 20, + height: 20, + child: Container( + key: downstreamHandleKey, + ), + ), + ], + ); + }, + ).pump(); + + // Double tap to select the first word and show the expanded handles. + await tester.doubleTapInParagraph('1', 0); + + // Ensure the custom handles are used. + expect(find.byKey(upstreamFinderKey), findsOneWidget); + expect(find.byKey(downstreamFinderKey), findsOneWidget); + }); + group('shows magnifier above the caret when dragging the collapsed handle', () { testWidgetsOnAndroid('with an ancestor scrollable', (tester) async { final scrollController = ScrollController(); diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index a4a5a5cfdf..a1d2a32e0a 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -330,6 +330,18 @@ class TestSuperEditorConfigurator { return this; } + /// Configures the [SuperEditor] to use the given [builder] as its android collapsed handle builder. + TestSuperEditorConfigurator withAndroidCollapsedHandleBuilder(DocumentCollapsedHandleBuilder? builder) { + _config.androidCollapsedHandleBuilder = builder; + return this; + } + + /// Configures the [SuperEditor] to use the given [builder] as its android expanded handles builder. + TestSuperEditorConfigurator withAndroidExpandedHandlesBuilder(DocumentExpandedHandlesBuilder? builder) { + _config.androidExpandedHandlesBuilder = builder; + return this; + } + /// Configures the [SuperEditor] to use the given [builder] as its iOS toolbar builder. TestSuperEditorConfigurator withiOSToolbarBuilder(DocumentFloatingToolbarBuilder? builder) { _config.iOSToolbarBuilder = builder; @@ -591,6 +603,8 @@ class _TestSuperEditorState extends State<_TestSuperEditor> { _androidControlsController = SuperEditorAndroidControlsController( toolbarBuilder: widget.testConfiguration.androidToolbarBuilder, + collapsedHandleBuilder: widget.testConfiguration.androidCollapsedHandleBuilder, + expandedHandlesBuilder: widget.testConfiguration.androidExpandedHandlesBuilder, ); } @@ -756,7 +770,11 @@ class SuperEditorTestConfiguration { final prependedKeyboardActions = []; final appendedKeyboardActions = []; final addedComponents = []; + DocumentFloatingToolbarBuilder? androidToolbarBuilder; + DocumentCollapsedHandleBuilder? androidCollapsedHandleBuilder; + DocumentExpandedHandlesBuilder? androidExpandedHandlesBuilder; + DocumentFloatingToolbarBuilder? iOSToolbarBuilder; DocumentSelection? selection;