Browse Source

feat: Add separators on context menu (#2659)

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Lipis <lipiridis@gmail.com>
Kartik Prajapati 4 năm trước cách đây
mục cha
commit
978e85a33b

+ 0 - 1
src/actions/actionAddToLibrary.ts

@@ -17,6 +17,5 @@ export const actionAddToLibrary = register({
     });
     return false;
   },
-  contextMenuOrder: 6,
   contextItemLabel: "labels.addToLibrary",
 });

+ 108 - 0
src/actions/actionClipboard.tsx

@@ -0,0 +1,108 @@
+import { CODES, KEYS } from "../keys";
+import { register } from "./register";
+import { copyToClipboard } from "../clipboard";
+import { actionDeleteSelected } from "./actionDeleteSelected";
+import { getSelectedElements } from "../scene/selection";
+import { exportCanvas } from "../data/index";
+import { getNonDeletedElements } from "../element";
+
+export const actionCopy = register({
+  name: "copy",
+  perform: (elements, appState) => {
+    copyToClipboard(getNonDeletedElements(elements), appState);
+
+    return {
+      commitToHistory: false,
+    };
+  },
+  contextItemLabel: "labels.copy",
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.C,
+});
+
+export const actionCut = register({
+  name: "cut",
+  perform: (elements, appState, data, app) => {
+    actionCopy.perform(elements, appState, data, app);
+    return actionDeleteSelected.perform(elements, appState, data, app);
+  },
+  contextItemLabel: "labels.cut",
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
+});
+
+export const actionCopyAsSvg = register({
+  name: "copyAsSvg",
+  perform: async (elements, appState, _data, app) => {
+    if (!app.canvas) {
+      return {
+        commitToHistory: false,
+      };
+    }
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    );
+    try {
+      await exportCanvas(
+        "clipboard-svg",
+        selectedElements.length
+          ? selectedElements
+          : getNonDeletedElements(elements),
+        appState,
+        app.canvas,
+        appState,
+      );
+      return {
+        commitToHistory: false,
+      };
+    } catch (error) {
+      console.error(error);
+      return {
+        appState: {
+          ...appState,
+          errorMessage: error.message,
+        },
+        commitToHistory: false,
+      };
+    }
+  },
+  contextItemLabel: "labels.copyAsSvg",
+});
+
+export const actionCopyAsPng = register({
+  name: "copyAsPng",
+  perform: async (elements, appState, _data, app) => {
+    if (!app.canvas) {
+      return {
+        commitToHistory: false,
+      };
+    }
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    );
+    try {
+      await exportCanvas(
+        "clipboard",
+        selectedElements.length
+          ? selectedElements
+          : getNonDeletedElements(elements),
+        appState,
+        app.canvas,
+        appState,
+      );
+      return {
+        commitToHistory: false,
+      };
+    } catch (error) {
+      console.error(error);
+      return {
+        appState: {
+          ...appState,
+          errorMessage: error.message,
+        },
+        commitToHistory: false,
+      };
+    }
+  },
+  contextItemLabel: "labels.copyAsPng",
+});

+ 0 - 1
src/actions/actionDeleteSelected.tsx

@@ -136,7 +136,6 @@ export const actionDeleteSelected = register({
     };
   },
   contextItemLabel: "labels.delete",
-  contextMenuOrder: 999999,
   keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton

+ 0 - 2
src/actions/actionGroup.tsx

@@ -125,7 +125,6 @@ export const actionGroup = register({
       commitToHistory: true,
     };
   },
-  contextMenuOrder: 4,
   contextItemLabel: "labels.group",
   contextItemPredicate: (elements, appState) =>
     enableActionGroup(elements, appState),
@@ -174,7 +173,6 @@ export const actionUngroup = register({
   },
   keyTest: (event) =>
     event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
-  contextMenuOrder: 5,
   contextItemLabel: "labels.ungroup",
   contextItemPredicate: (elements, appState) =>
     getSelectedGroupIds(appState).length > 0,

+ 0 - 2
src/actions/actionStyles.ts

@@ -34,7 +34,6 @@ export const actionCopyStyles = register({
   contextItemLabel: "labels.copyStyles",
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
-  contextMenuOrder: 0,
 });
 
 export const actionPasteStyles = register({
@@ -74,5 +73,4 @@ export const actionPasteStyles = register({
   contextItemLabel: "labels.pasteStyles",
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
-  contextMenuOrder: 1,
 });

+ 21 - 0
src/actions/actionToggleGridMode.tsx

@@ -0,0 +1,21 @@
+import { CODES, KEYS } from "../keys";
+import { register } from "./register";
+import { GRID_SIZE } from "../constants";
+
+export const actionToggleGridMode = register({
+  name: "gridMode",
+  perform(elements, appState) {
+    this.checked = !this.checked;
+    return {
+      appState: {
+        ...appState,
+        gridSize: this.checked ? GRID_SIZE : null,
+      },
+      commitToHistory: false,
+    };
+  },
+  checked: false,
+  contextItemLabel: "labels.gridMode",
+  // Wrong event code
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
+});

+ 17 - 0
src/actions/actionToggleStats.tsx

@@ -0,0 +1,17 @@
+import { register } from "./register";
+
+export const actionToggleStats = register({
+  name: "stats",
+  perform(elements, appState) {
+    this.checked = !this.checked;
+    return {
+      appState: {
+        ...appState,
+        showStats: !appState.showStats,
+      },
+      commitToHistory: false,
+    };
+  },
+  checked: false,
+  contextItemLabel: "stats.title",
+});

+ 20 - 0
src/actions/actionToggleZenMode.tsx

@@ -0,0 +1,20 @@
+import { CODES, KEYS } from "../keys";
+import { register } from "./register";
+
+export const actionToggleZenMode = register({
+  name: "zenMode",
+  perform(elements, appState) {
+    this.checked = !this.checked;
+    return {
+      appState: {
+        ...appState,
+        zenModeEnabled: this.checked,
+      },
+      commitToHistory: false,
+    };
+  },
+  checked: false,
+  contextItemLabel: "buttons.zenMode",
+  // Wrong event code
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
+});

+ 12 - 0
src/actions/index.ts

@@ -65,3 +65,15 @@ export {
   distributeHorizontally,
   distributeVertically,
 } from "./actionDistribute";
+
+export {
+  actionCopy,
+  actionCut,
+  actionCopyAsPng,
+  actionCopyAsSvg,
+} from "./actionClipboard";
+
+export { actionToggleGridMode } from "./actionToggleGridMode";
+export { actionToggleZenMode } from "./actionToggleZenMode";
+
+export { actionToggleStats } from "./actionToggleStats";

+ 10 - 37
src/actions/manager.tsx

@@ -3,14 +3,15 @@ import {
   Action,
   ActionsManagerInterface,
   UpdaterFn,
-  ActionFilterFn,
   ActionName,
   ActionResult,
 } from "./types";
 import { ExcalidrawElement } from "../element/types";
 import { AppState } from "../types";
-import { t } from "../i18n";
-import { ShortcutName } from "./shortcuts";
+
+// This is the <App> component, but for now we don't care about anything but its
+// `canvas` state.
+type App = { canvas: HTMLCanvasElement | null };
 
 export class ActionManager implements ActionsManagerInterface {
   actions = {} as ActionsManagerInterface["actions"];
@@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
   updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 
   getAppState: () => Readonly<AppState>;
-
   getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
+  app: App;
 
   constructor(
     updater: UpdaterFn,
     getAppState: () => AppState,
     getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
+    app: App,
   ) {
     this.updater = (actionResult) => {
       if (actionResult && "then" in actionResult) {
@@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
     };
     this.getAppState = getAppState;
     this.getElementsIncludingDeleted = getElementsIncludingDeleted;
+    this.app = app;
   }
 
   registerAction(action: Action) {
@@ -70,6 +73,7 @@ export class ActionManager implements ActionsManagerInterface {
         this.getElementsIncludingDeleted(),
         this.getAppState(),
         null,
+        this.app,
       ),
     );
     return true;
@@ -81,43 +85,11 @@ export class ActionManager implements ActionsManagerInterface {
         this.getElementsIncludingDeleted(),
         this.getAppState(),
         null,
+        this.app,
       ),
     );
   }
 
-  getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
-    return Object.values(this.actions)
-      .filter(actionFilter)
-      .filter((action) => "contextItemLabel" in action)
-      .filter((action) =>
-        action.contextItemPredicate
-          ? action.contextItemPredicate(
-              this.getElementsIncludingDeleted(),
-              this.getAppState(),
-            )
-          : true,
-      )
-      .sort(
-        (a, b) =>
-          (a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
-          (b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
-      )
-      .map((action) => ({
-        // take last bit of the label  "labels.<shortcutName>"
-        shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
-        label: action.contextItemLabel ? t(action.contextItemLabel) : "",
-        action: () => {
-          this.updater(
-            action.perform(
-              this.getElementsIncludingDeleted(),
-              this.getAppState(),
-              null,
-            ),
-          );
-        },
-      }));
-  }
-
   // Id is an attribute that we can use to pass in data like keys.
   // This is needed for dynamically generated action components
   // like the user list. We can use this key to extract more
@@ -132,6 +104,7 @@ export class ActionManager implements ActionsManagerInterface {
             this.getElementsIncludingDeleted(),
             this.getAppState(),
             formState,
+            this.app,
           ),
         );
       };

+ 2 - 2
src/actions/shortcuts.ts

@@ -9,7 +9,7 @@ export type ShortcutName =
   | "copyStyles"
   | "pasteStyles"
   | "selectAll"
-  | "delete"
+  | "deleteSelectedElements"
   | "duplicateSelection"
   | "sendBackward"
   | "bringForward"
@@ -31,7 +31,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
   pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
   selectAll: [getShortcutKey("CtrlOrCmd+A")],
-  delete: [getShortcutKey("Del")],
+  deleteSelectedElements: [getShortcutKey("Del")],
   duplicateSelection: [
     getShortcutKey("CtrlOrCmd+D"),
     getShortcutKey(`Alt+${t("helpDialog.drag")}`),

+ 10 - 4
src/actions/types.ts

@@ -16,12 +16,18 @@ type ActionFn = (
   elements: readonly ExcalidrawElement[],
   appState: Readonly<AppState>,
   formData: any,
+  app: { canvas: HTMLCanvasElement | null },
 ) => ActionResult | Promise<ActionResult>;
 
 export type UpdaterFn = (res: ActionResult) => void;
 export type ActionFilterFn = (action: Action) => void;
 
 export type ActionName =
+  | "copy"
+  | "cut"
+  | "paste"
+  | "copyAsPng"
+  | "copyAsSvg"
   | "sendBackward"
   | "bringForward"
   | "sendToBack"
@@ -29,6 +35,9 @@ export type ActionName =
   | "copyStyles"
   | "selectAll"
   | "pasteStyles"
+  | "gridMode"
+  | "zenMode"
+  | "stats"
   | "changeStrokeColor"
   | "changeBackgroundColor"
   | "changeFillStyle"
@@ -93,19 +102,16 @@ export interface Action {
     elements: readonly ExcalidrawElement[],
   ) => boolean;
   contextItemLabel?: string;
-  contextMenuOrder?: number;
   contextItemPredicate?: (
     elements: readonly ExcalidrawElement[],
     appState: AppState,
   ) => boolean;
+  checked?: boolean;
 }
 
 export interface ActionsManagerInterface {
   actions: Record<ActionName, Action>;
   registerAction: (action: Action) => void;
   handleKeyDown: (event: KeyboardEvent) => boolean;
-  getContextMenuItems: (
-    actionFilter: ActionFilterFn,
-  ) => { label: string; action: () => void }[];
   renderAction: (name: ActionName) => React.ReactElement | null;
 }

+ 89 - 88
src/components/App.tsx

@@ -3,7 +3,28 @@ import React from "react";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import rough from "roughjs/bin/rough";
 import "../actions";
-import { actionDeleteSelected, actionFinalize } from "../actions";
+import {
+  actionAddToLibrary,
+  actionBringForward,
+  actionBringToFront,
+  actionCopy,
+  actionCopyAsPng,
+  actionCopyAsSvg,
+  actionCopyStyles,
+  actionCut,
+  actionDeleteSelected,
+  actionDuplicateSelection,
+  actionFinalize,
+  actionGroup,
+  actionPasteStyles,
+  actionSelectAll,
+  actionSendBackward,
+  actionSendToBack,
+  actionToggleGridMode,
+  actionToggleStats,
+  actionToggleZenMode,
+  actionUngroup,
+} from "../actions";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { ActionManager } from "../actions/manager";
 import { actions } from "../actions/register";
@@ -18,7 +39,6 @@ import {
 } from "../clipboard";
 import {
   APP_NAME,
-  CANVAS_ONLY_ACTIONS,
   CURSOR_TYPE,
   DEFAULT_VERTICAL_ALIGN,
   DRAGGING_THRESHOLD,
@@ -26,7 +46,6 @@ import {
   ELEMENT_TRANSLATE_AMOUNT,
   ENV,
   EVENT,
-  GRID_SIZE,
   LINE_CONFIRM_THRESHOLD,
   MIME_TYPES,
   POINTER_BUTTON,
@@ -314,6 +333,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.syncActionResult,
       () => this.state,
       () => this.scene.getElementsIncludingDeleted(),
+      this,
     );
     this.actionManager.registerAll(actions);
 
@@ -927,25 +947,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     }
   };
 
-  private copyToClipboardAsSvg = async () => {
-    const selectedElements = getSelectedElements(
-      this.scene.getElements(),
-      this.state,
-    );
-    try {
-      await exportCanvas(
-        "clipboard-svg",
-        selectedElements.length ? selectedElements : this.scene.getElements(),
-        this.state,
-        this.canvas!,
-        this.state,
-      );
-    } catch (error) {
-      console.error(error);
-      this.setState({ errorMessage: error.message });
-    }
-  };
-
   private static resetTapTwice() {
     didTapTwice = false;
   }
@@ -1148,15 +1149,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   };
 
   toggleZenMode = () => {
-    this.setState({
-      zenModeEnabled: !this.state.zenModeEnabled,
-    });
+    this.actionManager.executeAction(actionToggleZenMode);
   };
 
   toggleGridMode = () => {
-    this.setState({
-      gridSize: this.state.gridSize ? null : GRID_SIZE,
-    });
+    this.actionManager.executeAction(actionToggleGridMode);
   };
 
   toggleStats = () => {
@@ -3618,52 +3615,52 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.state,
     );
 
+    const maybeGroupAction = actionGroup.contextItemPredicate!(
+      this.actionManager.getElementsIncludingDeleted(),
+      this.actionManager.getAppState(),
+    );
+
+    const maybeUngroupAction = actionUngroup.contextItemPredicate!(
+      this.actionManager.getElementsIncludingDeleted(),
+      this.actionManager.getAppState(),
+    );
+
+    const separator = "separator";
+
     const elements = this.scene.getElements();
     const element = this.getElementAtPosition(x, y);
     if (!element) {
       ContextMenu.push({
         options: [
           navigator.clipboard && {
-            shortcutName: "paste",
-            label: t("labels.paste"),
-            action: () => this.pasteFromClipboard(null),
+            name: "paste",
+            perform: (elements, appStates) => {
+              this.pasteFromClipboard(null);
+              return {
+                commitToHistory: false,
+              };
+            },
+            contextItemLabel: "labels.paste",
           },
+          separator,
           probablySupportsClipboardBlob &&
-            elements.length > 0 && {
-              shortcutName: "copyAsPng",
-              label: t("labels.copyAsPng"),
-              action: this.copyToClipboardAsPng,
-            },
+            elements.length > 0 &&
+            actionCopyAsPng,
           probablySupportsClipboardWriteText &&
-            elements.length > 0 && {
-              shortcutName: "copyAsSvg",
-              label: t("labels.copyAsSvg"),
-              action: this.copyToClipboardAsSvg,
-            },
-          ...this.actionManager.getContextMenuItems((action) =>
-            CANVAS_ONLY_ACTIONS.includes(action.name),
-          ),
-          {
-            checked: this.state.gridSize !== null,
-            shortcutName: "gridMode",
-            label: t("labels.gridMode"),
-            action: this.toggleGridMode,
-          },
-          {
-            checked: this.state.zenModeEnabled,
-            shortcutName: "zenMode",
-            label: t("buttons.zenMode"),
-            action: this.toggleZenMode,
-          },
-          {
-            checked: this.state.showStats,
-            shortcutName: "stats",
-            label: t("stats.title"),
-            action: this.toggleStats,
-          },
+            elements.length > 0 &&
+            actionCopyAsSvg,
+          ((probablySupportsClipboardBlob && elements.length > 0) ||
+            (probablySupportsClipboardWriteText && elements.length > 0)) &&
+            separator,
+          actionSelectAll,
+          separator,
+          actionToggleGridMode,
+          actionToggleZenMode,
+          actionToggleStats,
         ],
         top: clientY,
         left: clientX,
+        actionManager: this.actionManager,
       });
       return;
     }
@@ -3674,37 +3671,41 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
     ContextMenu.push({
       options: [
-        {
-          shortcutName: "cut",
-          label: t("labels.cut"),
-          action: this.cutAll,
-        },
+        actionCut,
+        navigator.clipboard && actionCopy,
         navigator.clipboard && {
-          shortcutName: "copy",
-          label: t("labels.copy"),
-          action: this.copyAll,
-        },
-        navigator.clipboard && {
-          shortcutName: "paste",
-          label: t("labels.paste"),
-          action: () => this.pasteFromClipboard(null),
-        },
-        probablySupportsClipboardBlob && {
-          shortcutName: "copyAsPng",
-          label: t("labels.copyAsPng"),
-          action: this.copyToClipboardAsPng,
-        },
-        probablySupportsClipboardWriteText && {
-          shortcutName: "copyAsSvg",
-          label: t("labels.copyAsSvg"),
-          action: this.copyToClipboardAsSvg,
+          name: "paste",
+          perform: (elements, appStates) => {
+            this.pasteFromClipboard(null);
+            return {
+              commitToHistory: false,
+            };
+          },
+          contextItemLabel: "labels.paste",
         },
-        ...this.actionManager.getContextMenuItems(
-          (action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
-        ),
+        separator,
+        probablySupportsClipboardBlob && actionCopyAsPng,
+        probablySupportsClipboardWriteText && actionCopyAsSvg,
+        separator,
+        actionCopyStyles,
+        actionPasteStyles,
+        separator,
+        maybeGroupAction && actionGroup,
+        maybeUngroupAction && actionUngroup,
+        (maybeGroupAction || maybeUngroupAction) && separator,
+        actionAddToLibrary,
+        separator,
+        actionSendBackward,
+        actionBringForward,
+        actionSendToBack,
+        actionBringToFront,
+        separator,
+        actionDuplicateSelection,
+        actionDeleteSelected,
       ],
       top: clientY,
       left: clientX,
+      actionManager: this.actionManager,
     });
   };
 

+ 7 - 1
src/components/ContextMenu.scss

@@ -9,9 +9,10 @@
     list-style: none;
     user-select: none;
     margin: -0.25rem 0 0 0.125rem;
-    padding: 0.25rem 0;
+    padding: 0.5rem 0;
     background-color: var(--popup-secondary-background-color);
     border: 1px solid var(--button-gray-3);
+    cursor: default;
   }
 
   .context-menu button {
@@ -88,4 +89,9 @@
       }
     }
   }
+
+  .context-menu-option-separator {
+    border: none;
+    border-top: 1px solid $oc-gray-5;
+  }
 }

+ 45 - 27
src/components/ContextMenu.tsx

@@ -2,31 +2,37 @@ import React from "react";
 import { render, unmountComponentAtNode } from "react-dom";
 import clsx from "clsx";
 import { Popover } from "./Popover";
+import { t } from "../i18n";
 
 import "./ContextMenu.scss";
 import {
   getShortcutFromShortcutName,
   ShortcutName,
 } from "../actions/shortcuts";
+import { Action } from "../actions/types";
+import { ActionManager } from "../actions/manager";
 
-type ContextMenuOption = {
-  checked?: boolean;
-  shortcutName: ShortcutName;
-  label: string;
-  action(): void;
-};
+type ContextMenuOption = "separator" | Action;
 
-type Props = {
+type ContextMenuProps = {
   options: ContextMenuOption[];
   onCloseRequest?(): void;
   top: number;
   left: number;
+  actionManager: ActionManager;
 };
 
-const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
+const ContextMenu = ({
+  options,
+  onCloseRequest,
+  top,
+  left,
+  actionManager,
+}: ContextMenuProps) => {
   const isDarkTheme = !!document
     .querySelector(".excalidraw")
     ?.classList.contains("Appearance_dark");
+
   return (
     <div
       className={clsx("excalidraw", {
@@ -43,23 +49,33 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
           className="context-menu"
           onContextMenu={(event) => event.preventDefault()}
         >
-          {options.map(({ action, checked, shortcutName, label }, idx) => (
-            <li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
-              <button
-                className={`context-menu-option 
-                ${shortcutName === "delete" ? "dangerous" : ""}
-                ${checked ? "checkmark" : ""}`}
-                onClick={action}
-              >
-                <div className="context-menu-option__label">{label}</div>
-                <kbd className="context-menu-option__shortcut">
-                  {shortcutName
-                    ? getShortcutFromShortcutName(shortcutName)
-                    : ""}
-                </kbd>
-              </button>
-            </li>
-          ))}
+          {options.map((option, idx) => {
+            if (option === "separator") {
+              return <hr key={idx} className="context-menu-option-separator" />;
+            }
+
+            const actionName = option.name;
+            const label = option.contextItemLabel
+              ? t(option.contextItemLabel)
+              : "";
+            return (
+              <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
+                <button
+                  className={`context-menu-option
+                  ${actionName === "deleteSelectedElements" ? "dangerous" : ""}
+                  ${option.checked ? "checkmark" : ""}`}
+                  onClick={() => actionManager.executeAction(option)}
+                >
+                  <div className="context-menu-option__label">{label}</div>
+                  <kbd className="context-menu-option__shortcut">
+                    {actionName
+                      ? getShortcutFromShortcutName(actionName as ShortcutName)
+                      : ""}
+                  </kbd>
+                </button>
+              </li>
+            );
+          })}
         </ul>
       </Popover>
     </div>
@@ -78,8 +94,9 @@ const getContextMenuNode = (): HTMLDivElement => {
 
 type ContextMenuParams = {
   options: (ContextMenuOption | false | null | undefined)[];
-  top: number;
-  left: number;
+  top: ContextMenuProps["top"];
+  left: ContextMenuProps["left"];
+  actionManager: ContextMenuProps["actionManager"];
 };
 
 const handleClose = () => {
@@ -101,6 +118,7 @@ export default {
           left={params.left}
           options={options}
           onCloseRequest={handleClose}
+          actionManager={params.actionManager}
         />,
         getContextMenuNode(),
       );

+ 1 - 1
src/is-mobile.tsx

@@ -12,7 +12,7 @@ export const IsMobileProvider = ({
     query.current = window.matchMedia
       ? window.matchMedia(
           // keep up to date with _variables.scss
-          "(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
+          "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)",
         )
       : (({
           matches: false,

+ 1 - 0
src/keys.ts

@@ -19,6 +19,7 @@ export const CODES = {
   F: "KeyF",
   H: "KeyH",
   V: "KeyV",
+  X: "KeyX",
   Z: "KeyZ",
 } as const;
 

+ 11 - 7
src/tests/regressionTests.test.tsx

@@ -618,6 +618,7 @@ describe("regression tests", () => {
       clientY: 1,
     });
     const contextMenu = document.querySelector(".context-menu");
+    const contextMenuOptions = document.querySelectorAll(".context-menu li");
     const expectedShortcutNames: ShortcutName[] = [
       "selectAll",
       "gridMode",
@@ -626,7 +627,7 @@ describe("regression tests", () => {
     ];
 
     expect(contextMenu).not.toBeNull();
-    expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
+    expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
     expectedShortcutNames.forEach((shortcutName) => {
       expect(
         contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
@@ -645,11 +646,12 @@ describe("regression tests", () => {
       clientY: 1,
     });
     const contextMenu = document.querySelector(".context-menu");
+    const contextMenuOptions = document.querySelectorAll(".context-menu li");
     const expectedShortcutNames: ShortcutName[] = [
       "cut",
       "copyStyles",
       "pasteStyles",
-      "delete",
+      "deleteSelectedElements",
       "addToLibrary",
       "sendBackward",
       "bringForward",
@@ -659,7 +661,7 @@ describe("regression tests", () => {
     ];
 
     expect(contextMenu).not.toBeNull();
-    expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
+    expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
     expectedShortcutNames.forEach((shortcutName) => {
       expect(
         contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
@@ -689,11 +691,12 @@ describe("regression tests", () => {
     });
 
     const contextMenu = document.querySelector(".context-menu");
+    const contextMenuOptions = document.querySelectorAll(".context-menu li");
     const expectedShortcutNames: ShortcutName[] = [
       "cut",
       "copyStyles",
       "pasteStyles",
-      "delete",
+      "deleteSelectedElements",
       "group",
       "addToLibrary",
       "sendBackward",
@@ -704,7 +707,7 @@ describe("regression tests", () => {
     ];
 
     expect(contextMenu).not.toBeNull();
-    expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
+    expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
     expectedShortcutNames.forEach((shortcutName) => {
       expect(
         contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
@@ -738,11 +741,12 @@ describe("regression tests", () => {
     });
 
     const contextMenu = document.querySelector(".context-menu");
+    const contextMenuOptions = document.querySelectorAll(".context-menu li");
     const expectedShortcutNames: ShortcutName[] = [
       "cut",
       "copyStyles",
       "pasteStyles",
-      "delete",
+      "deleteSelectedElements",
       "ungroup",
       "addToLibrary",
       "sendBackward",
@@ -753,7 +757,7 @@ describe("regression tests", () => {
     ];
 
     expect(contextMenu).not.toBeNull();
-    expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
+    expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
     expectedShortcutNames.forEach((shortcutName) => {
       expect(
         contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),