Show shortcut context menu (#2501)
Co-authored-by: rene_mbp <harryloveslearning@googlemail.com> Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
9cfe7b45e5
commit
94fe1ff6e6
@ -10,6 +10,7 @@ import {
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { ShortcutName } from "./shortcuts";
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
@ -102,6 +103,8 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
||||||
)
|
)
|
||||||
.map((action) => ({
|
.map((action) => ({
|
||||||
|
// take last bit of the label "labels.<shortcutName>"
|
||||||
|
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
|
||||||
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
||||||
action: () => {
|
action: () => {
|
||||||
this.updater(
|
this.updater(
|
||||||
|
63
src/actions/shortcuts.ts
Normal file
63
src/actions/shortcuts.ts
Normal file
@ -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<ShortcutName, string[]> = {
|
||||||
|
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] : "";
|
||||||
|
};
|
@ -3592,16 +3592,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
ContextMenu.push({
|
ContextMenu.push({
|
||||||
options: [
|
options: [
|
||||||
navigator.clipboard && {
|
navigator.clipboard && {
|
||||||
|
shortcutName: "paste",
|
||||||
label: t("labels.paste"),
|
label: t("labels.paste"),
|
||||||
action: () => this.pasteFromClipboard(null),
|
action: () => this.pasteFromClipboard(null),
|
||||||
},
|
},
|
||||||
probablySupportsClipboardBlob &&
|
probablySupportsClipboardBlob &&
|
||||||
elements.length > 0 && {
|
elements.length > 0 && {
|
||||||
|
shortcutName: "copyAsPng",
|
||||||
label: t("labels.copyAsPng"),
|
label: t("labels.copyAsPng"),
|
||||||
action: this.copyToClipboardAsPng,
|
action: this.copyToClipboardAsPng,
|
||||||
},
|
},
|
||||||
probablySupportsClipboardWriteText &&
|
probablySupportsClipboardWriteText &&
|
||||||
elements.length > 0 && {
|
elements.length > 0 && {
|
||||||
|
shortcutName: "copyAsSvg",
|
||||||
label: t("labels.copyAsSvg"),
|
label: t("labels.copyAsSvg"),
|
||||||
action: this.copyToClipboardAsSvg,
|
action: this.copyToClipboardAsSvg,
|
||||||
},
|
},
|
||||||
@ -3609,10 +3612,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
CANVAS_ONLY_ACTIONS.includes(action.name),
|
CANVAS_ONLY_ACTIONS.includes(action.name),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
shortcutName: "toggleGridMode",
|
||||||
label: t("labels.toggleGridMode"),
|
label: t("labels.toggleGridMode"),
|
||||||
action: this.toggleGridMode,
|
action: this.toggleGridMode,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
shortcutName: "toggleStats",
|
||||||
label: t("labels.toggleStats"),
|
label: t("labels.toggleStats"),
|
||||||
action: this.toggleStats,
|
action: this.toggleStats,
|
||||||
},
|
},
|
||||||
@ -3630,22 +3635,27 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
ContextMenu.push({
|
ContextMenu.push({
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
|
shortcutName: "cut",
|
||||||
label: t("labels.cut"),
|
label: t("labels.cut"),
|
||||||
action: this.cutAll,
|
action: this.cutAll,
|
||||||
},
|
},
|
||||||
navigator.clipboard && {
|
navigator.clipboard && {
|
||||||
|
shortcutName: "copy",
|
||||||
label: t("labels.copy"),
|
label: t("labels.copy"),
|
||||||
action: this.copyAll,
|
action: this.copyAll,
|
||||||
},
|
},
|
||||||
navigator.clipboard && {
|
navigator.clipboard && {
|
||||||
|
shortcutName: "paste",
|
||||||
label: t("labels.paste"),
|
label: t("labels.paste"),
|
||||||
action: () => this.pasteFromClipboard(null),
|
action: () => this.pasteFromClipboard(null),
|
||||||
},
|
},
|
||||||
probablySupportsClipboardBlob && {
|
probablySupportsClipboardBlob && {
|
||||||
|
shortcutName: "copyAsPng",
|
||||||
label: t("labels.copyAsPng"),
|
label: t("labels.copyAsPng"),
|
||||||
action: this.copyToClipboardAsPng,
|
action: this.copyToClipboardAsPng,
|
||||||
},
|
},
|
||||||
probablySupportsClipboardWriteText && {
|
probablySupportsClipboardWriteText && {
|
||||||
|
shortcutName: "copyAsSvg",
|
||||||
label: t("labels.copyAsSvg"),
|
label: t("labels.copyAsSvg"),
|
||||||
action: this.copyToClipboardAsSvg,
|
action: this.copyToClipboardAsSvg,
|
||||||
},
|
},
|
||||||
|
@ -29,6 +29,18 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
white-space: nowrap;
|
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 {
|
.context-menu-option:hover {
|
||||||
|
@ -4,8 +4,13 @@ import clsx from "clsx";
|
|||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
|
||||||
import "./ContextMenu.scss";
|
import "./ContextMenu.scss";
|
||||||
|
import {
|
||||||
|
getShortcutFromShortcutName,
|
||||||
|
ShortcutName,
|
||||||
|
} from "../actions/shortcuts";
|
||||||
|
|
||||||
type ContextMenuOption = {
|
type ContextMenuOption = {
|
||||||
|
shortcutName: ShortcutName;
|
||||||
label: string;
|
label: string;
|
||||||
action(): void;
|
action(): void;
|
||||||
};
|
};
|
||||||
@ -38,10 +43,15 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
|||||||
className="context-menu"
|
className="context-menu"
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{options.map(({ action, label }, idx) => (
|
{options.map(({ action, shortcutName, label }, idx) => (
|
||||||
<li key={idx} onClick={onCloseRequest}>
|
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
||||||
<button className="context-menu-option" onClick={action}>
|
<button className="context-menu-option" onClick={action}>
|
||||||
{label}
|
<div>{label}</div>
|
||||||
|
<div>
|
||||||
|
{shortcutName
|
||||||
|
? getShortcutFromShortcutName(shortcutName)
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
@ -2,8 +2,9 @@ import { queryByText } from "@testing-library/react";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { copiedStyles } from "../actions/actionStyles";
|
import { copiedStyles } from "../actions/actionStyles";
|
||||||
|
import { ShortcutName } from "../actions/shortcuts";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { setLanguage, t } from "../i18n";
|
import { setLanguage } from "../i18n";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import Excalidraw from "../packages/excalidraw/index";
|
import Excalidraw from "../packages/excalidraw/index";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
@ -632,16 +633,19 @@ describe("regression tests", () => {
|
|||||||
clientY: 1,
|
clientY: 1,
|
||||||
});
|
});
|
||||||
const contextMenu = document.querySelector(".context-menu");
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
const options = contextMenu?.querySelectorAll(".context-menu-option");
|
const expectedShortcutNames: ShortcutName[] = [
|
||||||
const expectedOptions = [
|
"selectAll",
|
||||||
t("labels.selectAll"),
|
"toggleGridMode",
|
||||||
t("labels.toggleGridMode"),
|
"toggleStats",
|
||||||
t("labels.toggleStats"),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(contextMenu).not.toBeNull();
|
expect(contextMenu).not.toBeNull();
|
||||||
expect(options?.length).toBe(3);
|
expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
|
||||||
expect(options?.item(0).textContent).toBe(expectedOptions[0]);
|
expectedShortcutNames.forEach((shortcutName) => {
|
||||||
|
expect(
|
||||||
|
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows context menu for element", () => {
|
it("shows context menu for element", () => {
|
||||||
@ -655,24 +659,25 @@ describe("regression tests", () => {
|
|||||||
clientY: 1,
|
clientY: 1,
|
||||||
});
|
});
|
||||||
const contextMenu = document.querySelector(".context-menu");
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
const options = contextMenu?.querySelectorAll(".context-menu-option");
|
const expectedShortcutNames: ShortcutName[] = [
|
||||||
const expectedOptions = [
|
"cut",
|
||||||
"Cut",
|
"copyStyles",
|
||||||
"Copy styles",
|
"pasteStyles",
|
||||||
"Paste styles",
|
"delete",
|
||||||
"Delete",
|
"addToLibrary",
|
||||||
"Add to library",
|
"sendBackward",
|
||||||
"Send backward",
|
"bringForward",
|
||||||
"Bring forward",
|
"sendToBack",
|
||||||
"Send to back",
|
"bringToFront",
|
||||||
"Bring to front",
|
"duplicateSelection",
|
||||||
"Duplicate",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(contextMenu).not.toBeNull();
|
expect(contextMenu).not.toBeNull();
|
||||||
expect(contextMenu?.children.length).toBe(10);
|
expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
|
||||||
options?.forEach((opt, i) => {
|
expectedShortcutNames.forEach((shortcutName) => {
|
||||||
expect(opt.textContent).toBe(expectedOptions[i]);
|
expect(
|
||||||
|
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||||
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -698,25 +703,26 @@ describe("regression tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const contextMenu = document.querySelector(".context-menu");
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
const options = contextMenu?.querySelectorAll(".context-menu-option");
|
const expectedShortcutNames: ShortcutName[] = [
|
||||||
const expectedOptions = [
|
"cut",
|
||||||
"Cut",
|
"copyStyles",
|
||||||
"Copy styles",
|
"pasteStyles",
|
||||||
"Paste styles",
|
"delete",
|
||||||
"Delete",
|
"group",
|
||||||
"Group selection",
|
"addToLibrary",
|
||||||
"Add to library",
|
"sendBackward",
|
||||||
"Send backward",
|
"bringForward",
|
||||||
"Bring forward",
|
"sendToBack",
|
||||||
"Send to back",
|
"bringToFront",
|
||||||
"Bring to front",
|
"duplicateSelection",
|
||||||
"Duplicate",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(contextMenu).not.toBeNull();
|
expect(contextMenu).not.toBeNull();
|
||||||
expect(contextMenu?.children.length).toBe(11);
|
expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
|
||||||
options?.forEach((opt, i) => {
|
expectedShortcutNames.forEach((shortcutName) => {
|
||||||
expect(opt.textContent).toBe(expectedOptions[i]);
|
expect(
|
||||||
|
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||||
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -746,25 +752,26 @@ describe("regression tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const contextMenu = document.querySelector(".context-menu");
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
const options = contextMenu?.querySelectorAll(".context-menu-option");
|
const expectedShortcutNames: ShortcutName[] = [
|
||||||
const expectedOptions = [
|
"cut",
|
||||||
"Cut",
|
"copyStyles",
|
||||||
"Copy styles",
|
"pasteStyles",
|
||||||
"Paste styles",
|
"delete",
|
||||||
"Delete",
|
"ungroup",
|
||||||
"Ungroup selection",
|
"addToLibrary",
|
||||||
"Add to library",
|
"sendBackward",
|
||||||
"Send backward",
|
"bringForward",
|
||||||
"Bring forward",
|
"sendToBack",
|
||||||
"Send to back",
|
"bringToFront",
|
||||||
"Bring to front",
|
"duplicateSelection",
|
||||||
"Duplicate",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(contextMenu).not.toBeNull();
|
expect(contextMenu).not.toBeNull();
|
||||||
expect(contextMenu?.children.length).toBe(11);
|
expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
|
||||||
options?.forEach((opt, i) => {
|
expectedShortcutNames.forEach((shortcutName) => {
|
||||||
expect(opt.textContent).toBe(expectedOptions[i]);
|
expect(
|
||||||
|
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||||
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user