From 94fe1ff6e67941a81691cc5d02d4c29cc23de8b6 Mon Sep 17 00:00:00 2001 From: Rene Date: Sat, 12 Dec 2020 23:03:58 +0100 Subject: [PATCH] Show shortcut context menu (#2501) Co-authored-by: rene_mbp Co-authored-by: dwelle --- src/actions/manager.tsx | 3 + src/actions/shortcuts.ts | 63 ++++++++++++++++ src/components/App.tsx | 10 +++ src/components/ContextMenu.scss | 12 +++ src/components/ContextMenu.tsx | 16 +++- src/tests/regressionTests.test.tsx | 117 +++++++++++++++-------------- 6 files changed, 163 insertions(+), 58 deletions(-) create mode 100644 src/actions/shortcuts.ts diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 8f6c5322..236a1441 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -10,6 +10,7 @@ import { import { ExcalidrawElement } from "../element/types"; import { AppState } from "../types"; import { t } from "../i18n"; +import { ShortcutName } from "./shortcuts"; export class ActionManager implements ActionsManagerInterface { actions = {} as ActionsManagerInterface["actions"]; @@ -102,6 +103,8 @@ export class ActionManager implements ActionsManagerInterface { (b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999), ) .map((action) => ({ + // take last bit of the label "labels." + shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName, label: action.contextItemLabel ? t(action.contextItemLabel) : "", action: () => { this.updater( diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts new file mode 100644 index 00000000..8a68555c --- /dev/null +++ b/src/actions/shortcuts.ts @@ -0,0 +1,63 @@ +import { t } from "../i18n"; +import { isDarwin } from "../keys"; +import { getShortcutKey } from "../utils"; + +export type ShortcutName = + | "cut" + | "copy" + | "paste" + | "copyStyles" + | "pasteStyles" + | "selectAll" + | "delete" + | "duplicateSelection" + | "sendBackward" + | "bringForward" + | "sendToBack" + | "bringToFront" + | "copyAsPng" + | "copyAsSvg" + | "group" + | "ungroup" + | "toggleGridMode" + | "toggleStats" + | "addToLibrary"; + +const shortcutMap: Record = { + cut: [getShortcutKey("CtrlOrCmd+X")], + copy: [getShortcutKey("CtrlOrCmd+C")], + paste: [getShortcutKey("CtrlOrCmd+V")], + copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")], + pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")], + selectAll: [getShortcutKey("CtrlOrCmd+A")], + delete: [getShortcutKey("Del")], + duplicateSelection: [ + getShortcutKey("CtrlOrCmd+D"), + getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`), + ], + sendBackward: [getShortcutKey("CtrlOrCmd+[")], + bringForward: [getShortcutKey("CtrlOrCmd+]")], + sendToBack: [ + isDarwin + ? getShortcutKey("CtrlOrCmd+Alt+[") + : getShortcutKey("CtrlOrCmd+Shift+["), + ], + bringToFront: [ + isDarwin + ? getShortcutKey("CtrlOrCmd+Alt+]") + : getShortcutKey("CtrlOrCmd+Shift+]"), + ], + copyAsPng: [getShortcutKey("Shift+Alt+C")], + copyAsSvg: [], + group: [getShortcutKey("CtrlOrCmd+G")], + ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")], + toggleGridMode: [getShortcutKey("CtrlOrCmd+'")], + toggleStats: [], + addToLibrary: [], +}; + +export const getShortcutFromShortcutName = (name: ShortcutName) => { + const shortcuts = shortcutMap[name]; + // if multiple shortcuts availiable, take the first one + return shortcuts && shortcuts.length > 0 ? shortcuts[0] : ""; +}; diff --git a/src/components/App.tsx b/src/components/App.tsx index a34b1a84..934c9651 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -3592,16 +3592,19 @@ class App extends React.Component { ContextMenu.push({ options: [ navigator.clipboard && { + shortcutName: "paste", label: t("labels.paste"), action: () => this.pasteFromClipboard(null), }, probablySupportsClipboardBlob && elements.length > 0 && { + shortcutName: "copyAsPng", label: t("labels.copyAsPng"), action: this.copyToClipboardAsPng, }, probablySupportsClipboardWriteText && elements.length > 0 && { + shortcutName: "copyAsSvg", label: t("labels.copyAsSvg"), action: this.copyToClipboardAsSvg, }, @@ -3609,10 +3612,12 @@ class App extends React.Component { CANVAS_ONLY_ACTIONS.includes(action.name), ), { + shortcutName: "toggleGridMode", label: t("labels.toggleGridMode"), action: this.toggleGridMode, }, { + shortcutName: "toggleStats", label: t("labels.toggleStats"), action: this.toggleStats, }, @@ -3630,22 +3635,27 @@ class App extends React.Component { ContextMenu.push({ options: [ { + shortcutName: "cut", label: t("labels.cut"), action: this.cutAll, }, 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, }, diff --git a/src/components/ContextMenu.scss b/src/components/ContextMenu.scss index 6e99f321..40d3ce76 100644 --- a/src/components/ContextMenu.scss +++ b/src/components/ContextMenu.scss @@ -29,6 +29,18 @@ background-color: transparent; border: none; white-space: nowrap; + + display: grid; + grid-template-columns: 1fr 0.2fr; + div:nth-child(1) { + justify-self: start; + margin-inline-end: 20px; + } + div:nth-child(2) { + justify-self: end; + opacity: 0.6; + font-size: 0.7rem; + } } .context-menu-option:hover { diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 335f96a0..f0b67552 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -4,8 +4,13 @@ import clsx from "clsx"; import { Popover } from "./Popover"; import "./ContextMenu.scss"; +import { + getShortcutFromShortcutName, + ShortcutName, +} from "../actions/shortcuts"; type ContextMenuOption = { + shortcutName: ShortcutName; label: string; action(): void; }; @@ -38,10 +43,15 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => { className="context-menu" onContextMenu={(event) => event.preventDefault()} > - {options.map(({ action, label }, idx) => ( -
  • + {options.map(({ action, shortcutName, label }, idx) => ( +
  • ))} diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index 02d78c47..5d4e7cd2 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -2,8 +2,9 @@ import { queryByText } from "@testing-library/react"; import React from "react"; import ReactDOM from "react-dom"; import { copiedStyles } from "../actions/actionStyles"; +import { ShortcutName } from "../actions/shortcuts"; import { ExcalidrawElement } from "../element/types"; -import { setLanguage, t } from "../i18n"; +import { setLanguage } from "../i18n"; import { CODES, KEYS } from "../keys"; import Excalidraw from "../packages/excalidraw/index"; import { reseed } from "../random"; @@ -632,16 +633,19 @@ describe("regression tests", () => { clientY: 1, }); const contextMenu = document.querySelector(".context-menu"); - const options = contextMenu?.querySelectorAll(".context-menu-option"); - const expectedOptions = [ - t("labels.selectAll"), - t("labels.toggleGridMode"), - t("labels.toggleStats"), + const expectedShortcutNames: ShortcutName[] = [ + "selectAll", + "toggleGridMode", + "toggleStats", ]; expect(contextMenu).not.toBeNull(); - expect(options?.length).toBe(3); - expect(options?.item(0).textContent).toBe(expectedOptions[0]); + expect(contextMenu?.children.length).toBe(expectedShortcutNames.length); + expectedShortcutNames.forEach((shortcutName) => { + expect( + contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`), + ).not.toBeNull(); + }); }); it("shows context menu for element", () => { @@ -655,24 +659,25 @@ describe("regression tests", () => { clientY: 1, }); const contextMenu = document.querySelector(".context-menu"); - const options = contextMenu?.querySelectorAll(".context-menu-option"); - const expectedOptions = [ - "Cut", - "Copy styles", - "Paste styles", - "Delete", - "Add to library", - "Send backward", - "Bring forward", - "Send to back", - "Bring to front", - "Duplicate", + const expectedShortcutNames: ShortcutName[] = [ + "cut", + "copyStyles", + "pasteStyles", + "delete", + "addToLibrary", + "sendBackward", + "bringForward", + "sendToBack", + "bringToFront", + "duplicateSelection", ]; expect(contextMenu).not.toBeNull(); - expect(contextMenu?.children.length).toBe(10); - options?.forEach((opt, i) => { - expect(opt.textContent).toBe(expectedOptions[i]); + expect(contextMenu?.children.length).toBe(expectedShortcutNames.length); + expectedShortcutNames.forEach((shortcutName) => { + expect( + contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`), + ).not.toBeNull(); }); }); @@ -698,25 +703,26 @@ describe("regression tests", () => { }); const contextMenu = document.querySelector(".context-menu"); - const options = contextMenu?.querySelectorAll(".context-menu-option"); - const expectedOptions = [ - "Cut", - "Copy styles", - "Paste styles", - "Delete", - "Group selection", - "Add to library", - "Send backward", - "Bring forward", - "Send to back", - "Bring to front", - "Duplicate", + const expectedShortcutNames: ShortcutName[] = [ + "cut", + "copyStyles", + "pasteStyles", + "delete", + "group", + "addToLibrary", + "sendBackward", + "bringForward", + "sendToBack", + "bringToFront", + "duplicateSelection", ]; expect(contextMenu).not.toBeNull(); - expect(contextMenu?.children.length).toBe(11); - options?.forEach((opt, i) => { - expect(opt.textContent).toBe(expectedOptions[i]); + expect(contextMenu?.children.length).toBe(expectedShortcutNames.length); + expectedShortcutNames.forEach((shortcutName) => { + expect( + contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`), + ).not.toBeNull(); }); }); @@ -746,25 +752,26 @@ describe("regression tests", () => { }); const contextMenu = document.querySelector(".context-menu"); - const options = contextMenu?.querySelectorAll(".context-menu-option"); - const expectedOptions = [ - "Cut", - "Copy styles", - "Paste styles", - "Delete", - "Ungroup selection", - "Add to library", - "Send backward", - "Bring forward", - "Send to back", - "Bring to front", - "Duplicate", + const expectedShortcutNames: ShortcutName[] = [ + "cut", + "copyStyles", + "pasteStyles", + "delete", + "ungroup", + "addToLibrary", + "sendBackward", + "bringForward", + "sendToBack", + "bringToFront", + "duplicateSelection", ]; expect(contextMenu).not.toBeNull(); - expect(contextMenu?.children.length).toBe(11); - options?.forEach((opt, i) => { - expect(opt.textContent).toBe(expectedOptions[i]); + expect(contextMenu?.children.length).toBe(expectedShortcutNames.length); + expectedShortcutNames.forEach((shortcutName) => { + expect( + contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`), + ).not.toBeNull(); }); });