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 0000000000..77ba70d8cf
Binary files /dev/null and b/super_editor/test/super_editor/mobile/mobile_long_press_selection_text_layout.png differ
diff --git a/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart b/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart
new file mode 100644
index 0000000000..1aa37831cd
--- /dev/null
+++ b/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart
@@ -0,0 +1,515 @@
+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/super_editor.dart';
+import 'package:super_editor/super_editor_test.dart';
+
+import '../supereditor_test_tools.dart';
+
+void main() {
+ group("SuperEditor 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(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,
),
);