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>
This commit is contained in:
Kartik Prajapati 2021-01-28 00:41:17 +05:30 committed by GitHub
parent b5e26ba81f
commit 978e85a33b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 354 additions and 173 deletions

View File

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

View File

@ -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",
});

View File

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

View File

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

View File

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

View File

@ -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,
});

View File

@ -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",
});

View File

@ -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,
});

View File

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

View File

@ -3,14 +3,15 @@ import {
Action, Action,
ActionsManagerInterface, ActionsManagerInterface,
UpdaterFn, UpdaterFn,
ActionFilterFn,
ActionName, ActionName,
ActionResult, ActionResult,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../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 { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>; getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
constructor( constructor(
updater: UpdaterFn, updater: UpdaterFn,
getAppState: () => AppState, getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[], getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: App,
) { ) {
this.updater = (actionResult) => { this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) { if (actionResult && "then" in actionResult) {
@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
}; };
this.getAppState = getAppState; this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted; this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
} }
registerAction(action: Action) { registerAction(action: Action) {
@ -70,6 +73,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
null, null,
this.app,
), ),
); );
return true; return true;
@ -81,43 +85,11 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
null, 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. // Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components // This is needed for dynamically generated action components
// like the user list. We can use this key to extract more // like the user list. We can use this key to extract more
@ -132,6 +104,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
formState, formState,
this.app,
), ),
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ export const IsMobileProvider = ({
query.current = window.matchMedia query.current = window.matchMedia
? window.matchMedia( ? window.matchMedia(
// keep up to date with _variables.scss // 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, matches: false,

View File

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

View File

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