Browse Source

feat: keyboard service improvement

Lucas.Xu 2 years ago
parent
commit
43a0a02328
26 changed files with 1085 additions and 130 deletions
  1. 4 0
      frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart
  2. 7 1
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/underscore_to_italic_key_event_handler.dart
  3. 3 0
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  4. 5 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart
  5. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart
  6. 12 12
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart
  7. 2 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart
  8. 0 25
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart
  9. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart
  10. 2 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart
  11. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/page_up_down_handler.dart
  12. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/redo_undo_handler.dart
  13. 2 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart
  14. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart
  15. 3 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart
  16. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart
  17. 28 16
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart
  18. 121 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart
  19. 451 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/key_mapping.dart
  20. 153 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/keybinding.dart
  21. 108 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart
  22. 7 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event_handler.dart
  23. 50 16
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart
  24. 26 9
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart
  25. 7 1
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart
  26. 83 29
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart

@@ -1,6 +1,7 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:example/plugin/underscore_to_italic_key_event_handler.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -95,6 +96,9 @@ class _MyHomePageState extends State<MyHomePage> {
             child: AppFlowyEditor(
               editorState: _editorState,
               editorStyle: const EditorStyle.defaultStyle(),
+              keyEventHandlers: [
+                underscoreToItalicEvent,
+              ],
             ),
           );
         } else {

+ 7 - 1
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/underscore_to_italic_key_event_handler.dart

@@ -2,7 +2,13 @@ import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
-AppFlowyKeyEventHandler underscoreToItalicHandler = (editorState, event) {
+ShortcutEvent underscoreToItalicEvent = ShortcutEvent(
+  key: 'Underscore to italic',
+  command: 'shift+underscore',
+  handler: _underscoreToItalicHandler,
+);
+
+ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
   // Since we only need to handler the input of `underscore`.
   // All inputs except `underscore` will be ignored directly.
   if (event.logicalKey != LogicalKeyboardKey.underscore) {

+ 3 - 0
frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart

@@ -23,3 +23,6 @@ export 'src/service/scroll_service.dart';
 export 'src/service/toolbar_service.dart';
 export 'src/service/keyboard_service.dart';
 export 'src/service/input_service.dart';
+export 'src/service/shortcut_event/keybinding.dart';
+export 'src/service/shortcut_event/shortcut_event.dart';
+export 'src/service/shortcut_event/shortcut_event_handler.dart';

+ 5 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart

@@ -1,7 +1,8 @@
 import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
 import 'package:appflowy_editor/src/render/style/editor_style.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
 import 'package:flutter/material.dart';
 
 import 'package:appflowy_editor/src/editor_state.dart';
@@ -46,7 +47,7 @@ class AppFlowyEditor extends StatefulWidget {
   final NodeWidgetBuilders customBuilders;
 
   /// Keyboard event handlers.
-  final List<AppFlowyKeyEventHandler> keyEventHandlers;
+  final List<ShortcutEvent> keyEventHandlers;
 
   final List<SelectionMenuItem> selectionMenuItems;
 
@@ -93,8 +94,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
             editorState: editorState,
             child: AppFlowyKeyboard(
               key: editorState.service.keyboardServiceKey,
-              handlers: [
-                ...defaultKeyEventHandlers,
+              shortcutEvents: [
+                ...builtInShortcutEvents,
                 ...widget.keyEventHandlers,
               ],
               editorState: editorState,

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

@@ -3,7 +3,7 @@ import 'package:appflowy_editor/src/extensions/node_extensions.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
-AppFlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
+ShortcutEventHandler arrowKeysHandler = (editorState, event) {
   if (!_arrowKeys.contains(event.logicalKey)) {
     return KeyEventResult.ignored;
   }

+ 12 - 12
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart

@@ -4,6 +4,18 @@ import 'package:flutter/services.dart';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
 
+// Handle delete text.
+ShortcutEventHandler deleteTextHandler = (editorState, event) {
+  if (event.logicalKey == LogicalKeyboardKey.backspace) {
+    return _handleBackspace(editorState, event);
+  }
+  if (event.logicalKey == LogicalKeyboardKey.delete) {
+    return _handleDelete(editorState, event);
+  }
+
+  return KeyEventResult.ignored;
+};
+
 KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
   var selection = editorState.service.selectionService.currentSelection.value;
   if (selection == null) {
@@ -159,15 +171,3 @@ void _deleteTextNodes(TransactionBuilder transactionBuilder,
       secondOffset: selection.end.offset,
     );
 }
-
-// Handle delete text.
-AppFlowyKeyEventHandler deleteTextHandler = (editorState, event) {
-  if (event.logicalKey == LogicalKeyboardKey.backspace) {
-    return _handleBackspace(editorState, event);
-  }
-  if (event.logicalKey == LogicalKeyboardKey.delete) {
-    return _handleDelete(editorState, event);
-  }
-
-  return KeyEventResult.ignored;
-};

+ 2 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart

@@ -1,6 +1,7 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/infra/html_converter.dart';
 import 'package:appflowy_editor/src/document/node_iterator.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:rich_clipboard/rich_clipboard.dart';
@@ -303,7 +304,7 @@ _deleteSelectedContent(EditorState editorState) {
   tb.commit();
 }
 
-AppFlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) {
+ShortcutEventHandler copyPasteKeysHandler = (editorState, event) {
   if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) {
     _handleCopy(editorState);
     return KeyEventResult.handled;

+ 0 - 25
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart

@@ -1,25 +0,0 @@
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart';
-import 'package:appflowy_editor/src/service/keyboard_service.dart';
-
-List<AppFlowyKeyEventHandler> defaultKeyEventHandlers = [
-  deleteTextHandler,
-  slashShortcutHandler,
-  // arrowKeysHandler,
-  arrowKeysHandler,
-  copyPasteKeysHandler,
-  redoUndoKeysHandler,
-  enterWithoutShiftInTextNodesHandler,
-  updateTextStyleByCommandXHandler,
-  whiteSpaceHandler,
-  selectAllHandler,
-  pageUpDownHandler,
-];

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart

@@ -8,7 +8,7 @@ import 'package:appflowy_editor/src/document/selection.dart';
 import 'package:appflowy_editor/src/extensions/path_extensions.dart';
 import 'package:appflowy_editor/src/operation/transaction_builder.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
-import 'package:appflowy_editor/src/service/keyboard_service.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 
 /// Handle some cases where enter is pressed and shift is not pressed.
 ///
@@ -18,7 +18,7 @@ import 'package:appflowy_editor/src/service/keyboard_service.dart';
 /// 2. Single selection and the selected node is [TextNode]
 ///   2.1 split the node into two nodes with style
 ///   2.2 or insert a empty text node before.
-AppFlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
+ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
     (editorState, event) {
   if (event.logicalKey != LogicalKeyboardKey.enter || event.isShiftPressed) {
     return KeyEventResult.ignored;

+ 2 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -103,7 +104,7 @@ KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) {
   return KeyEventResult.ignored;
 }
 
-AppFlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
+ShortcutEventHandler arrowKeysHandler = (editorState, event) {
   if (event.isShiftPressed) {
     return _handleShiftKey(editorState, event);
   }

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/page_up_down_handler.dart

@@ -1,8 +1,8 @@
-import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 
-AppFlowyKeyEventHandler pageUpDownHandler = (editorState, event) {
+ShortcutEventHandler pageUpDownHandler = (editorState, event) {
   if (event.logicalKey == LogicalKeyboardKey.pageUp) {
     final scrollHeight = editorState.service.scrollService?.onePageHeight;
     final scrollService = editorState.service.scrollService;

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/redo_undo_handler.dart

@@ -1,8 +1,8 @@
-import 'package:appflowy_editor/src/service/keyboard_service.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 
-AppFlowyKeyEventHandler redoUndoKeysHandler = (editorState, event) {
+ShortcutEventHandler redoUndoKeysHandler = (editorState, event) {
   if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyZ) {
     if (event.isShiftPressed) {
       editorState.undoManager.redo();

+ 2 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -18,7 +19,7 @@ KeyEventResult _selectAll(EditorState editorState) {
   return KeyEventResult.handled;
 }
 
-AppFlowyKeyEventHandler selectAllHandler = (editorState, event) {
+ShortcutEventHandler selectAllHandler = (editorState, event) {
   if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyA) {
     return _selectAll(editorState);
   }

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart

@@ -1,13 +1,13 @@
 import 'package:appflowy_editor/src/document/node.dart';
 import 'package:appflowy_editor/src/operation/transaction_builder.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
-import 'package:appflowy_editor/src/service/keyboard_service.dart';
 import 'package:appflowy_editor/src/extensions/node_extensions.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
 SelectionMenuService? _selectionMenuService;
-AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
+ShortcutEventHandler slashShortcutHandler = (editorState, event) {
   if (event.logicalKey != LogicalKeyboardKey.slash) {
     return KeyEventResult.ignored;
   }

+ 3 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart

@@ -1,12 +1,12 @@
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 
 import 'package:appflowy_editor/src/document/node.dart';
 import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
-import 'package:appflowy_editor/src/service/keyboard_service.dart';
+
 import 'package:flutter/services.dart';
 
-AppFlowyKeyEventHandler updateTextStyleByCommandXHandler =
-    (editorState, event) {
+ShortcutEventHandler updateTextStyleByCommandXHandler = (editorState, event) {
   if (!event.isMetaPressed) {
     return KeyEventResult.ignored;
   }

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart

@@ -1,3 +1,4 @@
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -7,7 +8,6 @@ import 'package:appflowy_editor/src/document/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/operation/transaction_builder.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
-import 'package:appflowy_editor/src/service/keyboard_service.dart';
 
 @visibleForTesting
 List<String> get checkboxListSymbols => _checkboxListSymbols;
@@ -20,7 +20,7 @@ const _bulletedListSymbols = ['*', '-'];
 const _checkboxListSymbols = ['[x]', '-[x]'];
 const _unCheckboxListSymbols = ['[]', '-[]'];
 
-AppFlowyKeyEventHandler whiteSpaceHandler = (editorState, event) {
+ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
   if (event.logicalKey != LogicalKeyboardKey.space) {
     return KeyEventResult.ignored;
   }

+ 28 - 16
frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart

@@ -22,6 +22,9 @@ abstract class AppFlowyKeyboardService {
   /// Processes shortcut key input.
   KeyEventResult onKey(RawKeyEvent event);
 
+  /// Gets the shortcut events
+  List<ShortcutEvent> get shortcutEvents;
+
   /// Enables shortcuts service.
   void enable();
 
@@ -35,23 +38,18 @@ abstract class AppFlowyKeyboardService {
   void disable();
 }
 
-typedef AppFlowyKeyEventHandler = KeyEventResult Function(
-  EditorState editorState,
-  RawKeyEvent event,
-);
-
 /// Process keyboard events
 class AppFlowyKeyboard extends StatefulWidget {
   const AppFlowyKeyboard({
     Key? key,
-    required this.handlers,
+    required this.shortcutEvents,
     required this.editorState,
     required this.child,
   }) : super(key: key);
 
   final EditorState editorState;
   final Widget child;
-  final List<AppFlowyKeyEventHandler> handlers;
+  final List<ShortcutEvent> shortcutEvents;
 
   @override
   State<AppFlowyKeyboard> createState() => _AppFlowyKeyboardState();
@@ -63,6 +61,10 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
 
   bool isFocus = true;
 
+  @override
+  // TODO: implement shortcutEvents
+  List<ShortcutEvent> get shortcutEvents => widget.shortcutEvents;
+
   @override
   Widget build(BuildContext context) {
     return Focus(
@@ -111,16 +113,14 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
       return KeyEventResult.ignored;
     }
 
-    for (final handler in widget.handlers) {
-      KeyEventResult result = handler(widget.editorState, event);
-
-      switch (result) {
-        case KeyEventResult.handled:
+    // TODO: use cache to optimize the searching time.
+    for (final shortcutEvent in widget.shortcutEvents) {
+      if (shortcutEvent.keybindings.containsKeyEvent(event)) {
+        final result = shortcutEvent.handler(widget.editorState, event);
+        if (result == KeyEventResult.handled) {
           return KeyEventResult.handled;
-        case KeyEventResult.skipRemainingHandlers:
-          return KeyEventResult.skipRemainingHandlers;
-        case KeyEventResult.ignored:
-          continue;
+        }
+        continue;
       }
     }
 
@@ -139,3 +139,15 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
     return onKey(event);
   }
 }
+
+extension on RawKeyEvent {
+  Keybinding get toKeybinding {
+    return Keybinding(
+      isAltPressed: isAltPressed,
+      isControlPressed: isControlPressed,
+      isMetaPressed: isMetaPressed,
+      isShiftPressed: isShiftPressed,
+      keyLabel: logicalKey.keyLabel,
+    );
+  }
+}

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

@@ -0,0 +1,121 @@
+// List<>
+
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
+
+//
+List<ShortcutEvent> builtInShortcutEvents = [
+  ShortcutEvent(
+    key: 'Move cursor up',
+    command: 'arrow up',
+    handler: arrowKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'Move cursor down',
+    command: 'arrow down',
+    handler: arrowKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'Move cursor left',
+    command: 'arrow left',
+    handler: arrowKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'Move cursor right',
+    command: 'arrow right',
+    handler: arrowKeysHandler,
+  ),
+  // TODO: split the keys.
+  ShortcutEvent(
+    key: 'Shift + Arrow Keys',
+    command:
+        'shift+arrow up,shift+arrow down,shift+arrow left,shift+arrow right',
+    handler: arrowKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'Control + Arrow Keys',
+    command: 'meta+arrow up,meta+arrow down,meta+arrow left,meta+arrow right',
+    windowsCommand:
+        'ctrl+arrow up,ctrl+arrow down,ctrl+arrow left,ctrl+arrow right',
+    macOSCommand: 'cmd+arrow up,cmd+arrow down,cmd+arrow left,cmd+arrow right',
+    handler: arrowKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'Meta + Shift + Arrow Keys',
+    command:
+        'meta+shift+arrow up,meta+shift+arrow down,meta+shift+arrow left,meta+shift+arrow right',
+    windowsCommand:
+        'ctrl+shift+arrow up,ctrl+shift+arrow down,ctrl+shift+arrow left,ctrl+shift+arrow right',
+    macOSCommand:
+        'cmd+shift+arrow up,cmd+shift+arrow down,cmd+shift+arrow left,cmd+shift+arrow right',
+    handler: arrowKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'Meta + Shift + Arrow Keys',
+    command:
+        'meta+shift+arrow up,meta+shift+arrow down,meta+shift+arrow left,meta+shift+arrow right',
+    windowsCommand:
+        'ctrl+shift+arrow up,ctrl+shift+arrow down,ctrl+shift+arrow left,ctrl+shift+arrow right',
+    macOSCommand:
+        'cmd+shift+arrow up,cmd+shift+arrow down,cmd+shift+arrow left,cmd+shift+arrow right',
+    handler: arrowKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'Delete Text',
+    command: 'delete,backspace',
+    handler: deleteTextHandler,
+  ),
+  ShortcutEvent(
+    key: 'selection menu',
+    command: 'slash',
+    handler: slashShortcutHandler,
+  ),
+  ShortcutEvent(
+    key: 'copy / paste',
+    command: 'meta+c,meta+v',
+    windowsCommand: 'ctrl+c,ctrl+v',
+    handler: copyPasteKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'redo / undo',
+    command: 'meta+z,meta+meta+shift+z',
+    windowsCommand: 'ctrl+z,meta+ctrl+shift+z',
+    handler: redoUndoKeysHandler,
+  ),
+  ShortcutEvent(
+    key: 'enter',
+    command: 'enter',
+    handler: enterWithoutShiftInTextNodesHandler,
+  ),
+  ShortcutEvent(
+    key: 'update text style',
+    command: 'meta+b,meta+i,meta+u,meta+shift+s,meta+shift+h,meta+k',
+    windowsCommand: 'ctrl+b,ctrl+i,ctrl+u,ctrl+shift+s,ctrl+shift+h,ctrl+k',
+    handler: updateTextStyleByCommandXHandler,
+  ),
+  ShortcutEvent(
+    key: 'markdown',
+    command: 'space',
+    handler: whiteSpaceHandler,
+  ),
+  ShortcutEvent(
+    key: 'select all',
+    command: 'meta+a',
+    windowsCommand: 'ctrl+a',
+    handler: selectAllHandler,
+  ),
+  ShortcutEvent(
+    key: 'page up / page down',
+    command: 'page up,page down',
+    handler: pageUpDownHandler,
+  ),
+];

+ 451 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/key_mapping.dart

@@ -0,0 +1,451 @@
+/// Keyboard key to keycode mapping table
+///
+/// Copy from flutter project, keyboard_key.dart.
+///
+
+Map<String, int> keyToCodeMapping = <String, int>{
+  'Space': 0x00000000020,
+  'Exclamation': 0x00000000021,
+  'Quote': 0x00000000022,
+  'Number Sign': 0x00000000023,
+  'Dollar': 0x00000000024,
+  'Percent': 0x00000000025,
+  'Ampersand': 0x00000000026,
+  'Quote Single': 0x00000000027,
+  'Parenthesis Left': 0x00000000028,
+  'Parenthesis Right': 0x00000000029,
+  'Asterisk': 0x0000000002a,
+  'Add': 0x0000000002b,
+  'Comma': 0x0000000002c,
+  'Minus': 0x0000000002d,
+  'Period': 0x0000000002e,
+  'Slash': 0x0000000002f,
+  'Digit 0': 0x00000000030,
+  'Digit 1': 0x00000000031,
+  'Digit 2': 0x00000000032,
+  'Digit 3': 0x00000000033,
+  'Digit 4': 0x00000000034,
+  'Digit 5': 0x00000000035,
+  'Digit 6': 0x00000000036,
+  'Digit 7': 0x00000000037,
+  'Digit 8': 0x00000000038,
+  'Digit 9': 0x00000000039,
+  'Colon': 0x0000000003a,
+  'Semicolon': 0x0000000003b,
+  'Less': 0x0000000003c,
+  'Equal': 0x0000000003d,
+  'Greater': 0x0000000003e,
+  'Question': 0x0000000003f,
+  'At': 0x00000000040,
+  'Bracket Left': 0x0000000005b,
+  'Backslash': 0x0000000005c,
+  'Bracket Right': 0x0000000005d,
+  'Caret': 0x0000000005e,
+  'Underscore': 0x0000000005f,
+  'Backquote': 0x00000000060,
+  'A': 0x00000000061,
+  'B': 0x00000000062,
+  'C': 0x00000000063,
+  'D': 0x00000000064,
+  'E': 0x00000000065,
+  'F': 0x00000000066,
+  'G': 0x00000000067,
+  'H': 0x00000000068,
+  'I': 0x00000000069,
+  'J': 0x0000000006a,
+  'K': 0x0000000006b,
+  'L': 0x0000000006c,
+  'M': 0x0000000006d,
+  'N': 0x0000000006e,
+  'O': 0x0000000006f,
+  'P': 0x00000000070,
+  'Q': 0x00000000071,
+  'R': 0x00000000072,
+  'S': 0x00000000073,
+  'T': 0x00000000074,
+  'U': 0x00000000075,
+  'V': 0x00000000076,
+  'W': 0x00000000077,
+  'X': 0x00000000078,
+  'Y': 0x00000000079,
+  'Z': 0x0000000007a,
+  'Brace Left': 0x0000000007b,
+  'Bar': 0x0000000007c,
+  'Brace Right': 0x0000000007d,
+  'Tilde': 0x0000000007e,
+  'Unidentified': 0x00100000001,
+  'Backspace': 0x00100000008,
+  'Tab': 0x00100000009,
+  'Enter': 0x0010000000d,
+  'Escape': 0x0010000001b,
+  'Delete': 0x0010000007f,
+  'Accel': 0x00100000101,
+  'Alt Graph': 0x00100000103,
+  'Caps Lock': 0x00100000104,
+  'Fn': 0x00100000106,
+  'Fn Lock': 0x00100000107,
+  'Hyper': 0x00100000108,
+  'Num Lock': 0x0010000010a,
+  'Scroll Lock': 0x0010000010c,
+  'Super': 0x0010000010e,
+  'Symbol': 0x0010000010f,
+  'Symbol Lock': 0x00100000110,
+  'Shift Level 5': 0x00100000111,
+  'Arrow Down': 0x00100000301,
+  'Arrow Left': 0x00100000302,
+  'Arrow Right': 0x00100000303,
+  'Arrow Up': 0x00100000304,
+  'End': 0x00100000305,
+  'Home': 0x00100000306,
+  'Page Down': 0x00100000307,
+  'Page Up': 0x00100000308,
+  'Clear': 0x00100000401,
+  'Copy': 0x00100000402,
+  'Cr Sel': 0x00100000403,
+  'Cut': 0x00100000404,
+  'Erase Eof': 0x00100000405,
+  'Ex Sel': 0x00100000406,
+  'Insert': 0x00100000407,
+  'Paste': 0x00100000408,
+  'Redo': 0x00100000409,
+  'Undo': 0x0010000040a,
+  'Accept': 0x00100000501,
+  'Again': 0x00100000502,
+  'Attn': 0x00100000503,
+  'Cancel': 0x00100000504,
+  'Context Menu': 0x00100000505,
+  'Execute': 0x00100000506,
+  'Find': 0x00100000507,
+  'Help': 0x00100000508,
+  'Pause': 0x00100000509,
+  'Play': 0x0010000050a,
+  'Props': 0x0010000050b,
+  'Select': 0x0010000050c,
+  'Zoom In': 0x0010000050d,
+  'Zoom Out': 0x0010000050e,
+  'Brightness Down': 0x00100000601,
+  'Brightness Up': 0x00100000602,
+  'Camera': 0x00100000603,
+  'Eject': 0x00100000604,
+  'Log Off': 0x00100000605,
+  'Power': 0x00100000606,
+  'Power Off': 0x00100000607,
+  'Print Screen': 0x00100000608,
+  'Hibernate': 0x00100000609,
+  'Standby': 0x0010000060a,
+  'Wake Up': 0x0010000060b,
+  'All Candidates': 0x00100000701,
+  'Alphanumeric': 0x00100000702,
+  'Code Input': 0x00100000703,
+  'Compose': 0x00100000704,
+  'Convert': 0x00100000705,
+  'Final Mode': 0x00100000706,
+  'Group First': 0x00100000707,
+  'Group Last': 0x00100000708,
+  'Group Next': 0x00100000709,
+  'Group Previous': 0x0010000070a,
+  'Mode Change': 0x0010000070b,
+  'Next Candidate': 0x0010000070c,
+  'Non Convert': 0x0010000070d,
+  'Previous Candidate': 0x0010000070e,
+  'Process': 0x0010000070f,
+  'Single Candidate': 0x00100000710,
+  'Hangul Mode': 0x00100000711,
+  'Hanja Mode': 0x00100000712,
+  'Junja Mode': 0x00100000713,
+  'Eisu': 0x00100000714,
+  'Hankaku': 0x00100000715,
+  'Hiragana': 0x00100000716,
+  'Hiragana Katakana': 0x00100000717,
+  'Kana Mode': 0x00100000718,
+  'Kanji Mode': 0x00100000719,
+  'Katakana': 0x0010000071a,
+  'Romaji': 0x0010000071b,
+  'Zenkaku': 0x0010000071c,
+  'Zenkaku Hankaku': 0x0010000071d,
+  'F1': 0x00100000801,
+  'F2': 0x00100000802,
+  'F3': 0x00100000803,
+  'F4': 0x00100000804,
+  'F5': 0x00100000805,
+  'F6': 0x00100000806,
+  'F7': 0x00100000807,
+  'F8': 0x00100000808,
+  'F9': 0x00100000809,
+  'F10': 0x0010000080a,
+  'F11': 0x0010000080b,
+  'F12': 0x0010000080c,
+  'F13': 0x0010000080d,
+  'F14': 0x0010000080e,
+  'F15': 0x0010000080f,
+  'F16': 0x00100000810,
+  'F17': 0x00100000811,
+  'F18': 0x00100000812,
+  'F19': 0x00100000813,
+  'F20': 0x00100000814,
+  'F21': 0x00100000815,
+  'F22': 0x00100000816,
+  'F23': 0x00100000817,
+  'F24': 0x00100000818,
+  'Soft 1': 0x00100000901,
+  'Soft 2': 0x00100000902,
+  'Soft 3': 0x00100000903,
+  'Soft 4': 0x00100000904,
+  'Soft 5': 0x00100000905,
+  'Soft 6': 0x00100000906,
+  'Soft 7': 0x00100000907,
+  'Soft 8': 0x00100000908,
+  'Close': 0x00100000a01,
+  'Mail Forward': 0x00100000a02,
+  'Mail Reply': 0x00100000a03,
+  'Mail Send': 0x00100000a04,
+  'Media Play Pause': 0x00100000a05,
+  'Media Stop': 0x00100000a07,
+  'Media Track Next': 0x00100000a08,
+  'Media Track Previous': 0x00100000a09,
+  'New': 0x00100000a0a,
+  'Open': 0x00100000a0b,
+  'Print': 0x00100000a0c,
+  'Save': 0x00100000a0d,
+  'Spell Check': 0x00100000a0e,
+  'Audio Volume Down': 0x00100000a0f,
+  'Audio Volume Up': 0x00100000a10,
+  'Audio Volume Mute': 0x00100000a11,
+  'Launch Application 2': 0x00100000b01,
+  'Launch Calendar': 0x00100000b02,
+  'Launch Mail': 0x00100000b03,
+  'Launch Media Player': 0x00100000b04,
+  'Launch Music Player': 0x00100000b05,
+  'Launch Application 1': 0x00100000b06,
+  'Launch Screen Saver': 0x00100000b07,
+  'Launch Spreadsheet': 0x00100000b08,
+  'Launch Web Browser': 0x00100000b09,
+  'Launch Web Cam': 0x00100000b0a,
+  'Launch Word Processor': 0x00100000b0b,
+  'Launch Contacts': 0x00100000b0c,
+  'Launch Phone': 0x00100000b0d,
+  'Launch Assistant': 0x00100000b0e,
+  'Launch Control Panel': 0x00100000b0f,
+  'Browser Back': 0x00100000c01,
+  'Browser Favorites': 0x00100000c02,
+  'Browser Forward': 0x00100000c03,
+  'Browser Home': 0x00100000c04,
+  'Browser Refresh': 0x00100000c05,
+  'Browser Search': 0x00100000c06,
+  'Browser Stop': 0x00100000c07,
+  'Audio Balance Left': 0x00100000d01,
+  'Audio Balance Right': 0x00100000d02,
+  'Audio Bass Boost Down': 0x00100000d03,
+  'Audio Bass Boost Up': 0x00100000d04,
+  'Audio Fader Front': 0x00100000d05,
+  'Audio Fader Rear': 0x00100000d06,
+  'Audio Surround Mode Next': 0x00100000d07,
+  'AVR Input': 0x00100000d08,
+  'AVR Power': 0x00100000d09,
+  'Channel Down': 0x00100000d0a,
+  'Channel Up': 0x00100000d0b,
+  'Color F0 Red': 0x00100000d0c,
+  'Color F1 Green': 0x00100000d0d,
+  'Color F2 Yellow': 0x00100000d0e,
+  'Color F3 Blue': 0x00100000d0f,
+  'Color F4 Grey': 0x00100000d10,
+  'Color F5 Brown': 0x00100000d11,
+  'Closed Caption Toggle': 0x00100000d12,
+  'Dimmer': 0x00100000d13,
+  'Display Swap': 0x00100000d14,
+  'Exit': 0x00100000d15,
+  'Favorite Clear 0': 0x00100000d16,
+  'Favorite Clear 1': 0x00100000d17,
+  'Favorite Clear 2': 0x00100000d18,
+  'Favorite Clear 3': 0x00100000d19,
+  'Favorite Recall 0': 0x00100000d1a,
+  'Favorite Recall 1': 0x00100000d1b,
+  'Favorite Recall 2': 0x00100000d1c,
+  'Favorite Recall 3': 0x00100000d1d,
+  'Favorite Store 0': 0x00100000d1e,
+  'Favorite Store 1': 0x00100000d1f,
+  'Favorite Store 2': 0x00100000d20,
+  'Favorite Store 3': 0x00100000d21,
+  'Guide': 0x00100000d22,
+  'Guide Next Day': 0x00100000d23,
+  'Guide Previous Day': 0x00100000d24,
+  'Info': 0x00100000d25,
+  'Instant Replay': 0x00100000d26,
+  'Link': 0x00100000d27,
+  'List Program': 0x00100000d28,
+  'Live Content': 0x00100000d29,
+  'Lock': 0x00100000d2a,
+  'Media Apps': 0x00100000d2b,
+  'Media Fast Forward': 0x00100000d2c,
+  'Media Last': 0x00100000d2d,
+  'Media Pause': 0x00100000d2e,
+  'Media Play': 0x00100000d2f,
+  'Media Record': 0x00100000d30,
+  'Media Rewind': 0x00100000d31,
+  'Media Skip': 0x00100000d32,
+  'Next Favorite Channel': 0x00100000d33,
+  'Next User Profile': 0x00100000d34,
+  'On Demand': 0x00100000d35,
+  'P In P Down': 0x00100000d36,
+  'P In P Move': 0x00100000d37,
+  'P In P Toggle': 0x00100000d38,
+  'P In P Up': 0x00100000d39,
+  'Play Speed Down': 0x00100000d3a,
+  'Play Speed Reset': 0x00100000d3b,
+  'Play Speed Up': 0x00100000d3c,
+  'Random Toggle': 0x00100000d3d,
+  'Rc Low Battery': 0x00100000d3e,
+  'Record Speed Next': 0x00100000d3f,
+  'Rf Bypass': 0x00100000d40,
+  'Scan Channels Toggle': 0x00100000d41,
+  'Screen Mode Next': 0x00100000d42,
+  'Settings': 0x00100000d43,
+  'Split Screen Toggle': 0x00100000d44,
+  'STB Input': 0x00100000d45,
+  'STB Power': 0x00100000d46,
+  'Subtitle': 0x00100000d47,
+  'Teletext': 0x00100000d48,
+  'TV': 0x00100000d49,
+  'TV Input': 0x00100000d4a,
+  'TV Power': 0x00100000d4b,
+  'Video Mode Next': 0x00100000d4c,
+  'Wink': 0x00100000d4d,
+  'Zoom Toggle': 0x00100000d4e,
+  'DVR': 0x00100000d4f,
+  'Media Audio Track': 0x00100000d50,
+  'Media Skip Backward': 0x00100000d51,
+  'Media Skip Forward': 0x00100000d52,
+  'Media Step Backward': 0x00100000d53,
+  'Media Step Forward': 0x00100000d54,
+  'Media Top Menu': 0x00100000d55,
+  'Navigate In': 0x00100000d56,
+  'Navigate Next': 0x00100000d57,
+  'Navigate Out': 0x00100000d58,
+  'Navigate Previous': 0x00100000d59,
+  'Pairing': 0x00100000d5a,
+  'Media Close': 0x00100000d5b,
+  'Audio Bass Boost Toggle': 0x00100000e02,
+  'Audio Treble Down': 0x00100000e04,
+  'Audio Treble Up': 0x00100000e05,
+  'Microphone Toggle': 0x00100000e06,
+  'Microphone Volume Down': 0x00100000e07,
+  'Microphone Volume Up': 0x00100000e08,
+  'Microphone Volume Mute': 0x00100000e09,
+  'Speech Correction List': 0x00100000f01,
+  'Speech Input Toggle': 0x00100000f02,
+  'App Switch': 0x00100001001,
+  'Call': 0x00100001002,
+  'Camera Focus': 0x00100001003,
+  'End Call': 0x00100001004,
+  'Go Back': 0x00100001005,
+  'Go Home': 0x00100001006,
+  'Headset Hook': 0x00100001007,
+  'Last Number Redial': 0x00100001008,
+  'Notification': 0x00100001009,
+  'Manner Mode': 0x0010000100a,
+  'Voice Dial': 0x0010000100b,
+  'TV 3 D Mode': 0x00100001101,
+  'TV Antenna Cable': 0x00100001102,
+  'TV Audio Description': 0x00100001103,
+  'TV Audio Description Mix Dow': 0x00100001104,
+  'TV Audio Description Mix Up': 0x00100001105,
+  'TV Contents Menu': 0x00100001106,
+  'TV Data Service': 0x00100001107,
+  'TV Input Component 1': 0x00100001108,
+  'TV Input Component 2': 0x00100001109,
+  'TV Input Composite 1': 0x0010000110a,
+  'TV Input Composite 2': 0x0010000110b,
+  'TV Input HDMI 1': 0x0010000110c,
+  'TV Input HDMI 2': 0x0010000110d,
+  'TV Input HDMI 3': 0x0010000110e,
+  'TV Input HDMI 4': 0x0010000110f,
+  'TV Input VGA 1': 0x00100001110,
+  'TV Media Context': 0x00100001111,
+  'TV Network': 0x00100001112,
+  'TV Number Entry': 0x00100001113,
+  'TV Radio Service': 0x00100001114,
+  'TV Satellite': 0x00100001115,
+  'TV Satellite BS': 0x00100001116,
+  'TV Satellite CS': 0x00100001117,
+  'TV Satellite Toggle': 0x00100001118,
+  'TV Terrestrial Analog': 0x00100001119,
+  'TV Terrestrial Digital': 0x0010000111a,
+  'TV Timer': 0x0010000111b,
+  'Key 11': 0x00100001201,
+  'Key 12': 0x00100001202,
+  'Suspend': 0x00200000000,
+  'Resume': 0x00200000001,
+  'Sleep': 0x00200000002,
+  'Abort': 0x00200000003,
+  'Lang 1': 0x00200000010,
+  'Lang 2': 0x00200000011,
+  'Lang 3': 0x00200000012,
+  'Lang 4': 0x00200000013,
+  'Lang 5': 0x00200000014,
+  'Intl Backslash': 0x00200000020,
+  'Intl Ro': 0x00200000021,
+  'Intl Yen': 0x00200000022,
+  'Control Left': 0x00200000100,
+  'Control Right': 0x00200000101,
+  'Shift Left': 0x00200000102,
+  'Shift Right': 0x00200000103,
+  'Alt Left': 0x00200000104,
+  'Alt Right': 0x00200000105,
+  'Meta Left': 0x00200000106,
+  'Meta Right': 0x00200000107,
+  'Control': 0x002000001f0,
+  'Shift': 0x002000001f2,
+  'Alt': 0x002000001f4,
+  'Meta': 0x002000001f6,
+  'Numpad Enter': 0x0020000020d,
+  'Numpad Paren Left': 0x00200000228,
+  'Numpad Paren Right': 0x00200000229,
+  'Numpad Multiply': 0x0020000022a,
+  'Numpad Add': 0x0020000022b,
+  'Numpad Comma': 0x0020000022c,
+  'Numpad Subtract': 0x0020000022d,
+  'Numpad Decimal': 0x0020000022e,
+  'Numpad Divide': 0x0020000022f,
+  'Numpad 0': 0x00200000230,
+  'Numpad 1': 0x00200000231,
+  'Numpad 2': 0x00200000232,
+  'Numpad 3': 0x00200000233,
+  'Numpad 4': 0x00200000234,
+  'Numpad 5': 0x00200000235,
+  'Numpad 6': 0x00200000236,
+  'Numpad 7': 0x00200000237,
+  'Numpad 8': 0x00200000238,
+  'Numpad 9': 0x00200000239,
+  'Numpad Equal': 0x0020000023d,
+  'Game Button 1': 0x00200000301,
+  'Game Button 2': 0x00200000302,
+  'Game Button 3': 0x00200000303,
+  'Game Button 4': 0x00200000304,
+  'Game Button 5': 0x00200000305,
+  'Game Button 6': 0x00200000306,
+  'Game Button 7': 0x00200000307,
+  'Game Button 8': 0x00200000308,
+  'Game Button 9': 0x00200000309,
+  'Game Button 10': 0x0020000030a,
+  'Game Button 11': 0x0020000030b,
+  'Game Button 12': 0x0020000030c,
+  'Game Button 13': 0x0020000030d,
+  'Game Button 14': 0x0020000030e,
+  'Game Button 15': 0x0020000030f,
+  'Game Button 16': 0x00200000310,
+  'Game Button A': 0x00200000311,
+  'Game Button B': 0x00200000312,
+  'Game Button C': 0x00200000313,
+  'Game Button Left 1': 0x00200000314,
+  'Game Button Left 2': 0x00200000315,
+  'Game Button Mode': 0x00200000316,
+  'Game Button Right 1': 0x00200000317,
+  'Game Button Right 2': 0x00200000318,
+  'Game Button Select': 0x00200000319,
+  'Game Button Start': 0x0020000031a,
+  'Game Button Thumb Left': 0x0020000031b,
+  'Game Button Thumb Right': 0x0020000031c,
+  'Game Button X': 0x0020000031d,
+  'Game Button Y': 0x0020000031e,
+  'Game Button Z': 0x0020000031f,
+}.map((key, value) => MapEntry(key.toLowerCase(), value));

+ 153 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/keybinding.dart

@@ -0,0 +1,153 @@
+import 'dart:convert';
+
+import 'package:appflowy_editor/src/service/shortcut_event/key_mapping.dart';
+import 'package:flutter/material.dart';
+
+extension KeybindingsExtension on List<Keybinding> {
+  bool containsKeyEvent(RawKeyEvent keyEvent) {
+    for (final keybinding in this) {
+      if (keybinding.isMetaPressed == keyEvent.isMetaPressed &&
+          keybinding.isControlPressed == keyEvent.isControlPressed &&
+          keybinding.isAltPressed == keyEvent.isAltPressed &&
+          keybinding.isShiftPressed == keyEvent.isShiftPressed &&
+          keybinding.keyCode == keyEvent.logicalKey.keyId) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
+
+class Keybinding {
+  Keybinding({
+    required this.isAltPressed,
+    required this.isControlPressed,
+    required this.isMetaPressed,
+    required this.isShiftPressed,
+    required this.keyLabel,
+  });
+
+  factory Keybinding.parse(String command) {
+    command = command.toLowerCase().trim();
+
+    var isAltPressed = false;
+    var isControlPressed = false;
+    var isMetaPressed = false;
+    var isShiftPressed = false;
+
+    var matchedModifier = false;
+
+    do {
+      matchedModifier = false;
+      if (RegExp(r'^alt(\+|\-)').hasMatch(command)) {
+        isAltPressed = true;
+        command = command.substring(4); // 4 = 'alt '.length
+        matchedModifier = true;
+      }
+      if (RegExp(r'^ctrl(\+|\-)').hasMatch(command)) {
+        isControlPressed = true;
+        command = command.substring(5); // 5 = 'ctrl '.length
+        matchedModifier = true;
+      }
+      if (RegExp(r'^shift(\+|\-)').hasMatch(command)) {
+        isShiftPressed = true;
+        command = command.substring(6); // 6 = 'shift '.length
+        matchedModifier = true;
+      }
+      if (RegExp(r'^meta(\+|\-)').hasMatch(command)) {
+        isMetaPressed = true;
+        command = command.substring(5); // 5 = 'meta '.length
+        matchedModifier = true;
+      }
+      if (RegExp(r'^cmd(\+|\-)').hasMatch(command) ||
+          RegExp(r'^win(\+|\-)').hasMatch(command)) {
+        isMetaPressed = true;
+        command = command.substring(4); // 4 = 'win '.length
+        matchedModifier = true;
+      }
+    } while (matchedModifier);
+
+    return Keybinding(
+      isAltPressed: isAltPressed,
+      isControlPressed: isControlPressed,
+      isMetaPressed: isMetaPressed,
+      isShiftPressed: isShiftPressed,
+      keyLabel: command,
+    );
+  }
+
+  final bool isAltPressed;
+  final bool isControlPressed;
+  final bool isMetaPressed;
+  final bool isShiftPressed;
+  final String keyLabel;
+
+  int get keyCode => keyToCodeMapping[keyLabel.toLowerCase()]!;
+
+  Keybinding copyWith({
+    bool? isAltPressed,
+    bool? isControlPressed,
+    bool? isMetaPressed,
+    bool? isShiftPressed,
+    String? keyLabel,
+  }) {
+    return Keybinding(
+      isAltPressed: isAltPressed ?? this.isAltPressed,
+      isControlPressed: isControlPressed ?? this.isControlPressed,
+      isMetaPressed: isMetaPressed ?? this.isMetaPressed,
+      isShiftPressed: isShiftPressed ?? this.isShiftPressed,
+      keyLabel: keyLabel ?? this.keyLabel,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'isAltPressed': isAltPressed,
+      'isControlPressed': isControlPressed,
+      'isMetaPressed': isMetaPressed,
+      'isShiftPressed': isShiftPressed,
+      'keyLabel': keyLabel,
+    };
+  }
+
+  factory Keybinding.fromMap(Map<String, dynamic> map) {
+    return Keybinding(
+      isAltPressed: map['isAltPressed'] ?? false,
+      isControlPressed: map['isControlPressed'] ?? false,
+      isMetaPressed: map['isMetaPressed'] ?? false,
+      isShiftPressed: map['isShiftPressed'] ?? false,
+      keyLabel: map['keyLabel'] ?? '',
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory Keybinding.fromJson(String source) =>
+      Keybinding.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'Keybinding(isAltPressed: $isAltPressed, isControlPressed: $isControlPressed, isMetaPressed: $isMetaPressed, isShiftPressed: $isShiftPressed, keyLabel: $keyLabel)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is Keybinding &&
+        other.isAltPressed == isAltPressed &&
+        other.isControlPressed == isControlPressed &&
+        other.isMetaPressed == isMetaPressed &&
+        other.isShiftPressed == isShiftPressed &&
+        other.keyCode == keyCode;
+  }
+
+  @override
+  int get hashCode {
+    return isAltPressed.hashCode ^
+        isControlPressed.hashCode ^
+        isMetaPressed.hashCode ^
+        isShiftPressed.hashCode ^
+        keyCode;
+  }
+}

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

@@ -0,0 +1,108 @@
+import 'dart:io';
+
+import 'package:appflowy_editor/src/service/shortcut_event/keybinding.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
+
+/// Defines the implementation of shortcut event.
+class ShortcutEvent {
+  ShortcutEvent({
+    required this.key,
+    required this.command,
+    required this.handler,
+    String? windowsCommand,
+    String? macOSCommand,
+    String? linuxCommand,
+  }) {
+    updateCommand(
+      command,
+      windowsCommand: windowsCommand,
+      macOSCommand: macOSCommand,
+      linuxCommand: linuxCommand,
+    );
+  }
+
+  /// The unique key.
+  ///
+  /// Usually, uses the description as the key.
+  final String key;
+
+  /// The string representation for the keyboard keys.
+  ///
+  /// The following is the mapping relationship of modify key.
+  ///   ctrl: Ctrl
+  ///   meta: Command in macOS or Control in Windows.
+  ///   alt: Alt
+  ///   shift: Shift
+  ///   cmd: meta
+  ///   win: meta
+  ///
+  /// Refer to [keyMapping] for other keys.
+  ///
+  /// Uses ',' to split different keyboard key combinations.
+  ///
+  /// Like, 'ctrl+c,cmd+c'
+  ///
+  String command;
+
+  final ShortcutEventHandler handler;
+
+  List<Keybinding> keybindings = [];
+
+  void updateCommand(
+    String command, {
+    String? windowsCommand,
+    String? macOSCommand,
+    String? linuxCommand,
+  }) {
+    if (Platform.isWindows &&
+        windowsCommand != null &&
+        windowsCommand.isNotEmpty) {
+      this.command = windowsCommand;
+    } else if (Platform.isMacOS &&
+        macOSCommand != null &&
+        macOSCommand.isNotEmpty) {
+      this.command = macOSCommand;
+    } else if (Platform.isLinux &&
+        linuxCommand != null &&
+        linuxCommand.isNotEmpty) {
+      this.command = linuxCommand;
+    } else {
+      this.command = command;
+    }
+
+    keybindings = this
+        .command
+        .split(',')
+        .map((e) => Keybinding.parse(e))
+        .toList(growable: false);
+  }
+
+  ShortcutEvent copyWith({
+    String? key,
+    String? command,
+    ShortcutEventHandler? handler,
+  }) {
+    return ShortcutEvent(
+      key: key ?? this.key,
+      command: command ?? this.command,
+      handler: handler ?? this.handler,
+    );
+  }
+
+  @override
+  String toString() =>
+      'ShortcutEvent(key: $key, command: $command, handler: $handler)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is ShortcutEvent &&
+        other.key == key &&
+        other.command == command &&
+        other.handler == handler;
+  }
+
+  @override
+  int get hashCode => key.hashCode ^ command.hashCode ^ handler.hashCode;
+}

+ 7 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event_handler.dart

@@ -0,0 +1,7 @@
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:flutter/material.dart';
+
+typedef ShortcutEventHandler = KeyEventResult Function(
+  EditorState editorState,
+  RawKeyEvent event,
+);

+ 50 - 16
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart

@@ -1,3 +1,5 @@
+import 'dart:io';
+
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -328,37 +330,69 @@ Future<void> _testPressArrowKeyWithMetaInSelection(
     }
   }
   await editor.updateSelection(selection);
-  await editor.pressLogicKey(
-    LogicalKeyboardKey.arrowLeft,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowLeft,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowLeft,
+      isMetaPressed: true,
+    );
+  }
+
   expect(
     editor.documentSelection,
     Selection.single(path: [0], startOffset: 0),
   );
 
-  await editor.pressLogicKey(
-    LogicalKeyboardKey.arrowRight,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowRight,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowRight,
+      isMetaPressed: true,
+    );
+  }
+
   expect(
     editor.documentSelection,
     Selection.single(path: [0], startOffset: text.length),
   );
 
-  await editor.pressLogicKey(
-    LogicalKeyboardKey.arrowUp,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowUp,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowUp,
+      isMetaPressed: true,
+    );
+  }
+
   expect(
     editor.documentSelection,
     Selection.single(path: [0], startOffset: 0),
   );
 
-  await editor.pressLogicKey(
-    LogicalKeyboardKey.arrowDown,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowDown,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.arrowDown,
+      isMetaPressed: true,
+    );
+  }
+
   expect(
     editor.documentSelection,
     Selection.single(path: [1], startOffset: text.length),

+ 26 - 9
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart

@@ -1,3 +1,5 @@
+import 'dart:io';
+
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -41,20 +43,35 @@ Future<void> _testBackspaceUndoRedo(
   await editor.pressLogicKey(LogicalKeyboardKey.backspace);
   expect(editor.documentLength, 2);
 
-  await editor.pressLogicKey(
-    LogicalKeyboardKey.keyZ,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.keyZ,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.keyZ,
+      isMetaPressed: true,
+    );
+  }
 
   expect(editor.documentLength, 3);
   expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
   expect(editor.documentSelection, selection);
 
-  await editor.pressLogicKey(
-    LogicalKeyboardKey.keyZ,
-    isMetaPressed: true,
-    isShiftPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.keyZ,
+      isControlPressed: true,
+      isShiftPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.keyZ,
+      isMetaPressed: true,
+      isShiftPressed: true,
+    );
+  }
 
   expect(editor.documentLength, 2);
 }

+ 7 - 1
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart

@@ -1,3 +1,5 @@
+import 'dart:io';
+
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -26,7 +28,11 @@ Future<void> _testSelectAllHandler(WidgetTester tester, int lines) async {
     editor.insertTextNode(text);
   }
   await editor.startTesting();
-  await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true);
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(LogicalKeyboardKey.keyA, isControlPressed: true);
+  } else {
+    await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true);
+  }
 
   expect(
     editor.documentSelection,

+ 83 - 29
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart

@@ -1,3 +1,5 @@
+import 'dart:io';
+
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
@@ -82,11 +84,19 @@ Future<void> _testUpdateTextStyleByCommandX(
   var selection =
       Selection.single(path: [1], startOffset: 2, endOffset: text.length - 2);
   await editor.updateSelection(selection);
-  await editor.pressLogicKey(
-    key,
-    isShiftPressed: isShiftPressed,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isMetaPressed: true,
+    );
+  }
   var textNode = editor.nodeAtPath([1]) as TextNode;
   expect(
       textNode.allSatisfyInSelection(
@@ -101,11 +111,19 @@ Future<void> _testUpdateTextStyleByCommandX(
   selection =
       Selection.single(path: [1], startOffset: 0, endOffset: text.length);
   await editor.updateSelection(selection);
-  await editor.pressLogicKey(
-    key,
-    isShiftPressed: isShiftPressed,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isMetaPressed: true,
+    );
+  }
   textNode = editor.nodeAtPath([1]) as TextNode;
   expect(
       textNode.allSatisfyInSelection(
@@ -118,11 +136,19 @@ Future<void> _testUpdateTextStyleByCommandX(
       true);
 
   await editor.updateSelection(selection);
-  await editor.pressLogicKey(
-    key,
-    isShiftPressed: isShiftPressed,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isMetaPressed: true,
+    );
+  }
   textNode = editor.nodeAtPath([1]) as TextNode;
   expect(textNode.allNotSatisfyInSelection(matchStyle, matchValue, selection),
       true);
@@ -132,11 +158,19 @@ Future<void> _testUpdateTextStyleByCommandX(
     end: Position(path: [2], offset: text.length),
   );
   await editor.updateSelection(selection);
-  await editor.pressLogicKey(
-    key,
-    isShiftPressed: isShiftPressed,
-    isMetaPressed: true,
-  );
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isMetaPressed: true,
+    );
+  }
   var nodes = editor.editorState.service.selectionService.currentSelectedNodes
       .whereType<TextNode>();
   expect(nodes.length, 3);
@@ -158,11 +192,20 @@ Future<void> _testUpdateTextStyleByCommandX(
   }
 
   await editor.updateSelection(selection);
-  await editor.pressLogicKey(
-    key,
-    isShiftPressed: isShiftPressed,
-    isMetaPressed: true,
-  );
+
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isControlPressed: true,
+    );
+  } else {
+    await editor.pressLogicKey(
+      key,
+      isShiftPressed: isShiftPressed,
+      isMetaPressed: true,
+    );
+  }
   nodes = editor.editorState.service.selectionService.currentSelectedNodes
       .whereType<TextNode>();
   expect(nodes.length, 3);
@@ -196,8 +239,11 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
   expect(find.byType(ToolbarWidget), findsOneWidget);
 
   // trigger the link menu
-  await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
-
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true);
+  } else {
+    await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
+  }
   expect(find.byType(LinkMenu), findsOneWidget);
 
   await tester.enterText(find.byType(TextField), link);
@@ -216,7 +262,11 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
       true);
 
   await editor.updateSelection(selection);
-  await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true);
+  } else {
+    await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
+  }
   expect(find.byType(LinkMenu), findsOneWidget);
   expect(
       find.text(link, findRichText: true, skipOffstage: false), findsOneWidget);
@@ -229,7 +279,11 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
   expect(find.byType(LinkMenu), findsNothing);
 
   // Remove link
-  await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
+  if (Platform.isWindows) {
+    await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true);
+  } else {
+    await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
+  }
   final removeLink = find.text('Remove link');
   expect(removeLink, findsOneWidget);
   await tester.tap(removeLink);