Skip to content

Commit 35a72f7

Browse files
committed
feat: #1624 add shortcut for Shift + Option + Left/Right Arrow
1 parent 2006d35 commit 35a72f7

File tree

8 files changed

+238
-19
lines changed

8 files changed

+238
-19
lines changed

frontend/app_flowy/lib/workspace/application/settings/settings_location_cubit.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ class SettingsLocation {
2323

2424
String? get path {
2525
if (Platform.isMacOS) {
26-
// remove the prefix `/Volumes/Macintosh HD/Users/`
27-
return _path?.replaceFirst('/Volumes/Macintosh HD/Users', '');
26+
// remove the prefix `/Volumes/*`
27+
return _path?.replaceFirst(RegExp(r'^/Volumes/[^/]+'), '');
2828
}
2929
return _path;
3030
}

frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ mixin DefaultSelectable {
3535
Offset localToGlobal(Offset offset) =>
3636
forward.localToGlobal(offset) - baseOffset;
3737

38-
Selection? getWorldBoundaryInOffset(Offset offset) =>
39-
forward.getWorldBoundaryInOffset(offset);
38+
Selection? getWordBoundaryInOffset(Offset offset) =>
39+
forward.getWordBoundaryInOffset(offset);
40+
41+
Selection? getWordBoundaryInPosition(Position position) =>
42+
forward.getWordBoundaryInPosition(position);
4043

4144
Position start() => forward.start();
4245

frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
112112
}
113113

114114
@override
115-
Selection? getWorldBoundaryInOffset(Offset offset) {
115+
Selection? getWordBoundaryInOffset(Offset offset) {
116116
final localOffset = _renderParagraph.globalToLocal(offset);
117117
final textPosition = _renderParagraph.getPositionForOffset(localOffset);
118118
final textRange = _renderParagraph.getWordBoundary(textPosition);
@@ -121,6 +121,15 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
121121
return Selection(start: start, end: end);
122122
}
123123

124+
@override
125+
Selection? getWordBoundaryInPosition(Position position) {
126+
final textPosition = TextPosition(offset: position.offset);
127+
final textRange = _renderParagraph.getWordBoundary(textPosition);
128+
final start = Position(path: widget.textNode.path, offset: textRange.start);
129+
final end = Position(path: widget.textNode.path, offset: textRange.end);
130+
return Selection(start: start, end: end);
131+
}
132+
124133
@override
125134
List<Rect> getRectsInSelection(Selection selection) {
126135
assert(selection.isSingle &&

frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,13 @@ mixin SelectableMixin<T extends StatefulWidget> on State<T> {
5555
///
5656
/// Only the widget rendered by [TextNode] need to implement the detail,
5757
/// and the rest can return null.
58-
Selection? getWorldBoundaryInOffset(Offset start) {
59-
return null;
60-
}
58+
Selection? getWordBoundaryInOffset(Offset start) => null;
59+
60+
/// For [TextNode] only.
61+
///
62+
/// Only the widget rendered by [TextNode] need to implement the detail,
63+
/// and the rest can return null.
64+
Selection? getWordBoundaryInPosition(Position position) => null;
6165

6266
bool get shouldCursorBlink => true;
6367

frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,50 @@ ShortcutEventHandler cursorRight = (editorState, event) {
289289
return KeyEventResult.handled;
290290
};
291291

292+
ShortcutEventHandler cursorLeftWordSelect = (editorState, event) {
293+
final nodes = editorState.service.selectionService.currentSelectedNodes;
294+
final selection = editorState.service.selectionService.currentSelection.value;
295+
if (nodes.isEmpty || selection == null) {
296+
return KeyEventResult.ignored;
297+
}
298+
final end =
299+
selection.end.goLeft(editorState, selectionRange: _SelectionRange.word);
300+
if (end == null) {
301+
return KeyEventResult.ignored;
302+
}
303+
editorState.service.selectionService.updateSelection(
304+
selection.copyWith(end: end),
305+
);
306+
return KeyEventResult.handled;
307+
};
308+
309+
ShortcutEventHandler cursorRightWordSelect = (editorState, event) {
310+
final nodes = editorState.service.selectionService.currentSelectedNodes;
311+
final selection = editorState.service.selectionService.currentSelection.value;
312+
if (nodes.isEmpty || selection == null) {
313+
return KeyEventResult.ignored;
314+
}
315+
final end =
316+
selection.end.goRight(editorState, selectionRange: _SelectionRange.word);
317+
if (end == null) {
318+
return KeyEventResult.ignored;
319+
}
320+
editorState.service.selectionService.updateSelection(
321+
selection.copyWith(end: end),
322+
);
323+
return KeyEventResult.handled;
324+
};
325+
326+
enum _SelectionRange {
327+
character,
328+
word,
329+
}
330+
292331
extension on Position {
293-
Position? goLeft(EditorState editorState) {
332+
Position? goLeft(
333+
EditorState editorState, {
334+
_SelectionRange selectionRange = _SelectionRange.character,
335+
}) {
294336
final node = editorState.document.nodeAtPath(path);
295337
if (node == null) {
296338
return null;
@@ -302,14 +344,38 @@ extension on Position {
302344
}
303345
return null;
304346
}
305-
if (node is TextNode) {
306-
return Position(path: path, offset: node.delta.prevRunePosition(offset));
307-
} else {
308-
return Position(path: path, offset: offset);
347+
switch (selectionRange) {
348+
case _SelectionRange.character:
349+
if (node is TextNode) {
350+
return Position(
351+
path: path,
352+
offset: node.delta.prevRunePosition(offset),
353+
);
354+
} else {
355+
return Position(path: path, offset: offset);
356+
}
357+
case _SelectionRange.word:
358+
if (node is TextNode) {
359+
final result = node.selectable?.getWordBoundaryInPosition(
360+
Position(
361+
path: path,
362+
offset: node.delta.prevRunePosition(offset),
363+
),
364+
);
365+
if (result != null) {
366+
return result.start;
367+
}
368+
} else {
369+
return Position(path: path, offset: offset);
370+
}
309371
}
372+
return null;
310373
}
311374

312-
Position? goRight(EditorState editorState) {
375+
Position? goRight(
376+
EditorState editorState, {
377+
_SelectionRange selectionRange = _SelectionRange.character,
378+
}) {
313379
final node = editorState.document.nodeAtPath(path);
314380
if (node == null) {
315381
return null;
@@ -322,11 +388,30 @@ extension on Position {
322388
}
323389
return null;
324390
}
325-
if (node is TextNode) {
326-
return Position(path: path, offset: node.delta.nextRunePosition(offset));
327-
} else {
328-
return Position(path: path, offset: offset);
391+
switch (selectionRange) {
392+
case _SelectionRange.character:
393+
if (node is TextNode) {
394+
return Position(
395+
path: path, offset: node.delta.nextRunePosition(offset));
396+
} else {
397+
return Position(path: path, offset: offset);
398+
}
399+
case _SelectionRange.word:
400+
if (node is TextNode) {
401+
final result = node.selectable?.getWordBoundaryInPosition(
402+
Position(
403+
path: path,
404+
offset: node.delta.nextRunePosition(offset),
405+
),
406+
);
407+
if (result != null) {
408+
return result.end;
409+
}
410+
} else {
411+
return Position(path: path, offset: offset);
412+
}
329413
}
414+
return null;
330415
}
331416
}
332417

frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
298298
void _onDoubleTapDown(TapDownDetails details) {
299299
final offset = details.globalPosition;
300300
final node = getNodeInOffset(offset);
301-
final selection = node?.selectable?.getWorldBoundaryInOffset(offset);
301+
final selection = node?.selectable?.getWordBoundaryInOffset(offset);
302302
if (selection == null) {
303303
clearSelection();
304304
return;

frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ List<ShortcutEvent> builtInShortcutEvents = [
4848
command: 'shift+arrow down',
4949
handler: cursorDownSelect,
5050
),
51+
ShortcutEvent(
52+
key: 'Cursor down select',
53+
command: 'shift+alt+arrow left',
54+
handler: cursorLeftWordSelect,
55+
),
56+
ShortcutEvent(
57+
key: 'Cursor down select',
58+
command: 'shift+alt+arrow right',
59+
handler: cursorRightWordSelect,
60+
),
5161
ShortcutEvent(
5262
key: 'Cursor left select',
5363
command: 'shift+arrow left',

frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,114 @@ void main() async {
341341
),
342342
);
343343
});
344+
345+
testWidgets('Presses shift + alt + arrow left to select a word',
346+
(tester) async {
347+
const text = 'Welcome to Appflowy 😁';
348+
final editor = tester.editor
349+
..insertTextNode(text)
350+
..insertTextNode(text);
351+
await editor.startTesting();
352+
final selection = Selection.single(path: [1], startOffset: 10);
353+
await editor.updateSelection(selection);
354+
await editor.pressLogicKey(
355+
LogicalKeyboardKey.arrowLeft,
356+
isShiftPressed: true,
357+
isAltPressed: true,
358+
);
359+
// <to>
360+
expect(
361+
editor.documentSelection,
362+
selection.copyWith(
363+
end: Position(path: [1], offset: 8),
364+
),
365+
);
366+
await editor.pressLogicKey(
367+
LogicalKeyboardKey.arrowLeft,
368+
isShiftPressed: true,
369+
isAltPressed: true,
370+
);
371+
// < to>
372+
expect(
373+
editor.documentSelection,
374+
selection.copyWith(
375+
end: Position(path: [1], offset: 7),
376+
),
377+
);
378+
await editor.pressLogicKey(
379+
LogicalKeyboardKey.arrowLeft,
380+
isShiftPressed: true,
381+
isAltPressed: true,
382+
);
383+
// <Welcome to>
384+
expect(
385+
editor.documentSelection,
386+
selection.copyWith(
387+
end: Position(path: [1], offset: 0),
388+
),
389+
);
390+
await editor.pressLogicKey(
391+
LogicalKeyboardKey.arrowLeft,
392+
isShiftPressed: true,
393+
isAltPressed: true,
394+
);
395+
// <😁>
396+
// <Welcome to>
397+
expect(
398+
editor.documentSelection,
399+
selection.copyWith(
400+
end: Position(path: [0], offset: 22),
401+
),
402+
);
403+
});
404+
405+
testWidgets('Presses shift + alt + arrow left to select a word',
406+
(tester) async {
407+
const text = 'Welcome to Appflowy 😁';
408+
final editor = tester.editor
409+
..insertTextNode(text)
410+
..insertTextNode(text);
411+
await editor.startTesting();
412+
final selection = Selection.single(path: [0], startOffset: 10);
413+
await editor.updateSelection(selection);
414+
await editor.pressLogicKey(
415+
LogicalKeyboardKey.arrowRight,
416+
isShiftPressed: true,
417+
isAltPressed: true,
418+
);
419+
// < Appflowy>
420+
expect(
421+
editor.documentSelection,
422+
selection.copyWith(
423+
end: Position(path: [0], offset: 19),
424+
),
425+
);
426+
await editor.pressLogicKey(
427+
LogicalKeyboardKey.arrowRight,
428+
isShiftPressed: true,
429+
isAltPressed: true,
430+
);
431+
// < Appflowy 😁>
432+
expect(
433+
editor.documentSelection,
434+
selection.copyWith(
435+
end: Position(path: [0], offset: 22),
436+
),
437+
);
438+
await editor.pressLogicKey(
439+
LogicalKeyboardKey.arrowRight,
440+
isShiftPressed: true,
441+
isAltPressed: true,
442+
);
443+
// < Appflowy 😁>
444+
// <>
445+
expect(
446+
editor.documentSelection,
447+
selection.copyWith(
448+
end: Position(path: [1], offset: 0),
449+
),
450+
);
451+
});
344452
}
345453

346454
Future<void> _testPressArrowKeyInNotCollapsedSelection(

0 commit comments

Comments
 (0)