Skip to content

Commit 4998442

Browse files
angelosilvestrematthew-carroll
authored andcommitted
[SuperEditor][SuperTextField][Android] Implement selection by word using drag handle (Resolves #2112) (#2205)
1 parent cf2a0b9 commit 4998442

File tree

8 files changed

+964
-31
lines changed

8 files changed

+964
-31
lines changed

super_editor/lib/src/default_editor/document_gestures_touch_android.dart

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import 'package:super_editor/src/infrastructure/flutter/build_context.dart';
2222
import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart';
2323
import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart';
2424
import 'package:super_editor/src/infrastructure/platforms/android/android_document_controls.dart';
25+
import 'package:super_editor/src/infrastructure/platforms/android/drag_handle_selection.dart';
2526
import 'package:super_editor/src/infrastructure/platforms/android/long_press_selection.dart';
2627
import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart';
2728
import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart';
@@ -1361,6 +1362,7 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
13611362
//
13621363
// The drag handle type varies independently from the drag selection bound.
13631364
HandleType? _dragHandleType;
1365+
AndroidTextFieldDragHandleSelectionStrategy? _dragHandleSelectionStrategy;
13641366

13651367
final _dragHandleSelectionGlobalFocalPoint = ValueNotifier<Offset?>(null);
13661368
final _magnifierFocalPoint = ValueNotifier<Offset?>(null);
@@ -1466,17 +1468,17 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
14661468
_controlsController!.toggleToolbar();
14671469
}
14681470

1471+
void _updateDragHandleSelection(DocumentSelection newSelection) {
1472+
if (newSelection != widget.selection.value) {
1473+
widget.setSelection(newSelection);
1474+
}
1475+
}
1476+
14691477
void _onHandlePanStart(DragStartDetails details, HandleType handleType) {
14701478
final selection = widget.selection.value;
14711479
if (selection == null) {
14721480
throw Exception("Tried to drag a collapsed Android handle when there's no selection.");
14731481
}
1474-
if (handleType == HandleType.collapsed && !selection.isCollapsed) {
1475-
throw Exception("Tried to drag a collapsed Android handle but the selection is expanded.");
1476-
}
1477-
if (handleType != HandleType.collapsed && selection.isCollapsed) {
1478-
throw Exception("Tried to drag an expanded Android handle but the selection is collapsed.");
1479-
}
14801482

14811483
final isSelectionDownstream = widget.selection.value!.hasDownstreamAffinity(widget.document);
14821484
_dragHandleType = handleType;
@@ -1498,6 +1500,12 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
14981500
_dragHandleSelectionGlobalFocalPoint.value = centerOfContentAtOffset;
14991501
_magnifierFocalPoint.value = centerOfContentAtOffset;
15001502

1503+
_dragHandleSelectionStrategy = AndroidTextFieldDragHandleSelectionStrategy(
1504+
document: widget.document,
1505+
documentLayout: widget.getDocumentLayout(),
1506+
select: _updateDragHandleSelection,
1507+
)..onHandlePanStart(details, selection, handleType);
1508+
15011509
// Update the controls for handle dragging.
15021510
_controlsController!
15031511
..cancelCollapsedHandleAutoHideCountdown()
@@ -1522,20 +1530,24 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
15221530
// Move the selection focal point by the given delta.
15231531
_dragHandleSelectionGlobalFocalPoint.value = _dragHandleSelectionGlobalFocalPoint.value! + details.delta;
15241532

1525-
// Update the selection and magnifier based on the latest drag handle offset.
1526-
_moveSelectionAndMagnifierToDragHandleOffset(dragDx: details.delta.dx);
1533+
_dragHandleSelectionStrategy!.onHandlePanUpdate(details);
1534+
1535+
// Update the magnifier based on the latest drag handle offset.
1536+
_moveMagnifierToDragHandleOffset(dragDx: details.delta.dx);
15271537
}
15281538

15291539
void _onHandlePanEnd(DragEndDetails details, HandleType handleType) {
1540+
_dragHandleSelectionStrategy = null;
15301541
_onHandleDragEnd(handleType);
15311542
}
15321543

15331544
void _onHandlePanCancel(HandleType handleType) {
1545+
_dragHandleSelectionStrategy = null;
15341546
_onHandleDragEnd(handleType);
15351547
}
15361548

15371549
void _onHandleDragEnd(HandleType handleType) {
1538-
_dragHandleSelectionBound = null;
1550+
_dragHandleSelectionStrategy = null;
15391551
_dragHandleType = null;
15401552
_dragHandleSelectionGlobalFocalPoint.value = null;
15411553
_magnifierFocalPoint.value = null;
@@ -1581,6 +1593,13 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
15811593

15821594
void _moveSelectionAndMagnifierToDragHandleOffset({
15831595
double dragDx = 0,
1596+
}) {
1597+
_moveSelectionToDragHandleOffset();
1598+
_moveMagnifierToDragHandleOffset(dragDx: dragDx);
1599+
}
1600+
1601+
void _moveMagnifierToDragHandleOffset({
1602+
double dragDx = 0,
15841603
}) {
15851604
// Move the selection to the document position that's nearest the focal point.
15861605
final documentLayout = widget.getDocumentLayout();
@@ -1599,6 +1618,20 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
15991618
centerOfContentAtNearestPosition.dy,
16001619
);
16011620

1621+
// Update the auto-scroll focal point so that the viewport scrolls if we're
1622+
// close to the boundary.
1623+
widget.dragHandleAutoScroller.value?.updateAutoScrollHandleMonitoring(
1624+
dragEndInViewport: _contentOffsetInViewport(centerOfContentInContentSpace),
1625+
);
1626+
}
1627+
1628+
void _moveSelectionToDragHandleOffset() {
1629+
// Move the selection to the document position that's nearest the focal point.
1630+
final documentLayout = widget.getDocumentLayout();
1631+
final nearestPosition = documentLayout.getDocumentPositionNearestToOffset(
1632+
documentLayout.getDocumentOffsetFromAncestorOffset(_dragHandleSelectionGlobalFocalPoint.value!),
1633+
)!;
1634+
16021635
switch (_dragHandleType!) {
16031636
case HandleType.collapsed:
16041637
widget.setSelection(DocumentSelection.collapsed(
@@ -1619,12 +1652,6 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
16191652
));
16201653
}
16211654
}
1622-
1623-
// Update the auto-scroll focal point so that the viewport scrolls if we're
1624-
// close to the boundary.
1625-
widget.dragHandleAutoScroller.value?.updateAutoScrollHandleMonitoring(
1626-
dragEndInViewport: _contentOffsetInViewport(centerOfContentInContentSpace),
1627-
);
16281655
}
16291656

16301657
/// Converts the [offset] in content space to an offset in the viewport space.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import 'package:flutter/widgets.dart';
2+
import 'package:super_editor/src/core/document.dart';
3+
import 'package:super_editor/src/core/document_layout.dart';
4+
import 'package:super_editor/src/core/document_selection.dart';
5+
import 'package:super_editor/src/default_editor/text.dart';
6+
import 'package:super_editor/src/default_editor/text_tools.dart';
7+
import 'package:super_editor/src/infrastructure/touch_controls.dart';
8+
9+
/// A strategy for selecting text while the user is dragging a drag handle,
10+
/// similar to how the Android OS selects text during a handle drag.
11+
///
12+
/// The following behaviors are implemented:
13+
///
14+
/// - When the user drags a downstream handle in downstream direction,
15+
/// the selection expands by word.
16+
///
17+
/// - When the user drags a downstream handle in upstream direction,
18+
/// the selection expands by character.
19+
///
20+
/// - When the user drags an upstream handle in upstream direction,
21+
/// the selection expands by word.
22+
///
23+
/// - When the user drags an upstream handle in downstream direction,
24+
/// the selection expands by character.
25+
///
26+
/// - When the user drags a collapsed handle, the selection is placed
27+
/// at the drag handle focal point.
28+
class AndroidTextFieldDragHandleSelectionStrategy {
29+
AndroidTextFieldDragHandleSelectionStrategy({
30+
required Document document,
31+
required DocumentLayout documentLayout,
32+
required void Function(DocumentSelection) select,
33+
}) : _document = document,
34+
_docLayout = documentLayout,
35+
_select = select;
36+
37+
final Document _document;
38+
final DocumentLayout _docLayout;
39+
final void Function(DocumentSelection) _select;
40+
41+
DocumentSelection? _lastSelection;
42+
43+
/// The last position pointed by the drag handle.
44+
DocumentPosition? _lastFocalPosition;
45+
46+
/// Whether the user is dragging upstream or downstream.
47+
TextAffinity? _currentDragDirection;
48+
49+
/// The current focal point of the drag handle, in content space.
50+
///
51+
/// This is the center of the [DocumentPosition] that the drag handle points to.
52+
Offset? _currentFocalPoint;
53+
54+
/// The drag handle used to start the gesture.
55+
HandleType? _dragHandleType;
56+
57+
/// The effective drag handle type based on the selection affinity.
58+
///
59+
/// When the user the starts dragging a handle and causes the selection
60+
/// to invert the affinity, for example, dragging the extent handle until the
61+
/// extent position is upstream of the base position, the downstream handle
62+
/// will behave as if it were the upstream handle, i.e., it will select by word
63+
/// upstream and by character downstream.
64+
HandleType? _effectiveDragHandleType;
65+
66+
/// Whether the user is selecting by character or by word.
67+
_SelectionModifier? _selectionModifier;
68+
69+
/// Clients should call this method when a drag handle gesture is initially recognized.
70+
void onHandlePanStart(DragStartDetails details, DocumentSelection initialSelection, HandleType handleType) {
71+
_lastSelection = initialSelection;
72+
73+
if (handleType == HandleType.collapsed && !_lastSelection!.isCollapsed) {
74+
throw Exception("Tried to drag a collapsed Android handle but the selection is expanded.");
75+
}
76+
if (handleType != HandleType.collapsed && _lastSelection!.isCollapsed) {
77+
throw Exception("Tried to drag an expanded Android handle but the selection is collapsed.");
78+
}
79+
_dragHandleType = handleType;
80+
81+
final isSelectionDownstream = initialSelection.hasDownstreamAffinity(_document);
82+
late final DocumentPosition selectionBoundPosition;
83+
if (isSelectionDownstream) {
84+
selectionBoundPosition = handleType == HandleType.upstream ? initialSelection.base : initialSelection.extent;
85+
} else {
86+
selectionBoundPosition = handleType == HandleType.upstream ? initialSelection.extent : initialSelection.base;
87+
}
88+
89+
_currentFocalPoint = _docLayout.getAncestorOffsetFromDocumentOffset(
90+
_docLayout.getRectForPosition(selectionBoundPosition)!.center,
91+
);
92+
93+
_dragHandleType = handleType;
94+
_effectiveDragHandleType = _dragHandleType;
95+
_lastFocalPosition = selectionBoundPosition;
96+
}
97+
98+
/// Clients should call this method when a drag handle gesture is updated.
99+
void onHandlePanUpdate(DragUpdateDetails details) {
100+
_currentFocalPoint = _currentFocalPoint! + details.delta;
101+
102+
final nearestPosition = _docLayout.getDocumentPositionNearestToOffset(
103+
_docLayout.getDocumentOffsetFromAncestorOffset(_currentFocalPoint!),
104+
);
105+
if (nearestPosition == null) {
106+
return;
107+
}
108+
109+
if (_dragHandleType == HandleType.collapsed) {
110+
// A collapsed handle always produces a collapsed selection.
111+
_lastSelection = DocumentSelection.collapsed(position: nearestPosition);
112+
_select(_lastSelection!);
113+
return;
114+
}
115+
116+
final isOverNonTextNode = nearestPosition.nodePosition is! TextNodePosition;
117+
if (isOverNonTextNode) {
118+
// Don't change selection if the user long-presses over a non-text node and then
119+
// moves the finger over the same node. This prevents the selection from collapsing
120+
// when the user moves the finger towards the starting edge of the node.
121+
if (nearestPosition.nodeId != _lastSelection!.base.nodeId) {
122+
// The user is dragging over content that isn't text, therefore it doesn't have
123+
// a concept of "words". Select the whole node.
124+
_select(_lastSelection!.expandTo(nearestPosition));
125+
}
126+
return;
127+
}
128+
129+
final nearestPositionTextOffset = (nearestPosition.nodePosition as TextNodePosition).offset;
130+
final nearestPositionNodeIndex = _document.getNodeIndexById(nearestPosition.nodeId);
131+
132+
final previousNearestPositionTextOffset = (_lastFocalPosition!.nodePosition as TextNodePosition).offset;
133+
final previousNearestPositionNodeIndex = _document.getNodeIndexById(_lastFocalPosition!.nodeId);
134+
135+
final didFocalPointMoveToDownstreamNode = nearestPositionNodeIndex > previousNearestPositionNodeIndex;
136+
final didFocalPointMoveToUpstreamNode = nearestPositionNodeIndex < previousNearestPositionNodeIndex;
137+
final didFocalPointStayInSameNode = nearestPositionNodeIndex == previousNearestPositionNodeIndex;
138+
139+
final didFocalPointMoveDownstream = didFocalPointMoveToDownstreamNode ||
140+
(didFocalPointStayInSameNode && nearestPositionTextOffset > previousNearestPositionTextOffset);
141+
142+
final didFocalPointMoveUpstream = didFocalPointMoveToUpstreamNode ||
143+
(didFocalPointStayInSameNode && nearestPositionTextOffset < previousNearestPositionTextOffset);
144+
145+
_lastFocalPosition = nearestPosition;
146+
147+
if (_currentDragDirection == null) {
148+
// The user just started dragging the handle.
149+
_currentDragDirection = didFocalPointMoveDownstream ? TextAffinity.downstream : TextAffinity.upstream;
150+
151+
if (_dragHandleType == HandleType.upstream && didFocalPointMoveUpstream) {
152+
_selectionModifier = _SelectionModifier.word;
153+
} else if (_dragHandleType == HandleType.downstream && didFocalPointMoveDownstream) {
154+
_selectionModifier = _SelectionModifier.word;
155+
} else {
156+
_selectionModifier = _SelectionModifier.character;
157+
}
158+
} else {
159+
// Check if the user started dragging the handle in the opposite direction.
160+
late TextAffinity newDragDirection;
161+
if (_currentDragDirection == TextAffinity.upstream) {
162+
newDragDirection = didFocalPointMoveDownstream ? TextAffinity.downstream : TextAffinity.upstream;
163+
} else {
164+
newDragDirection = didFocalPointMoveUpstream ? TextAffinity.upstream : TextAffinity.downstream;
165+
}
166+
167+
// Invert the drag handle type if the selection has upstream affinity.
168+
final newEffectiveHandleType = _lastSelection!.hasDownstreamAffinity(_document) //
169+
? _dragHandleType!
170+
: (_dragHandleType == HandleType.upstream ? HandleType.downstream : HandleType.upstream);
171+
172+
if (newDragDirection != _currentDragDirection || newEffectiveHandleType != _effectiveDragHandleType) {
173+
_currentDragDirection = newDragDirection;
174+
_effectiveDragHandleType = newEffectiveHandleType;
175+
176+
if (_effectiveDragHandleType == HandleType.downstream && newDragDirection == TextAffinity.downstream) {
177+
_selectionModifier = _SelectionModifier.word;
178+
} else if (_effectiveDragHandleType == HandleType.upstream && newDragDirection == TextAffinity.upstream) {
179+
_selectionModifier = _SelectionModifier.word;
180+
} else {
181+
_selectionModifier = _SelectionModifier.character;
182+
}
183+
}
184+
}
185+
186+
final rangeToExpandSelection = _selectionModifier == _SelectionModifier.word
187+
? _dragHandleType == _effectiveDragHandleType
188+
? getWordSelection(docPosition: nearestPosition, docLayout: _docLayout)
189+
: _flipSelection(getWordSelection(docPosition: nearestPosition, docLayout: _docLayout)!)
190+
: DocumentSelection.collapsed(position: nearestPosition);
191+
192+
if (rangeToExpandSelection != null) {
193+
_lastSelection = _lastSelection!.copyWith(
194+
base: _dragHandleType == HandleType.upstream ? rangeToExpandSelection.base : _lastSelection!.base,
195+
extent: _dragHandleType == HandleType.downstream ? rangeToExpandSelection.extent : _lastSelection!.extent,
196+
);
197+
_select(_lastSelection!);
198+
}
199+
}
200+
201+
/// Invert the selection so that the base and extent are swapped.
202+
DocumentSelection _flipSelection(DocumentSelection selection) {
203+
return selection.copyWith(
204+
base: selection.extent,
205+
extent: selection.base,
206+
);
207+
}
208+
}
209+
210+
enum _SelectionModifier {
211+
character,
212+
word,
213+
}

0 commit comments

Comments
 (0)