ContextMenu.tsx 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. import { render, unmountComponentAtNode } from "react-dom";
  2. import clsx from "clsx";
  3. import { Popover } from "./Popover";
  4. import { t } from "../i18n";
  5. import "./ContextMenu.scss";
  6. import {
  7. getShortcutFromShortcutName,
  8. ShortcutName,
  9. } from "../actions/shortcuts";
  10. import { Action } from "../actions/types";
  11. import { ActionManager } from "../actions/manager";
  12. import { AppState } from "../types";
  13. export type ContextMenuOption = "separator" | Action;
  14. type ContextMenuProps = {
  15. options: ContextMenuOption[];
  16. onCloseRequest?(): void;
  17. top: number;
  18. left: number;
  19. actionManager: ActionManager;
  20. appState: Readonly<AppState>;
  21. };
  22. const ContextMenu = ({
  23. options,
  24. onCloseRequest,
  25. top,
  26. left,
  27. actionManager,
  28. appState,
  29. }: ContextMenuProps) => {
  30. return (
  31. <Popover
  32. onCloseRequest={onCloseRequest}
  33. top={top}
  34. left={left}
  35. fitInViewport={true}
  36. offsetLeft={appState.offsetLeft}
  37. offsetTop={appState.offsetTop}
  38. viewportWidth={appState.width}
  39. viewportHeight={appState.height}
  40. >
  41. <ul
  42. className="context-menu"
  43. onContextMenu={(event) => event.preventDefault()}
  44. >
  45. {options.map((option, idx) => {
  46. if (option === "separator") {
  47. return <hr key={idx} className="context-menu-option-separator" />;
  48. }
  49. const actionName = option.name;
  50. const label = option.contextItemLabel
  51. ? t(option.contextItemLabel)
  52. : "";
  53. return (
  54. <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
  55. <button
  56. className={clsx("context-menu-option", {
  57. dangerous: actionName === "deleteSelectedElements",
  58. checkmark: option.checked?.(appState),
  59. })}
  60. onClick={() => actionManager.executeAction(option)}
  61. >
  62. <div className="context-menu-option__label">{label}</div>
  63. <kbd className="context-menu-option__shortcut">
  64. {actionName
  65. ? getShortcutFromShortcutName(actionName as ShortcutName)
  66. : ""}
  67. </kbd>
  68. </button>
  69. </li>
  70. );
  71. })}
  72. </ul>
  73. </Popover>
  74. );
  75. };
  76. const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
  77. const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
  78. let contextMenuNode = contextMenuNodeByContainer.get(container);
  79. if (contextMenuNode) {
  80. return contextMenuNode;
  81. }
  82. contextMenuNode = document.createElement("div");
  83. container
  84. .querySelector(".excalidraw-contextMenuContainer")!
  85. .appendChild(contextMenuNode);
  86. contextMenuNodeByContainer.set(container, contextMenuNode);
  87. return contextMenuNode;
  88. };
  89. type ContextMenuParams = {
  90. options: (ContextMenuOption | false | null | undefined)[];
  91. top: ContextMenuProps["top"];
  92. left: ContextMenuProps["left"];
  93. actionManager: ContextMenuProps["actionManager"];
  94. appState: Readonly<AppState>;
  95. container: HTMLElement;
  96. };
  97. const handleClose = (container: HTMLElement) => {
  98. const contextMenuNode = contextMenuNodeByContainer.get(container);
  99. if (contextMenuNode) {
  100. unmountComponentAtNode(contextMenuNode);
  101. contextMenuNode.remove();
  102. contextMenuNodeByContainer.delete(container);
  103. }
  104. };
  105. export default {
  106. push(params: ContextMenuParams) {
  107. const options = Array.of<ContextMenuOption>();
  108. params.options.forEach((option) => {
  109. if (option) {
  110. options.push(option);
  111. }
  112. });
  113. if (options.length) {
  114. render(
  115. <ContextMenu
  116. top={params.top}
  117. left={params.left}
  118. options={options}
  119. onCloseRequest={() => handleClose(params.container)}
  120. actionManager={params.actionManager}
  121. appState={params.appState}
  122. />,
  123. getContextMenuNode(params.container),
  124. );
  125. }
  126. },
  127. };