|
| 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