From c54bbb2e7b0f67ef92165a57fcbf430b8df201b2 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Sun, 8 Oct 2023 13:15:55 -0700 Subject: [PATCH] [SuperEditor][SuperReader][iOS][Android] - Long-press content selection (Resolves #1203) (#1489) --- .../.run/Super Editor Demo (debug).run.xml | 6 + .../.run/Super Reader Demo (debug).run.xml | 6 + .../demo_read_only_scrolling_document.dart | 0 .../demo_super_reader.dart | 0 .../example_document.dart | 0 super_editor/example/lib/main.dart | 4 +- .../example/lib/main_super_editor.dart | 34 + .../example/lib/main_super_reader.dart | 16 + .../document_gestures_touch_android.dart | 220 +++++- .../document_gestures_touch_ios.dart | 152 +++- super_editor/lib/src/default_editor/text.dart | 34 +- .../lib/src/default_editor/text_tools.dart | 4 +- .../lib/src/infrastructure/_logging.dart | 2 + .../android/android_document_controls.dart | 18 +- .../android/long_press_selection.dart | 683 ++++++++++++++++++ .../platforms/ios/long_press_selection.dart | 128 ++++ ...nly_document_android_touch_interactor.dart | 172 +++++ ...ad_only_document_ios_touch_interactor.dart | 129 +++- .../super_editor_test/supereditor_robot.dart | 132 +++- ...obile_long_press_selection_text_layout.png | Bin 0 -> 25007 bytes .../super_editor_android_selection_test.dart | 515 +++++++++++++ .../super_editor_ios_selection_test.dart | 244 +++++++ .../super_reader_android_selection_test.dart | 516 +++++++++++++ .../super_reader_ios_selection_test.dart | 248 +++++++ .../test/super_reader/reader_test_tools.dart | 16 + 25 files changed, 3196 insertions(+), 83 deletions(-) create mode 100644 super_editor/.run/Super Editor Demo (debug).run.xml create mode 100644 super_editor/.run/Super Reader Demo (debug).run.xml rename super_editor/example/lib/demos/{super_document => super_reader}/demo_read_only_scrolling_document.dart (100%) rename super_editor/example/lib/demos/{super_document => super_reader}/demo_super_reader.dart (100%) rename super_editor/example/lib/demos/{super_document => super_reader}/example_document.dart (100%) create mode 100644 super_editor/example/lib/main_super_editor.dart create mode 100644 super_editor/example/lib/main_super_reader.dart create mode 100644 super_editor/lib/src/infrastructure/platforms/android/long_press_selection.dart create mode 100644 super_editor/lib/src/infrastructure/platforms/ios/long_press_selection.dart create mode 100644 super_editor/test/super_editor/mobile/mobile_long_press_selection_text_layout.png create mode 100644 super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart create mode 100644 super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart create mode 100644 super_editor/test/super_reader/mobile/super_reader_android_selection_test.dart create mode 100644 super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart diff --git a/super_editor/.run/Super Editor Demo (debug).run.xml b/super_editor/.run/Super Editor Demo (debug).run.xml new file mode 100644 index 0000000000..19e93ac66d --- /dev/null +++ b/super_editor/.run/Super Editor Demo (debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Super Reader Demo (debug).run.xml b/super_editor/.run/Super Reader Demo (debug).run.xml new file mode 100644 index 0000000000..e03456ac99 --- /dev/null +++ b/super_editor/.run/Super Reader Demo (debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/example/lib/demos/super_document/demo_read_only_scrolling_document.dart b/super_editor/example/lib/demos/super_reader/demo_read_only_scrolling_document.dart similarity index 100% rename from super_editor/example/lib/demos/super_document/demo_read_only_scrolling_document.dart rename to super_editor/example/lib/demos/super_reader/demo_read_only_scrolling_document.dart diff --git a/super_editor/example/lib/demos/super_document/demo_super_reader.dart b/super_editor/example/lib/demos/super_reader/demo_super_reader.dart similarity index 100% rename from super_editor/example/lib/demos/super_document/demo_super_reader.dart rename to super_editor/example/lib/demos/super_reader/demo_super_reader.dart diff --git a/super_editor/example/lib/demos/super_document/example_document.dart b/super_editor/example/lib/demos/super_reader/example_document.dart similarity index 100% rename from super_editor/example/lib/demos/super_document/example_document.dart rename to super_editor/example/lib/demos/super_reader/example_document.dart diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 168c6b9cea..a6053be507 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -21,7 +21,7 @@ import 'package:example/demos/in_the_lab/selected_text_colors_demo.dart'; import 'package:example/demos/scrolling/demo_task_and_chat_with_customscrollview.dart'; import 'package:example/demos/sliver_example_editor.dart'; import 'package:example/demos/styles/demo_doc_styles.dart'; -import 'package:example/demos/super_document/demo_super_reader.dart'; +import 'package:example/demos/super_reader/demo_super_reader.dart'; import 'package:example/demos/supertextfield/demo_textfield.dart'; import 'package:example/demos/supertextfield/ios/demo_superiostextfield.dart'; import 'package:example/logging.dart'; @@ -34,7 +34,7 @@ import 'package:super_editor/super_editor.dart'; import 'demos/demo_attributed_text.dart'; import 'demos/demo_document_loses_focus.dart'; import 'demos/demo_switch_document_content.dart'; -import 'demos/super_document/demo_read_only_scrolling_document.dart'; +import 'demos/super_reader/demo_read_only_scrolling_document.dart'; import 'demos/supertextfield/android/demo_superandroidtextfield.dart'; /// Demo of a basic text editor, as well as various widgets that diff --git a/super_editor/example/lib/main_super_editor.dart b/super_editor/example/lib/main_super_editor.dart new file mode 100644 index 0000000000..8156040cd7 --- /dev/null +++ b/super_editor/example/lib/main_super_editor.dart @@ -0,0 +1,34 @@ +import 'package:example/demos/example_editor/example_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A demo of a [SuperEditor] experience. +/// +/// This demo only shows a single, typical [SuperEditor]. To see a variety of +/// demos, see the main demo experience in this project. +void main() { + initLoggers(Level.FINEST, { + // editorScrollingLog, + // editorGesturesLog, + // longPressSelectionLog, + // editorImeLog, + // editorImeDeltasLog, + // editorKeyLog, + // editorOpsLog, + // editorLayoutLog, + // editorDocLog, + // editorStyleLog, + // textFieldLog, + // editorUserTagsLog, + // contentLayersLog, + }); + + runApp( + MaterialApp( + home: Scaffold( + body: ExampleEditor(), + ), + ), + ); +} diff --git a/super_editor/example/lib/main_super_reader.dart b/super_editor/example/lib/main_super_reader.dart new file mode 100644 index 0000000000..169cac9ed2 --- /dev/null +++ b/super_editor/example/lib/main_super_reader.dart @@ -0,0 +1,16 @@ +import 'package:example/demos/super_reader/demo_super_reader.dart'; +import 'package:flutter/material.dart'; + +/// A demo of a [SuperReader] experience. +/// +/// This demo only shows a single, typical [SuperReader]. To see a variety of +/// demos, see the main demo experience in this project. +void main() { + runApp( + MaterialApp( + home: Scaffold( + body: SuperReaderDemo(), + ), + ), + ); +} 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 f13082f8ce..7606abb7bd 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 @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; @@ -16,6 +18,7 @@ import 'package:super_editor/src/infrastructure/blinking_caret.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/android/android_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/long_press_selection.dart'; import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; @@ -130,6 +133,12 @@ class _AndroidDocumentTouchInteractorState extends State _longPressStrategy != null; + AndroidDocumentLongPressSelectionStrategy? _longPressStrategy; + final _longPressMagnifierGlobalOffset = ValueNotifier(null); + @override void initState() { super.initState(); @@ -440,7 +449,7 @@ class _AndroidDocumentTouchInteractorState extends State (_findAncestorScrollable(context)?.context.findRenderObject() ?? context.findRenderObject()) as RenderBox; - Offset _getDocOffsetFromGlobalOffset(Offset globalOffset) { + Offset _getDocumentOffsetFromGlobalOffset(Offset globalOffset) { return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } @@ -477,9 +486,57 @@ class _AndroidDocumentTouchInteractorState extends State PanGestureRecognizer(), (PanGestureRecognizer recognizer) { recognizer + ..onStart = _onPanStart ..onUpdate = _onPanUpdate ..onEnd = _onPanEnd + ..onCancel = _onPanCancel ..gestureSettings = gestureSettings; }, ), @@ -1109,6 +1293,7 @@ class AndroidDocumentTouchEditingControls extends StatefulWidget { this.onHandleDragEnd, required this.popoverToolbarBuilder, this.createOverlayControlsClipper, + required this.longPressMagnifierGlobalOffset, this.showDebugPaint = false, }) : super(key: key); @@ -1142,6 +1327,8 @@ class AndroidDocumentTouchEditingControls extends StatefulWidget { /// Typically, this bar includes actions like "copy", "cut", "paste", etc. final WidgetBuilder popoverToolbarBuilder; + final ValueNotifier longPressMagnifierGlobalOffset; + final bool showDebugPaint; @override @@ -1308,7 +1495,8 @@ class _AndroidDocumentTouchEditingControlsState extends State // not collapsed/upstream/downstream. Change the type once it's working. HandleType? _dragHandleType; + Timer? _tapDownLongPressTimer; + Offset? _globalTapDownOffset; + bool get _isLongPressInProgress => _longPressStrategy != null; + IosLongPressSelectionStrategy? _longPressStrategy; + // Whether we're currently waiting to see if the user taps // again on the document. // @@ -496,9 +504,55 @@ class _IOSDocumentTouchInteractorState extends State (scrollPosition as ScrollPositionWithSingleContext).goIdle(); return; } + + _globalTapDownOffset = details.globalPosition; + _tapDownLongPressTimer?.cancel(); + _tapDownLongPressTimer = Timer(kLongPressTimeout, _onLongPressDown); + } + + // Runs when a tap down has lasted long enough to signify a long-press. + void _onLongPressDown() { + final interactorOffset = interactorBox.globalToLocal(_globalTapDownOffset!); + final tapDownDocumentOffset = _interactorOffsetToDocOffset(interactorOffset); + final tapDownDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); + if (tapDownDocumentPosition == null) { + return; + } + + if (_isOverBaseHandle(interactorOffset) || + _isOverExtentHandle(interactorOffset) || + _isOverCollapsedHandle(interactorOffset)) { + // Don't do anything for long presses over the handles, because we want the user + // to be able to drag them without worrying about how long they've pressed. + return; + } + + _globalDragOffset = _globalTapDownOffset; + _longPressStrategy = IosLongPressSelectionStrategy( + document: widget.document, + documentLayout: _docLayout, + select: _select, + ); + final didLongPressSelectionStart = _longPressStrategy!.onLongPressStart( + tapDownDocumentOffset: tapDownDocumentOffset, + ); + if (!didLongPressSelectionStart) { + _longPressStrategy = null; + return; + } + + _editingController.hideToolbar(); + _editingController.showMagnifier(); + _controlsOverlayEntry?.markNeedsBuild(); + + widget.focusNode.requestFocus(); } void _onTapUp(TapUpDetails details) { + // Stop waiting for a long-press to start. + _globalTapDownOffset = null; + _tapDownLongPressTimer?.cancel(); + if (_wasScrollingOnTapDown) { // The scrollable was scrolling when the user touched down. We expect that the // touch down stopped the scrolling momentum. We don't want to take any further @@ -727,6 +781,10 @@ class _IOSDocumentTouchInteractorState extends State } void _onPanStart(DragStartDetails details) { + // Stop waiting for a long-press to start, if a long press isn't already in-progress. + _globalTapDownOffset = null; + _tapDownLongPressTimer?.cancel(); + // TODO: to help the user drag handles instead of scrolling, try checking touch // placement during onTapDown, and then pick that up here. I think the little // bit of slop might be the problem. @@ -735,7 +793,11 @@ class _IOSDocumentTouchInteractorState extends State return; } - if (selection.isCollapsed && _isOverCollapsedHandle(details.localPosition)) { + if (_isLongPressInProgress) { + _dragMode = DragMode.longPress; + _dragHandleType = null; + _longPressStrategy!.onLongPressDragStart(); + } else if (selection.isCollapsed && _isOverCollapsedHandle(details.localPosition)) { _dragMode = DragMode.collapsed; _dragHandleType = HandleType.collapsed; } else if (_isOverBaseHandle(details.localPosition)) { @@ -755,11 +817,17 @@ class _IOSDocumentTouchInteractorState extends State final handleOffsetInInteractor = interactorBox.globalToLocal(details.globalPosition); _dragStartInDoc = _interactorOffsetToDocOffset(handleOffsetInInteractor); - _startDragPositionOffset = _docLayout - .getRectForPosition( - _dragHandleType! == HandleType.upstream ? selection.base : selection.extent, - )! - .center; + if (_dragHandleType != null) { + _startDragPositionOffset = _docLayout + .getRectForPosition( + _dragHandleType == HandleType.upstream ? selection.base : selection.extent, + )! + .center; + } else { + // User is long-press dragging, which is why there's no drag handle type. + // In this case, the start drag offset is wherever the user touched. + _startDragPositionOffset = _dragStartInDoc!; + } // We need to record the scroll offset at the beginning of // a drag for the case that this interactor is embedded @@ -829,15 +897,24 @@ class _IOSDocumentTouchInteractorState extends State return; } - // The user is dragging a handle. Update the document selection, and - // auto-scroll, if needed. _globalDragOffset = details.globalPosition; final interactorBox = context.findRenderObject() as RenderBox; _dragEndInInteractor = interactorBox.globalToLocal(details.globalPosition); final dragEndInViewport = _interactorOffsetInViewport(_dragEndInInteractor!); - _updateSelectionForNewDragHandleLocation(); + if (_isLongPressInProgress) { + final fingerDragDelta = _globalDragOffset! - _globalStartDragOffset!; + final scrollDelta = _dragStartScrollOffset! - scrollPosition.pixels; + final fingerDocumentOffset = _docLayout.getDocumentOffsetFromAncestorOffset(details.globalPosition); + final fingerDocumentPosition = _docLayout.getDocumentPositionNearestToOffset( + _startDragPositionOffset! + fingerDragDelta - Offset(0, scrollDelta), + ); + _longPressStrategy!.onLongPressDragUpdate(fingerDocumentOffset, fingerDocumentPosition); + } else { + _updateSelectionForNewDragHandleLocation(); + } + // Auto-scroll, if needed, for either handle dragging or long press dragging. _handleAutoScrolling.updateAutoScrollHandleMonitoring( dragEndInViewport: dragEndInViewport, ); @@ -852,7 +929,6 @@ class _IOSDocumentTouchInteractorState extends State final dragScrollDelta = _dragStartScrollOffset! - scrollPosition.pixels; final docDragPosition = _docLayout .getDocumentPositionNearestToOffset(_startDragPositionOffset! + docDragDelta - Offset(0, dragScrollDelta)); - if (docDragPosition == null) { return; } @@ -905,22 +981,44 @@ class _IOSDocumentTouchInteractorState extends State } } } else { - // The user was dragging a handle. Stop any auto-scrolling that may have started. - _onHandleDragEnd(); + // The user was dragging a selection change in some way, either with handles + // or with a long-press. Finish that interaction. + _onDragSelectionEnd(); } } void _onPanCancel() { if (_dragMode != null) { - _onHandleDragEnd(); + _onDragSelectionEnd(); } } - void _onHandleDragEnd() { + void _onDragSelectionEnd() { + if (_dragMode == DragMode.longPress) { + _onLongPressEnd(); + } else { + _onHandleDragEnd(); + } + _handleAutoScrolling.stopAutoScrollHandleMonitoring(); scrollPosition.removeListener(_updateDragSelection); + } + + void _onLongPressEnd() { + _longPressStrategy!.onLongPressEnd(); + _longPressStrategy = null; _dragMode = null; + _updateOverlayControlsAfterFinishingDragSelection(); + } + + void _onHandleDragEnd() { + _dragMode = null; + + _updateOverlayControlsAfterFinishingDragSelection(); + } + + void _updateOverlayControlsAfterFinishingDragSelection() { _editingController.hideMagnifier(); if (!widget.selection.value!.isCollapsed) { _editingController.showToolbar(); @@ -942,6 +1040,11 @@ class _IOSDocumentTouchInteractorState extends State return; } + if (_dragHandleType == null) { + // The user is probably doing a long-press drag. Nothing for us to do here. + return; + } + final dragEndInDoc = _interactorOffsetToDocOffset(_dragEndInInteractor!); final dragPosition = _docLayout.getDocumentPositionNearestToOffset(dragEndInDoc); editorGesturesLog.info("Selecting new position during drag: $dragPosition"); @@ -1164,19 +1267,23 @@ class _IOSDocumentTouchInteractorState extends State }) { final newSelection = getWordSelection(docPosition: docPosition, docLayout: docLayout); if (newSelection != null) { - widget.editor.execute([ - ChangeSelectionRequest( - newSelection, - SelectionChangeType.expandSelection, - SelectionReason.userInteraction, - ), - ]); + _select(newSelection); return true; } else { return false; } } + void _select(DocumentSelection newSelection) { + widget.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + } + void _selectParagraphAtCaret() { final docSelection = widget.selection.value; if (docSelection == null) { @@ -1318,4 +1425,7 @@ enum DragMode { base, // Dragging the extent handle extent, + // Dragging after a long-press, which selects by the word + // around the selected word. + longPress, } diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index cefdfe8c54..7e58cb609d 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -552,8 +552,40 @@ class TextComponentState extends State with DocumentComponent imp baseOffset: baseNodePosition.offset, extentOffset: extentNodePosition.offset, ); - final boxes = textLayout.getBoxesForSelection(selection); + if (selection.isCollapsed) { + // A collapsed selection reports no boxes, but we want to return a rect at the + // selection's x-offset, and with a height that matches the text. Try to calculate + // a selection rectangle based on the character that's either after, or before, the + // collapsed selection position. + TextBox? characterBox = textLayout.getCharacterBox(selection.extent); + if (characterBox != null) { + final rect = characterBox.toRect(); + return Rect.fromLTWH(rect.left, rect.top, 0, rect.height); + } + + // We didn't find a character at the given offset. That offset might be at the end + // of the text. Try looking one character upstream. + characterBox = selection.extent.offset > 0 + ? textLayout.getCharacterBox(TextPosition(offset: selection.extent.offset - 1)) + : null; + if (characterBox != null) { + final rect = characterBox.toRect(); + // Use the right side of the character because this is the character that appears + // BEFORE the position we want, which means the position we want is just after + // this character box. + return Rect.fromLTWH(rect.right, rect.top, 0, rect.height); + } + + // We couldn't find a character box, which means the text is empty. Return + // the caret height, or the estimated line height. + final caretHeight = textLayout.getHeightForCaret(selection.extent); + return caretHeight != null + ? Rect.fromLTWH(0, 0, 0, caretHeight) + : Rect.fromLTWH(0, 0, 0, textLayout.estimatedLineHeight); + } + + final boxes = textLayout.getBoxesForSelection(selection); Rect boundingBox = boxes.isNotEmpty ? boxes.first.toRect() : Rect.zero; for (int i = 1; i < boxes.length; ++i) { boundingBox = boundingBox.expandToInclude(boxes[i].toRect()); diff --git a/super_editor/lib/src/default_editor/text_tools.dart b/super_editor/lib/src/default_editor/text_tools.dart index cc83643246..03b5dd7558 100644 --- a/super_editor/lib/src/default_editor/text_tools.dart +++ b/super_editor/lib/src/default_editor/text_tools.dart @@ -33,7 +33,9 @@ DocumentSelection? getWordSelection({ return null; } - final TextSelection wordTextSelection = (component as TextComposable).getWordSelectionAt(nodePosition); + // Create a new TextNodePosition to ensure that we're searching with downstream affinity, for consistent results. + final searchPosition = TextNodePosition(offset: nodePosition.offset); + final TextSelection wordTextSelection = (component as TextComposable).getWordSelectionAt(searchPosition); final wordNodeSelection = TextNodeSelection.fromTextSelection(wordTextSelection); _log.log('getWordSelection', ' - word selection: $wordNodeSelection'); diff --git a/super_editor/lib/src/infrastructure/_logging.dart b/super_editor/lib/src/infrastructure/_logging.dart index 5860f8871a..a065714e4d 100644 --- a/super_editor/lib/src/infrastructure/_logging.dart +++ b/super_editor/lib/src/infrastructure/_logging.dart @@ -40,6 +40,7 @@ class LogNames { static const iosTextField = 'textfield.ios'; static const infrastructure = 'infrastructure'; + static const longPressSelection = 'infrastructure.gestures.longPress'; static const scheduler = 'infrastructure.scheduler'; static const contentLayers = 'infrastructure.content_layers'; static const attributions = 'infrastructure.attributions'; @@ -81,6 +82,7 @@ final iosTextFieldLog = logging.Logger(LogNames.iosTextField); final docGesturesLog = logging.Logger(LogNames.documentGestures); final infrastructureLog = logging.Logger(LogNames.infrastructure); +final longPressSelectionLog = logging.Logger(LogNames.longPressSelection); final schedulerLog = logging.Logger(LogNames.scheduler); final contentLayersLog = logging.Logger(LogNames.contentLayers); final attributionsLog = logging.Logger(LogNames.attributions); diff --git a/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart index 78f7d87f18..38f15fc3cb 100644 --- a/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart @@ -68,8 +68,21 @@ class AndroidDocumentGestureEditingController extends GestureEditingController { notifyListeners(); } + void allowHandles() => _allowedToShowHandles = true; + + void disallowHandles() => _allowedToShowHandles = false; + + /// Whether or not the overlay is allowed to show handles, regardless of selection. + /// + /// When this is `false`, the handles should be hidden, even if there's a selection, + /// and the handles have valid visual offsets. + /// + /// When this is `true`, the handles MAY be shown, assuming all other necessary + /// conditions are met, e.g., there's a selection. + bool _allowedToShowHandles = true; + /// Whether a collapsed handle should be displayed. - bool get shouldDisplayCollapsedHandle => _collapsedHandleOffset != null; + bool get shouldDisplayCollapsedHandle => _allowedToShowHandles && _collapsedHandleOffset != null; /// The offset of the collapsed handle focal point, within the coordinate space /// of the document layout, or `null` if no collapsed handle should be displayed. @@ -83,7 +96,8 @@ class AndroidDocumentGestureEditingController extends GestureEditingController { } /// Whether the expanded handles (base + extent) should be displayed. - bool get shouldDisplayExpandedHandles => _upstreamHandleOffset != null && _downstreamHandleOffset != null; + bool get shouldDisplayExpandedHandles => + _allowedToShowHandles && _upstreamHandleOffset != null && _downstreamHandleOffset != null; /// The offset of the upstream handle focal point, within the coordinate space /// of the document layout, or `null` if no upstream handle should be displayed. diff --git a/super_editor/lib/src/infrastructure/platforms/android/long_press_selection.dart b/super_editor/lib/src/infrastructure/platforms/android/long_press_selection.dart new file mode 100644 index 0000000000..1a02c172e1 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/android/long_press_selection.dart @@ -0,0 +1,683 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/composable_text.dart'; + +/// A strategy for selecting text during a long-press drag gesture, similar to +/// how the Android OS selects text during a long-press drag. +/// +/// This strategy is made to operate over a document layout. +/// +/// This strategy isn't identical to the Android OS behavior, but it's very similar. +/// +/// Differences: +/// +/// * Android lets the user collapse the initial word selection when selecting +/// in one direction, and then selecting in the other. This strategy always +/// keeps the initial word selection, regardless of whether the user initially +/// drags in one direction and then reverses back and crosses to the other +/// side of the initial word. +/// +/// * Android selects a word when the user selects at least half way into the word. +/// This strategy selects the word as soon as the user selects any part of the +/// word. +/// +/// * Android seems to maintain a virtual selection offset, which can be different +/// from the finger position. This is easy to see when the user initially selects +/// by word, and then reverses direction to select by character. The placement of +/// the virtual selection offset, as compared to the finger offset, is different +/// depending on whether the user is pulling back by character, or pushing forward +/// by character. +/// +/// * This strategy attempts to match Android's virtual selection offset when the +/// user pulls back from per-word selection and begins per-character selection. +/// +/// * When the user starts pushing forward again, after previously pulling back +/// for per-character selection, this strategy waits until the user exceeds +/// the current word boundary and then switches back to per-word selection. +/// This is different from Android, which applies some kind of heuristic to +/// begin pushing the selection forward before the user's finger reaches the +/// edge of the word. +/// +class AndroidDocumentLongPressSelectionStrategy { + /// The default distance between the user's finger, the far boundary of + /// a word, when the user is dragging in the reverse direction, which + /// triggers a switch from per-word selection to per-character selection. + /// + /// This value was chosen experimentally. + static const _defaultBoundaryDistanceToSwitchToCharacterSelection = 24.0; + + AndroidDocumentLongPressSelectionStrategy({ + required Document document, + required DocumentLayout documentLayout, + required void Function(DocumentSelection) select, + }) : _document = document, + _docLayout = documentLayout, + _select = select; + + final Document _document; + final DocumentLayout _docLayout; + final void Function(DocumentSelection) _select; + + /// The word the user initially selects upon long-pressing. + DocumentSelection? _longPressInitialSelection; + + /// The node where the user's finger was dragging most recently. + String? _longPressMostRecentBoundaryNodeId; + + /// The direction of the user's current selection in relation to the initial word selection. + TextAffinity? _longPressSelectionDirection; + + /// The most recent select-by-word boundary in the upstream direction. + /// + /// Initially, a long-press drag selects the word under the users finger as the + /// user drags upstream. The user can drag in the opposite direction (downstream) + /// to begin selecting by character, instead of by word. However, once the user + /// switches back to the original upstream drag direction, and the user passes + /// this boundary, the selection mode returns to per-word selection. + /// + /// As the user selects words, this boundary is set to the edge of the selected + /// word that's furthest from the initial selection. When the user drags in reverse + /// and selects characters, this boundary moves back to the upstream edge of + /// whichever word contains the characters that the user is currently selecting. + /// + /// Examples of boundary movement: + /// - "[" and "]" are selection bounds + /// - "|" is the upstream word boundary + /// - "*" is a stationary finger + /// - "*--" is a finger moving upstream + /// - "--*" is a finger moving downstream + /// + /// ``` + /// one two three four five six seven + /// [ * ] + /// + /// one two three four five six seven + /// |[ *-- ] <- selection by word + /// + /// one two three four five six seven + /// |[ * ] <- selection by word + /// + /// one two three four five six seven + /// | [--* ] <- selection by character + /// + /// one two three four five six seven + /// | [--* ] <- selection by character + /// + /// one two three four five six seven + /// | [ * ] + /// + /// one two three four five six seven + /// | [ *-- ] <- selection by character + /// + /// one two three four five six seven + /// |[ *-- ] <- selection by word + /// + /// one two three four five six seven + /// |[ *-- ] <- selection by word + /// + /// one two three four five six seven + /// |[ * ] + /// ``` + int? _longPressMostRecentUpstreamWordBoundary; + + /// The most recent select-by-word boundary in the downstream direction. + /// + /// See [_longPressMostRecentUpstreamWordBoundary] for move info. + int? _longPressMostRecentDownstreamWordBoundary; + + /// Whether the user is currently selecting by character, or by word. + bool _isSelectingByCharacter = false; + + /// The [DocumentPosition] that the user most recently touched with the + /// long-press finger. + DocumentPosition? _longPressMostRecentTouchDocumentPosition; + + /// When dragging by word, this value is `0`, when dragging by character, + /// this is the horizontal offset between the user's finger and the + /// [_longPressMostRecentUpstreamWordBoundary] or the + /// [_longPressMostRecentDownstreamWordBoundary] when the user switched to + /// dragging by character. + /// + /// This offset is used, during character selection, to select text that's + /// some distance away from the user's finger. The closer the user's finger + /// is to the edge of a word, before going into character selection mode, + /// the shorter this distance will be. If the user's finger sits directly on + /// the edge of a word before going into character selection mode, this + /// value will be near zero, and the visual effect will be unnoticeable. + double _longPressCharacterSelectionXOffset = 0; + + /// Clients should call this method when a long press gesture is initially + /// recognized. + /// + /// Returns `true` if a long-press selection started, or `false` if the user's + /// press didn't occur over selectable content. + bool onLongPressStart({ + required Offset tapDownDocumentOffset, + }) { + longPressSelectionLog.fine("Long press start"); + final docPosition = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); + if (docPosition == null) { + longPressSelectionLog.finer("No doc position where the user pressed"); + return false; + } + + _longPressInitialSelection = getWordSelection(docPosition: docPosition, docLayout: _docLayout); + _select(_longPressInitialSelection!); + + // Initially, the word vs character selection bound tracking is set equal to + // the word boundaries of the first selected word. + longPressSelectionLog.finer("Setting initial long-press upstream bound to: ${_longPressInitialSelection!.start}"); + _longPressMostRecentBoundaryNodeId = _longPressInitialSelection!.start.nodeId; + _longPressMostRecentUpstreamWordBoundary = + (_longPressInitialSelection!.start.nodePosition as TextNodePosition).offset; + _longPressMostRecentDownstreamWordBoundary = + (_longPressInitialSelection!.end.nodePosition as TextNodePosition).offset; + + return true; + } + + /// Clients should call this method when an existing long-press gesture first + /// begins to pan. + /// + /// Upon long-press pan movements, clients should call [onLongPressDragUpdate]. + void onLongPressDragStart(DragStartDetails details) { + longPressSelectionLog.fine("Long press drag start"); + } + + /// Clients should call this method whenever a long-press gesture pans, after + /// initially calling [onLongPressStart]. + void onLongPressDragUpdate(Offset fingerDocumentOffset, DocumentPosition? fingerDocumentPosition) { + longPressSelectionLog.finer("--------------------------------------------"); + longPressSelectionLog.fine("Long press drag update"); + longPressSelectionLog.finer("Finger offset: $fingerDocumentOffset"); + longPressSelectionLog.finer("Finger position: $fingerDocumentPosition"); + if (fingerDocumentPosition == null) { + return; + } + + final isOverNonTextNode = fingerDocumentPosition.nodePosition is! TextNodePosition; + if (isOverNonTextNode) { + // The user is dragging over content that isn't text, therefore it doesn't have + // a concept of "words". Select the whole node. + longPressSelectionLog.finer("Dragging over non-text node. Selecting the whole node."); + _select(_longPressInitialSelection!.expandTo(fingerDocumentPosition)); + return; + } + + final focalPointDocumentOffset = !_isSelectingByCharacter + ? fingerDocumentOffset + : fingerDocumentOffset + Offset(_longPressCharacterSelectionXOffset, 0); + final focalPointDocumentPosition = !_isSelectingByCharacter + ? fingerDocumentPosition + : _docLayout.getDocumentPositionNearestToOffset(focalPointDocumentOffset)!; + + final fingerIsInInitialWord = + _document.doesSelectionContainPosition(_longPressInitialSelection!, focalPointDocumentPosition); + if (fingerIsInInitialWord) { + longPressSelectionLog.finer("Dragging in the initial word."); + _onLongPressFingerIsInInitialWord(fingerDocumentOffset); + return; + } + + final componentUnderFinger = _docLayout.getComponentByNodeId(fingerDocumentPosition.nodeId); + final textComponent = + componentUnderFinger is TextComponentState ? componentUnderFinger : componentUnderFinger as ProxyTextComposable; + final fingerTextOffset = (fingerDocumentPosition.nodePosition as TextNodePosition).offset; + final initialSelectionStartOffset = (_longPressInitialSelection!.base.nodePosition as TextNodePosition).offset; + final initialSelectionEndOffset = (_longPressInitialSelection!.end.nodePosition as TextNodePosition).offset; + final mostRecentBoundaryTextOffset = _longPressSelectionDirection == TextAffinity.upstream + ? _longPressMostRecentUpstreamWordBoundary ?? initialSelectionStartOffset + : _longPressMostRecentDownstreamWordBoundary ?? initialSelectionEndOffset; + final fingerLine = textComponent.getPositionAtStartOfLine(TextNodePosition(offset: fingerTextOffset)); + final mostRecentBoundaryLine = + textComponent.getPositionAtStartOfLine(TextNodePosition(offset: mostRecentBoundaryTextOffset)); + final fingerIsOnNewLine = fingerLine != mostRecentBoundaryLine; + if (fingerIsOnNewLine || fingerDocumentPosition.nodeId != _longPressMostRecentBoundaryNodeId) { + // The user either dragged from one line of text to another, or the user dragged + // from one text node to another. For either case, we want to stop any on-going + // per-character dragging and return to per-word dragging. + _resetWordVsCharacterTracking(); + } + + final fingerIsUpstream = + _document.getAffinityBetween(base: fingerDocumentPosition, extent: _longPressInitialSelection!.end) == + TextAffinity.downstream; + if (fingerIsUpstream) { + _onLongPressDragUpstreamOfInitialWord( + fingerDocumentOffset: fingerDocumentOffset, + fingerDocumentPosition: fingerDocumentPosition, + focalPointDocumentPosition: focalPointDocumentPosition, + ); + } else { + _onLongPressDragDownstreamOfInitialWord( + fingerDocumentOffset: fingerDocumentOffset, + fingerDocumentPosition: fingerDocumentPosition, + focalPointDocumentPosition: focalPointDocumentPosition, + ); + } + } + + void _resetWordVsCharacterTracking() { + longPressSelectionLog.finest("Resetting word-vs-character tracking"); + _longPressMostRecentBoundaryNodeId = _longPressInitialSelection!.start.nodeId; + _longPressMostRecentUpstreamWordBoundary = + (_longPressInitialSelection!.start.nodePosition as TextNodePosition).offset; + _longPressMostRecentDownstreamWordBoundary = + (_longPressInitialSelection!.end.nodePosition as TextNodePosition).offset; + _isSelectingByCharacter = false; + _longPressCharacterSelectionXOffset = 0; + } + + void _onLongPressFingerIsInInitialWord(Offset fingerOffsetInDocument) { + // The initial word always remains selected. The entire word is the basis for + // selection. Whenever the user presses over the initial word, the user isn't + // selecting in on particular direction or the other. + _longPressMostRecentUpstreamWordBoundary = + (_longPressInitialSelection!.start.nodePosition as TextNodePosition).offset; + _longPressMostRecentDownstreamWordBoundary = + (_longPressInitialSelection!.end.nodePosition as TextNodePosition).offset; + _longPressSelectionDirection = null; + _isSelectingByCharacter = false; + _longPressCharacterSelectionXOffset = 0; + + final initialWordRect = + _docLayout.getRectForSelection(_longPressInitialSelection!.base, _longPressInitialSelection!.extent)!; + final distanceToUpstream = (fingerOffsetInDocument.dx - initialWordRect.left).abs(); + final distanceToDownstream = (fingerOffsetInDocument.dx - initialWordRect.right).abs(); + + if (distanceToDownstream <= distanceToUpstream) { + // The user's finger is closer to the downstream side than the upstream side. + // Report the selection with the extent at the downstream edge to indicate the + // direction the user is likely to move. + _select(DocumentSelection( + base: _longPressInitialSelection!.start, + extent: _longPressInitialSelection!.end, + )); + } else { + // The user's finger is closer to the upstream side than the downstream side. + // Report the selection with the extent at the upstream edge to indicate the + // direction the user is likely to move. + _select(DocumentSelection( + base: _longPressInitialSelection!.end, + extent: _longPressInitialSelection!.start, + )); + } + } + + void _onLongPressDragUpstreamOfInitialWord({ + required Offset fingerDocumentOffset, + required DocumentPosition fingerDocumentPosition, + required DocumentPosition focalPointDocumentPosition, + }) { + longPressSelectionLog.finest("Dragging upstream from initial word."); + + _longPressSelectionDirection = TextAffinity.upstream; + + final focalPointNodeId = focalPointDocumentPosition.nodeId; + + if (focalPointNodeId != _longPressMostRecentBoundaryNodeId) { + // The user dragged into a different node. The word boundary from the previous + // node is no longer useful for calculations. Select a new boundary in the + // newly selected node. + _longPressMostRecentBoundaryNodeId = focalPointNodeId; + + // When the user initially drags into a new node, we want the user to drag + // by word, even if the user was previously dragging by character. To help + // ensure this strategy accomplishes that, place the new upstream boundary + // at the end of the text so that any user selection position will be seen + // as passing that boundary and therefore triggering a selection by word + // instead of a selection by character. + final textNode = _document.getNodeById(focalPointNodeId) as TextNode; + _longPressMostRecentUpstreamWordBoundary = textNode.endPosition.offset; + } + + int focalPointTextOffset = (focalPointDocumentPosition.nodePosition as TextNodePosition).offset; + final focalPointIsBeyondMostRecentUpstreamWordBoundary = focalPointNodeId == _longPressMostRecentBoundaryNodeId && + focalPointTextOffset < _longPressMostRecentUpstreamWordBoundary!; + longPressSelectionLog.finest( + "Focal point: $focalPointTextOffset, boundary: $_longPressMostRecentUpstreamWordBoundary, most recent touch position: $_longPressMostRecentTouchDocumentPosition"); + + late final bool selectByWord; + if (focalPointIsBeyondMostRecentUpstreamWordBoundary) { + longPressSelectionLog.finest("Select by word because finger is beyond most recent boundary."); + longPressSelectionLog.finest(" - most recent boundary position: $_longPressMostRecentUpstreamWordBoundary"); + longPressSelectionLog.finest(" - focal point position: $focalPointDocumentPosition"); + selectByWord = true; + } else { + longPressSelectionLog.finest("Focal point is NOT beyond boundary. Considering per-character selection."); + final isMovingBackward = _longPressMostRecentTouchDocumentPosition != null && + fingerDocumentPosition != _longPressMostRecentTouchDocumentPosition && + _document.getAffinityBetween( + base: _longPressMostRecentTouchDocumentPosition!, + extent: fingerDocumentPosition, + ) == + TextAffinity.downstream; + final longPressMostRecentUpstreamWordBoundaryPosition = DocumentPosition( + nodeId: _longPressMostRecentBoundaryNodeId!, + nodePosition: TextNodePosition(offset: _longPressMostRecentUpstreamWordBoundary!), + ); + final upstreamSelectionX = _docLayout + .getRectForSelection(longPressMostRecentUpstreamWordBoundaryPosition, _longPressInitialSelection!.start)! + .left; + final reverseDirectionDistance = fingerDocumentOffset.dx - upstreamSelectionX; + final startedMovingBackward = !_isSelectingByCharacter && + isMovingBackward && + reverseDirectionDistance > _defaultBoundaryDistanceToSwitchToCharacterSelection; + longPressSelectionLog.finest(" - current doc drag position: $fingerDocumentPosition"); + longPressSelectionLog.finest(" - most recent drag position: $_longPressMostRecentTouchDocumentPosition"); + longPressSelectionLog.finest(" - is moving backward? $isMovingBackward"); + longPressSelectionLog.finest(" - is already selecting by character? $_isSelectingByCharacter"); + longPressSelectionLog.finest(" - reverse direction distance: $reverseDirectionDistance"); + + if (startedMovingBackward || _isSelectingByCharacter) { + longPressSelectionLog.finest("Selecting by character:"); + longPressSelectionLog.finest(" - just started moving backward: $startedMovingBackward"); + longPressSelectionLog.finest(" - continuing an existing character selection: $_isSelectingByCharacter"); + selectByWord = false; + } else { + longPressSelectionLog.finest("User is still dragging away from initial word, selecting by word."); + selectByWord = true; + } + } + + if (!selectByWord && !_isSelectingByCharacter) { + // This will be the first frame where we start selecting by character. + // Move the drag reference point from the user's finger to the end of the + // current selected word. + + if (_longPressSelectionDirection == null) { + // If we've triggered a "select by character" position, then in theory + // it shouldn't be possible that we don't know the direction of the user's + // selection, but that information is null. Log a warning and skip this + // calculation. + longPressSelectionLog.warning( + "The user triggered per-character selection, but we don't know which direction the user started moving the selection. We expected to know that information at this point."); + } else { + longPressSelectionLog.finest("Switched to per-character..."); + // The user is selecting upstream. The end of the current selected word + // is the upstream bound of the current selection. + final longPressMostRecentUpstreamWordBoundaryPosition = DocumentPosition( + nodeId: _longPressMostRecentBoundaryNodeId!, + nodePosition: TextNodePosition(offset: _longPressMostRecentUpstreamWordBoundary!), + ); + final DocumentPosition boundary = longPressMostRecentUpstreamWordBoundaryPosition; + + final boundaryOffsetInDocument = _docLayout.getRectForPosition(boundary)!.center; + _longPressCharacterSelectionXOffset = boundaryOffsetInDocument.dx - fingerDocumentOffset.dx; + + longPressSelectionLog.finest(" - Upstream boundary position: $boundary"); + longPressSelectionLog.finest(" - Upstream boundary offset in document: $boundaryOffsetInDocument"); + longPressSelectionLog.finest(" - Touch document offset: $fingerDocumentOffset"); + longPressSelectionLog.finest(" - Per-character selection x-offset: $_longPressCharacterSelectionXOffset"); + + // Calculate an updated focal point now that we've started selecting by character. + final focalPointDocumentOffset = fingerDocumentOffset + Offset(_longPressCharacterSelectionXOffset, 0); + focalPointDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(focalPointDocumentOffset)!; + focalPointTextOffset = (focalPointDocumentPosition.nodePosition as TextNodePosition).offset; + longPressSelectionLog.finest("Updated the focal point because we just started selecting by character"); + longPressSelectionLog.finest(" - new focal point text offset: $focalPointTextOffset"); + } + } + + _isSelectingByCharacter = !selectByWord; + + late final DocumentSelection newSelection; + if (selectByWord) { + longPressSelectionLog.finest("Selecting by word..."); + longPressSelectionLog.finest(" - finding word around finger position: ${fingerDocumentPosition.nodePosition}"); + final wordUnderFinger = getWordSelection(docPosition: fingerDocumentPosition, docLayout: _docLayout); + if (wordUnderFinger == null) { + // This shouldn't happen. If we've gotten here, the user is selecting over + // text content but we couldn't find a word selection. The best we can do + // is fizzle. + longPressSelectionLog.warning("Long-press selecting. Couldn't find word at position: $fingerDocumentPosition"); + return; + } + + final wordSelection = TextSelection( + baseOffset: (wordUnderFinger.base.nodePosition as TextNodePosition).offset, + extentOffset: (wordUnderFinger.extent.nodePosition as TextNodePosition).offset, + ); + longPressSelectionLog.finest(" - word selection: $wordSelection"); + final textNode = _document.getNodeById(wordUnderFinger.base.nodeId) as TextNode; + final wordText = textNode.text.substring(wordSelection.start, wordSelection.end); + longPressSelectionLog.finest("Selected word text: '$wordText'"); + + newSelection = DocumentSelection(base: _longPressInitialSelection!.end, extent: wordUnderFinger.start); + + // Update the most recent bounds for word-by-word selection. + final longPressMostRecentUpstreamTextOffset = _longPressMostRecentUpstreamWordBoundary!; + longPressSelectionLog.finest( + "Word upstream offset: ${wordSelection.start}, long press upstream bound: $longPressMostRecentUpstreamTextOffset"); + final newSelectionIsBeyondLastUpstreamWordBoundary = wordSelection.start < longPressMostRecentUpstreamTextOffset; + if (newSelectionIsBeyondLastUpstreamWordBoundary) { + _longPressMostRecentUpstreamWordBoundary = wordSelection.start; + longPressSelectionLog.finest( + "Updating long-press most recent upstream word boundary: $_longPressMostRecentUpstreamWordBoundary"); + } + } else { + // Select by character. + longPressSelectionLog.finest("Selecting by character..."); + longPressSelectionLog.finest("Calculating the character drag position:"); + longPressSelectionLog.finest(" - character drag position: $focalPointDocumentPosition"); + longPressSelectionLog.finest(" - long-press character x-offset: $_longPressCharacterSelectionXOffset"); + newSelection = + _document.getAffinityBetween(base: focalPointDocumentPosition, extent: _longPressInitialSelection!.end) == + TextAffinity.downstream + ? DocumentSelection(base: _longPressInitialSelection!.end, extent: focalPointDocumentPosition) + : DocumentSelection(base: _longPressInitialSelection!.start, extent: focalPointDocumentPosition); + + // When dragging by character, if the user drags backward far enough to move to + // an earlier word, we want to re-activate drag-by-word for the word that we just + // moved away from. To accomplish this, we update our word boundary as the user + // drags by character. + final focalPointWord = getWordSelection(docPosition: focalPointDocumentPosition, docLayout: _docLayout); + if (focalPointWord != null) { + final upstreamWordBoundary = (focalPointWord.start.nodePosition as TextNodePosition).offset; + + if (upstreamWordBoundary > _longPressMostRecentUpstreamWordBoundary!) { + longPressSelectionLog.finest( + "The user moved backward into another word. We're pushing back the upstream boundary from $_longPressMostRecentUpstreamWordBoundary to $upstreamWordBoundary"); + _longPressMostRecentUpstreamWordBoundary = upstreamWordBoundary; + } + } + } + + _longPressMostRecentTouchDocumentPosition = fingerDocumentPosition; + + _select(newSelection); + } + + void _onLongPressDragDownstreamOfInitialWord({ + required Offset fingerDocumentOffset, + required DocumentPosition fingerDocumentPosition, + required DocumentPosition focalPointDocumentPosition, + }) { + longPressSelectionLog.finest("Dragging downstream from initial word."); + + _longPressSelectionDirection = TextAffinity.downstream; + + final focalPointNodeId = focalPointDocumentPosition.nodeId; + + if (focalPointNodeId != _longPressMostRecentBoundaryNodeId) { + // The user dragged into a different node. The word boundary from the previous + // node is no longer useful for calculations. Select a new boundary in the + // newly selected node. + _longPressMostRecentBoundaryNodeId = focalPointNodeId; + + // When the user initially drags into a new node, we want the user to drag + // by word, even if the user was previously dragging by character. To help + // ensure this strategy accomplishes that, place the new downstream boundary + // at the beginning of the text so that any user selection position will + // be seen as passing that boundary and therefore triggering a selection + // by word instead of a selection by character. + final textNode = _document.getNodeById(focalPointNodeId) as TextNode; + _longPressMostRecentDownstreamWordBoundary = textNode.beginningPosition.offset; + } + + int focalPointTextOffset = (focalPointDocumentPosition.nodePosition as TextNodePosition).offset; + final focalPointIsBeyondMostRecentDownstreamWordBoundary = focalPointNodeId == _longPressMostRecentBoundaryNodeId && + focalPointTextOffset > _longPressMostRecentDownstreamWordBoundary!; + longPressSelectionLog.finest( + "Focal point: $focalPointTextOffset, boundary: $_longPressMostRecentDownstreamWordBoundary, most recent touch position: $_longPressMostRecentTouchDocumentPosition"); + + late final bool selectByWord; + if (focalPointIsBeyondMostRecentDownstreamWordBoundary) { + longPressSelectionLog.finest("Select by word because finger is beyond most recent boundary."); + longPressSelectionLog.finest(" - most recent boundary position: $_longPressMostRecentDownstreamWordBoundary"); + longPressSelectionLog.finest(" - focal point position: $focalPointDocumentPosition"); + selectByWord = true; + } else { + longPressSelectionLog.finest("Focal point is NOT beyond boundary. Considering per-character selection."); + final isMovingBackward = _longPressMostRecentTouchDocumentPosition != null && + fingerDocumentPosition != _longPressMostRecentTouchDocumentPosition && + _document.getAffinityBetween( + base: fingerDocumentPosition, + extent: _longPressMostRecentTouchDocumentPosition!, + ) == + TextAffinity.downstream; + final longPressMostRecentDownstreamWordBoundaryPosition = DocumentPosition( + nodeId: _longPressMostRecentBoundaryNodeId!, + nodePosition: TextNodePosition(offset: _longPressMostRecentDownstreamWordBoundary!), + ); + final downstreamSelectionX = _docLayout + .getRectForSelection(longPressMostRecentDownstreamWordBoundaryPosition, _longPressInitialSelection!.start)! + .right; + final reverseDirectionDistance = downstreamSelectionX - fingerDocumentOffset.dx; + final startedMovingBackward = !_isSelectingByCharacter && + isMovingBackward && + reverseDirectionDistance > _defaultBoundaryDistanceToSwitchToCharacterSelection; + longPressSelectionLog.finest(" - current doc drag position: $fingerDocumentPosition"); + longPressSelectionLog.finest(" - most recent drag position: $_longPressMostRecentTouchDocumentPosition"); + longPressSelectionLog.finest(" - is moving backward? $isMovingBackward"); + longPressSelectionLog.finest(" - is already selecting by character? $_isSelectingByCharacter"); + longPressSelectionLog.finest(" - reverse direction distance: $reverseDirectionDistance"); + + if (startedMovingBackward || _isSelectingByCharacter) { + longPressSelectionLog.finest("Selecting by character:"); + longPressSelectionLog.finest(" - just started moving backward: $startedMovingBackward"); + longPressSelectionLog.finest(" - continuing an existing character selection: $_isSelectingByCharacter"); + selectByWord = false; + } else { + longPressSelectionLog.finest("User is still dragging away from initial word, selecting by word."); + selectByWord = true; + } + } + + if (!selectByWord && !_isSelectingByCharacter) { + // This will be the first frame where we start selecting by character. + // Move the drag reference point from the user's finger to the end of the + // current selected word. + + if (_longPressSelectionDirection == null) { + // If we've triggered a "select by character" position, then in theory + // it shouldn't be possible that we don't know the direction of the user's + // selection, but that information is null. Log a warning and skip this + // calculation. + longPressSelectionLog.warning( + "The user triggered per-character selection, but we don't know which direction the user started moving the selection. We expected to know that information at this point."); + } else { + longPressSelectionLog.finest("Switched to per-character..."); + // The user is selecting downstream. The end of the current selected word + // is the downstream bound of the current selection. + final longPressMostRecentDownstreamWordBoundaryPosition = DocumentPosition( + nodeId: _longPressMostRecentBoundaryNodeId!, + nodePosition: TextNodePosition(offset: _longPressMostRecentDownstreamWordBoundary!), + ); + final DocumentPosition boundary = longPressMostRecentDownstreamWordBoundaryPosition; + + final boundaryOffsetInDocument = _docLayout.getRectForPosition(boundary)!.center; + _longPressCharacterSelectionXOffset = boundaryOffsetInDocument.dx - fingerDocumentOffset.dx; + + longPressSelectionLog.finest(" - Downstream boundary position: $boundary"); + longPressSelectionLog.finest(" - Downstream boundary offset in document: $boundaryOffsetInDocument"); + longPressSelectionLog.finest(" - Touch document offset: $fingerDocumentOffset"); + longPressSelectionLog.finest(" - Per-character selection x-offset: $_longPressCharacterSelectionXOffset"); + + // Calculate an updated focal point now that we've started selecting by character. + final focalPointDocumentOffset = fingerDocumentOffset + Offset(_longPressCharacterSelectionXOffset, 0); + focalPointDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(focalPointDocumentOffset)!; + focalPointTextOffset = (focalPointDocumentPosition.nodePosition as TextNodePosition).offset; + longPressSelectionLog.finest("Updated the focal point because we just started selecting by character"); + longPressSelectionLog.finest(" - new focal point text offset: $focalPointTextOffset"); + } + } + + _isSelectingByCharacter = !selectByWord; + + late final DocumentSelection newSelection; + if (selectByWord) { + longPressSelectionLog.finest("Selecting by word..."); + longPressSelectionLog.finest(" - finger document position: $fingerDocumentPosition"); + final wordUnderFinger = getWordSelection(docPosition: fingerDocumentPosition, docLayout: _docLayout); + if (wordUnderFinger == null) { + // This shouldn't happen. If we've gotten here, the user is selecting over + // text content but we couldn't find a word selection. The best we can do + // is fizzle. + longPressSelectionLog.warning("Long-press selecting. Couldn't find word at position: $fingerDocumentPosition"); + return; + } + + final wordSelection = TextSelection( + baseOffset: (wordUnderFinger.base.nodePosition as TextNodePosition).offset, + extentOffset: (wordUnderFinger.extent.nodePosition as TextNodePosition).offset, + ); + final textNode = _document.getNodeById(wordUnderFinger.base.nodeId) as TextNode; + final wordText = textNode.text.substring(wordSelection.start, wordSelection.end); + longPressSelectionLog.finest("Selected word text: '$wordText'"); + + newSelection = DocumentSelection(base: _longPressInitialSelection!.start, extent: wordUnderFinger.end); + + // Update the most recent bounds for word-by-word selection. + final longPressMostRecentDownstreamTextOffset = _longPressMostRecentDownstreamWordBoundary!; + longPressSelectionLog.finest( + "Word downstream offset: ${wordSelection.end}, long press downstream bound: $longPressMostRecentDownstreamTextOffset"); + final newSelectionIsBeyondLastDownstreamWordBoundary = + wordSelection.end > longPressMostRecentDownstreamTextOffset; + if (newSelectionIsBeyondLastDownstreamWordBoundary) { + _longPressMostRecentDownstreamWordBoundary = wordSelection.end; + longPressSelectionLog.finest( + "Updating long-press most recent downstream word boundary: $_longPressMostRecentDownstreamWordBoundary"); + } + } else { + // Select by character. + longPressSelectionLog.finest("Selecting by character..."); + longPressSelectionLog.finest("Calculating the character drag position:"); + longPressSelectionLog.finest(" - character drag position: $focalPointDocumentPosition"); + longPressSelectionLog.finest(" - long-press character x-offset: $_longPressCharacterSelectionXOffset"); + newSelection = DocumentSelection(base: _longPressInitialSelection!.start, extent: focalPointDocumentPosition); + + // When dragging by character, if the user drags backward far enough to move to + // an earlier word, we want to re-activate drag-by-word for the word that we just + // moved away from. To accomplish this, we update our word boundary as the user + // drags by character. + final focalPointWord = getWordSelection(docPosition: focalPointDocumentPosition, docLayout: _docLayout); + if (focalPointWord != null) { + final downstreamWordBoundary = (focalPointWord.end.nodePosition as TextNodePosition).offset; + + if (downstreamWordBoundary < _longPressMostRecentDownstreamWordBoundary!) { + longPressSelectionLog.finest( + "The user moved backward into another word. We're pushing back the downstream boundary from $_longPressMostRecentDownstreamWordBoundary to $downstreamWordBoundary"); + _longPressMostRecentDownstreamWordBoundary = downstreamWordBoundary; + } + } + } + + _longPressMostRecentTouchDocumentPosition = fingerDocumentPosition; + + _select(newSelection); + } + + /// Clients should call this method when a long-press drag ends, or is cancelled. + void onLongPressEnd() { + longPressSelectionLog.fine("Long press end"); + _longPressInitialSelection = null; + _longPressMostRecentUpstreamWordBoundary = null; + _longPressMostRecentDownstreamWordBoundary = null; + } +} diff --git a/super_editor/lib/src/infrastructure/platforms/ios/long_press_selection.dart b/super_editor/lib/src/infrastructure/platforms/ios/long_press_selection.dart new file mode 100644 index 0000000000..1bd28b7c19 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/ios/long_press_selection.dart @@ -0,0 +1,128 @@ +import 'package:flutter/rendering.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// A strategy for selecting text during a long-press drag gesture, similar to +/// how iOS selects text during a long-press drag. +/// +/// This strategy is made to operate over a document layout. +/// +/// This strategy is expected to be identical to iOS. If differences are found, +/// they should be logged as bugs. +class IosLongPressSelectionStrategy { + IosLongPressSelectionStrategy({ + required Document document, + required DocumentLayout documentLayout, + required void Function(DocumentSelection) select, + }) : _document = document, + _docLayout = documentLayout, + _select = select; + + final Document _document; + final DocumentLayout _docLayout; + final void Function(DocumentSelection) _select; + + /// The word the user initially selects upon long-pressing. + DocumentSelection? _longPressInitialSelection; + + /// Clients should call this method when a long press gesture is initially + /// recognized. + /// + /// Returns `true` if a long-press selection started, or `false` if the user's + /// press didn't occur over selectable content. + bool onLongPressStart({ + required Offset tapDownDocumentOffset, + }) { + longPressSelectionLog.fine("Long press start"); + final docPosition = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); + if (docPosition == null) { + longPressSelectionLog.finer("No doc position where the user pressed"); + return false; + } + + _longPressInitialSelection = getWordSelection(docPosition: docPosition, docLayout: _docLayout); + _select(_longPressInitialSelection!); + return true; + } + + /// Clients should call this method when an existing long-press gesture first + /// begins to pan. + /// + /// Upon long-press pan movements, clients should call [onLongPressDragUpdate]. + void onLongPressDragStart() { + longPressSelectionLog.fine("Long press drag start"); + } + + /// Clients should call this method whenever a long-press gesture pans, after + /// initially calling [onLongPressStart]. + void onLongPressDragUpdate(Offset fingerDocumentOffset, DocumentPosition? fingerDocumentPosition) { + longPressSelectionLog.finer("--------------------------------------------"); + longPressSelectionLog.fine("Long press drag update"); + + if (fingerDocumentPosition == null) { + return; + } + + final isOverNonTextNode = fingerDocumentPosition.nodePosition is! TextNodePosition; + if (isOverNonTextNode) { + // The user is dragging over content that isn't text, therefore it doesn't have + // a concept of "words". Select the whole node. + _select(_longPressInitialSelection!.expandTo(fingerDocumentPosition)); + return; + } + + // In the case of long-press dragging, we select by word, and the base/extent + // of the selection depends on whether the user drags upstream or downstream + // from the originally selected word. + // + // Examples: + // - one two th|ree four five + // - one two [three] four five + // - one [two three] four five + // - one two [three four] five + final wordUnderFinger = getWordSelection(docPosition: fingerDocumentPosition, docLayout: _docLayout); + if (wordUnderFinger == null) { + // This shouldn't happen. If we've gotten here, the user is selecting over + // text content but we couldn't find a word selection. The best we can do + // is fizzle. + longPressSelectionLog.warning("Long-press selecting. Couldn't find word at position: $fingerDocumentPosition"); + return; + } + + if (wordUnderFinger == _longPressInitialSelection) { + // The user is on the original word. Nothing more to do. + _select(_longPressInitialSelection!); + return; + } + + // Figure out whether the newly selected word comes before or after the initially + // selected word. + final newWordDirection = _document.getAffinityForSelection( + DocumentSelection( + base: wordUnderFinger.start, + extent: _longPressInitialSelection!.start, + ), + ); + + late final DocumentSelection newSelection; + if (newWordDirection == TextAffinity.downstream) { + // The newly selected word comes before the initially selected word. + newSelection = DocumentSelection(base: wordUnderFinger.start, extent: _longPressInitialSelection!.end); + } else { + // The newly selected word comes after the initially selected word. + newSelection = DocumentSelection(base: _longPressInitialSelection!.start, extent: wordUnderFinger.end); + } + + _select(newSelection); + } + + /// Clients should call this method when a long-press drag ends, or is cancelled. + void onLongPressEnd() { + longPressSelectionLog.fine("Long press end"); + _longPressInitialSelection = null; + } +} diff --git a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart index 769341d45a..6c0faa3791 100644 --- a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart @@ -1,7 +1,9 @@ +import 'dart:async'; import 'dart:math'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; @@ -13,6 +15,7 @@ import 'package:super_editor/src/infrastructure/document_gestures_interaction_ov import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/android/android_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/long_press_selection.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_editor/src/infrastructure/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; @@ -122,6 +125,12 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State _longPressStrategy != null; + AndroidDocumentLongPressSelectionStrategy? _longPressStrategy; + final _longPressMagnifierGlobalOffset = ValueNotifier(null); + @override void initState() { super.initState(); @@ -417,6 +426,10 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State (_findAncestorScrollable(context)?.context.findRenderObject() ?? context.findRenderObject()) as RenderBox; + Offset _getDocumentOffsetFromGlobalOffset(Offset globalOffset) { + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); + } + /// Converts the given [interactorOffset] from the [DocumentInteractor]'s coordinate /// space to the [DocumentLayout]'s coordinate space. Offset _interactorOffsetToDocOffset(Offset interactorOffset) { @@ -457,9 +470,57 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State PanGestureRecognizer(), (PanGestureRecognizer recognizer) { recognizer + ..onStart = _onPanStart ..onUpdate = _onPanUpdate ..onEnd = _onPanEnd + ..onCancel = _onPanCancel ..gestureSettings = gestureSettings; }, ), diff --git a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart index 047465f906..d55468b4ee 100644 --- a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:flutter/cupertino.dart'; @@ -14,6 +15,7 @@ import 'package:super_editor/src/infrastructure/document_gestures_interaction_ov import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/long_press_selection.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_editor/src/infrastructure/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; @@ -137,6 +139,11 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State _longPressStrategy != null; + IosLongPressSelectionStrategy? _longPressStrategy; + /// Shows, hides, and positions a floating toolbar and magnifier. late MagnifierAndToolbarController _overlayController; @@ -424,7 +431,53 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State TapSequenceGestureRecognizer(), (TapSequenceGestureRecognizer recognizer) { recognizer + ..onTapDown = _onTapDown ..onTapUp = _onTapUp ..onDoubleTapUp = _onDoubleTapUp ..onTripleTapUp = _onTripleTapUp diff --git a/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart b/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart index dad420aa44..ab9e3fd14f 100644 --- a/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart +++ b/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart @@ -44,6 +44,35 @@ extension SuperEditorRobot on WidgetTester { await _tapInParagraph(nodeId, offset, affinity, 1, superEditorFinder); } + /// Simulates a long-press at the given text [offset] within the paragraph + /// with the given [nodeId]. + Future longPressInParagraph( + String nodeId, + int offset, { + TextAffinity affinity = TextAffinity.downstream, + Finder? superEditorFinder, + }) async { + final gesture = await _pressDownInParagraph(nodeId, offset, affinity, 1, superEditorFinder); + await pump(kLongPressTimeout + kPressTimeout); + + await gesture.up(); + await pump(); + } + + /// Simulates a long-press down at the given text [offset] within the paragraph + /// with the given [nodeId], and returns the [TestGesture] so that a test can + /// decide to drag it, or release. + Future longPressDownInParagraph( + String nodeId, + int offset, { + TextAffinity affinity = TextAffinity.downstream, + Finder? superEditorFinder, + }) async { + final gesture = await _pressDownInParagraph(nodeId, offset, affinity, 1, superEditorFinder); + await pump(kLongPressTimeout + kPressTimeout); + return gesture; + } + /// Simulates a double tap at the given [offset] within the paragraph with the given /// [nodeId]. Future doubleTapInParagraph( @@ -74,41 +103,9 @@ extension SuperEditorRobot on WidgetTester { int tapCount, [ Finder? superEditorFinder, ]) async { - late final Finder layoutFinder; - if (superEditorFinder != null) { - layoutFinder = find.descendant(of: superEditorFinder, matching: find.byType(SingleColumnDocumentLayout)); - } else { - layoutFinder = find.byType(SingleColumnDocumentLayout); - } - final documentLayoutElement = layoutFinder.evaluate().single as StatefulElement; - final documentLayout = documentLayoutElement.state as DocumentLayout; - - // Collect the various text UI artifacts needed to find the - // desired caret offset. - final componentState = documentLayout.getComponentByNodeId(nodeId) as State; - late final GlobalKey textComponentKey; - if (componentState is ProxyDocumentComponent) { - textComponentKey = componentState.childDocumentComponentKey; - } else { - textComponentKey = componentState.widget.key as GlobalKey; - } - - final textLayout = (textComponentKey.currentState as TextComponentState).textLayout; - final textRenderBox = textComponentKey.currentContext!.findRenderObject() as RenderBox; - // Calculate the global tap position based on the TextLayout and desired // TextPosition. - final position = TextPosition(offset: offset, affinity: affinity); - // For the local tap offset, we add a small vertical adjustment downward. This - // prevents flaky edge effects, which might occur if we try to tap exactly at the - // top of the line. In general, we could use the caret height to choose a vertical - // offset, but the caret height is null when the text is empty. So we use a - // hard-coded value, instead. We also adjust the horizontal offset by a pixel left - // or right depending on the requested affinity. Without this the resulting selection - // may contain an incorrect affinity if the gesture did not occur at a line break. - final localTapOffset = - textLayout.getOffsetForCaret(position) + Offset(affinity == TextAffinity.upstream ? -1 : 1, 5); - final globalTapOffset = localTapOffset + textRenderBox.localToGlobal(Offset.zero); + final globalTapOffset = _findGlobalOffsetForTextPosition(nodeId, offset, affinity, superEditorFinder); // TODO: check that the tap offset is visible within the viewport. Add option to // auto-scroll, or throw exception when it's not tappable. @@ -125,6 +122,23 @@ extension SuperEditorRobot on WidgetTester { await pumpAndSettle(); } + Future _pressDownInParagraph( + String nodeId, + int offset, + TextAffinity affinity, + int tapCount, [ + Finder? superEditorFinder, + ]) async { + // Calculate the global tap position based on the TextLayout and desired + // TextPosition. + final globalTapOffset = _findGlobalOffsetForTextPosition(nodeId, offset, affinity, superEditorFinder); + + // TODO: check that the tap offset is visible within the viewport. Add option to + // auto-scroll, or throw exception when it's not tappable. + + return await startGesture(globalTapOffset); + } + /// Taps at the center of the content at the given [position] within a [SuperEditor]. /// /// {@macro supereditor_finder} @@ -274,6 +288,43 @@ extension SuperEditorRobot on WidgetTester { await _updateFloatingCursor(action: "FloatingCursorDragState.end", offset: Offset.zero); } + Offset _findGlobalOffsetForTextPosition( + String nodeId, + int offset, + TextAffinity affinity, [ + Finder? superEditorFinder, + ]) { + final textComponentKey = _findComponentKeyForTextNode(nodeId, superEditorFinder); + final textRenderBox = textComponentKey.currentContext!.findRenderObject() as RenderBox; + + final localTapOffset = _findLocalOffsetForTextPosition(nodeId, offset, affinity, superEditorFinder); + return localTapOffset + textRenderBox.localToGlobal(Offset.zero); + } + + Offset _findLocalOffsetForTextPosition( + String nodeId, + int offset, + TextAffinity affinity, [ + Finder? superEditorFinder, + ]) { + final textComponentKey = _findComponentKeyForTextNode(nodeId, superEditorFinder); + final textLayout = (textComponentKey.currentState as TextComponentState).textLayout; + + // Calculate the global tap position based on the TextLayout and desired + // TextPosition. + final position = TextPosition(offset: offset, affinity: affinity); + // For the local tap offset, we add a small vertical adjustment downward. This + // prevents flaky edge effects, which might occur if we try to tap exactly at the + // top of the line. In general, we could use the caret height to choose a vertical + // offset, but the caret height is null when the text is empty. So we use a + // hard-coded value, instead. We also adjust the horizontal offset by a pixel left + // or right depending on the requested affinity. Without this the resulting selection + // may contain an incorrect affinity if the gesture did not occur at a line break. + return textLayout.getOffsetForCaret(position) + Offset(affinity == TextAffinity.upstream ? -1 : 1, 5); + } + + /// Finds and returns the [DocumentLayout] within the only [SuperEditor] in the + /// widget tree, or within the [SuperEditor] found via the optional [superEditorFinder]. DocumentLayout _findDocumentLayout([Finder? superEditorFinder]) { late final Finder layoutFinder; if (superEditorFinder != null) { @@ -285,6 +336,21 @@ extension SuperEditorRobot on WidgetTester { return documentLayoutElement.state as DocumentLayout; } + /// Finds the [GlobalKey] that's attached to the [TextComponent], which presents the + /// given [nodeId]. + /// + /// The given [nodeId] must refer to a [TextNode] or subclass. + GlobalKey _findComponentKeyForTextNode(String nodeId, [Finder? superEditorFinder]) { + final documentLayout = _findDocumentLayout(superEditorFinder); + + final componentState = documentLayout.getComponentByNodeId(nodeId) as State; + if (componentState is ProxyDocumentComponent) { + return componentState.childDocumentComponentKey; + } else { + return componentState.widget.key as GlobalKey; + } + } + Future _updateFloatingCursor({required String action, required Offset offset}) async { await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( SystemChannels.textInput.name, diff --git a/super_editor/test/super_editor/mobile/mobile_long_press_selection_text_layout.png b/super_editor/test/super_editor/mobile/mobile_long_press_selection_text_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..77ba70d8cf2c5ae26949b7361e447fbfdc035d4d GIT binary patch literal 25007 zcmeHQ30PBC+CCsEb`fg)U6I&vD${Dk4Os+2)v9z9T5Uip5GYn#D*^%%k`U6Wf6BN- zYyDBSv|}GpQdvr6HH!t!xM1vH1RDsOG=dO_F$73R=A3&8$aMUt&e%uPd!Ia-8$(FG zd(L~_?|t7Bli&4Q_3{hTUH|~R?7Mo!IsnFn0x;Irb_{%^Sl(v?|FVi%w`v*4x#rLc zPi$hA`Mz%p50>rbF94Vcd{?~nK0Up?Zo$Ec8z)E`Pf^njY%Ka(=CiwadX)8+dFMCm z-s@BO-jssc?mrz4|KaAbqX9pgA}8g}>JgDvoC*=W)9lm|W-x`%fv=4pjvX|?Sj!2x z^A2aU<-~qMsFmgZ9-9r8yWmx;NtXLQtBI!*l_3VfQ#U~iV# zzRx`0LKgwYV!Otjo-|-LTge4L{0II_o0T4XnP3%|mP(IbQ`=fUTfHROiIGa5ek_o@WIfjcLYzb3Ma%t}@V8s;+m6e#+y%>sr;n)0#GYlq zYqR>Cxxc7S+borbx3?*1^#MHmf=OnfOZRbWIhkyE2zb9`Yq^?-p7_FaXPXXylqjw7 zq*9nr#@Jt1S6?3(L9LeM1oFP0!_+N|%gyGNg-_FmId`{x)D}Wjm;0T}T#o(28~QLf zWg2e?vps1UYrMGl6BczY2OQc@Xl>r)g=heF_^f*1V?Q(NaZZ38h#IFTl()uy;B`@5 z7$L~cE^o}Ef0=koFqx_QFgK)Keg)Pi`=GSBSa^=y4a+$QH2HxPb+7lYi@C*{21vGf zoWp97Qhud6H-KjauK#@qd|LVsW&NlfJwJ`<*zlRbe%{t5c=DOQz4fdNnMytjfFE2m z!P^E=W~PhAFTI!)l|bq|n7s6-BL%iyiwQh=nvwRpybYtX?EmRZGM_OSd!pgO*VkQL zgf2b+l6BY;!QtN?LY?-jPPT&-_{lFJ!Dk#n!-O4~^*72~BLjK)Wd?&G`%GKkEnVAt zyWqOrF5f3)@n> z>bE!f0b^A~8QTInpOhwxZ&hRfPS)0@rP7LwliD<^u0;guJPW8}sxs6k2`ChHkk`c= zQNJ!ihtV0yXj?>5IZmn})ajyV#x1kVzY#!g1ED@pyeCcNs#8}gAX8JDOrmXqQuZz( zB z;N2H3(=X6D{47X0&7_vtEj^Auj5k+iXV7!YW`ksPJgu{>NJk70FB2x@uh0ib+1(nljv?of_`VR?Z;B zTQ~4)b)d^kA`~A&+xuA$f`V?v(yO4dKTFtG#oYL;YvgeHc={#W@`?=BQMdMf5veTMZTT|MFk6_JTp=N zj>Qp*wpfoduy%?CX{G!W_Zsmhtkc*x$N1> z=|JK=3{VqsT_Vca?&#!NAu=b21TVFqHc5cYWe}?tB{ghUgcdp}St5SQ?*4B>8 z{4!Zq1NllCc2;~YIUceX(36!M-(Qi@@VaKd6)+zlM4JDC$t4xUOWoFO_I2Q%_;$(k zf?D^t^iw~!zu6wPOFvOEZp$wd_PcDbz2UHTf#OZxq>?c=b9No>iM)C4FAWAi`)?S! zg$MXw5enFEP{6Pz@mgc|FNGmu)>$a*WcX#F7V8pfvErFAWsE&_?pz2`Wc^C=wJ>LR z8!qs`CIZT*Exp4g*?5J@S#r!v>VnC#K%SLkY9d`PQfDe5>IG)f05Cdr3PJ2* zP48&X^(GX__TTU6DQ4J!YulTGDbY>%^;Ld_TRs zqcTFs?%f8(rKK5uh}j$&WHyM_CkpRoX^exi3?@48^h#XO#L>e=i;Ze-hc@N;kY(JV z5oKp*Cy>_E6-Ges$<1Ms|M$+A5(N?zXHIgVDGMQQYE|Jrf$E>)O^c+JcfzxMJ-xeQZos(LE{$NcsavA?(U zpI>3g0ub)=V{)I7DS;g%<2^;LPO2zE!;xY(@%mb`_{dEFQTYSa&fD!OMzY67V~2`A z8YLs@8%TRB&Bh1_NMH`j_#)RLU0;g;Nj9+_RA^8657<*U%;zWoco0-3K?|zhJWEp zAyY5AS>v|DSvI7#cehEI?_-@Q!X`|-;V0|I{96Jep`B+=>=M>am*`XJkpQsv{ThBi zu_vkCeYV;rWph&!REwj0jZ(lN6P?sE)6PFI`bLz<{r|Q>nA3#(S&>p#MQ7uF?EsTM z(T7QyP=dgOoBlQDTmcZZ8XLnNo~dc^MktGCXmE3h7gHleij2j- zqbN`GMUA)mXDU<48tQ=5%V#!v-=5&c4YG#RWoEP2U;aEF0(DdgCe3Ix(K=h7<(PAA zrE=A%j}=$Nl9CDr#a|GWFk2L}ruGvL@l=rX8qpd(&CLrERM8RiR~p-J)>@ zINCH6;j^5Ab?Oy>GxhO&=EubKi8u3zg~Z014@GS3{f`83b|l>3-wmn~&2KmDQFM<9PkaI|O6r?j`b z5}m$ySIF9U(f^%C&e2YW*#Wn`58*@*rsQ&Yv9MO#@5Z$R1^SCsxEhQY<`yM)eTj+* zoh!^S?-5%?WnzvMU)^?Vp9)&wZZDGSlTInJP`7>*IfnM3OZ1+Scv5Hi=~^v_Z849L zBtW@6^joP^3RlGR1Ih7CgI1L#<;PGT^fm^-XU`aeet~7&j(|>{cr%X*RUg7=E;=L$ zrDAa|jh$G4^0`SRG%x54&;Me;hV04pHusk(jo~3k!pn0SQD$*$KD1KS7ng`mS3A>? z&ns=thlv~3&JxB~!B|E%CI)`2J$1h+J%anbsl7>~LO(C>)MdPa=B3a+My`S&h~H^zt8DbimJ z7Mb5I@<*}DIr=f_|HmBF^Pc);;6^6ZUx9;-IRr71k4gOetL+`>l5;Wy!iqiScdXd{$Mj1&Yloa33fTkM8SyY!0j#d z{JWKuUod+LNY;!j$p?|Ntf-j8^#hX6L1{RYe0p#1#3dhuG)NM(wjzQ!xXl1I)tQqEVP^9XOy`?RqGr~XG6Ui=Q=T1*2cR|j%RIjL!h*lEi#5g zd1N}n$dbvLqg|j8z62x=LwTE1*~hm=H~E71K`pM$bsU&-4cp;n zhK4;#Q|~T(cLRJw>4!#CvcDA4H_7X@LG?H8rob(N`DM+T4QHvr-X%d^mviu4hobr! zRmFD9;gExQ;@GwzY$Cy}4YNflR^V9kb54UWFVLdm42wk$M;=ST*eg*!r_4WoAfJ=I zm)A+T(NTr=m0b3+%W6?JIJj{!#2ILNHebvvD+mT+}x(kPgy2<(DhD7os#KRC3ptelZ1gire1_WLQ zVnE<^AO^%h9f$!z3", () { + group("Android >", () { + group("long press >", () { + testWidgetsOnAndroid("selects word under finger", (tester) async { + await _pumpAppWithLongText(tester); + + // Ensure that no overlay controls are visible. + _expectNoControlsAreVisible(); + + // Long press on the middle of "conse|ctetur" + await tester.longPressInParagraph("1", 33); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection(), _wordConsecteturSelection); + + // Ensure the drag handles and toolbar are visible, but the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by word when dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag upstream to the end of the previous word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -130 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + // Now that we've started dragging, ensure the magnifier is visible and the + // toolbar is hidden. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsOne); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + + // Now that the drag is done, ensure the handles and toolbar are visible and + // the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by character when dragging upstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the upstream word. + // "Lorem i|psum dolor sit amet" + // ^ position 7 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -15.0; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const downstreamDragDistance = 110 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the upstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when jumping up a line and dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "adi|piscing". + final gesture = await tester.longPressDownInParagraph("1", 42); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordAdipiscingSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag up one line to select "dolor". + const dragIncrementCount = 10; + const verticalDragDistance = -24 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(0, verticalDragDistance)); + await tester.pump(); + } + + // Ensure the selection begins at the end of "adipiscing" and goes to the + // beginning of "dolor", which is upstream. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorStart), + ), + ), + ); + + // Drag upstream to select the previous word. + const upstreamDragDistance = -80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordIpsumStart), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag downstream to the beginning of the next word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + // + // "Lorem ipsum dolor sit| amet" + // ^ position 21 + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Now that we've started dragging, ensure the magnifier is visible and the + // toolbar is hidden. + _expectOnlyMagnifier(); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + + // Now that the drag is done, ensure the handles and toolbar are visible and + // the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by character when dragging downstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the downstream word. + // "Lorem ipsum dolor si|t amet" + // ^ position 20 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = 100 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const downstreamDragDistance = -40 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the downstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when jumping down a line and dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "adi|piscing". + final gesture = await tester.longPressDownInParagraph("1", 42); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordAdipiscingSelection); + + // Drag down one line to select "tempor". + const dragIncrementCount = 10; + const verticalDragDistance = 24 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(0, verticalDragDistance)); + await tester.pump(); + } + + // Ensure the selection begins at the start of "adipiscing" and goes to the + // end of "tempor", which is upstream. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordTemporEnd), + ), + ), + ); + + // Drag downstream to select the next word. + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordIncididuntEnd), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + }); + }); + }); +} + +// The test suite was originally laid out and calculated with: +// - physical size: 2400x1800 +// - device pixel ratio: 3.0 + +// 01) Lorem ipsum dolor sit amet, [0, 28] +// 02) consectetur adipiscing elit, sed [28, 61] +// 03) do eiusmod tempor incididunt ut [61, 93] +// 04) labore et dolore magna aliqua. +// 05) Ut enim ad minim veniam, quis +// 06) nostrud exercitation ullamco +// 07) laboris nisi ut aliquip ex ea +// 08) commodo consequat. Duis aute +// 09) irure dolor in reprehenderit in +// 10) voluptate velit esse cillum +// 11) dolore eu fugiat nulla pariatur. +// 12) Excepteur sint occaecat +// 13) cupidatat non proident, sunt in +// 14) culpa qui officia deserunt +// 15) mollit anim id est laborum. + +Future _pumpAppWithLongText(WidgetTester tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withAndroidToolbarBuilder((context) => const AndroidTextEditingFloatingToolbar()) + .pump(); +} + +const _wordConsecteturSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), +); + +const _wordIpsumStart = 6; +// ignore: unused_element +const _wordIpsumEnd = 11; + +const _wordDolorStart = 12; +const _wordDolorEnd = 17; +const _wordDolorSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorEnd), + ), +); + +const _wordAdipiscingStart = 40; +const _wordAdipiscingEnd = 50; +const _wordAdipiscingSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), +); + +// ignore: unused_element +const _wordTemporStart = 72; +const _wordTemporEnd = 78; + +// ignore: unused_element +const _wordIncididuntStart = 79; +const _wordIncididuntEnd = 89; + +void _expectNoControlsAreVisible() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} + +void _expectOnlyToolbar() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOne); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} + +void _expectOnlyMagnifier() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsOne); +} + +void _expectHandlesAndToolbar() { + expect(find.byType(AndroidSelectionHandle), findsExactly(2)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOne); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} diff --git a/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart b/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart new file mode 100644 index 0000000000..c5c8251445 --- /dev/null +++ b/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart @@ -0,0 +1,244 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../supereditor_test_tools.dart'; + +void main() { + group("SuperEditor mobile selection >", () { + group("iOS >", () { + group("long press >", () { + testWidgetsOnIos("selects word under finger", (tester) async { + await _pumpAppWithLongText(tester); + + // Ensure that no overlay controls are visible. + _expectNoControlsAreVisible(); + + // Long press on the middle of "conse|ctetur" + await tester.longPressInParagraph("1", 33); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection(), _wordConsecteturSelection); + + // Ensure the drag handles and toolbar are visible, but the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnIos("over handle does nothing", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + await tester.longPressInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Long-press near the upstream handle, but just before the selected word. + await tester.longPressInParagraph("1", 11); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Long-press near the downstream handle, but just after the selected word. + await tester.longPressInParagraph("1", 18); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + }); + + testWidgetsOnIos("selects by word when dragging upstream and then back downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the drag handles and magnifier are visible, but the toolbar isn't. + _expectHandlesAndMagnifier(); + + // Drag upstream to the end of the previous word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -130 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Drag back towards the original long-press offset. + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that only the original word is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + // Note: when we move the selection back the other way, the word calculation + // decided to include the leading space, which is why we pass a different + // selection here. + nodePosition: TextNodePosition(offset: 11), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + }); + + testWidgetsOnIos("selects by word when dragging downstream and then back upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the drag handles and magnifier are visible, but the toolbar isn't. + _expectHandlesAndMagnifier(); + + // Drag downstream to the beginning of the next word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Drag back towards the original long-press offset. + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const upstreamDragDistance = -40 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that only the original word is selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + }); + }); + }); + }); +} + +// The test suite was originally laid out and calculated with: +// - physical size: 2400x1800 +// - device pixel ratio: 3.0 + +Future _pumpAppWithLongText(WidgetTester tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .pump(); +} + +const _wordConsecteturSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), +); + +const _wordDolorSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), +); + +void _expectNoControlsAreVisible() { + expect(find.byType(IOSSelectionHandle), findsNothing); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsNothing); +} + +void _expectHandlesAndMagnifier() { + expect(find.byType(IOSSelectionHandle), findsExactly(2)); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsOne); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); +} + +void _expectHandlesAndToolbar() { + expect(find.byType(IOSSelectionHandle), findsExactly(2)); + expect(find.byType(IOSTextEditingFloatingToolbar), findsOne); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsNothing); +} diff --git a/super_editor/test/super_reader/mobile/super_reader_android_selection_test.dart b/super_editor/test/super_reader/mobile/super_reader_android_selection_test.dart new file mode 100644 index 0000000000..2415c319f8 --- /dev/null +++ b/super_editor/test/super_reader/mobile/super_reader_android_selection_test.dart @@ -0,0 +1,516 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_inspector.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../reader_test_tools.dart'; + +void main() { + group("SuperReader mobile selection >", () { + group("Android >", () { + group("long press >", () { + testWidgetsOnAndroid("selects word under finger", (tester) async { + await _pumpAppWithLongText(tester); + + // Ensure that no overlay controls are visible. + _expectNoControlsAreVisible(); + + // Long press on the middle of "conse|ctetur" + await tester.longPressInParagraph("1", 33); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection(), _wordConsecteturSelection); + + // Ensure the drag handles and toolbar are visible, but the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by word when dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag upstream to the end of the previous word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -130 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + // Now that we've started dragging, ensure the magnifier is visible and the + // toolbar is hidden. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsOne); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + + // Now that the drag is done, ensure the handles and toolbar are visible and + // the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by character when dragging upstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the upstream word. + // "Lorem i|psum dolor sit amet" + // ^ position 7 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -15.0; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const downstreamDragDistance = 110 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the upstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when jumping up a line and dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "adi|piscing". + final gesture = await tester.longPressDownInParagraph("1", 42); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordAdipiscingSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag up one line to select "dolor". + const dragIncrementCount = 10; + const verticalDragDistance = -24 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(0, verticalDragDistance)); + await tester.pump(); + } + + // Ensure the selection begins at the end of "adipiscing" and goes to the + // beginning of "dolor", which is upstream. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorStart), + ), + ), + ); + + // Drag upstream to select the previous word. + const upstreamDragDistance = -80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordIpsumStart), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag downstream to the beginning of the next word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + // + // "Lorem ipsum dolor sit| amet" + // ^ position 21 + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Now that we've started dragging, ensure the magnifier is visible and the + // toolbar is hidden. + _expectOnlyMagnifier(); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + + // Now that the drag is done, ensure the handles and toolbar are visible and + // the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by character when dragging downstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the downstream word. + // "Lorem ipsum dolor si|t amet" + // ^ position 20 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = 100 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const downstreamDragDistance = -40 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the downstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when jumping down a line and dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "adi|piscing". + final gesture = await tester.longPressDownInParagraph("1", 42); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordAdipiscingSelection); + + // Drag down one line to select "tempor". + const dragIncrementCount = 10; + const verticalDragDistance = 24 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(0, verticalDragDistance)); + await tester.pump(); + } + + // Ensure the selection begins at the start of "adipiscing" and goes to the + // end of "tempor", which is upstream. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordTemporEnd), + ), + ), + ); + + // Drag downstream to select the next word. + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordIncididuntEnd), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + }); + }); + }); +} + +// The test suite was originally laid out and calculated with: +// - physical size: 2400x1800 +// - device pixel ratio: 3.0 + +// 01) Lorem ipsum dolor sit amet, [0, 28] +// 02) consectetur adipiscing elit, sed [28, 61] +// 03) do eiusmod tempor incididunt ut [61, 93] +// 04) labore et dolore magna aliqua. +// 05) Ut enim ad minim veniam, quis +// 06) nostrud exercitation ullamco +// 07) laboris nisi ut aliquip ex ea +// 08) commodo consequat. Duis aute +// 09) irure dolor in reprehenderit in +// 10) voluptate velit esse cillum +// 11) dolore eu fugiat nulla pariatur. +// 12) Excepteur sint occaecat +// 13) cupidatat non proident, sunt in +// 14) culpa qui officia deserunt +// 15) mollit anim id est laborum. + +Future _pumpAppWithLongText(WidgetTester tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withAndroidToolbarBuilder((context) => const AndroidTextEditingFloatingToolbar()) + .pump(); +} + +const _wordConsecteturSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), +); + +const _wordIpsumStart = 6; +// ignore: unused_element +const _wordIpsumEnd = 11; + +const _wordDolorStart = 12; +const _wordDolorEnd = 17; +const _wordDolorSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorEnd), + ), +); + +const _wordAdipiscingStart = 40; +const _wordAdipiscingEnd = 50; +const _wordAdipiscingSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), +); + +// ignore: unused_element +const _wordTemporStart = 72; +const _wordTemporEnd = 78; + +// ignore: unused_element +const _wordIncididuntStart = 79; +const _wordIncididuntEnd = 89; + +void _expectNoControlsAreVisible() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} + +void _expectOnlyToolbar() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOne); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} + +void _expectOnlyMagnifier() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsOne); +} + +void _expectHandlesAndToolbar() { + expect(find.byType(AndroidSelectionHandle), findsExactly(2)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOne); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} diff --git a/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart b/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart new file mode 100644 index 0000000000..11025a32c9 --- /dev/null +++ b/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart @@ -0,0 +1,248 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_reader_test.dart'; + +import '../reader_test_tools.dart'; + +void main() { + group("SuperReader mobile selection >", () { + group("iOS >", () { + group("long press >", () { + testWidgetsOnIos("selects word under finger", (tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .pump(); + + // Ensure that no overlay controls are visible. + expect(find.byType(IOSSelectionHandle), findsNothing); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsNothing); + + // Long press on the middle of "conse|ctetur" + await tester.longPressInParagraph("1", 33); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), + ), + ); + + // Ensure the drag handles and toolbar are visible, but the magnifier isn't. + expect(find.byType(IOSSelectionHandle), findsExactly(2)); + expect(find.byType(IOSTextEditingFloatingToolbar), findsOne); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsNothing); + }); + + testWidgetsOnIos("over handle does nothing", (tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .pump(); + + // Long press on the middle of "do|lor". + await tester.longPressInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + const wordSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ); + + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Long-press near the upstream handle, but just before the selected word. + await tester.longPressInParagraph("1", 11); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change. + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Long-press near the downstream handle, but just after the selected word. + await tester.longPressInParagraph("1", 18); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change. + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + }); + + testWidgetsOnIos("selects by word when dragging upstream and then back downstream", (tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .pump(); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + const wordSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ); + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Ensure the drag handles and magnifier are visible, but the toolbar isn't. + expect(find.byType(IOSSelectionHandle), findsExactly(2)); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsOne); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + + // Drag upstream to the end of the previous word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -130 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Drag back towards the original long-press offset. + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const downstreamDragDistance = 100 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that only the original word is selected. + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Release the gesture so the test system doesn't complain. + gesture.up(); + }); + + testWidgetsOnIos("selects by word when dragging downstream and then back upstream", (tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .pump(); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + const wordSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ); + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Ensure the drag handles and magnifier are visible, but the toolbar isn't. + expect(find.byType(IOSSelectionHandle), findsExactly(2)); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsOne); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + + // Drag downstream to the beginning of the next word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Drag back towards the original long-press offset. + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const upstreamDragDistance = -40 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that only the original word is selected. + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Release the gesture so the test system doesn't complain. + gesture.up(); + }); + }); + }); + }); +} diff --git a/super_editor/test/super_reader/reader_test_tools.dart b/super_editor/test/super_reader/reader_test_tools.dart index a6df40ea7f..924e33cf9a 100644 --- a/super_editor/test/super_reader/reader_test_tools.dart +++ b/super_editor/test/super_reader/reader_test_tools.dart @@ -92,6 +92,8 @@ class TestDocumentConfigurator { ScrollController? _scrollController; FocusNode? _focusNode; DocumentSelection? _selection; + WidgetBuilder? _androidToolbarBuilder; + WidgetBuilder? _iOSToolbarBuilder; /// Configures the [SuperReader] for standard desktop interactions, /// e.g., mouse and keyboard input. @@ -174,6 +176,18 @@ class TestDocumentConfigurator { } } + /// Configures the [SuperEditor] to use the given [builder] as its android toolbar builder. + TestDocumentConfigurator withAndroidToolbarBuilder(WidgetBuilder? builder) { + _androidToolbarBuilder = builder; + return this; + } + + /// Configures the [SuperEditor] to use the given [builder] as its iOS toolbar builder. + TestDocumentConfigurator withiOSToolbarBuilder(WidgetBuilder? builder) { + _iOSToolbarBuilder = builder; + return this; + } + /// Configures the [ThemeData] used for the [MaterialApp] that wraps /// the [SuperReader]. TestDocumentConfigurator useAppTheme(ThemeData theme) { @@ -234,6 +248,8 @@ class TestDocumentConfigurator { ], autofocus: _autoFocus, scrollController: _scrollController, + androidToolbarBuilder: _androidToolbarBuilder, + iOSToolbarBuilder: _iOSToolbarBuilder, ), );