Skip to content

Commit 1bf2a8b

Browse files
[SuperEditor][SuperTextField][iOS] - When user taps, place caret at word boundary (Resolves #2080) (#2100)
1 parent e4561f8 commit 1bf2a8b

16 files changed

+305
-91
lines changed

super_editor/lib/src/default_editor/document_gestures_touch_ios.dart

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import 'package:super_editor/src/infrastructure/platforms/ios/floating_cursor.da
2323
import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart';
2424
import 'package:super_editor/src/infrastructure/platforms/ios/long_press_selection.dart';
2525
import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart';
26+
import 'package:super_editor/src/infrastructure/platforms/ios/selection_heuristics.dart';
2627
import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart';
2728
import 'package:super_editor/src/infrastructure/platforms/platform.dart';
2829
import 'package:super_editor/src/infrastructure/signal_notifier.dart';
@@ -111,6 +112,7 @@ class SuperEditorIosControlsScope extends InheritedWidget {
111112
/// the caret, handles, floating cursor, magnifier, and toolbar.
112113
class SuperEditorIosControlsController {
113114
SuperEditorIosControlsController({
115+
this.useIosSelectionHeuristics = true,
114116
this.handleColor,
115117
FloatingCursorController? floatingCursorController,
116118
this.magnifierBuilder,
@@ -125,6 +127,17 @@ class SuperEditorIosControlsController {
125127
_shouldShowToolbar.dispose();
126128
}
127129

130+
/// Whether to adjust the user's selection similar to the way iOS does.
131+
///
132+
/// For example: iOS doesn't let users tap directly on a text offset. Instead,
133+
/// iOS places the caret at the end of the word, or beginning of the word,
134+
/// based on how close the user is to those locations when he taps.
135+
///
136+
/// When this property is `true`, iOS-style heuristics should be used. When
137+
/// this value is `false`, the user's gestures should directly impact the
138+
/// area they touched.
139+
final bool useIosSelectionHeuristics;
140+
128141
/// Color of the text selection drag handles on iOS.
129142
final Color? handleColor;
130143

@@ -641,10 +654,20 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
641654
}
642655

643656
if (docPosition != null) {
657+
late final DocumentPosition adjustedSelectionPosition;
658+
if (docPosition.nodePosition is TextNodePosition) {
659+
// The user tapped a text position. Adjust the position to the start
660+
// or end of the word, as per iOS behavior.
661+
adjustedSelectionPosition = _moveTapPositionToWordBoundary(docPosition);
662+
} else {
663+
// Selection isn't text. Don't adjust the position.
664+
adjustedSelectionPosition = docPosition;
665+
}
666+
644667
final didTapOnExistingSelection = selection != null &&
645668
selection.isCollapsed &&
646-
selection.extent.nodeId == docPosition.nodeId &&
647-
selection.extent.nodePosition.isEquivalentTo(docPosition.nodePosition);
669+
selection.extent.nodeId == adjustedSelectionPosition.nodeId &&
670+
selection.extent.nodePosition.isEquivalentTo(adjustedSelectionPosition.nodePosition);
648671

649672
if (didTapOnExistingSelection) {
650673
// Toggle the toolbar display when the user taps on the collapsed caret,
@@ -655,7 +678,7 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
655678
_controlsController!.hideToolbar();
656679
}
657680

658-
final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!;
681+
final tappedComponent = _docLayout.getComponentByNodeId(adjustedSelectionPosition.nodeId)!;
659682
if (!tappedComponent.isVisualSelectionSupported()) {
660683
// The user tapped a non-selectable component.
661684
// Place the document selection at the nearest selectable node
@@ -665,13 +688,13 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
665688
document: widget.document,
666689
documentLayoutResolver: widget.getDocumentLayout,
667690
currentSelection: widget.selection.value,
668-
startingNode: widget.document.getNodeById(docPosition.nodeId)!,
691+
startingNode: widget.document.getNodeById(adjustedSelectionPosition.nodeId)!,
669692
);
670693
return;
671694
} else {
672695
// Place the document selection at the location where the
673696
// user tapped.
674-
_selectPosition(docPosition);
697+
_selectPosition(adjustedSelectionPosition);
675698
}
676699

677700
if (didTapOnExistingSelection) {
@@ -691,6 +714,25 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
691714
widget.focusNode.requestFocus();
692715
}
693716

717+
DocumentPosition _moveTapPositionToWordBoundary(DocumentPosition docPosition) {
718+
if (!SuperEditorIosControlsScope.rootOf(context).useIosSelectionHeuristics) {
719+
// iOS-style adjustments aren't desired. Don't adjust th given position.
720+
return docPosition;
721+
}
722+
723+
final text = (widget.document.getNodeById(docPosition.nodeId) as TextNode).text.text;
724+
final tapOffset = (docPosition.nodePosition as TextNodePosition).offset;
725+
if (tapOffset == text.length) {
726+
return docPosition;
727+
}
728+
final adjustedSelectionOffset = IosHeuristics.adjustTapOffset(text, tapOffset);
729+
730+
return DocumentPosition(
731+
nodeId: docPosition.nodeId,
732+
nodePosition: TextNodePosition(offset: adjustedSelectionOffset),
733+
);
734+
}
735+
694736
void _onDoubleTapUp(TapUpDetails details) {
695737
final selection = widget.selection.value;
696738
if (selection != null &&

super_editor/lib/src/default_editor/text_tools.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:math';
2+
23
import 'package:flutter/services.dart';
34
import 'package:super_editor/src/core/document.dart';
45
import 'package:super_editor/src/core/document_layout.dart';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import 'package:super_editor/src/infrastructure/strings.dart';
2+
3+
/// User interaction heuristics that simulate observed behavior on
4+
/// iOS devices.
5+
class IosHeuristics {
6+
/// Adjusts a user's tap offset within a text field, or a paragraph, by
7+
/// moving the caret to a word boundary, as observed on iOS.
8+
static int adjustTapOffset(String text, int tapOffset) {
9+
assert(tapOffset >= 0 && tapOffset < text.length);
10+
if (tapOffset == 0) {
11+
return 0;
12+
}
13+
14+
final upstreamWordStart = text.moveOffsetUpstreamByWord(tapOffset) ?? 0;
15+
final upstreamWordEnd = text.moveOffsetDownstreamByWord(upstreamWordStart) ?? text.length;
16+
17+
final downstreamWordEnd = text.moveOffsetDownstreamByWord(tapOffset) ?? text.length;
18+
final downstreamWordStart = text.moveOffsetUpstreamByWord(downstreamWordEnd) ?? 0;
19+
20+
if (text[tapOffset] == " ") {
21+
// User tapped between words. Pick the nearest word.
22+
return downstreamWordStart - tapOffset < tapOffset - upstreamWordEnd ? downstreamWordStart : upstreamWordEnd;
23+
} else {
24+
// User tapped within a word. Adjust the offset to the end of the
25+
// word unless the user is within 1 character of the start of the word.
26+
if (tapOffset <= upstreamWordEnd) {
27+
// The tap position is within the upstream word.
28+
return tapOffset - upstreamWordStart <= 1 ? upstreamWordStart : upstreamWordEnd;
29+
} else {
30+
// The tap position is within the downstream word.
31+
return tapOffset - downstreamWordStart <= 1 ? downstreamWordStart : downstreamWordEnd;
32+
}
33+
}
34+
}
35+
36+
const IosHeuristics._();
37+
}

super_editor/lib/src/super_textfield/ios/_user_interaction.dart

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import 'package:super_editor/src/infrastructure/_logging.dart';
55
import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart';
66
import 'package:super_editor/src/infrastructure/flutter/text_selection.dart';
77
import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart';
8+
import 'package:super_editor/src/infrastructure/platforms/ios/selection_heuristics.dart';
89
import 'package:super_editor/src/super_textfield/super_textfield.dart';
10+
import 'package:super_editor/src/test/test_globals.dart';
911
import 'package:super_text_layout/super_text_layout.dart';
1012

1113
import '_editing_controls.dart';
@@ -196,16 +198,18 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
196198
widget.focusNode.requestFocus();
197199
}
198200

199-
final exactTapTextPosition = _getTextPositionAtOffset(details.localPosition);
200-
final didTapOnExistingSelection = exactTapTextPosition != null &&
201+
final exactTapTextPosition = _getTextPositionNearestToOffset(details.localPosition);
202+
final adjustedTapTextPosition =
203+
exactTapTextPosition != null ? _moveTapPositionToWordBoundary(exactTapTextPosition) : null;
204+
final didTapOnExistingSelection = adjustedTapTextPosition != null &&
201205
_selectionBeforeTap != null &&
202206
(_selectionBeforeTap!.isCollapsed
203-
? exactTapTextPosition.offset == _selectionBeforeTap!.extent.offset
204-
: exactTapTextPosition.offset >= _selectionBeforeTap!.start &&
205-
exactTapTextPosition.offset <= _selectionBeforeTap!.end);
207+
? adjustedTapTextPosition.offset == _selectionBeforeTap!.extent.offset
208+
: adjustedTapTextPosition.offset >= _selectionBeforeTap!.start &&
209+
adjustedTapTextPosition.offset <= _selectionBeforeTap!.end);
206210

207211
// Select the text that's nearest to where the user tapped.
208-
_selectAtOffset(details.localPosition);
212+
_selectPosition(adjustedTapTextPosition);
209213

210214
final didCaretStayInSamePlace = _selectionBeforeTap != null &&
211215
_selectionBeforeTap?.hasSameBoundsAs(widget.textController.selection) == true &&
@@ -227,18 +231,38 @@ class IOSTextFieldTouchInteractorState extends State<IOSTextFieldTouchInteractor
227231
_selectionBeforeTap = null;
228232
}
229233

230-
/// Places the caret in the field's text based on the given [localOffset],
231-
/// and displays the drag handle.
232-
void _selectAtOffset(Offset localOffset) {
233-
final tapTextPosition = _getTextPositionNearestToOffset(localOffset);
234-
if (tapTextPosition == null || tapTextPosition.offset < 0) {
234+
TextPosition _moveTapPositionToWordBoundary(TextPosition textPosition) {
235+
if (Testing.isInTest) {
236+
// Don't adjust the tap location in tests because we want tests to be
237+
// able to precisely position the caret at a given offset.
238+
// TODO: Make this decision configurable, similar to SuperEditor, so that
239+
// we can add tests for this behavior.
240+
return textPosition;
241+
}
242+
243+
if (textPosition.offset < 0) {
244+
return textPosition;
245+
}
246+
247+
final text = widget.textController.text.text;
248+
final tapOffset = textPosition.offset;
249+
if (tapOffset == text.length) {
250+
return textPosition;
251+
}
252+
final adjustedSelectionOffset = IosHeuristics.adjustTapOffset(text, tapOffset);
253+
254+
return TextPosition(offset: adjustedSelectionOffset);
255+
}
256+
257+
void _selectPosition(TextPosition? textPosition) {
258+
if (textPosition == null || textPosition.offset < 0) {
235259
// This situation indicates the user tapped in empty space
236260
widget.textController.selection = TextSelection.collapsed(offset: widget.textController.text.length);
237261
return;
238262
}
239263

240264
// Update the text selection to a collapsed selection where the user tapped.
241-
widget.textController.selection = TextSelection.collapsed(offset: tapTextPosition.offset);
265+
widget.textController.selection = TextSelection.collapsed(offset: textPosition.offset);
242266
widget.textController.composingRegion = TextRange.empty;
243267
}
244268

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Testing {
2+
static bool isInTest = false;
3+
4+
const Testing._();
5+
}

super_editor/lib/super_editor.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export 'src/infrastructure/platforms/ios/ios_document_controls.dart';
7777
export 'src/infrastructure/platforms/ios/floating_cursor.dart';
7878
export 'src/infrastructure/platforms/ios/toolbar.dart';
7979
export 'src/infrastructure/platforms/ios/magnifier.dart';
80+
export 'src/infrastructure/platforms/ios/selection_heuristics.dart';
8081
export 'src/infrastructure/platforms/mac/mac_ime.dart';
8182
export 'src/infrastructure/platforms/mobile_documents.dart';
8283
export 'src/infrastructure/scrolling_diagnostics/scrolling_diagnostics.dart';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import 'dart:async';
22

3+
import 'package:super_editor/src/test/test_globals.dart';
34
import 'package:super_text_layout/super_text_layout.dart';
45

56
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
67
// Disable indeterminate animations
78
BlinkController.indeterminateAnimationsEnabled = false;
89

10+
Testing.isInTest = true;
11+
912
return testMain();
1013
}

super_editor/test/infrastructure/multi_tap_gesture_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,9 +423,9 @@ void main() {
423423
await tester.pump(kTapMinTime);
424424

425425
// Trigger a horizontal drag.
426-
await gesture.moveBy(Offset(20, 0));
426+
await gesture.moveBy(const Offset(20, 0));
427427
await tester.pump();
428-
await gesture.moveBy(Offset(20, 0));
428+
await gesture.moveBy(const Offset(20, 0));
429429
await tester.pump();
430430

431431
// Release the gesture.

super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,103 @@ import '../supereditor_test_tools.dart';
1010
void main() {
1111
group("SuperEditor mobile selection >", () {
1212
group("iOS >", () {
13+
group("on tap >", () {
14+
testWidgetsOnIos("when beyond first character > places caret at end of word", (tester) async {
15+
// Note: We pump the following text.
16+
// "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...",
17+
await _pumpAppWithLongText(tester);
18+
19+
// Tap near the end of a word "consectet|ur".
20+
await tester.tapInParagraph("1", 37);
21+
await tester.pumpAndSettle();
22+
23+
// Ensure that the caret is at the end of the world "consectetur|".
24+
expect(
25+
SuperEditorInspector.findDocumentSelection(),
26+
const DocumentSelection.collapsed(
27+
position: DocumentPosition(
28+
nodeId: "1",
29+
nodePosition: TextNodePosition(offset: 39),
30+
),
31+
),
32+
);
33+
34+
// Tap near the middle of a word "adipi|scing".
35+
await tester.tapInParagraph("1", 45);
36+
await tester.pumpAndSettle();
37+
38+
// Ensure that the caret is at the end of the world "adipiscing|".
39+
expect(
40+
SuperEditorInspector.findDocumentSelection(),
41+
const DocumentSelection.collapsed(
42+
position: DocumentPosition(
43+
nodeId: "1",
44+
nodePosition: TextNodePosition(offset: 50),
45+
),
46+
),
47+
);
48+
49+
// Tap near the beginning of a word "co|nsectetur".
50+
await tester.tapInParagraph("1", 30);
51+
await tester.pumpAndSettle();
52+
53+
// Ensure that the caret is at the end of the word "consectetur|".
54+
expect(
55+
SuperEditorInspector.findDocumentSelection(),
56+
const DocumentSelection.collapsed(
57+
position: DocumentPosition(
58+
nodeId: "1",
59+
nodePosition: TextNodePosition(offset: 39),
60+
),
61+
),
62+
);
63+
});
64+
65+
testWidgetsOnIos("when near first character > places caret at start of word", (tester) async {
66+
// Note: We pump the following text.
67+
// "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...",
68+
await _pumpAppWithLongText(tester);
69+
70+
// Tap just before first character of word " |consectetur".
71+
await tester.tapInParagraph("1", 28);
72+
await tester.pumpAndSettle();
73+
74+
// Ensure that the caret is at the start of the world "|consectetur".
75+
expect(
76+
SuperEditorInspector.findDocumentSelection(),
77+
const DocumentSelection.collapsed(
78+
position: DocumentPosition(
79+
nodeId: "1",
80+
nodePosition: TextNodePosition(offset: 28),
81+
),
82+
),
83+
);
84+
85+
// Tap just after the start of the word " a|dipiscing".
86+
await tester.tapInParagraph("1", 41);
87+
await tester.pumpAndSettle();
88+
89+
// Ensure that the caret is at the start of the word " |adipiscing".
90+
expect(
91+
SuperEditorInspector.findDocumentSelection(),
92+
const DocumentSelection.collapsed(
93+
position: DocumentPosition(
94+
nodeId: "1",
95+
nodePosition: TextNodePosition(offset: 40),
96+
),
97+
),
98+
);
99+
});
100+
});
101+
13102
group("long press >", () {
14103
testWidgetsOnIos("selects word under finger", (tester) async {
15104
await _pumpAppWithLongText(tester);
16105

17106
// Ensure that no overlay controls are visible.
18107
_expectNoControlsAreVisible();
19108

20-
// Long press on the middle of "conse|ctetur"
109+
// Long press on the middle of "conse|ctetur".
21110
await tester.longPressInParagraph("1", 33);
22111
await tester.pumpAndSettle();
23112

@@ -35,7 +124,7 @@ void main() {
35124

36125
await _pumpAppWithLongText(tester);
37126

38-
// Long press down on the middle of "conse|ctetur"
127+
// Long press down on the middle of "conse|ctetur".
39128
final gesture = await tester.longPressDownInParagraph("1", 33);
40129
await tester.pump();
41130

@@ -311,6 +400,7 @@ Future<void> _pumpAppWithLongText(WidgetTester tester) async {
311400
.createDocument()
312401
// "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...",
313402
.withSingleParagraph()
403+
.useIosSelectionHeuristics(true)
314404
.withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) =>
315405
IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint))
316406
.pump();

0 commit comments

Comments
 (0)