Przeglądaj źródła

feat: move contextMenu into the component tree and control via appState (#6021)

David Luzar 2 lat temu
rodzic
commit
7e135c4e22

+ 30 - 0
src/actions/actionClipboard.tsx

@@ -3,6 +3,7 @@ import { register } from "./register";
 import {
   copyTextToSystemClipboard,
   copyToClipboard,
+  probablySupportsClipboardBlob,
   probablySupportsClipboardWriteText,
 } from "../clipboard";
 import { actionDeleteSelected } from "./actionDeleteSelected";
@@ -23,11 +24,31 @@ export const actionCopy = register({
       commitToHistory: false,
     };
   },
+  contextItemPredicate: (elements, appState, appProps, app) => {
+    return app.device.isMobile && !!navigator.clipboard;
+  },
   contextItemLabel: "labels.copy",
   // don't supply a shortcut since we handle this conditionally via onCopy event
   keyTest: undefined,
 });
 
+export const actionPaste = register({
+  name: "paste",
+  trackEvent: { category: "element" },
+  perform: (elements: any, appStates: any, data, app) => {
+    app.pasteFromClipboard(null);
+    return {
+      commitToHistory: false,
+    };
+  },
+  contextItemPredicate: (elements, appState, appProps, app) => {
+    return app.device.isMobile && !!navigator.clipboard;
+  },
+  contextItemLabel: "labels.paste",
+  // don't supply a shortcut since we handle this conditionally via onCopy event
+  keyTest: undefined,
+});
+
 export const actionCut = register({
   name: "cut",
   trackEvent: { category: "element" },
@@ -35,6 +56,9 @@ export const actionCut = register({
     actionCopy.perform(elements, appState, data, app);
     return actionDeleteSelected.perform(elements, appState);
   },
+  contextItemPredicate: (elements, appState, appProps, app) => {
+    return app.device.isMobile && !!navigator.clipboard;
+  },
   contextItemLabel: "labels.cut",
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
 });
@@ -77,6 +101,9 @@ export const actionCopyAsSvg = register({
       };
     }
   },
+  contextItemPredicate: (elements) => {
+    return probablySupportsClipboardWriteText && elements.length > 0;
+  },
   contextItemLabel: "labels.copyAsSvg",
 });
 
@@ -131,6 +158,9 @@ export const actionCopyAsPng = register({
       };
     }
   },
+  contextItemPredicate: (elements) => {
+    return probablySupportsClipboardBlob && elements.length > 0;
+  },
   contextItemLabel: "labels.copyAsPng",
   keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
 });

+ 3 - 0
src/actions/actionToggleGridMode.tsx

@@ -20,6 +20,9 @@ export const actionToggleGridMode = register({
     };
   },
   checked: (appState: AppState) => appState.gridSize !== null,
+  contextItemPredicate: (element, appState, props) => {
+    return typeof props.gridModeEnabled === "undefined";
+  },
   contextItemLabel: "labels.showGrid",
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
 });

+ 3 - 9
src/actions/actionToggleLock.ts

@@ -41,15 +41,9 @@ export const actionToggleLock = register({
         : "labels.elementLock.lock";
     }
 
-    if (selected.length > 1) {
-      return getOperation(selected) === "lock"
-        ? "labels.elementLock.lockAll"
-        : "labels.elementLock.unlockAll";
-    }
-
-    throw new Error(
-      "Unexpected zero elements to lock/unlock. This should never happen.",
-    );
+    return getOperation(selected) === "lock"
+      ? "labels.elementLock.lockAll"
+      : "labels.elementLock.unlockAll";
   },
   keyTest: (event, appState, elements) => {
     return (

+ 3 - 0
src/actions/actionToggleViewMode.tsx

@@ -18,6 +18,9 @@ export const actionToggleViewMode = register({
     };
   },
   checked: (appState) => appState.viewModeEnabled,
+  contextItemPredicate: (elements, appState, appProps) => {
+    return typeof appProps.viewModeEnabled === "undefined";
+  },
   contextItemLabel: "labels.viewMode",
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,

+ 3 - 0
src/actions/actionToggleZenMode.tsx

@@ -18,6 +18,9 @@ export const actionToggleZenMode = register({
     };
   },
   checked: (appState) => appState.zenModeEnabled,
+  contextItemPredicate: (elements, appState, appProps) => {
+    return typeof appProps.zenModeEnabled === "undefined";
+  },
   contextItemLabel: "buttons.zenMode",
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,

+ 2 - 0
src/actions/types.ts

@@ -143,6 +143,8 @@ export interface Action {
   contextItemPredicate?: (
     elements: readonly ExcalidrawElement[],
     appState: AppState,
+    appProps: ExcalidrawProps,
+    app: AppClassProperties,
   ) => boolean;
   checked?: (appState: Readonly<AppState>) => boolean;
   trackEvent:

+ 2 - 0
src/appState.ts

@@ -64,6 +64,7 @@ export const getDefaultAppState = (): Omit<
     lastPointerDownWith: "mouse",
     multiElement: null,
     name: `${t("labels.untitled")}-${getDateTime()}`,
+    contextMenu: null,
     openMenu: null,
     openPopup: null,
     openSidebar: null,
@@ -157,6 +158,7 @@ const APP_STATE_STORAGE_CONF = (<
   name: { browser: true, export: false, server: false },
   offsetLeft: { browser: false, export: false, server: false },
   offsetTop: { browser: false, export: false, server: false },
+  contextMenu: { browser: false, export: false, server: false },
   openMenu: { browser: true, export: false, server: false },
   openPopup: { browser: false, export: false, server: false },
   openSidebar: { browser: true, export: false, server: false },

+ 141 - 238
src/components/App.tsx

@@ -42,11 +42,7 @@ import { actions } from "../actions/register";
 import { ActionResult } from "../actions/types";
 import { trackEvent } from "../analytics";
 import { getDefaultAppState, isEraserActive } from "../appState";
-import {
-  parseClipboard,
-  probablySupportsClipboardBlob,
-  probablySupportsClipboardWriteText,
-} from "../clipboard";
+import { parseClipboard } from "../clipboard";
 import {
   APP_NAME,
   CURSOR_TYPE,
@@ -227,7 +223,11 @@ import {
   updateActiveTool,
   getShortcutKey,
 } from "../utils";
-import ContextMenu, { ContextMenuOption } from "./ContextMenu";
+import {
+  ContextMenu,
+  ContextMenuItems,
+  CONTEXT_MENU_SEPARATOR,
+} from "./ContextMenu";
 import LayerUI from "./LayerUI";
 import { Toast } from "./Toast";
 import { actionToggleViewMode } from "../actions/actionToggleViewMode";
@@ -274,6 +274,7 @@ import {
 import { shouldShowBoundingBox } from "../element/transformHandles";
 import { atom } from "jotai";
 import { Fonts } from "../scene/Fonts";
+import { actionPaste } from "../actions/actionClipboard";
 
 export const isMenuOpenAtom = atom(false);
 export const isDropdownOpenAtom = atom(false);
@@ -383,7 +384,6 @@ class App extends React.Component<AppProps, AppState> {
   hitLinkElement?: NonDeletedExcalidrawElement;
   lastPointerDown: React.PointerEvent<HTMLCanvasElement> | null = null;
   lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
-  contextMenuOpen: boolean = false;
   lastScenePointer: { x: number; y: number } | null = null;
 
   constructor(props: AppProps) {
@@ -602,6 +602,7 @@ class App extends React.Component<AppProps, AppState> {
                   <div className="excalidraw-textEditorContainer" />
                   <div className="excalidraw-contextMenuContainer" />
                   {selectedElement.length === 1 &&
+                    !this.state.contextMenu &&
                     this.state.showHyperlinkPopup && (
                       <Hyperlink
                         key={selectedElement[0].id}
@@ -618,6 +619,14 @@ class App extends React.Component<AppProps, AppState> {
                       closable={this.state.toast.closable}
                     />
                   )}
+                  {this.state.contextMenu && (
+                    <ContextMenu
+                      items={this.state.contextMenu.items}
+                      top={this.state.contextMenu.top}
+                      left={this.state.contextMenu.left}
+                      actionManager={this.actionManager}
+                    />
+                  )}
                   <main>{this.renderCanvas()}</main>
                 </ExcalidrawElementsContext.Provider>{" "}
               </ExcalidrawAppStateContext.Provider>
@@ -644,8 +653,6 @@ class App extends React.Component<AppProps, AppState> {
 
   private syncActionResult = withBatchedUpdates(
     (actionResult: ActionResult) => {
-      // Since context menu closes when action triggered so setting to false
-      this.contextMenuOpen = false;
       if (this.unmounted || actionResult === false) {
         return;
       }
@@ -674,7 +681,7 @@ class App extends React.Component<AppProps, AppState> {
         this.addNewImagesToImageCache();
       }
 
-      if (actionResult.appState || editingElement) {
+      if (actionResult.appState || editingElement || this.state.contextMenu) {
         if (actionResult.commitToHistory) {
           this.history.resumeRecording();
         }
@@ -700,12 +707,17 @@ class App extends React.Component<AppProps, AppState> {
         if (typeof this.props.name !== "undefined") {
           name = this.props.name;
         }
+
         this.setState(
           (state) => {
             // using Object.assign instead of spread to fool TS 4.2.2+ into
             // regarding the resulting type as not containing undefined
             // (which the following expression will never contain)
             return Object.assign(actionResult.appState || {}, {
+              // NOTE this will prevent opening context menu using an action
+              // or programmatically from the host, so it will need to be
+              // rewritten later
+              contextMenu: null,
               editingElement:
                 editingElement || actionResult.appState?.editingElement || null,
               viewModeEnabled,
@@ -1462,7 +1474,7 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
-  private pasteFromClipboard = withBatchedUpdates(
+  public pasteFromClipboard = withBatchedUpdates(
     async (event: ClipboardEvent | null) => {
       const isPlainPaste = !!(IS_PLAIN_PASTE && event);
 
@@ -1470,7 +1482,7 @@ class App extends React.Component<AppProps, AppState> {
       const target = document.activeElement;
       const isExcalidrawActive =
         this.excalidrawContainerRef.current?.contains(target);
-      if (!isExcalidrawActive) {
+      if (event && !isExcalidrawActive) {
         return;
       }
 
@@ -1744,10 +1756,11 @@ class App extends React.Component<AppProps, AppState> {
     this.history.resumeRecording();
   }
 
-  // Collaboration
-
-  setAppState: React.Component<any, AppState>["setState"] = (state) => {
-    this.setState(state);
+  setAppState: React.Component<any, AppState>["setState"] = (
+    state,
+    callback,
+  ) => {
+    this.setState(state, callback);
   };
 
   removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
@@ -3101,7 +3114,7 @@ class App extends React.Component<AppProps, AppState> {
         hitElement &&
         hitElement.link &&
         this.state.selectedElementIds[hitElement.id] &&
-        !this.contextMenuOpen &&
+        !this.state.contextMenu &&
         !this.state.showHyperlinkPopup
       ) {
         this.setState({ showHyperlinkPopup: "info" });
@@ -3323,6 +3336,14 @@ class App extends React.Component<AppProps, AppState> {
   private handleCanvasPointerDown = (
     event: React.PointerEvent<HTMLCanvasElement>,
   ) => {
+    // since contextMenu options are potentially evaluated on each render,
+    // and an contextMenu action may depend on selection state, we must
+    // close the contextMenu before we update the selection on pointerDown
+    // (e.g. resetting selection)
+    if (this.state.contextMenu) {
+      this.setState({ contextMenu: null });
+    }
+
     // remove any active selection when we start to interact with canvas
     // (mainly, we care about removing selection outside the component which
     //  would prevent our copy handling otherwise)
@@ -3389,8 +3410,6 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
-    // Since context menu closes on pointer down so setting to false
-    this.contextMenuOpen = false;
     this.clearSelectionIfNotUsingSelection();
     this.updateBindingEnabledOnPointerMove(event);
 
@@ -5949,7 +5968,17 @@ class App extends React.Component<AppProps, AppState> {
       includeLockedElements: true,
     });
 
-    const type = element ? "element" : "canvas";
+    const selectedElements = getSelectedElements(
+      this.scene.getNonDeletedElements(),
+      this.state,
+    );
+    const isHittignCommonBoundBox =
+      this.isHittingCommonBoundingBoxOfSelectedElements(
+        { x, y },
+        selectedElements,
+      );
+
+    const type = element || isHittignCommonBoundBox ? "element" : "canvas";
 
     const container = this.excalidrawContainerRef.current!;
     const { top: offsetTop, left: offsetLeft } =
@@ -5957,25 +5986,30 @@ class App extends React.Component<AppProps, AppState> {
     const left = event.clientX - offsetLeft;
     const top = event.clientY - offsetTop;
 
-    if (element && !this.state.selectedElementIds[element.id]) {
-      this.setState(
-        selectGroupsForSelectedElements(
-          {
-            ...this.state,
-            selectedElementIds: { [element.id]: true },
-            selectedLinearElement: isLinearElement(element)
-              ? new LinearElementEditor(element, this.scene)
-              : null,
-          },
-          this.scene.getNonDeletedElements(),
-        ),
-        () => {
-          this._openContextMenu({ top, left }, type);
-        },
-      );
-    } else {
-      this._openContextMenu({ top, left }, type);
-    }
+    trackEvent("contextMenu", "openContextMenu", type);
+
+    this.setState(
+      {
+        ...(element && !this.state.selectedElementIds[element.id]
+          ? selectGroupsForSelectedElements(
+              {
+                ...this.state,
+                selectedElementIds: { [element.id]: true },
+                selectedLinearElement: isLinearElement(element)
+                  ? new LinearElementEditor(element, this.scene)
+                  : null,
+              },
+              this.scene.getNonDeletedElements(),
+            )
+          : this.state),
+        showHyperlinkPopup: false,
+      },
+      () => {
+        this.setState({
+          contextMenu: { top, left, items: this.getContextMenuItems(type) },
+        });
+      },
+    );
   };
 
   private maybeDragNewGenericElement = (
@@ -6083,215 +6117,84 @@ class App extends React.Component<AppProps, AppState> {
     return false;
   };
 
-  /** @private use this.handleCanvasContextMenu */
-  private _openContextMenu = (
-    {
-      left,
-      top,
-    }: {
-      left: number;
-      top: number;
-    },
+  private getContextMenuItems = (
     type: "canvas" | "element",
-  ) => {
-    trackEvent("contextMenu", "openContextMenu", type);
-    if (this.state.showHyperlinkPopup) {
-      this.setState({ showHyperlinkPopup: false });
-    }
-    this.contextMenuOpen = true;
-    const maybeGroupAction = actionGroup.contextItemPredicate!(
-      this.actionManager.getElementsIncludingDeleted(),
-      this.actionManager.getAppState(),
-    );
-
-    const maybeUngroupAction = actionUngroup.contextItemPredicate!(
-      this.actionManager.getElementsIncludingDeleted(),
-      this.actionManager.getAppState(),
-    );
+  ): ContextMenuItems => {
+    const options: ContextMenuItems = [];
 
-    const maybeFlipHorizontal = actionFlipHorizontal.contextItemPredicate!(
-      this.actionManager.getElementsIncludingDeleted(),
-      this.actionManager.getAppState(),
-    );
-
-    const maybeFlipVertical = actionFlipVertical.contextItemPredicate!(
-      this.actionManager.getElementsIncludingDeleted(),
-      this.actionManager.getAppState(),
-    );
+    options.push(actionCopyAsPng, actionCopyAsSvg);
 
-    const mayBeAllowUnbinding = actionUnbindText.contextItemPredicate(
-      this.actionManager.getElementsIncludingDeleted(),
-      this.actionManager.getAppState(),
-    );
+    // canvas contextMenu
+    // -------------------------------------------------------------------------
 
-    const mayBeAllowBinding = actionBindText.contextItemPredicate(
-      this.actionManager.getElementsIncludingDeleted(),
-      this.actionManager.getAppState(),
-    );
-
-    const mayBeAllowToggleLineEditing =
-      actionToggleLinearEditor.contextItemPredicate(
-        this.actionManager.getElementsIncludingDeleted(),
-        this.actionManager.getAppState(),
-      );
-
-    const separator = "separator";
-
-    const elements = this.scene.getNonDeletedElements();
-
-    const selectedElements = getSelectedElements(
-      this.scene.getNonDeletedElements(),
-      this.state,
-    );
-
-    const options: ContextMenuOption[] = [];
-    if (probablySupportsClipboardBlob && elements.length > 0) {
-      options.push(actionCopyAsPng);
-    }
-
-    if (probablySupportsClipboardWriteText && elements.length > 0) {
-      options.push(actionCopyAsSvg);
-    }
-
-    if (
-      type === "element" &&
-      copyText.contextItemPredicate(elements, this.state) &&
-      probablySupportsClipboardWriteText
-    ) {
-      options.push(copyText);
-    }
     if (type === "canvas") {
-      const viewModeOptions = [
-        ...options,
-        typeof this.props.gridModeEnabled === "undefined" &&
+      if (this.state.viewModeEnabled) {
+        return [
+          ...options,
           actionToggleGridMode,
-        typeof this.props.zenModeEnabled === "undefined" && actionToggleZenMode,
-        typeof this.props.viewModeEnabled === "undefined" &&
+          actionToggleZenMode,
           actionToggleViewMode,
+          actionToggleStats,
+        ];
+      }
+
+      return [
+        actionPaste,
+        CONTEXT_MENU_SEPARATOR,
+        actionCopyAsPng,
+        actionCopyAsSvg,
+        copyText,
+        CONTEXT_MENU_SEPARATOR,
+        actionSelectAll,
+        CONTEXT_MENU_SEPARATOR,
+        actionToggleGridMode,
+        actionToggleZenMode,
+        actionToggleViewMode,
         actionToggleStats,
       ];
-
-      if (this.state.viewModeEnabled) {
-        ContextMenu.push({
-          options: viewModeOptions,
-          top,
-          left,
-          actionManager: this.actionManager,
-          appState: this.state,
-          container: this.excalidrawContainerRef.current!,
-          elements,
-        });
-      } else {
-        ContextMenu.push({
-          options: [
-            this.device.isMobile &&
-              navigator.clipboard && {
-                trackEvent: false,
-                name: "paste",
-                perform: (elements, appStates) => {
-                  this.pasteFromClipboard(null);
-                  return {
-                    commitToHistory: false,
-                  };
-                },
-                contextItemLabel: "labels.paste",
-              },
-            this.device.isMobile && navigator.clipboard && separator,
-            probablySupportsClipboardBlob &&
-              elements.length > 0 &&
-              actionCopyAsPng,
-            probablySupportsClipboardWriteText &&
-              elements.length > 0 &&
-              actionCopyAsSvg,
-            probablySupportsClipboardWriteText &&
-              selectedElements.length > 0 &&
-              copyText,
-            ((probablySupportsClipboardBlob && elements.length > 0) ||
-              (probablySupportsClipboardWriteText && elements.length > 0)) &&
-              separator,
-            actionSelectAll,
-            separator,
-            typeof this.props.gridModeEnabled === "undefined" &&
-              actionToggleGridMode,
-            typeof this.props.zenModeEnabled === "undefined" &&
-              actionToggleZenMode,
-            typeof this.props.viewModeEnabled === "undefined" &&
-              actionToggleViewMode,
-            actionToggleStats,
-          ],
-          top,
-          left,
-          actionManager: this.actionManager,
-          appState: this.state,
-          container: this.excalidrawContainerRef.current!,
-          elements,
-        });
-      }
-    } else if (type === "element") {
-      if (this.state.viewModeEnabled) {
-        ContextMenu.push({
-          options: [navigator.clipboard && actionCopy, ...options],
-          top,
-          left,
-          actionManager: this.actionManager,
-          appState: this.state,
-          container: this.excalidrawContainerRef.current!,
-          elements,
-        });
-      } else {
-        ContextMenu.push({
-          options: [
-            this.device.isMobile && actionCut,
-            this.device.isMobile && navigator.clipboard && actionCopy,
-            this.device.isMobile &&
-              navigator.clipboard && {
-                name: "paste",
-                trackEvent: false,
-                perform: (elements, appStates) => {
-                  this.pasteFromClipboard(null);
-                  return {
-                    commitToHistory: false,
-                  };
-                },
-                contextItemLabel: "labels.paste",
-              },
-            this.device.isMobile && separator,
-            ...options,
-            separator,
-            actionCopyStyles,
-            actionPasteStyles,
-            separator,
-            maybeGroupAction && actionGroup,
-            mayBeAllowUnbinding && actionUnbindText,
-            mayBeAllowBinding && actionBindText,
-            maybeUngroupAction && actionUngroup,
-            (maybeGroupAction || maybeUngroupAction) && separator,
-            actionAddToLibrary,
-            separator,
-            actionSendBackward,
-            actionBringForward,
-            actionSendToBack,
-            actionBringToFront,
-            separator,
-            maybeFlipHorizontal && actionFlipHorizontal,
-            maybeFlipVertical && actionFlipVertical,
-            (maybeFlipHorizontal || maybeFlipVertical) && separator,
-            mayBeAllowToggleLineEditing && actionToggleLinearEditor,
-            actionLink.contextItemPredicate(elements, this.state) && actionLink,
-            actionDuplicateSelection,
-            actionToggleLock,
-            separator,
-            actionDeleteSelected,
-          ],
-          top,
-          left,
-          actionManager: this.actionManager,
-          appState: this.state,
-          container: this.excalidrawContainerRef.current!,
-          elements,
-        });
-      }
     }
+
+    // element contextMenu
+    // -------------------------------------------------------------------------
+
+    options.push(copyText);
+
+    if (this.state.viewModeEnabled) {
+      return [actionCopy, ...options];
+    }
+
+    return [
+      actionCut,
+      actionCopy,
+      actionPaste,
+      CONTEXT_MENU_SEPARATOR,
+      ...options,
+      CONTEXT_MENU_SEPARATOR,
+      actionCopyStyles,
+      actionPasteStyles,
+      CONTEXT_MENU_SEPARATOR,
+      actionGroup,
+      actionUnbindText,
+      actionBindText,
+      actionUngroup,
+      CONTEXT_MENU_SEPARATOR,
+      actionAddToLibrary,
+      CONTEXT_MENU_SEPARATOR,
+      actionSendBackward,
+      actionBringForward,
+      actionSendToBack,
+      actionBringToFront,
+      CONTEXT_MENU_SEPARATOR,
+      actionFlipHorizontal,
+      actionFlipVertical,
+      CONTEXT_MENU_SEPARATOR,
+      actionToggleLinearEditor,
+      actionLink,
+      actionDuplicateSelection,
+      actionToggleLock,
+      CONTEXT_MENU_SEPARATOR,
+      actionDeleteSelected,
+    ];
   };
 
   private handleWheel = withBatchedUpdates((event: WheelEvent) => {

+ 11 - 11
src/components/ContextMenu.scss

@@ -19,7 +19,7 @@
     color: var(--popup-text-color);
   }
 
-  .context-menu-option {
+  .context-menu-item {
     position: relative;
     width: 100%;
     min-width: 9.5rem;
@@ -43,16 +43,16 @@
     }
 
     &.dangerous {
-      .context-menu-option__label {
+      .context-menu-item__label {
         color: $oc-red-7;
       }
     }
 
-    .context-menu-option__label {
+    .context-menu-item__label {
       justify-self: start;
       margin-inline-end: 20px;
     }
-    .context-menu-option__shortcut {
+    .context-menu-item__shortcut {
       justify-self: end;
       opacity: 0.6;
       font-family: inherit;
@@ -60,37 +60,37 @@
     }
   }
 
-  .context-menu-option:hover {
+  .context-menu-item:hover {
     color: var(--popup-bg-color);
     background-color: var(--select-highlight-color);
 
     &.dangerous {
-      .context-menu-option__label {
+      .context-menu-item__label {
         color: var(--popup-bg-color);
       }
       background-color: $oc-red-6;
     }
   }
 
-  .context-menu-option:focus {
+  .context-menu-item:focus {
     z-index: 1;
   }
 
   @include isMobile {
-    .context-menu-option {
+    .context-menu-item {
       display: block;
 
-      .context-menu-option__label {
+      .context-menu-item__label {
         margin-inline-end: 0;
       }
 
-      .context-menu-option__shortcut {
+      .context-menu-item__shortcut {
         display: none;
       }
     }
   }
 
-  .context-menu-option-separator {
+  .context-menu-item-separator {
     border: none;
     border-top: 1px solid $oc-gray-5;
   }

+ 100 - 120
src/components/ContextMenu.tsx

@@ -1,4 +1,3 @@
-import { createRoot, Root } from "react-dom/client";
 import clsx from "clsx";
 import { Popover } from "./Popover";
 import { t } from "../i18n";
@@ -10,135 +9,116 @@ import {
 } from "../actions/shortcuts";
 import { Action } from "../actions/types";
 import { ActionManager } from "../actions/manager";
-import { AppState } from "../types";
-import { NonDeletedExcalidrawElement } from "../element/types";
+import {
+  useExcalidrawAppState,
+  useExcalidrawElements,
+  useExcalidrawSetAppState,
+} from "./App";
+import React from "react";
+
+export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
 
-export type ContextMenuOption = "separator" | Action;
+export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
 
 type ContextMenuProps = {
-  options: ContextMenuOption[];
-  onCloseRequest?(): void;
+  actionManager: ActionManager;
+  items: ContextMenuItems;
   top: number;
   left: number;
-  actionManager: ActionManager;
-  appState: Readonly<AppState>;
-  elements: readonly NonDeletedExcalidrawElement[];
 };
 
-const ContextMenu = ({
-  options,
-  onCloseRequest,
-  top,
-  left,
-  actionManager,
-  appState,
-  elements,
-}: ContextMenuProps) => {
-  return (
-    <Popover
-      onCloseRequest={onCloseRequest}
-      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()}
-      >
-        {options.map((option, idx) => {
-          if (option === "separator") {
-            return <hr key={idx} className="context-menu-option-separator" />;
-          }
+export const CONTEXT_MENU_SEPARATOR = "separator";
 
-          const actionName = option.name;
-          let label = "";
-          if (option.contextItemLabel) {
-            if (typeof option.contextItemLabel === "function") {
-              label = t(option.contextItemLabel(elements, appState));
-            } else {
-              label = t(option.contextItemLabel);
-            }
-          }
-          return (
-            <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
-              <button
-                className={clsx("context-menu-option", {
-                  dangerous: actionName === "deleteSelectedElements",
-                  checkmark: option.checked?.(appState),
-                })}
-                onClick={() =>
-                  actionManager.executeAction(option, "contextMenu")
-                }
-              >
-                <div className="context-menu-option__label">{label}</div>
-                <kbd className="context-menu-option__shortcut">
-                  {actionName
-                    ? getShortcutFromShortcutName(actionName as ShortcutName)
-                    : ""}
-                </kbd>
-              </button>
-            </li>
-          );
-        })}
-      </ul>
-    </Popover>
-  );
-};
+export const ContextMenu = React.memo(
+  ({ actionManager, items, top, left }: ContextMenuProps) => {
+    const appState = useExcalidrawAppState();
+    const setAppState = useExcalidrawSetAppState();
+    const elements = useExcalidrawElements();
 
-const contextMenuRoots = new WeakMap<HTMLElement, Root>();
+    const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
+      if (
+        item &&
+        (item === CONTEXT_MENU_SEPARATOR ||
+          !item.contextItemPredicate ||
+          item.contextItemPredicate(
+            elements,
+            appState,
+            actionManager.app.props,
+            actionManager.app,
+          ))
+      ) {
+        acc.push(item);
+      }
+      return acc;
+    }, []);
 
-const getContextMenuRoot = (container: HTMLElement): Root => {
-  let contextMenuRoot = contextMenuRoots.get(container);
-  if (contextMenuRoot) {
-    return contextMenuRoot;
-  }
-  contextMenuRoot = createRoot(
-    container.querySelector(".excalidraw-contextMenuContainer")!,
-  );
-  contextMenuRoots.set(container, contextMenuRoot);
-  return contextMenuRoot;
-};
+    return (
+      <Popover
+        onCloseRequest={() => setAppState({ contextMenu: null })}
+        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 handleClose = (container: HTMLElement) => {
-  const contextMenuRoot = contextMenuRoots.get(container);
-  if (contextMenuRoot) {
-    contextMenuRoot.unmount();
-    contextMenuRoots.delete(container);
-  }
-};
+            const actionName = item.name;
+            let label = "";
+            if (item.contextItemLabel) {
+              if (typeof item.contextItemLabel === "function") {
+                label = t(item.contextItemLabel(elements, appState));
+              } else {
+                label = t(item.contextItemLabel);
+              }
+            }
 
-export default {
-  push(params: {
-    options: (ContextMenuOption | false | null | undefined)[];
-    top: ContextMenuProps["top"];
-    left: ContextMenuProps["left"];
-    actionManager: ContextMenuProps["actionManager"];
-    appState: Readonly<AppState>;
-    container: HTMLElement;
-    elements: readonly NonDeletedExcalidrawElement[];
-  }) {
-    const options = Array.of<ContextMenuOption>();
-    params.options.forEach((option) => {
-      if (option) {
-        options.push(option);
-      }
-    });
-    if (options.length) {
-      getContextMenuRoot(params.container).render(
-        <ContextMenu
-          top={params.top}
-          left={params.left}
-          options={options}
-          onCloseRequest={() => handleClose(params.container)}
-          actionManager={params.actionManager}
-          appState={params.appState}
-          elements={params.elements}
-        />,
-      );
-    }
+            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.
+                  setAppState({ contextMenu: null }, () => {
+                    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>
+    );
   },
-};
+);

Plik diff jest za duży
+ 884 - 14
src/tests/__snapshots__/contextmenu.test.tsx.snap


+ 53 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -9,6 +9,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -544,6 +545,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -1085,6 +1087,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -1991,6 +1994,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -2220,6 +2224,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -2752,6 +2757,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -3040,6 +3046,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -3223,6 +3230,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -3738,6 +3746,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "#fa5252",
   "currentItemEndArrowhead": "arrow",
@@ -4005,6 +4014,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -4234,6 +4244,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -4509,6 +4520,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -4796,6 +4808,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -5213,6 +5226,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -5553,6 +5567,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -5866,6 +5881,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -6103,6 +6119,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -6288,6 +6305,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -6815,6 +6833,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -7179,6 +7198,7 @@ Object {
     "type": "freedraw",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -9530,6 +9550,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -9948,6 +9969,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "#fa5252",
   "currentItemEndArrowhead": "arrow",
@@ -10236,6 +10258,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -10483,6 +10506,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -10803,6 +10827,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -10986,6 +11011,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -11169,6 +11195,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -11352,6 +11379,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -11588,6 +11616,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -11824,6 +11853,7 @@ Object {
     "type": "freedraw",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -12051,6 +12081,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -12287,6 +12318,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -12470,6 +12502,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -12706,6 +12739,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -12889,6 +12923,7 @@ Object {
     "type": "freedraw",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -13116,6 +13151,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -13299,6 +13335,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -14137,6 +14174,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -14425,6 +14463,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -14535,6 +14574,7 @@ Object {
     "type": "rectangle",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -14643,6 +14683,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -14829,6 +14870,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -15196,6 +15238,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -15826,6 +15869,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "#fa5252",
   "currentItemEndArrowhead": "arrow",
@@ -16051,6 +16095,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -17013,6 +17058,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -17121,6 +17167,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -17979,6 +18026,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -18450,6 +18498,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -18790,6 +18839,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -18900,6 +18950,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -19470,6 +19521,7 @@ Object {
     "type": "text",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
@@ -19578,6 +19630,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",

+ 1 - 1
src/tests/elementLocking.test.tsx

@@ -152,7 +152,7 @@ describe("element locking", () => {
     expect(contextMenu).not.toBeNull();
     expect(
       contextMenu?.querySelector(
-        `li[data-testid="toggleLock"] .context-menu-option__label`,
+        `li[data-testid="toggleLock"] .context-menu-item__label`,
       ),
     ).toHaveTextContent(t("labels.elementLock.unlock"));
   });

+ 1 - 0
src/tests/packages/__snapshots__/utils.test.ts.snap

@@ -9,6 +9,7 @@ Object {
     "type": "selection",
   },
   "collaborators": Map {},
+  "contextMenu": null,
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",

+ 8 - 0
src/types.ts

@@ -30,6 +30,7 @@ import { MaybeTransformHandleType } from "./element/transformHandles";
 import Library from "./data/library";
 import type { FileSystemHandle } from "./data/filesystem";
 import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
+import { ContextMenuItems } from "./components/ContextMenu";
 
 export type Point = Readonly<RoughPoint>;
 
@@ -92,6 +93,11 @@ export type LastActiveToolBeforeEraser =
   | null;
 
 export type AppState = {
+  contextMenu: {
+    items: ContextMenuItems;
+    top: number;
+    left: number;
+  } | null;
   showWelcomeScreen: boolean;
   isLoading: boolean;
   errorMessage: string | null;
@@ -147,6 +153,7 @@ export type AppState = {
   isResizing: boolean;
   isRotating: boolean;
   zoom: Zoom;
+  // mobile-only
   openMenu: "canvas" | "shape" | null;
   openPopup:
     | "canvasColorPicker"
@@ -407,6 +414,7 @@ export type AppClassProperties = {
   files: BinaryFiles;
   device: App["device"];
   scene: App["scene"];
+  pasteFromClipboard: App["pasteFromClipboard"];
 };
 
 export type PointerDownState = Readonly<{

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików