You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
128 lines
3.9 KiB
TypeScript
128 lines
3.9 KiB
TypeScript
import clsx from "clsx";
|
|
import { Popover } from "./Popover";
|
|
import type { TranslationKeys } from "../i18n";
|
|
import { t } from "../i18n";
|
|
|
|
import "./ContextMenu.scss";
|
|
import type { ShortcutName } from "../actions/shortcuts";
|
|
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
|
import type { Action } from "../actions/types";
|
|
import type { ActionManager } from "../actions/manager";
|
|
import { useExcalidrawAppState, useExcalidrawElements } from "./App";
|
|
import React from "react";
|
|
|
|
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
|
|
|
|
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
|
|
|
|
type ContextMenuProps = {
|
|
actionManager: ActionManager;
|
|
items: ContextMenuItems;
|
|
top: number;
|
|
left: number;
|
|
onClose: (callback?: () => void) => void;
|
|
};
|
|
|
|
export const CONTEXT_MENU_SEPARATOR = "separator";
|
|
|
|
export const ContextMenu = React.memo(
|
|
({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
|
|
const appState = useExcalidrawAppState();
|
|
const elements = useExcalidrawElements();
|
|
|
|
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
|
|
if (
|
|
item &&
|
|
(item === CONTEXT_MENU_SEPARATOR ||
|
|
!item.predicate ||
|
|
item.predicate(
|
|
elements,
|
|
appState,
|
|
actionManager.app.props,
|
|
actionManager.app,
|
|
))
|
|
) {
|
|
acc.push(item);
|
|
}
|
|
return acc;
|
|
}, []);
|
|
|
|
return (
|
|
<Popover
|
|
onCloseRequest={() => {
|
|
onClose();
|
|
}}
|
|
top={top}
|
|
left={left}
|
|
fitInViewport={true}
|
|
offsetLeft={appState.offsetLeft}
|
|
offsetTop={appState.offsetTop}
|
|
viewportWidth={appState.width}
|
|
viewportHeight={appState.height}
|
|
>
|
|
<ul
|
|
className="context-menu"
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
>
|
|
{filteredItems.map((item, idx) => {
|
|
if (item === CONTEXT_MENU_SEPARATOR) {
|
|
if (
|
|
!filteredItems[idx - 1] ||
|
|
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
|
|
) {
|
|
return null;
|
|
}
|
|
return <hr key={idx} className="context-menu-item-separator" />;
|
|
}
|
|
|
|
const actionName = item.name;
|
|
let label = "";
|
|
if (item.label) {
|
|
if (typeof item.label === "function") {
|
|
label = t(
|
|
item.label(
|
|
elements,
|
|
appState,
|
|
actionManager.app,
|
|
) as unknown as TranslationKeys,
|
|
);
|
|
} else {
|
|
label = t(item.label as unknown as TranslationKeys);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<li
|
|
key={idx}
|
|
data-testid={actionName}
|
|
onClick={() => {
|
|
// we need update state before executing the action in case
|
|
// the action uses the appState it's being passed (that still
|
|
// contains a defined contextMenu) to return the next state.
|
|
onClose(() => {
|
|
actionManager.executeAction(item, "contextMenu");
|
|
});
|
|
}}
|
|
>
|
|
<button
|
|
className={clsx("context-menu-item", {
|
|
dangerous: actionName === "deleteSelectedElements",
|
|
checkmark: item.checked?.(appState),
|
|
})}
|
|
>
|
|
<div className="context-menu-item__label">{label}</div>
|
|
<kbd className="context-menu-item__shortcut">
|
|
{actionName
|
|
? getShortcutFromShortcutName(actionName as ShortcutName)
|
|
: ""}
|
|
</kbd>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</Popover>
|
|
);
|
|
},
|
|
);
|