Explorar el Código

Merge pull request #1314 from LucasXu0/feature/context_menu

feat: implement context menu
Lucas.Xu hace 2 años
padre
commit
833a6cd95f
Se han modificado 14 ficheros con 269 adiciones y 203 borrados
  1. 14 0
      frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart
  2. 28 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/built_in_context_menu_item.dart
  3. 90 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/context_menu.dart
  4. 8 8
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart
  5. 4 17
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart
  6. 13 8
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart
  7. 0 153
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart
  8. 6 7
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart
  9. 0 5
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart
  10. 3 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection/selection_gesture.dart
  11. 41 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart
  12. 7 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart
  13. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event_handler.dart
  14. 54 0
      frontend/app_flowy/packages/appflowy_editor/test/service/selection_service_test.dart

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

@@ -65,6 +65,20 @@ class _MyHomePageState extends State<MyHomePage> {
     return Scaffold(
       extendBodyBehindAppBar: true,
       body: _buildEditor(context),
+      // body: Center(
+      //   child: ContextMenu(editorState: EditorState.empty(), items: [
+      //     [
+      //       ContextMenuItem(name: 'ABCDEFGHIJKLM', onPressed: (editorState) {}),
+      //       ContextMenuItem(name: 'A', onPressed: (editorState) {}),
+      //       ContextMenuItem(name: 'A', onPressed: (editorState) {})
+      //     ],
+      //     [
+      //       ContextMenuItem(name: 'B', onPressed: (editorState) {}),
+      //       ContextMenuItem(name: 'B', onPressed: (editorState) {}),
+      //       ContextMenuItem(name: 'B', onPressed: (editorState) {})
+      //     ]
+      //   ]),
+      // ),
       floatingActionButton: _buildExpandableFab(),
     );
   }

+ 28 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/built_in_context_menu_item.dart

@@ -0,0 +1,28 @@
+import 'package:appflowy_editor/src/service/context_menu/context_menu.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
+
+final builtInContextMenuItems = [
+  [
+    // cut
+    ContextMenuItem(
+      name: 'Cut',
+      onPressed: (editorState) {
+        cutEventHandler(editorState, null);
+      },
+    ),
+    // copy
+    ContextMenuItem(
+      name: 'Copy',
+      onPressed: (editorState) {
+        copyEventHandler(editorState, null);
+      },
+    ),
+    // Paste
+    ContextMenuItem(
+      name: 'Paste',
+      onPressed: (editorState) {
+        pasteEventHandler(editorState, null);
+      },
+    ),
+  ],
+];

+ 90 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/context_menu.dart

@@ -0,0 +1,90 @@
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:flutter/material.dart';
+
+class ContextMenuItem {
+  ContextMenuItem({
+    required this.name,
+    required this.onPressed,
+  });
+
+  final String name;
+  final void Function(EditorState editorState) onPressed;
+}
+
+class ContextMenu extends StatelessWidget {
+  const ContextMenu({
+    Key? key,
+    required this.position,
+    required this.editorState,
+    required this.items,
+    required this.onPressed,
+  }) : super(key: key);
+
+  final Offset position;
+  final EditorState editorState;
+  final List<List<ContextMenuItem>> items;
+  final VoidCallback onPressed;
+
+  @override
+  Widget build(BuildContext context) {
+    final children = <Widget>[];
+    for (var i = 0; i < items.length; i++) {
+      for (var j = 0; j < items[i].length; j++) {
+        children.add(
+          Material(
+            child: InkWell(
+              hoverColor: const Color(0xFFE0F8FF),
+              customBorder: RoundedRectangleBorder(
+                borderRadius: BorderRadius.circular(6),
+              ),
+              onTap: () {
+                items[i][j].onPressed(editorState);
+                onPressed();
+              },
+              child: Padding(
+                padding: const EdgeInsets.all(8.0),
+                child: Text(
+                  items[i][j].name,
+                  textAlign: TextAlign.start,
+                  style: const TextStyle(fontSize: 14),
+                ),
+              ),
+            ),
+          ),
+        );
+      }
+      if (i != items.length - 1) {
+        children.add(const Divider());
+      }
+    }
+
+    return Positioned(
+      top: position.dy,
+      left: position.dx,
+      child: Container(
+        padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
+        constraints: const BoxConstraints(
+          minWidth: 140,
+        ),
+        decoration: BoxDecoration(
+          color: Colors.white,
+          boxShadow: [
+            BoxShadow(
+              blurRadius: 5,
+              spreadRadius: 1,
+              color: Colors.black.withOpacity(0.1),
+            ),
+          ],
+          borderRadius: BorderRadius.circular(6.0),
+        ),
+        child: IntrinsicWidth(
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            crossAxisAlignment: CrossAxisAlignment.stretch,
+            children: children,
+          ),
+        ),
+      ),
+    );
+  }
+}

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

@@ -217,7 +217,7 @@ ShortcutEventHandler cursorEndSelect = (editorState, event) {
   return KeyEventResult.handled;
 };
 
-KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
+ShortcutEventHandler cursorUp = (editorState, event) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final selection =
       editorState.service.selectionService.currentSelection.value?.normalized;
@@ -229,9 +229,9 @@ KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
     upPosition == null ? null : Selection.collapsed(upPosition),
   );
   return KeyEventResult.handled;
-}
+};
 
-KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
+ShortcutEventHandler cursorDown = (editorState, event) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final selection =
       editorState.service.selectionService.currentSelection.value?.normalized;
@@ -243,9 +243,9 @@ KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
     downPosition == null ? null : Selection.collapsed(downPosition),
   );
   return KeyEventResult.handled;
-}
+};
 
-KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
+ShortcutEventHandler cursorLeft = (editorState, event) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final selection =
       editorState.service.selectionService.currentSelection.value?.normalized;
@@ -265,9 +265,9 @@ KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
     );
   }
   return KeyEventResult.handled;
-}
+};
 
-KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
+ShortcutEventHandler cursorRight = (editorState, event) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final selection =
       editorState.service.selectionService.currentSelection.value?.normalized;
@@ -287,7 +287,7 @@ KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
     );
   }
   return KeyEventResult.handled;
-}
+};
 
 extension on Position {
   Position? goLeft(EditorState editorState) {

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

@@ -1,22 +1,9 @@
 import 'package:appflowy_editor/src/infra/infra.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
 import 'package:flutter/material.dart';
-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) {
+ShortcutEventHandler backspaceEventHandler = (editorState, event) {
   var selection = editorState.service.selectionService.currentSelection.value;
   if (selection == null) {
     return KeyEventResult.ignored;
@@ -122,7 +109,7 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
   }
 
   return KeyEventResult.handled;
-}
+};
 
 KeyEventResult _backDeleteToPreviousTextNode(
   EditorState editorState,
@@ -182,7 +169,7 @@ KeyEventResult _backDeleteToPreviousTextNode(
   return KeyEventResult.handled;
 }
 
-KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
+ShortcutEventHandler deleteEventHandler = (editorState, event) {
   var selection = editorState.service.selectionService.currentSelection.value;
   if (selection == null) {
     return KeyEventResult.ignored;
@@ -238,7 +225,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
   }
 
   return KeyEventResult.handled;
-}
+};
 
 KeyEventResult _mergeNextLineIntoThisLine(EditorState editorState,
     TextNode textNode, Transaction transaction, Selection selection) {

+ 13 - 8
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart

@@ -39,7 +39,10 @@ void _handleCopy(EditorState editorState) async {
               endOffset: selection.end.offset)
           .toHTMLString();
       Log.keyboard.debug('copy html: $htmlString');
-      RichClipboard.setData(RichClipboardData(html: htmlString));
+      RichClipboard.setData(RichClipboardData(
+        html: htmlString,
+        text: textNode.toPlainText(),
+      ));
     } else {
       Log.keyboard.debug('unimplemented: copy non-text');
     }
@@ -55,13 +58,15 @@ void _handleCopy(EditorState editorState) async {
     endNode: endNode,
   ).toList();
 
-  final copyString = NodesToHTMLConverter(
-          nodes: nodes,
-          startOffset: selection.start.offset,
-          endOffset: selection.end.offset)
-      .toHTMLString();
-  Log.keyboard.debug('copy html: $copyString');
-  RichClipboard.setData(RichClipboardData(html: copyString));
+  final html = NodesToHTMLConverter(
+    nodes: nodes,
+    startOffset: selection.start.offset,
+    endOffset: selection.end.offset,
+  ).toHTMLString();
+  final text = nodes
+      .map((node) => node is TextNode ? node.toPlainText() : '\n')
+      .join('\n');
+  RichClipboard.setData(RichClipboardData(html: html, text: text));
 }
 
 void _pasteHTML(EditorState editorState, String html) {

+ 0 - 153
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart

@@ -1,153 +0,0 @@
-import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-
-int _endOffsetOfNode(Node node) {
-  if (node is TextNode) {
-    return node.delta.length;
-  }
-  return 0;
-}
-
-extension on Position {
-  Position? goLeft(EditorState editorState) {
-    final node = editorState.document.nodeAtPath(path)!;
-    if (offset == 0) {
-      final prevNode = node.previous;
-      if (prevNode != null) {
-        return Position(
-            path: prevNode.path, offset: _endOffsetOfNode(prevNode));
-      }
-      return null;
-    }
-
-    if (node is TextNode) {
-      return Position(path: path, offset: node.delta.prevRunePosition(offset));
-    } else {
-      return Position(path: path, offset: offset);
-    }
-  }
-
-  Position? goRight(EditorState editorState) {
-    final node = editorState.document.nodeAtPath(path)!;
-    final lengthOfNode = _endOffsetOfNode(node);
-    if (offset >= lengthOfNode) {
-      final nextNode = node.next;
-      if (nextNode != null) {
-        return Position(path: nextNode.path, offset: 0);
-      }
-      return null;
-    }
-
-    if (node is TextNode) {
-      return Position(path: path, offset: node.delta.nextRunePosition(offset));
-    } else {
-      return Position(path: path, offset: offset);
-    }
-  }
-}
-
-Position? _goUp(EditorState editorState) {
-  final rects = editorState.service.selectionService.selectionRects;
-  if (rects.isEmpty) {
-    return null;
-  }
-  final first = rects.first;
-  final firstOffset = Offset(first.left, first.top);
-  final hitOffset = firstOffset - Offset(0, first.height * 0.5);
-  return editorState.service.selectionService.getPositionInOffset(hitOffset);
-}
-
-Position? _goDown(EditorState editorState) {
-  final rects = editorState.service.selectionService.selectionRects;
-  if (rects.isEmpty) {
-    return null;
-  }
-  final first = rects.last;
-  final firstOffset = Offset(first.right, first.bottom);
-  final hitOffset = firstOffset + Offset(0, first.height * 0.5);
-  return editorState.service.selectionService.getPositionInOffset(hitOffset);
-}
-
-KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) {
-  final currentSelection = editorState.cursorSelection;
-  if (currentSelection == null) {
-    return KeyEventResult.ignored;
-  }
-
-  if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
-    final leftPosition = currentSelection.end.goLeft(editorState);
-    editorState.updateCursorSelection(leftPosition == null
-        ? null
-        : Selection(start: currentSelection.start, end: leftPosition));
-    return KeyEventResult.handled;
-  } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
-    final rightPosition = currentSelection.start.goRight(editorState);
-    editorState.updateCursorSelection(rightPosition == null
-        ? null
-        : Selection(start: rightPosition, end: currentSelection.end));
-    return KeyEventResult.handled;
-  } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
-    final position = _goUp(editorState);
-    editorState.updateCursorSelection(position == null
-        ? null
-        : Selection(start: position, end: currentSelection.end));
-    return KeyEventResult.handled;
-  } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
-    final position = _goDown(editorState);
-    editorState.updateCursorSelection(position == null
-        ? null
-        : Selection(start: currentSelection.start, end: position));
-    return KeyEventResult.handled;
-  }
-  return KeyEventResult.ignored;
-}
-
-ShortcutEventHandler arrowKeysHandler = (editorState, event) {
-  if (event.isShiftPressed) {
-    return _handleShiftKey(editorState, event);
-  }
-
-  final currentSelection = editorState.cursorSelection;
-  if (currentSelection == null) {
-    return KeyEventResult.ignored;
-  }
-
-  if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
-    if (currentSelection.isCollapsed) {
-      final leftPosition = currentSelection.start.goLeft(editorState);
-      if (leftPosition != null) {
-        editorState.updateCursorSelection(Selection.collapsed(leftPosition));
-      }
-    } else {
-      editorState.updateCursorSelection(
-        currentSelection.collapse(atStart: currentSelection.isBackward),
-      );
-    }
-    return KeyEventResult.handled;
-  } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
-    if (currentSelection.isCollapsed) {
-      final rightPosition = currentSelection.end.goRight(editorState);
-      if (rightPosition != null) {
-        editorState.updateCursorSelection(Selection.collapsed(rightPosition));
-      }
-    } else {
-      editorState.updateCursorSelection(
-        currentSelection.collapse(atStart: !currentSelection.isBackward),
-      );
-    }
-    return KeyEventResult.handled;
-  } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
-    final position = _goUp(editorState);
-    editorState.updateCursorSelection(
-        position == null ? null : Selection.collapsed(position));
-    return KeyEventResult.handled;
-  } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
-    final position = _goDown(editorState);
-    editorState.updateCursorSelection(
-        position == null ? null : Selection.collapsed(position));
-    return KeyEventResult.handled;
-  }
-
-  return KeyEventResult.ignored;
-};

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

@@ -4,14 +4,9 @@ import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service
 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;
 ShortcutEventHandler slashShortcutHandler = (editorState, event) {
-  if (event.logicalKey != LogicalKeyboardKey.slash) {
-    return KeyEventResult.ignored;
-  }
-
   final textNodes = editorState.service.selectionService.currentSelectedNodes
       .whereType<TextNode>();
   if (textNodes.length != 1) {
@@ -26,8 +21,12 @@ ShortcutEventHandler slashShortcutHandler = (editorState, event) {
     return KeyEventResult.ignored;
   }
   final transaction = editorState.transaction
-    ..replaceText(textNode, selection.start.offset,
-        selection.end.offset - selection.start.offset, event.character ?? '');
+    ..replaceText(
+      textNode,
+      selection.start.offset,
+      selection.end.offset - selection.start.offset,
+      '/',
+    );
   editorState.apply(transaction);
 
   WidgetsBinding.instance.addPostFrameCallback((_) {

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

@@ -1,7 +1,6 @@
 import 'package:appflowy_editor/src/core/transform/transaction.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:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
 import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/core/location/position.dart';
@@ -24,10 +23,6 @@ const _unCheckboxListSymbols = ['[]', '-[]'];
 final _numberRegex = RegExp(r'^(\d+)\.');
 
 ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
-  if (event.logicalKey != LogicalKeyboardKey.space) {
-    return KeyEventResult.ignored;
-  }
-
   /// Process markdown input style.
   ///
   /// like, #, *, -, 1., -[],

+ 3 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection/selection_gesture.dart

@@ -13,6 +13,7 @@ class SelectionGestureDetector extends StatefulWidget {
     this.onTapDown,
     this.onDoubleTapDown,
     this.onTripleTapDown,
+    this.onSecondaryTapDown,
     this.onPanStart,
     this.onPanUpdate,
     this.onPanEnd,
@@ -27,6 +28,7 @@ class SelectionGestureDetector extends StatefulWidget {
   final GestureTapDownCallback? onTapDown;
   final GestureTapDownCallback? onDoubleTapDown;
   final GestureTapDownCallback? onTripleTapDown;
+  final GestureTapDownCallback? onSecondaryTapDown;
   final GestureDragStartCallback? onPanStart;
   final GestureDragUpdateCallback? onPanUpdate;
   final GestureDragEndCallback? onPanEnd;
@@ -60,6 +62,7 @@ class SelectionGestureDetectorState extends State<SelectionGestureDetector> {
           () => TapGestureRecognizer(),
           (recognizer) {
             recognizer.onTapDown = _tapDownDelegate;
+            recognizer.onSecondaryTapDown = widget.onSecondaryTapDown;
           },
         ),
       },

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

@@ -1,4 +1,6 @@
 import 'package:appflowy_editor/src/infra/log.dart';
+import 'package:appflowy_editor/src/service/context_menu/built_in_context_menu_item.dart';
+import 'package:appflowy_editor/src/service/context_menu/context_menu.dart';
 import 'package:flutter/material.dart';
 
 import 'package:appflowy_editor/src/core/document/node.dart';
@@ -108,6 +110,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
   final List<Rect> selectionRects = [];
   final List<OverlayEntry> _selectionAreas = [];
   final List<OverlayEntry> _cursorAreas = [];
+  final List<OverlayEntry> _contextMenuAreas = [];
 
   // OverlayEntry? _debugOverlay;
 
@@ -156,6 +159,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
         onPanUpdate: _onPanUpdate,
         onPanEnd: _onPanEnd,
         onTapDown: _onTapDown,
+        onSecondaryTapDown: _onSecondaryTapDown,
         onDoubleTapDown: _onDoubleTapDown,
         onTripleTapDown: _onTripleTapDown,
         child: widget.child,
@@ -232,6 +236,9 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
 
     // hide toolbar
     editorState.service.toolbarService?.hide();
+
+    // clear context menu
+    _clearContextMenu();
   }
 
   @override
@@ -242,6 +249,12 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
       ..clear();
   }
 
+  void _clearContextMenu() {
+    _contextMenuAreas
+      ..forEach((overlay) => overlay.remove())
+      ..clear();
+  }
+
   @override
   Node? getNodeInOffset(Offset offset) {
     final sortedNodes =
@@ -311,6 +324,20 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
     _enableInteraction();
   }
 
+  void _onSecondaryTapDown(TapDownDetails details) {
+    // if selection is null, or
+    // selection.isCollapsedand and the selected node is TextNode.
+    // try to select the word.
+    final selection = currentSelection.value;
+    if (selection == null ||
+        (selection.isCollapsed == true &&
+            currentSelectedNodes.first is TextNode)) {
+      _onDoubleTapDown(details);
+    }
+
+    _showContextMenu(details);
+  }
+
   void _onPanStart(DragStartDetails details) {
     clearSelection();
 
@@ -477,6 +504,20 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
     _cursorKey.currentState?.unwrapOrNull<CursorWidgetState>()?.show();
   }
 
+  void _showContextMenu(TapDownDetails details) {
+    final contextMenu = OverlayEntry(
+      builder: (context) => ContextMenu(
+        position: details.globalPosition,
+        editorState: editorState,
+        items: builtInContextMenuItems,
+        onPressed: () => _clearContextMenu(),
+      ),
+    );
+
+    _contextMenuAreas.add(contextMenu);
+    Overlay.of(context)?.insert(contextMenu);
+  }
+
   void _scrollUpOrDownIfNeeded() {
     final dy = editorState.service.scrollService?.dy;
     final selectNodes = currentSelectedNodes;

+ 7 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart

@@ -208,12 +208,15 @@ List<ShortcutEvent> builtInShortcutEvents = [
     command: 'end',
     handler: cursorEnd,
   ),
-
-  // TODO: split the keys.
+  ShortcutEvent(
+    key: 'Delete Text by backspace',
+    command: 'backspace',
+    handler: backspaceEventHandler,
+  ),
   ShortcutEvent(
     key: 'Delete Text',
-    command: 'delete,backspace',
-    handler: deleteTextHandler,
+    command: 'delete',
+    handler: deleteEventHandler,
   ),
   ShortcutEvent(
     key: 'selection menu',

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

@@ -3,5 +3,5 @@ import 'package:flutter/material.dart';
 
 typedef ShortcutEventHandler = KeyEventResult Function(
   EditorState editorState,
-  RawKeyEvent event,
+  RawKeyEvent? event,
 );

+ 54 - 0
frontend/app_flowy/packages/appflowy_editor/test/service/selection_service_test.dart

@@ -1,4 +1,8 @@
+import 'dart:io';
+
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/service/context_menu/context_menu.dart';
+import 'package:flutter/gestures.dart';
 import 'package:flutter_test/flutter_test.dart';
 import '../infra/test_editor.dart';
 
@@ -79,5 +83,55 @@ void main() async {
         Selection.single(path: [1], startOffset: 0, endOffset: text.length),
       );
     });
+
+    testWidgets('Test secondary tap', (tester) async {
+      const text = 'Welcome to Appflowy 😁';
+      final editor = tester.editor
+        ..insertTextNode(text)
+        ..insertTextNode(text)
+        ..insertTextNode(text);
+      await editor.startTesting();
+
+      final secondTextNode = editor.nodeAtPath([1]) as TextNode;
+      final finder = find.byKey(secondTextNode.key!);
+
+      final rect = tester.getRect(finder);
+      // secondary tap
+      await tester.tapAt(
+        rect.centerLeft + const Offset(10.0, 0.0),
+        buttons: kSecondaryButton,
+      );
+      await tester.pump();
+
+      const welcome = 'Welcome';
+      expect(
+        editor.documentSelection,
+        Selection.single(
+          path: [1],
+          startOffset: 0,
+          endOffset: welcome.length,
+        ), // Welcome
+      );
+
+      final contextMenu = find.byType(ContextMenu);
+      expect(contextMenu, findsOneWidget);
+
+      // test built in context menu items
+
+      // Skip the Windows platform because the rich_clipboard package doesn't support it perfectly.
+      if (Platform.isWindows) {
+        return;
+      }
+
+      // cut
+      await tester.tap(find.text('Cut'));
+      await tester.pump();
+      expect(
+        secondTextNode.toPlainText(),
+        text.replaceAll(welcome, ''),
+      );
+
+      // TODO: the copy and paste test is not working during test env.
+    });
   });
 }