Browse Source

feat: #1624 add shortcut for Shift + Option + Left/Right Arrow

Lucas.Xu 2 years ago
parent
commit
35a72f701b

+ 2 - 2
frontend/app_flowy/lib/workspace/application/settings/settings_location_cubit.dart

@@ -23,8 +23,8 @@ class SettingsLocation {
 
   String? get path {
     if (Platform.isMacOS) {
-      // remove the prefix `/Volumes/Macintosh HD/Users/`
-      return _path?.replaceFirst('/Volumes/Macintosh HD/Users', '');
+      // remove the prefix `/Volumes/*`
+      return _path?.replaceFirst(RegExp(r'^/Volumes/[^/]+'), '');
     }
     return _path;
   }

+ 5 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart

@@ -35,8 +35,11 @@ mixin DefaultSelectable {
   Offset localToGlobal(Offset offset) =>
       forward.localToGlobal(offset) - baseOffset;
 
-  Selection? getWorldBoundaryInOffset(Offset offset) =>
-      forward.getWorldBoundaryInOffset(offset);
+  Selection? getWordBoundaryInOffset(Offset offset) =>
+      forward.getWordBoundaryInOffset(offset);
+
+  Selection? getWordBoundaryInPosition(Position position) =>
+      forward.getWordBoundaryInPosition(position);
 
   Position start() => forward.start();
 

+ 10 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -112,7 +112,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
   }
 
   @override
-  Selection? getWorldBoundaryInOffset(Offset offset) {
+  Selection? getWordBoundaryInOffset(Offset offset) {
     final localOffset = _renderParagraph.globalToLocal(offset);
     final textPosition = _renderParagraph.getPositionForOffset(localOffset);
     final textRange = _renderParagraph.getWordBoundary(textPosition);
@@ -121,6 +121,15 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
     return Selection(start: start, end: end);
   }
 
+  @override
+  Selection? getWordBoundaryInPosition(Position position) {
+    final textPosition = TextPosition(offset: position.offset);
+    final textRange = _renderParagraph.getWordBoundary(textPosition);
+    final start = Position(path: widget.textNode.path, offset: textRange.start);
+    final end = Position(path: widget.textNode.path, offset: textRange.end);
+    return Selection(start: start, end: end);
+  }
+
   @override
   List<Rect> getRectsInSelection(Selection selection) {
     assert(selection.isSingle &&

+ 7 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart

@@ -55,9 +55,13 @@ mixin SelectableMixin<T extends StatefulWidget> on State<T> {
   ///
   /// Only the widget rendered by [TextNode] need to implement the detail,
   ///   and the rest can return null.
-  Selection? getWorldBoundaryInOffset(Offset start) {
-    return null;
-  }
+  Selection? getWordBoundaryInOffset(Offset start) => null;
+
+  /// For [TextNode] only.
+  ///
+  /// Only the widget rendered by [TextNode] need to implement the detail,
+  ///   and the rest can return null.
+  Selection? getWordBoundaryInPosition(Position position) => null;
 
   bool get shouldCursorBlink => true;
 

+ 95 - 10
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart

@@ -289,8 +289,50 @@ ShortcutEventHandler cursorRight = (editorState, event) {
   return KeyEventResult.handled;
 };
 
+ShortcutEventHandler cursorLeftWordSelect = (editorState, event) {
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  final selection = editorState.service.selectionService.currentSelection.value;
+  if (nodes.isEmpty || selection == null) {
+    return KeyEventResult.ignored;
+  }
+  final end =
+      selection.end.goLeft(editorState, selectionRange: _SelectionRange.word);
+  if (end == null) {
+    return KeyEventResult.ignored;
+  }
+  editorState.service.selectionService.updateSelection(
+    selection.copyWith(end: end),
+  );
+  return KeyEventResult.handled;
+};
+
+ShortcutEventHandler cursorRightWordSelect = (editorState, event) {
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  final selection = editorState.service.selectionService.currentSelection.value;
+  if (nodes.isEmpty || selection == null) {
+    return KeyEventResult.ignored;
+  }
+  final end =
+      selection.end.goRight(editorState, selectionRange: _SelectionRange.word);
+  if (end == null) {
+    return KeyEventResult.ignored;
+  }
+  editorState.service.selectionService.updateSelection(
+    selection.copyWith(end: end),
+  );
+  return KeyEventResult.handled;
+};
+
+enum _SelectionRange {
+  character,
+  word,
+}
+
 extension on Position {
-  Position? goLeft(EditorState editorState) {
+  Position? goLeft(
+    EditorState editorState, {
+    _SelectionRange selectionRange = _SelectionRange.character,
+  }) {
     final node = editorState.document.nodeAtPath(path);
     if (node == null) {
       return null;
@@ -302,14 +344,38 @@ extension on Position {
       }
       return null;
     }
-    if (node is TextNode) {
-      return Position(path: path, offset: node.delta.prevRunePosition(offset));
-    } else {
-      return Position(path: path, offset: offset);
+    switch (selectionRange) {
+      case _SelectionRange.character:
+        if (node is TextNode) {
+          return Position(
+            path: path,
+            offset: node.delta.prevRunePosition(offset),
+          );
+        } else {
+          return Position(path: path, offset: offset);
+        }
+      case _SelectionRange.word:
+        if (node is TextNode) {
+          final result = node.selectable?.getWordBoundaryInPosition(
+            Position(
+              path: path,
+              offset: node.delta.prevRunePosition(offset),
+            ),
+          );
+          if (result != null) {
+            return result.start;
+          }
+        } else {
+          return Position(path: path, offset: offset);
+        }
     }
+    return null;
   }
 
-  Position? goRight(EditorState editorState) {
+  Position? goRight(
+    EditorState editorState, {
+    _SelectionRange selectionRange = _SelectionRange.character,
+  }) {
     final node = editorState.document.nodeAtPath(path);
     if (node == null) {
       return null;
@@ -322,11 +388,30 @@ extension on Position {
       }
       return null;
     }
-    if (node is TextNode) {
-      return Position(path: path, offset: node.delta.nextRunePosition(offset));
-    } else {
-      return Position(path: path, offset: offset);
+    switch (selectionRange) {
+      case _SelectionRange.character:
+        if (node is TextNode) {
+          return Position(
+              path: path, offset: node.delta.nextRunePosition(offset));
+        } else {
+          return Position(path: path, offset: offset);
+        }
+      case _SelectionRange.word:
+        if (node is TextNode) {
+          final result = node.selectable?.getWordBoundaryInPosition(
+            Position(
+              path: path,
+              offset: node.delta.nextRunePosition(offset),
+            ),
+          );
+          if (result != null) {
+            return result.end;
+          }
+        } else {
+          return Position(path: path, offset: offset);
+        }
     }
+    return null;
   }
 }
 

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart

@@ -298,7 +298,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
   void _onDoubleTapDown(TapDownDetails details) {
     final offset = details.globalPosition;
     final node = getNodeInOffset(offset);
-    final selection = node?.selectable?.getWorldBoundaryInOffset(offset);
+    final selection = node?.selectable?.getWordBoundaryInOffset(offset);
     if (selection == null) {
       clearSelection();
       return;

+ 10 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart

@@ -48,6 +48,16 @@ List<ShortcutEvent> builtInShortcutEvents = [
     command: 'shift+arrow down',
     handler: cursorDownSelect,
   ),
+  ShortcutEvent(
+    key: 'Cursor down select',
+    command: 'shift+alt+arrow left',
+    handler: cursorLeftWordSelect,
+  ),
+  ShortcutEvent(
+    key: 'Cursor down select',
+    command: 'shift+alt+arrow right',
+    handler: cursorRightWordSelect,
+  ),
   ShortcutEvent(
     key: 'Cursor left select',
     command: 'shift+arrow left',

+ 108 - 0
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart

@@ -341,6 +341,114 @@ void main() async {
       ),
     );
   });
+
+  testWidgets('Presses shift + alt + arrow left to select a word',
+      (tester) async {
+    const text = 'Welcome to Appflowy 😁';
+    final editor = tester.editor
+      ..insertTextNode(text)
+      ..insertTextNode(text);
+    await editor.startTesting();
+    final selection = Selection.single(path: [1], startOffset: 10);
+    await editor.updateSelection(selection);
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowLeft,
+      isShiftPressed: true,
+      isAltPressed: true,
+    );
+    // <to>
+    expect(
+      editor.documentSelection,
+      selection.copyWith(
+        end: Position(path: [1], offset: 8),
+      ),
+    );
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowLeft,
+      isShiftPressed: true,
+      isAltPressed: true,
+    );
+    // < to>
+    expect(
+      editor.documentSelection,
+      selection.copyWith(
+        end: Position(path: [1], offset: 7),
+      ),
+    );
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowLeft,
+      isShiftPressed: true,
+      isAltPressed: true,
+    );
+    // <Welcome to>
+    expect(
+      editor.documentSelection,
+      selection.copyWith(
+        end: Position(path: [1], offset: 0),
+      ),
+    );
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowLeft,
+      isShiftPressed: true,
+      isAltPressed: true,
+    );
+    // <😁>
+    // <Welcome to>
+    expect(
+      editor.documentSelection,
+      selection.copyWith(
+        end: Position(path: [0], offset: 22),
+      ),
+    );
+  });
+
+  testWidgets('Presses shift + alt + arrow left to select a word',
+      (tester) async {
+    const text = 'Welcome to Appflowy 😁';
+    final editor = tester.editor
+      ..insertTextNode(text)
+      ..insertTextNode(text);
+    await editor.startTesting();
+    final selection = Selection.single(path: [0], startOffset: 10);
+    await editor.updateSelection(selection);
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowRight,
+      isShiftPressed: true,
+      isAltPressed: true,
+    );
+    // < Appflowy>
+    expect(
+      editor.documentSelection,
+      selection.copyWith(
+        end: Position(path: [0], offset: 19),
+      ),
+    );
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowRight,
+      isShiftPressed: true,
+      isAltPressed: true,
+    );
+    // < Appflowy 😁>
+    expect(
+      editor.documentSelection,
+      selection.copyWith(
+        end: Position(path: [0], offset: 22),
+      ),
+    );
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowRight,
+      isShiftPressed: true,
+      isAltPressed: true,
+    );
+    // < Appflowy 😁>
+    // <>
+    expect(
+      editor.documentSelection,
+      selection.copyWith(
+        end: Position(path: [1], offset: 0),
+      ),
+    );
+  });
 }
 
 Future<void> _testPressArrowKeyInNotCollapsedSelection(