Skip to content

Commit 3f4ec4c

Browse files
authored
fix: word selection issue (#1219)
* feat: select whole words when double-click then drag on desktop Double-clicking a word and holding to drag now extends the selection by whole words in either direction, keeping the initial word fully selected, instead of starting a character-level drag from the pointer. * fix: skip underscore italic for intra-word underscores Auto-italic on typing a closing underscore now only triggers when the opening underscore starts a word (at the line start or preceded by whitespace), so `a_b_c` is no longer italicized. Asterisk formatting is unaffected.
1 parent 85b3f7e commit 3f4ec4c

4 files changed

Lines changed: 237 additions & 15 deletions

File tree

lib/src/editor/editor_component/service/selection/desktop_selection_service.dart

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
22
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
33
import 'package:appflowy_editor/src/editor/editor_component/service/selection/shared.dart';
44
import 'package:appflowy_editor/src/service/selection/selection_gesture.dart';
5+
import 'package:flutter/gestures.dart';
56
import 'package:flutter/material.dart';
67
import 'package:flutter/services.dart';
78
import 'package:provider/provider.dart';
@@ -51,6 +52,17 @@ class _DesktopSelectionServiceWidgetState
5152

5253
Position? _panStartPosition;
5354

55+
/// The word selected by a double-tap. When non-null, the pan that immediately
56+
/// follows the double-tap extends the selection by whole words instead of by
57+
/// characters, keeping this word fully selected. Cleared on a plain tap,
58+
/// triple-tap, or when the pan ends.
59+
Selection? _wordSelectionAnchor;
60+
61+
/// The global offset of the double-tap that produced [_wordSelectionAnchor].
62+
/// A following pan only enters word mode when it starts at (essentially) the
63+
/// same point, so a stale anchor cannot hijack an unrelated later drag.
64+
Offset? _wordSelectionAnchorOffset;
65+
5466
bool _isDraggingSelection = false;
5567
Offset? _lastPanOffset;
5668

@@ -162,6 +174,8 @@ class _DesktopSelectionServiceWidgetState
162174
_panStartScrollDy = null;
163175
_panStartPosition = null;
164176
_lastPanOffset = null;
177+
_wordSelectionAnchor = null;
178+
_wordSelectionAnchorOffset = null;
165179
}
166180

167181
void _clearContextMenu() {
@@ -251,6 +265,10 @@ class _DesktopSelectionServiceWidgetState
251265
void _onTapDown(TapDownDetails details) {
252266
_clearContextMenu();
253267

268+
// A plain tap starts a fresh gesture; drop any pending word-drag anchor.
269+
_wordSelectionAnchor = null;
270+
_wordSelectionAnchorOffset = null;
271+
254272
final canTap = _interceptors.every(
255273
(element) => element.canTap?.call(details) ?? true,
256274
);
@@ -297,9 +315,15 @@ class _DesktopSelectionServiceWidgetState
297315
final node = getNodeInOffset(offset);
298316
final selection = node?.selectable?.getWordBoundaryInOffset(offset);
299317
if (selection == null) {
318+
_wordSelectionAnchor = null;
319+
_wordSelectionAnchorOffset = null;
300320
clearSelection();
301321
return;
302322
}
323+
// Arm word-drag mode: the pan that continues from this double-tap will
324+
// extend the selection by whole words, keeping this word fully selected.
325+
_wordSelectionAnchor = selection.normalized;
326+
_wordSelectionAnchorOffset = offset;
303327
updateSelection(selection);
304328
}
305329

@@ -311,6 +335,9 @@ class _DesktopSelectionServiceWidgetState
311335
if (!canTripleTap) {
312336
return updateSelection(null);
313337
}
338+
// Triple-tap is not word-drag mode.
339+
_wordSelectionAnchor = null;
340+
_wordSelectionAnchorOffset = null;
314341
final offset = details.globalPosition;
315342
final node = getNodeInOffset(offset);
316343
final selectable = node?.selectable;
@@ -372,7 +399,19 @@ class _DesktopSelectionServiceWidgetState
372399
}
373400

374401
void _onPanStart(DragStartDetails details) {
375-
clearSelection();
402+
// In word-drag mode (a pan that continues from a double-tap) keep the
403+
// double-tapped word selected; the drag extends it by whole words. The pan
404+
// shares the double-tap's pointer-down, so it must start at essentially the
405+
// same point — otherwise a stale anchor is dropped and this is a plain drag.
406+
final anchorOffset = _wordSelectionAnchorOffset;
407+
final isWordSelectionDrag = _wordSelectionAnchor != null &&
408+
anchorOffset != null &&
409+
(details.globalPosition - anchorOffset).distance <= kDoubleTapSlop;
410+
if (!isWordSelectionDrag) {
411+
_wordSelectionAnchor = null;
412+
_wordSelectionAnchorOffset = null;
413+
clearSelection();
414+
}
376415

377416
final canPanStart = _interceptors.every(
378417
(interceptor) => interceptor.canPanStart?.call(details) ?? true,
@@ -440,6 +479,26 @@ class _DesktopSelectionServiceWidgetState
440479
return;
441480
}
442481

482+
final selectable = getNodeInOffset(panEndOffset)?.selectable;
483+
if (selectable == null) {
484+
return;
485+
}
486+
487+
final Selection? selection = _wordSelectionAnchor != null
488+
? _wordSelectionDuringDrag(selectable, panEndOffset)
489+
: _characterSelectionDuringDrag(selectable, panEndOffset);
490+
491+
if (selection != null && selection != currentSelection.value) {
492+
updateSelection(selection);
493+
}
494+
}
495+
496+
/// Character-level drag selection: the anchor is the exact character position
497+
/// where the pan started and the extent tracks the cursor character.
498+
Selection? _characterSelectionDuringDrag(
499+
SelectableMixin selectable,
500+
Offset panEndOffset,
501+
) {
443502
final double? currentDy = editorState.service.scrollService?.dy;
444503
final Offset panStartOffset = currentDy == null || _panStartScrollDy == null
445504
? _panStartOffset!
@@ -448,12 +507,7 @@ class _DesktopSelectionServiceWidgetState
448507
_panStartScrollDy! - currentDy,
449508
);
450509

451-
final selectable = getNodeInOffset(panEndOffset)?.selectable;
452-
if (selectable == null) {
453-
return;
454-
}
455-
456-
final Selection selection = Selection(
510+
return Selection(
457511
start: _panStartPosition!,
458512
end: selectable
459513
.getSelectionInRange(
@@ -462,10 +516,42 @@ class _DesktopSelectionServiceWidgetState
462516
)
463517
.end,
464518
);
519+
}
465520

466-
if (selection != currentSelection.value) {
467-
updateSelection(selection);
521+
/// Word-level drag selection (double-tap then drag): the double-tapped word
522+
/// stays fully selected while the selection extends to the whole word under
523+
/// the cursor, in either direction.
524+
Selection? _wordSelectionDuringDrag(
525+
SelectableMixin selectable,
526+
Offset panEndOffset,
527+
) {
528+
final anchor = _wordSelectionAnchor;
529+
if (anchor == null) {
530+
return null;
531+
}
532+
533+
final focus = selectable.getWordBoundaryInOffset(panEndOffset)?.normalized;
534+
if (focus == null) {
535+
return null;
536+
}
537+
538+
// If the focus word starts before the anchor word, the drag goes backward:
539+
// keep the anchor word's end as the base and extend to the focus word's
540+
// start. Otherwise the drag goes forward (or stays on the same word).
541+
if (_comparePosition(focus.start, anchor.start) < 0) {
542+
return Selection(start: anchor.end, end: focus.start);
543+
}
544+
return Selection(start: anchor.start, end: focus.end);
545+
}
546+
547+
/// Compares two positions in document order.
548+
/// Returns a negative value if [a] is before [b], zero if equal, and a
549+
/// positive value if [a] is after [b].
550+
int _comparePosition(Position a, Position b) {
551+
if (a.path.equals(b.path)) {
552+
return a.offset.compareTo(b.offset);
468553
}
554+
return a.path < b.path ? -1 : 1;
469555
}
470556

471557
void _handleAutoScrollWhileDragging() {

lib/src/editor/editor_component/service/shortcuts/character/format_single_character/format_single_character.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@ class CheckSingleFormatFormatResult {
116116
return (false, null);
117117
}
118118

119+
// 6. For underscore italic, only auto-format when the opening underscore
120+
// starts a word, i.e. it is at the very start or preceded by whitespace.
121+
// This prevents intra-word underscores like `a_b_c` from being italicized,
122+
// matching the common markdown behavior (asterisks are unaffected).
123+
if (character == '_') {
124+
final openingIndex = startIndex + lastCharIndex;
125+
if (openingIndex > 0 &&
126+
rawPlainText[openingIndex - 1].trim().isNotEmpty) {
127+
return (false, null);
128+
}
129+
}
130+
119131
return (
120132
true,
121133
CheckSingleFormatFormatResult(

test/new/service/shortcuts/character_shortcut_events/format_by_wrapping_with_single_char/format_italic_test.dart

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ void main() async {
3535

3636
// Before
3737
// App_Flowy|
38-
// After
39-
// App[italic]Flowy
40-
test('App_Flowy_ to App[italic]Flowy', () async {
38+
// After (unchanged): the opening underscore is not preceded by
39+
// whitespace, so an intra-word underscore like `a_b_` is not italicized.
40+
test('App_Flowy_ does not italicize (intra-word underscore)', () async {
4141
const text1 = 'App';
4242
const text2 = 'Flowy';
4343
final document = Document.blank().addParagraphs(
@@ -54,11 +54,43 @@ void main() async {
5454

5555
final result = await formatUnderscoreToItalic.execute(editorState);
5656

57+
expect(result, false);
58+
final after = editorState.getNodeAtPath([0])!;
59+
expect(after.delta!.toPlainText(), '${text1}_$text2');
60+
final isItalic = after.delta!
61+
.everyAttributes((element) => element['italic'] == true);
62+
expect(isItalic, false);
63+
});
64+
65+
// Before
66+
// hi _AppFlowy|
67+
// After
68+
// hi [italic]AppFlowy
69+
// The opening underscore is preceded by whitespace, so it still formats.
70+
test('hi _AppFlowy_ italicizes AppFlowy (underscore after space)',
71+
() async {
72+
const prefix = 'hi ';
73+
const text = 'AppFlowy';
74+
final document = Document.blank().addParagraphs(
75+
1,
76+
builder: (index) => Delta()..insert('${prefix}_$text'),
77+
);
78+
79+
final editorState = EditorState(document: document);
80+
81+
final selection = Selection.collapsed(
82+
Position(path: [0], offset: prefix.length + text.length + 1),
83+
);
84+
editorState.selection = selection;
85+
86+
final result = await formatUnderscoreToItalic.execute(editorState);
87+
5788
expect(result, true);
5889
final after = editorState.getNodeAtPath([0])!;
59-
expect(after.delta!.toPlainText(), '$text1$text2');
60-
expect(after.delta!.toList()[0].attributes, null);
61-
expect(after.delta!.toList()[1].attributes, {'italic': true});
90+
expect(after.delta!.toPlainText(), '$prefix$text');
91+
final deltaList = after.delta!.toList();
92+
expect(deltaList[0].attributes, null);
93+
expect(deltaList[1].attributes, {'italic': true});
6294
});
6395

6496
// Before

test/service/selection_service_test.dart

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,98 @@ void main() async {
5959
await editor.dispose();
6060
});
6161

62+
testWidgets('Test double tap then drag forward selects whole words',
63+
(tester) async {
64+
const text = 'Welcome to Appflowy 😁';
65+
final editor = tester.editor..addParagraphs(3, initialText: text);
66+
await editor.startTesting();
67+
68+
final secondNode = editor.nodeAtPath([1]);
69+
final finder = find.byKey(secondNode!.key);
70+
final rect = tester.getRect(finder);
71+
72+
// Press inside the first word "Welcome" (not at its start).
73+
final startPos = rect.centerLeft + const Offset(10.0, 0.0);
74+
75+
// First tap of the double-tap.
76+
await tester.tapAt(startPos);
77+
// Second tap: press down, hold briefly (so the double-tap is recognized),
78+
// then drag to the end of the line.
79+
final gesture = await tester.startGesture(
80+
startPos,
81+
kind: PointerDeviceKind.mouse,
82+
);
83+
await tester.pump(const Duration(milliseconds: 150));
84+
// A small move starts the drag, then extend to the end of the line.
85+
await gesture.moveTo(startPos + const Offset(30.0, 0.0));
86+
await tester.pump();
87+
await gesture.moveTo(rect.centerRight);
88+
await tester.pump();
89+
await gesture.up();
90+
await tester.pump();
91+
92+
// The whole first word stays selected (start at offset 0, not mid-word)
93+
// and the selection extends by whole words to the end of the line.
94+
expect(
95+
editor.selection?.normalized,
96+
Selection.single(path: [1], startOffset: 0, endOffset: text.length),
97+
);
98+
99+
await editor.dispose();
100+
});
101+
102+
testWidgets('Test double tap then drag backward keeps the anchor word',
103+
(tester) async {
104+
const text = 'Welcome to Appflowy 😁';
105+
final editor = tester.editor..addParagraphs(3, initialText: text);
106+
await editor.startTesting();
107+
108+
final secondNode = editor.nodeAtPath([1]);
109+
final finder = find.byKey(secondNode!.key);
110+
final rect = tester.getRect(finder);
111+
112+
// Probe which word lives further along the line so the assertion does not
113+
// depend on exact glyph widths.
114+
final probePos = rect.centerLeft + const Offset(120.0, 0.0);
115+
await tester.tapAt(probePos);
116+
await tester.tapAt(probePos);
117+
await tester.pump();
118+
final probeWord = editor.selection!.normalized;
119+
expect(probeWord.start.offset, greaterThan(0));
120+
121+
// Reset with a plain tap (also clears any pending word-drag anchor).
122+
await tester.tapAt(rect.centerLeft);
123+
await tester.pump();
124+
125+
// Double-tap that later word, then hold and drag back to the start.
126+
await tester.tapAt(probePos);
127+
final gesture = await tester.startGesture(
128+
probePos,
129+
kind: PointerDeviceKind.mouse,
130+
);
131+
await tester.pump(const Duration(milliseconds: 150));
132+
// A small move starts the drag, then extend back to the start of the line.
133+
await gesture.moveTo(probePos - const Offset(30.0, 0.0));
134+
await tester.pump();
135+
await gesture.moveTo(rect.centerLeft);
136+
await tester.pump();
137+
await gesture.up();
138+
await tester.pump();
139+
140+
// The double-tapped word's end stays selected and the selection extends
141+
// by whole words back to the start of the line.
142+
expect(
143+
editor.selection?.normalized,
144+
Selection.single(
145+
path: [1],
146+
startOffset: 0,
147+
endOffset: probeWord.end.offset,
148+
),
149+
);
150+
151+
await editor.dispose();
152+
});
153+
62154
testWidgets('Test triple tap', (tester) async {
63155
const text = 'Welcome to Appflowy 😁';
64156
final editor = tester.editor..addParagraphs(3, initialText: text);

0 commit comments

Comments
 (0)