Procházet zdrojové kódy

fix: stale appState of MainMenu defaultItems rendered from Actions (#6074)

David Luzar před 2 roky
rodič
revize
40d53d9231

+ 2 - 2
src/actions/actionBoundText.tsx

@@ -26,7 +26,7 @@ export const actionUnbindText = register({
   name: "unbindText",
   contextItemLabel: "labels.unbindText",
   trackEvent: { category: "element" },
-  contextItemPredicate: (elements, appState) => {
+  predicate: (elements, appState) => {
     const selectedElements = getSelectedElements(elements, appState);
     return selectedElements.some((element) => hasBoundTextElement(element));
   },
@@ -76,7 +76,7 @@ export const actionBindText = register({
   name: "bindText",
   contextItemLabel: "labels.bindText",
   trackEvent: { category: "element" },
-  contextItemPredicate: (elements, appState) => {
+  predicate: (elements, appState) => {
     const selectedElements = getSelectedElements(elements, appState);
 
     if (selectedElements.length === 2) {

+ 17 - 31
src/actions/actionCanvas.tsx

@@ -1,11 +1,5 @@
 import { ColorPicker } from "../components/ColorPicker";
-import {
-  eraser,
-  MoonIcon,
-  SunIcon,
-  ZoomInIcon,
-  ZoomOutIcon,
-} from "../components/icons";
+import { eraser, ZoomInIcon, ZoomOutIcon } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
 import { getCommonBounds, getNonDeletedElements } from "../element";
@@ -21,14 +15,17 @@ import { register } from "./register";
 import { Tooltip } from "../components/Tooltip";
 import { newElementWith } from "../element/mutateElement";
 import { getDefaultAppState, isEraserActive } from "../appState";
-import ClearCanvas from "../components/ClearCanvas";
 import clsx from "clsx";
-import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
-import { getShortcutFromShortcutName } from "./shortcuts";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
   trackEvent: false,
+  predicate: (elements, appState, props, app) => {
+    return (
+      !!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
+      !appState.viewModeEnabled
+    );
+  },
   perform: (_, appState, value) => {
     return {
       appState: { ...appState, ...value },
@@ -36,6 +33,7 @@ export const actionChangeViewBackgroundColor = register({
     };
   },
   PanelComponent: ({ elements, appState, updateData }) => {
+    // FIXME move me to src/components/mainMenu/DefaultItems.tsx
     return (
       <div style={{ position: "relative" }}>
         <ColorPicker
@@ -59,6 +57,12 @@ export const actionChangeViewBackgroundColor = register({
 export const actionClearCanvas = register({
   name: "clearCanvas",
   trackEvent: { category: "canvas" },
+  predicate: (elements, appState, props, app) => {
+    return (
+      !!app.props.UIOptions.canvasActions.clearCanvas &&
+      !appState.viewModeEnabled
+    );
+  },
   perform: (elements, appState, _, app) => {
     app.imageCache.clear();
     return {
@@ -84,8 +88,6 @@ export const actionClearCanvas = register({
       commitToHistory: true,
     };
   },
-
-  PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
 });
 
 export const actionZoomIn = register({
@@ -298,26 +300,10 @@ export const actionToggleTheme = register({
       commitToHistory: false,
     };
   },
-  PanelComponent: ({ appState, updateData }) => (
-    <DropdownMenuItem
-      onSelect={() => {
-        updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
-      }}
-      icon={appState.theme === "dark" ? SunIcon : MoonIcon}
-      dataTestId="toggle-dark-mode"
-      shortcut={getShortcutFromShortcutName("toggleTheme")}
-      ariaLabel={
-        appState.theme === "dark"
-          ? t("buttons.lightMode")
-          : t("buttons.darkMode")
-      }
-    >
-      {appState.theme === "dark"
-        ? t("buttons.lightMode")
-        : t("buttons.darkMode")}
-    </DropdownMenuItem>
-  ),
   keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
+  predicate: (elements, appState, props, app) => {
+    return !!app.props.UIOptions.canvasActions.toggleTheme;
+  },
 });
 
 export const actionErase = register({

+ 6 - 6
src/actions/actionClipboard.tsx

@@ -24,7 +24,7 @@ export const actionCopy = register({
       commitToHistory: false,
     };
   },
-  contextItemPredicate: (elements, appState, appProps, app) => {
+  predicate: (elements, appState, appProps, app) => {
     return app.device.isMobile && !!navigator.clipboard;
   },
   contextItemLabel: "labels.copy",
@@ -41,7 +41,7 @@ export const actionPaste = register({
       commitToHistory: false,
     };
   },
-  contextItemPredicate: (elements, appState, appProps, app) => {
+  predicate: (elements, appState, appProps, app) => {
     return app.device.isMobile && !!navigator.clipboard;
   },
   contextItemLabel: "labels.paste",
@@ -56,7 +56,7 @@ export const actionCut = register({
     actionCopy.perform(elements, appState, data, app);
     return actionDeleteSelected.perform(elements, appState);
   },
-  contextItemPredicate: (elements, appState, appProps, app) => {
+  predicate: (elements, appState, appProps, app) => {
     return app.device.isMobile && !!navigator.clipboard;
   },
   contextItemLabel: "labels.cut",
@@ -101,7 +101,7 @@ export const actionCopyAsSvg = register({
       };
     }
   },
-  contextItemPredicate: (elements) => {
+  predicate: (elements) => {
     return probablySupportsClipboardWriteText && elements.length > 0;
   },
   contextItemLabel: "labels.copyAsSvg",
@@ -158,7 +158,7 @@ export const actionCopyAsPng = register({
       };
     }
   },
-  contextItemPredicate: (elements) => {
+  predicate: (elements) => {
     return probablySupportsClipboardBlob && elements.length > 0;
   },
   contextItemLabel: "labels.copyAsPng",
@@ -188,7 +188,7 @@ export const copyText = register({
       commitToHistory: false,
     };
   },
-  contextItemPredicate: (elements, appState) => {
+  predicate: (elements, appState) => {
     return (
       probablySupportsClipboardWriteText &&
       getSelectedElements(elements, appState, true).some(isTextElement)

+ 15 - 24
src/actions/actionExport.tsx

@@ -1,7 +1,6 @@
-import { LoadIcon, questionCircle, saveAs } from "../components/icons";
+import { questionCircle, saveAs } from "../components/icons";
 import { ProjectName } from "../components/ProjectName";
 import { ToolButton } from "../components/ToolButton";
-import "../components/ToolIcon.scss";
 import { Tooltip } from "../components/Tooltip";
 import { DarkModeToggle } from "../components/DarkModeToggle";
 import { loadFromJSON, saveAsJSON } from "../data";
@@ -15,12 +14,11 @@ import { getExportSize } from "../scene/export";
 import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { getNonDeletedElements } from "../element";
-import { ActiveFile } from "../components/ActiveFile";
 import { isImageFileHandle } from "../data/blob";
 import { nativeFileSystemSupported } from "../data/filesystem";
 import { Theme } from "../element/types";
-import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
-import { getShortcutFromShortcutName } from "./shortcuts";
+
+import "../components/ToolIcon.scss";
 
 export const actionChangeProjectName = register({
   name: "changeProjectName",
@@ -133,6 +131,13 @@ export const actionChangeExportEmbedScene = register({
 export const actionSaveToActiveFile = register({
   name: "saveToActiveFile",
   trackEvent: { category: "export" },
+  predicate: (elements, appState, props, app) => {
+    return (
+      !!app.props.UIOptions.canvasActions.saveToActiveFile &&
+      !!appState.fileHandle &&
+      !appState.viewModeEnabled
+    );
+  },
   perform: async (elements, appState, value, app) => {
     const fileHandleExists = !!appState.fileHandle;
 
@@ -169,12 +174,6 @@ export const actionSaveToActiveFile = register({
   },
   keyTest: (event) =>
     event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
-  PanelComponent: ({ updateData, appState }) => (
-    <ActiveFile
-      onSave={() => updateData(null)}
-      fileName={appState.fileHandle?.name}
-    />
-  ),
 });
 
 export const actionSaveFileToDisk = register({
@@ -220,6 +219,11 @@ export const actionSaveFileToDisk = register({
 export const actionLoadScene = register({
   name: "loadScene",
   trackEvent: { category: "export" },
+  predicate: (elements, appState, props, app) => {
+    return (
+      !!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
+    );
+  },
   perform: async (elements, appState, _, app) => {
     try {
       const {
@@ -247,19 +251,6 @@ export const actionLoadScene = register({
     }
   },
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
-  PanelComponent: ({ updateData }) => {
-    return (
-      <DropdownMenuItem
-        icon={LoadIcon}
-        onSelect={updateData}
-        dataTestId="load-button"
-        shortcut={getShortcutFromShortcutName("loadScene")}
-        ariaLabel={t("buttons.load")}
-      >
-        {t("buttons.load")}
-      </DropdownMenuItem>
-    );
-  },
 });
 
 export const actionExportWithDarkMode = register({

+ 2 - 2
src/actions/actionFlip.ts

@@ -50,7 +50,7 @@ export const actionFlipHorizontal = register({
   },
   keyTest: (event) => event.shiftKey && event.code === "KeyH",
   contextItemLabel: "labels.flipHorizontal",
-  contextItemPredicate: (elements, appState) =>
+  predicate: (elements, appState) =>
     enableActionFlipHorizontal(elements, appState),
 });
 
@@ -67,7 +67,7 @@ export const actionFlipVertical = register({
   keyTest: (event) =>
     event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
   contextItemLabel: "labels.flipVertical",
-  contextItemPredicate: (elements, appState) =>
+  predicate: (elements, appState) =>
     enableActionFlipVertical(elements, appState),
 });
 

+ 2 - 4
src/actions/actionGroup.tsx

@@ -129,8 +129,7 @@ export const actionGroup = register({
     };
   },
   contextItemLabel: "labels.group",
-  contextItemPredicate: (elements, appState) =>
-    enableActionGroup(elements, appState),
+  predicate: (elements, appState) => enableActionGroup(elements, appState),
   keyTest: (event) =>
     !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
   PanelComponent: ({ elements, appState, updateData }) => (
@@ -193,8 +192,7 @@ export const actionUngroup = register({
     event[KEYS.CTRL_OR_CMD] &&
     event.key === KEYS.G.toUpperCase(),
   contextItemLabel: "labels.ungroup",
-  contextItemPredicate: (elements, appState) =>
-    getSelectedGroupIds(appState).length > 0,
+  predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
 
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton

+ 1 - 1
src/actions/actionLinearEditor.ts

@@ -10,7 +10,7 @@ export const actionToggleLinearEditor = register({
   trackEvent: {
     category: "element",
   },
-  contextItemPredicate: (elements, appState) => {
+  predicate: (elements, appState) => {
     const selectedElements = getSelectedElements(elements, appState);
     if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
       return true;

+ 1 - 17
src/actions/actionMenu.tsx

@@ -1,12 +1,10 @@
-import { HamburgerMenuIcon, HelpIcon, palette } from "../components/icons";
+import { HamburgerMenuIcon, palette } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 import { showSelectedShapeActions, getNonDeletedElements } from "../element";
 import { register } from "./register";
 import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
 import { KEYS } from "../keys";
-import { HelpButton } from "../components/HelpButton";
-import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
 
 export const actionToggleCanvasMenu = register({
   name: "toggleCanvasMenu",
@@ -88,19 +86,5 @@ export const actionShortcuts = register({
       commitToHistory: false,
     };
   },
-  PanelComponent: ({ updateData, isInHamburgerMenu }) =>
-    isInHamburgerMenu ? (
-      <DropdownMenuItem
-        dataTestId="help-menu-item"
-        icon={HelpIcon}
-        onSelect={updateData}
-        shortcut="?"
-        ariaLabel={t("helpDialog.title")}
-      >
-        {t("helpDialog.title")}
-      </DropdownMenuItem>
-    ) : (
-      <HelpButton title={t("helpDialog.title")} onClick={updateData} />
-    ),
   keyTest: (event) => event.key === KEYS.QUESTION_MARK,
 });

+ 1 - 1
src/actions/actionToggleGridMode.tsx

@@ -20,7 +20,7 @@ export const actionToggleGridMode = register({
     };
   },
   checked: (appState: AppState) => appState.gridSize !== null,
-  contextItemPredicate: (element, appState, props) => {
+  predicate: (element, appState, props) => {
     return typeof props.gridModeEnabled === "undefined";
   },
   contextItemLabel: "labels.showGrid",

+ 1 - 1
src/actions/actionToggleViewMode.tsx

@@ -18,7 +18,7 @@ export const actionToggleViewMode = register({
     };
   },
   checked: (appState) => appState.viewModeEnabled,
-  contextItemPredicate: (elements, appState, appProps) => {
+  predicate: (elements, appState, appProps) => {
     return typeof appProps.viewModeEnabled === "undefined";
   },
   contextItemLabel: "labels.viewMode",

+ 1 - 1
src/actions/actionToggleZenMode.tsx

@@ -18,7 +18,7 @@ export const actionToggleZenMode = register({
     };
   },
   checked: (appState) => appState.zenModeEnabled,
-  contextItemPredicate: (elements, appState, appProps) => {
+  predicate: (elements, appState, appProps) => {
     return typeof appProps.zenModeEnabled === "undefined";
   },
   contextItemLabel: "buttons.zenMode",

+ 11 - 6
src/actions/manager.tsx

@@ -131,11 +131,7 @@ export class ActionManager {
   /**
    * @param data additional data sent to the PanelComponent
    */
-  renderAction = (
-    name: ActionName,
-    data?: PanelComponentProps["data"],
-    isInHamburgerMenu = false,
-  ) => {
+  renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
     const canvasActions = this.app.props.UIOptions.canvasActions;
 
     if (
@@ -170,11 +166,20 @@ export class ActionManager {
           updateData={updateData}
           appProps={this.app.props}
           data={data}
-          isInHamburgerMenu={isInHamburgerMenu}
         />
       );
     }
 
     return null;
   };
+
+  isActionEnabled = (action: Action) => {
+    const elements = this.getElementsIncludingDeleted();
+    const appState = this.getAppState();
+
+    return (
+      !action.predicate ||
+      action.predicate(elements, appState, this.app.props, this.app)
+    );
+  };
 }

+ 2 - 4
src/actions/types.ts

@@ -124,9 +124,7 @@ export type PanelComponentProps = {
 
 export interface Action {
   name: ActionName;
-  PanelComponent?: React.FC<
-    PanelComponentProps & { isInHamburgerMenu: boolean }
-  >;
+  PanelComponent?: React.FC<PanelComponentProps>;
   perform: ActionFn;
   keyPriority?: number;
   keyTest?: (
@@ -140,7 +138,7 @@ export interface Action {
         elements: readonly ExcalidrawElement[],
         appState: Readonly<AppState>,
       ) => string);
-  contextItemPredicate?: (
+  predicate?: (
     elements: readonly ExcalidrawElement[],
     appState: AppState,
     appProps: ExcalidrawProps,

+ 0 - 23
src/components/ActiveFile.tsx

@@ -1,23 +0,0 @@
-// TODO barnabasmolnar/editor-redesign
-// this icon is not great
-import { getShortcutFromShortcutName } from "../actions/shortcuts";
-import { save } from "../components/icons";
-import { t } from "../i18n";
-
-import "./ActiveFile.scss";
-import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
-
-type ActiveFileProps = {
-  fileName?: string;
-  onSave: () => void;
-};
-
-export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
-  <DropdownMenuItem
-    shortcut={getShortcutFromShortcutName("saveScene")}
-    dataTestId="save-button"
-    onSelect={onSave}
-    icon={save}
-    ariaLabel={`${t("buttons.save")}`}
-  >{`${t("buttons.save")}`}</DropdownMenuItem>
-);

+ 3 - 3
src/components/App.tsx

@@ -312,9 +312,9 @@ const ExcalidrawSetAppStateContext = React.createContext<
 >(() => {});
 ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";
 
-const ExcalidrawActionManagerContext = React.createContext<
-  ActionManager | { renderAction: ActionManager["renderAction"] }
->({ renderAction: () => null });
+const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
+  null!,
+);
 ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
 
 export const useExcalidrawElements = () =>

+ 0 - 41
src/components/ClearCanvas.tsx

@@ -1,41 +0,0 @@
-import { useState } from "react";
-import { t } from "../i18n";
-import { TrashIcon } from "./icons";
-
-import ConfirmDialog from "./ConfirmDialog";
-import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
-
-const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
-  const [showDialog, setShowDialog] = useState(false);
-  const toggleDialog = () => {
-    setShowDialog(!showDialog);
-  };
-
-  return (
-    <>
-      <DropdownMenuItem
-        icon={TrashIcon}
-        onSelect={toggleDialog}
-        dataTestId="clear-canvas-button"
-        ariaLabel={t("buttons.clearReset")}
-      >
-        {t("buttons.clearReset")}
-      </DropdownMenuItem>
-
-      {showDialog && (
-        <ConfirmDialog
-          onConfirm={() => {
-            onConfirm();
-            toggleDialog();
-          }}
-          onCancel={toggleDialog}
-          title={t("clearCanvasDialog.title")}
-        >
-          <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
-        </ConfirmDialog>
-      )}
-    </>
-  );
-};
-
-export default ClearCanvas;

+ 11 - 29
src/components/CollabButton.tsx

@@ -2,48 +2,30 @@ import { t } from "../i18n";
 import { UsersIcon } from "./icons";
 
 import "./CollabButton.scss";
-import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
 import clsx from "clsx";
 
 const CollabButton = ({
   isCollaborating,
   collaboratorCount,
   onClick,
-  isInHamburgerMenu = true,
 }: {
   isCollaborating: boolean;
   collaboratorCount: number;
   onClick: () => void;
-  isInHamburgerMenu?: boolean;
 }) => {
   return (
-    <>
-      {isInHamburgerMenu ? (
-        <DropdownMenuItem
-          dataTestId="collab-button"
-          icon={UsersIcon}
-          onSelect={onClick}
-          ariaLabel={t("labels.liveCollaboration")}
-        >
-          {t("labels.liveCollaboration")}
-        </DropdownMenuItem>
-      ) : (
-        <button
-          className={clsx("collab-button", { active: isCollaborating })}
-          type="button"
-          onClick={onClick}
-          style={{ position: "relative" }}
-          title={t("labels.liveCollaboration")}
-        >
-          {UsersIcon}
-          {collaboratorCount > 0 && (
-            <div className="CollabButton-collaborators">
-              {collaboratorCount}
-            </div>
-          )}
-        </button>
+    <button
+      className={clsx("collab-button", { active: isCollaborating })}
+      type="button"
+      onClick={onClick}
+      style={{ position: "relative" }}
+      title={t("labels.liveCollaboration")}
+    >
+      {UsersIcon}
+      {collaboratorCount > 0 && (
+        <div className="CollabButton-collaborators">{collaboratorCount}</div>
       )}
-    </>
+    </button>
   );
 };
 

+ 2 - 2
src/components/ContextMenu.tsx

@@ -39,8 +39,8 @@ export const ContextMenu = React.memo(
       if (
         item &&
         (item === CONTEXT_MENU_SEPARATOR ||
-          !item.contextItemPredicate ||
-          item.contextItemPredicate(
+          !item.predicate ||
+          item.predicate(
             elements,
             appState,
             actionManager.app.props,

+ 2 - 1
src/components/LayerUI.tsx

@@ -183,7 +183,9 @@ const LayerUI = ({
         <MainMenu>
           <MainMenu.DefaultItems.LoadScene />
           <MainMenu.DefaultItems.SaveToActiveFile />
+          {/* FIXME we should to test for this inside the item itself */}
           {UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
+          {/* FIXME we should to test for this inside the item itself */}
           {UIOptions.canvasActions.saveAsImage && (
             <MainMenu.DefaultItems.SaveAsImage />
           )}
@@ -350,7 +352,6 @@ const LayerUI = ({
             <UserList collaborators={appState.collaborators} />
             {onCollabButtonClick && (
               <CollabButton
-                isInHamburgerMenu={false}
                 isCollaborating={isCollaborating}
                 collaboratorCount={appState.collaborators.size}
                 onClick={onCollabButtonClick}

+ 6 - 1
src/components/footer/Footer.tsx

@@ -1,4 +1,5 @@
 import clsx from "clsx";
+import { actionShortcuts } from "../../actions";
 import { ActionManager } from "../../actions/manager";
 import { t } from "../../i18n";
 import { AppState, UIChildrenComponents } from "../../types";
@@ -9,6 +10,7 @@ import {
   ZoomActions,
 } from "../Actions";
 import { useDevice } from "../App";
+import { HelpButton } from "../HelpButton";
 import { WelcomeScreenHelpArrow } from "../icons";
 import { Section } from "../Section";
 import Stack from "../Stack";
@@ -86,7 +88,10 @@ const Footer = ({
             </div>
           </WelcomeScreenDecor>
 
-          {actionManager.renderAction("toggleShortcuts")}
+          <HelpButton
+            title={t("helpDialog.title")}
+            onClick={() => actionManager.executeAction(actionShortcuts)}
+          />
         </div>
       </div>
       <ExitZenModeAction

+ 0 - 0
src/components/ActiveFile.scss → src/components/mainMenu/DefaultItems.scss


+ 126 - 15
src/components/mainMenu/DefaultItems.tsx

@@ -6,34 +6,81 @@ import {
   useExcalidrawSetAppState,
   useExcalidrawActionManager,
 } from "../App";
-import { ExportIcon, ExportImageIcon, UsersIcon } from "../icons";
+import {
+  ExportIcon,
+  ExportImageIcon,
+  HelpIcon,
+  LoadIcon,
+  MoonIcon,
+  save,
+  SunIcon,
+  TrashIcon,
+  UsersIcon,
+} from "../icons";
 import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
 import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
 import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
+import {
+  actionClearCanvas,
+  actionLoadScene,
+  actionSaveToActiveFile,
+  actionShortcuts,
+  actionToggleTheme,
+} from "../../actions";
+
+import "./DefaultItems.scss";
+import { useState } from "react";
+import ConfirmDialog from "../ConfirmDialog";
 
 export const LoadScene = () => {
+  // FIXME Hack until we tie "t" to lang state
+  // eslint-disable-next-line
   const appState = useExcalidrawAppState();
   const actionManager = useExcalidrawActionManager();
-  if (appState.viewModeEnabled) {
+
+  if (!actionManager.isActionEnabled(actionLoadScene)) {
     return null;
   }
-  return actionManager.renderAction("loadScene");
+
+  return (
+    <DropdownMenuItem
+      icon={LoadIcon}
+      onSelect={() => actionManager.executeAction(actionLoadScene)}
+      dataTestId="load-button"
+      shortcut={getShortcutFromShortcutName("loadScene")}
+      ariaLabel={t("buttons.load")}
+    >
+      {t("buttons.load")}
+    </DropdownMenuItem>
+  );
 };
 LoadScene.displayName = "LoadScene";
 
 export const SaveToActiveFile = () => {
+  // FIXME Hack until we tie "t" to lang state
+  // eslint-disable-next-line
   const appState = useExcalidrawAppState();
   const actionManager = useExcalidrawActionManager();
-  if (!appState.fileHandle) {
+
+  if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
     return null;
   }
-  return actionManager.renderAction("saveToActiveFile");
+
+  return (
+    <DropdownMenuItem
+      shortcut={getShortcutFromShortcutName("saveScene")}
+      dataTestId="save-button"
+      onSelect={() => actionManager.executeAction(actionSaveToActiveFile)}
+      icon={save}
+      ariaLabel={`${t("buttons.save")}`}
+    >{`${t("buttons.save")}`}</DropdownMenuItem>
+  );
 };
 SaveToActiveFile.displayName = "SaveToActiveFile";
 
 export const SaveAsImage = () => {
   const setAppState = useExcalidrawSetAppState();
-  // Hack until we tie "t" to lang state
+  // FIXME Hack until we tie "t" to lang state
   // eslint-disable-next-line
   const appState = useExcalidrawAppState();
   return (
@@ -51,32 +98,96 @@ export const SaveAsImage = () => {
 SaveAsImage.displayName = "SaveAsImage";
 
 export const Help = () => {
-  // Hack until we tie "t" to lang state
+  // FIXME Hack until we tie "t" to lang state
   // eslint-disable-next-line
   const appState = useExcalidrawAppState();
 
   const actionManager = useExcalidrawActionManager();
-  return actionManager.renderAction("toggleShortcuts", undefined, true);
+
+  return (
+    <DropdownMenuItem
+      dataTestId="help-menu-item"
+      icon={HelpIcon}
+      onSelect={() => actionManager.executeAction(actionShortcuts)}
+      shortcut="?"
+      ariaLabel={t("helpDialog.title")}
+    >
+      {t("helpDialog.title")}
+    </DropdownMenuItem>
+  );
 };
 Help.displayName = "Help";
 
 export const ClearCanvas = () => {
+  // FIXME Hack until we tie "t" to lang state
+  // eslint-disable-next-line
   const appState = useExcalidrawAppState();
   const actionManager = useExcalidrawActionManager();
 
-  if (appState.viewModeEnabled) {
+  const [showDialog, setShowDialog] = useState(false);
+  const toggleDialog = () => setShowDialog(!showDialog);
+
+  if (!actionManager.isActionEnabled(actionClearCanvas)) {
     return null;
   }
-  return actionManager.renderAction("clearCanvas");
+
+  return (
+    <>
+      <DropdownMenuItem
+        icon={TrashIcon}
+        onSelect={toggleDialog}
+        dataTestId="clear-canvas-button"
+        ariaLabel={t("buttons.clearReset")}
+      >
+        {t("buttons.clearReset")}
+      </DropdownMenuItem>
+
+      {/* FIXME this should live outside MainMenu so it stays open
+          if menu is closed */}
+      {showDialog && (
+        <ConfirmDialog
+          onConfirm={() => {
+            actionManager.executeAction(actionClearCanvas);
+            toggleDialog();
+          }}
+          onCancel={toggleDialog}
+          title={t("clearCanvasDialog.title")}
+        >
+          <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
+        </ConfirmDialog>
+      )}
+    </>
+  );
 };
 ClearCanvas.displayName = "ClearCanvas";
 
 export const ToggleTheme = () => {
-  // Hack until we tie "t" to lang state
-  // eslint-disable-next-line
   const appState = useExcalidrawAppState();
   const actionManager = useExcalidrawActionManager();
-  return actionManager.renderAction("toggleTheme");
+
+  if (!actionManager.isActionEnabled(actionToggleTheme)) {
+    return null;
+  }
+
+  return (
+    <DropdownMenuItem
+      onSelect={() => {
+        return actionManager.executeAction(actionToggleTheme);
+      }}
+      icon={appState.theme === "dark" ? SunIcon : MoonIcon}
+      dataTestId="toggle-dark-mode"
+      shortcut={getShortcutFromShortcutName("toggleTheme")}
+      ariaLabel={
+        appState.theme === "dark"
+          ? t("buttons.lightMode")
+          : t("buttons.darkMode")
+      }
+    >
+      {appState.theme === "dark"
+        ? t("buttons.lightMode")
+        : t("buttons.darkMode")}
+    </DropdownMenuItem>
+  );
 };
 ToggleTheme.displayName = "ToggleTheme";
 
@@ -101,7 +212,7 @@ export const ChangeCanvasBackground = () => {
 ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
 
 export const Export = () => {
-  // Hack until we tie "t" to lang state
+  // FIXME Hack until we tie "t" to lang state
   // eslint-disable-next-line
   const appState = useExcalidrawAppState();
   const setAppState = useExcalidrawSetAppState();
@@ -154,7 +265,7 @@ export const LiveCollaboration = ({
   onSelect: () => void;
   isCollaborating: boolean;
 }) => {
-  // Hack until we tie "t" to lang state
+  // FIXME Hack until we tie "t" to lang state
   // eslint-disable-next-line
   const appState = useExcalidrawAppState();
   return (

+ 1 - 1
src/element/Hyperlink.tsx

@@ -267,7 +267,7 @@ export const actionLink = register({
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
   contextItemLabel: (elements, appState) =>
     getContextMenuLabel(elements, appState),
-  contextItemPredicate: (elements, appState) => {
+  predicate: (elements, appState) => {
     const selectedElements = getSelectedElements(elements, appState);
     return selectedElements.length === 1;
   },

+ 77 - 77
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -13,30 +13,30 @@ Object {
     "items": Array [
       Object {
         "contextItemLabel": "labels.cut",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "cut",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copy",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "copy",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.paste",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "paste",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -44,28 +44,28 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.copyAsPng",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "copyAsPng",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyAsSvg",
-        "contextItemPredicate": [Function],
         "name": "copyAsSvg",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyText",
-        "contextItemPredicate": [Function],
         "name": "copyText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -93,28 +93,28 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.group",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "group",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.unbindText",
-        "contextItemPredicate": [Function],
         "name": "unbindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.bindText",
-        "contextItemPredicate": [Function],
         "name": "bindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -122,10 +122,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "ungroup",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -185,20 +185,20 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.flipHorizontal",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.flipVertical",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -206,9 +206,9 @@ Object {
       "separator",
       Object {
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "name": "toggleLinearEditor",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -216,10 +216,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "hyperlink",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "action": "click",
           "category": "hyperlink",
@@ -4401,30 +4401,30 @@ Object {
     "items": Array [
       Object {
         "contextItemLabel": "labels.cut",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "cut",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copy",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "copy",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.paste",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "paste",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4432,28 +4432,28 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.copyAsPng",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "copyAsPng",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyAsSvg",
-        "contextItemPredicate": [Function],
         "name": "copyAsSvg",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyText",
-        "contextItemPredicate": [Function],
         "name": "copyText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4481,28 +4481,28 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.group",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "group",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.unbindText",
-        "contextItemPredicate": [Function],
         "name": "unbindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.bindText",
-        "contextItemPredicate": [Function],
         "name": "bindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4510,10 +4510,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "ungroup",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4573,20 +4573,20 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.flipHorizontal",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.flipVertical",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4594,9 +4594,9 @@ Object {
       "separator",
       Object {
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "name": "toggleLinearEditor",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4604,10 +4604,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "hyperlink",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "action": "click",
           "category": "hyperlink",
@@ -4942,30 +4942,30 @@ Object {
     "items": Array [
       Object {
         "contextItemLabel": "labels.cut",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "cut",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copy",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "copy",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.paste",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "paste",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4973,28 +4973,28 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.copyAsPng",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "copyAsPng",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyAsSvg",
-        "contextItemPredicate": [Function],
         "name": "copyAsSvg",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyText",
-        "contextItemPredicate": [Function],
         "name": "copyText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5022,28 +5022,28 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.group",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "group",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.unbindText",
-        "contextItemPredicate": [Function],
         "name": "unbindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.bindText",
-        "contextItemPredicate": [Function],
         "name": "bindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5051,10 +5051,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "ungroup",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5114,20 +5114,20 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.flipHorizontal",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.flipVertical",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5135,9 +5135,9 @@ Object {
       "separator",
       Object {
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "name": "toggleLinearEditor",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5145,10 +5145,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "hyperlink",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "action": "click",
           "category": "hyperlink",
@@ -5568,10 +5568,10 @@ Object {
     "items": Array [
       Object {
         "contextItemLabel": "labels.paste",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "paste",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5579,28 +5579,28 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.copyAsPng",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "copyAsPng",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyAsSvg",
-        "contextItemPredicate": [Function],
         "name": "copyAsSvg",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyText",
-        "contextItemPredicate": [Function],
         "name": "copyText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5619,10 +5619,10 @@ Object {
       Object {
         "checked": [Function],
         "contextItemLabel": "labels.showGrid",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "gridMode",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "canvas",
           "predicate": [Function],
@@ -5632,10 +5632,10 @@ Object {
       Object {
         "checked": [Function],
         "contextItemLabel": "buttons.zenMode",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "zenMode",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "canvas",
           "predicate": [Function],
@@ -5645,10 +5645,10 @@ Object {
       Object {
         "checked": [Function],
         "contextItemLabel": "labels.viewMode",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "viewMode",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "canvas",
           "predicate": [Function],
@@ -5782,30 +5782,30 @@ Object {
     "items": Array [
       Object {
         "contextItemLabel": "labels.cut",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "cut",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copy",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "copy",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.paste",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "paste",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5813,28 +5813,28 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.copyAsPng",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "copyAsPng",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyAsSvg",
-        "contextItemPredicate": [Function],
         "name": "copyAsSvg",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyText",
-        "contextItemPredicate": [Function],
         "name": "copyText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5862,28 +5862,28 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.group",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "group",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.unbindText",
-        "contextItemPredicate": [Function],
         "name": "unbindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.bindText",
-        "contextItemPredicate": [Function],
         "name": "bindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5891,10 +5891,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "ungroup",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5954,20 +5954,20 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.flipHorizontal",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.flipVertical",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5975,9 +5975,9 @@ Object {
       "separator",
       Object {
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "name": "toggleLinearEditor",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5985,10 +5985,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "hyperlink",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "action": "click",
           "category": "hyperlink",
@@ -6119,30 +6119,30 @@ Object {
     "items": Array [
       Object {
         "contextItemLabel": "labels.cut",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "cut",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copy",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "copy",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.paste",
-        "contextItemPredicate": [Function],
         "keyTest": undefined,
         "name": "paste",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6150,28 +6150,28 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.copyAsPng",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "copyAsPng",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyAsSvg",
-        "contextItemPredicate": [Function],
         "name": "copyAsSvg",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.copyText",
-        "contextItemPredicate": [Function],
         "name": "copyText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6199,28 +6199,28 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.group",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "group",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.unbindText",
-        "contextItemPredicate": [Function],
         "name": "unbindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.bindText",
-        "contextItemPredicate": [Function],
         "name": "bindText",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6228,10 +6228,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "ungroup",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6291,20 +6291,20 @@ Object {
       "separator",
       Object {
         "contextItemLabel": "labels.flipHorizontal",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
       },
       Object {
         "contextItemLabel": "labels.flipVertical",
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6312,9 +6312,9 @@ Object {
       "separator",
       Object {
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "name": "toggleLinearEditor",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6322,10 +6322,10 @@ Object {
       Object {
         "PanelComponent": [Function],
         "contextItemLabel": [Function],
-        "contextItemPredicate": [Function],
         "keyTest": [Function],
         "name": "hyperlink",
         "perform": [Function],
+        "predicate": [Function],
         "trackEvent": Object {
           "action": "click",
           "category": "hyperlink",

+ 109 - 109
src/tests/packages/__snapshots__/excalidraw.test.tsx.snap

@@ -1,5 +1,114 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`<Excalidraw/> <MainMenu/> should render main menu with host menu items if passed from host 1`] = `
+<div
+  class="dropdown-menu"
+  data-testid="dropdown-menu"
+>
+  <div
+    class="Island dropdown-menu-container"
+    style="--padding: 2; z-index: 1;"
+  >
+    <button
+      class="dropdown-menu-item dropdown-menu-item-base"
+      type="button"
+    >
+      <div
+        class="dropdown-menu-item__icon"
+      />
+      <div
+        class="dropdown-menu-item__text"
+      >
+        Click me
+      </div>
+    </button>
+    <a
+      class="dropdown-menu-item dropdown-menu-item-base"
+      href="blog.excalidaw.com"
+      rel="noreferrer"
+      target="_blank"
+    >
+      <div
+        class="dropdown-menu-item__icon"
+      />
+      <div
+        class="dropdown-menu-item__text"
+      >
+        Excalidraw blog
+      </div>
+    </a>
+    <div
+      class="dropdown-menu-item-base dropdown-menu-item-custom"
+    >
+      <button
+        style="height: 2rem;"
+      >
+         
+        custom menu item
+      </button>
+    </div>
+    <button
+      aria-label="Help"
+      class="dropdown-menu-item dropdown-menu-item-base"
+      data-testid="help-menu-item"
+      title="Help"
+      type="button"
+    >
+      <div
+        class="dropdown-menu-item__icon"
+      >
+        <svg
+          aria-hidden="true"
+          class=""
+          fill="none"
+          focusable="false"
+          role="img"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+        >
+          <g
+            stroke-width="1.5"
+          >
+            <path
+              d="M0 0h24v24H0z"
+              fill="none"
+              stroke="none"
+            />
+            <circle
+              cx="12"
+              cy="12"
+              r="9"
+            />
+            <line
+              x1="12"
+              x2="12"
+              y1="17"
+              y2="17.01"
+            />
+            <path
+              d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
+            />
+          </g>
+        </svg>
+      </div>
+      <div
+        class="dropdown-menu-item__text"
+      >
+        Help
+      </div>
+      <div
+        class="dropdown-menu-item__shortcut"
+      >
+        ?
+      </div>
+    </button>
+  </div>
+</div>
+`;
+
 exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu with default items when "UIOPtions" is "undefined" 1`] = `
 <div
   class="dropdown-menu"
@@ -454,112 +563,3 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu
   </div>
 </div>
 `;
-
-exports[`<Excalidraw/> should render main menu with host menu items if passed from host 1`] = `
-<div
-  class="dropdown-menu"
-  data-testid="dropdown-menu"
->
-  <div
-    class="Island dropdown-menu-container"
-    style="--padding: 2; z-index: 1;"
-  >
-    <button
-      class="dropdown-menu-item dropdown-menu-item-base"
-      type="button"
-    >
-      <div
-        class="dropdown-menu-item__icon"
-      />
-      <div
-        class="dropdown-menu-item__text"
-      >
-        Click me
-      </div>
-    </button>
-    <a
-      class="dropdown-menu-item dropdown-menu-item-base"
-      href="blog.excalidaw.com"
-      rel="noreferrer"
-      target="_blank"
-    >
-      <div
-        class="dropdown-menu-item__icon"
-      />
-      <div
-        class="dropdown-menu-item__text"
-      >
-        Excalidraw blog
-      </div>
-    </a>
-    <div
-      class="dropdown-menu-item-base dropdown-menu-item-custom"
-    >
-      <button
-        style="height: 2rem;"
-      >
-         
-        custom menu item
-      </button>
-    </div>
-    <button
-      aria-label="Help"
-      class="dropdown-menu-item dropdown-menu-item-base"
-      data-testid="help-menu-item"
-      title="Help"
-      type="button"
-    >
-      <div
-        class="dropdown-menu-item__icon"
-      >
-        <svg
-          aria-hidden="true"
-          class=""
-          fill="none"
-          focusable="false"
-          role="img"
-          stroke="currentColor"
-          stroke-linecap="round"
-          stroke-linejoin="round"
-          stroke-width="2"
-          viewBox="0 0 24 24"
-        >
-          <g
-            stroke-width="1.5"
-          >
-            <path
-              d="M0 0h24v24H0z"
-              fill="none"
-              stroke="none"
-            />
-            <circle
-              cx="12"
-              cy="12"
-              r="9"
-            />
-            <line
-              x1="12"
-              x2="12"
-              y1="17"
-              y2="17.01"
-            />
-            <path
-              d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
-            />
-          </g>
-        </svg>
-      </div>
-      <div
-        class="dropdown-menu-item__text"
-      >
-        Help
-      </div>
-      <div
-        class="dropdown-menu-item__shortcut"
-      >
-        ?
-      </div>
-    </button>
-  </div>
-</div>
-`;

+ 64 - 29
src/tests/packages/excalidraw.test.tsx

@@ -3,6 +3,7 @@ import { Excalidraw, Footer, MainMenu } from "../../packages/excalidraw/index";
 import { queryByText, queryByTestId } from "@testing-library/react";
 import { GRID_SIZE, THEME } from "../../constants";
 import { t } from "../../i18n";
+import { useMemo } from "react";
 
 const { h } = window;
 
@@ -13,6 +14,7 @@ describe("<Excalidraw/>", () => {
       toggleMenu(document.querySelector(".excalidraw")!);
     }
   });
+
   describe("Test zenModeEnabled prop", () => {
     it('should show exit zen mode button when zen mode is set and zen mode option in context menu when zenModeEnabled is "undefined"', async () => {
       const { container } = await render(<Excalidraw />);
@@ -121,34 +123,6 @@ describe("<Excalidraw/>", () => {
     });
   });
 
-  it("should render main menu with host menu items if passed from host", async () => {
-    const { container } = await render(
-      <Excalidraw UIOptions={undefined}>
-        <MainMenu>
-          <MainMenu.Item onSelect={() => window.alert("Clicked")}>
-            Click me
-          </MainMenu.Item>
-          <MainMenu.ItemLink href="blog.excalidaw.com">
-            Excalidraw blog
-          </MainMenu.ItemLink>
-          <MainMenu.ItemCustom>
-            <button
-              style={{ height: "2rem" }}
-              onClick={() => window.alert("custom menu item")}
-            >
-              {" "}
-              custom menu item
-            </button>
-          </MainMenu.ItemCustom>
-          <MainMenu.DefaultItems.Help />
-        </MainMenu>
-      </Excalidraw>,
-    );
-    //open menu
-    toggleMenu(container);
-    expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
-  });
-
   describe("Test UIOptions prop", () => {
     describe("Test canvasActions", () => {
       it('should render menu with default items when "UIOPtions" is "undefined"', async () => {
@@ -306,7 +280,7 @@ describe("<Excalidraw/>", () => {
       //open menu
       toggleMenu(container);
       const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
-      expect(darkModeToggle).toBeFalsy();
+      expect(darkModeToggle).toBe(null);
     });
   });
 
@@ -336,6 +310,7 @@ describe("<Excalidraw/>", () => {
       expect(textInput?.nodeName).toBe("SPAN");
     });
   });
+
   describe("Test autoFocus prop", () => {
     it("should not focus when autoFocus is false", async () => {
       const { container } = await render(<Excalidraw />);
@@ -353,4 +328,64 @@ describe("<Excalidraw/>", () => {
       ).toBe(true);
     });
   });
+
+  describe("<MainMenu/>", () => {
+    it("should render main menu with host menu items if passed from host", async () => {
+      const { container } = await render(
+        <Excalidraw>
+          <MainMenu>
+            <MainMenu.Item onSelect={() => window.alert("Clicked")}>
+              Click me
+            </MainMenu.Item>
+            <MainMenu.ItemLink href="blog.excalidaw.com">
+              Excalidraw blog
+            </MainMenu.ItemLink>
+            <MainMenu.ItemCustom>
+              <button
+                style={{ height: "2rem" }}
+                onClick={() => window.alert("custom menu item")}
+              >
+                {" "}
+                custom menu item
+              </button>
+            </MainMenu.ItemCustom>
+            <MainMenu.DefaultItems.Help />
+          </MainMenu>
+        </Excalidraw>,
+      );
+      //open menu
+      toggleMenu(container);
+      expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
+    });
+
+    it("should update themeToggle text even if MainMenu memoized", async () => {
+      const CustomExcalidraw = () => {
+        const customMenu = useMemo(() => {
+          return (
+            <MainMenu>
+              <MainMenu.DefaultItems.ToggleTheme />
+            </MainMenu>
+          );
+        }, []);
+
+        return <Excalidraw>{customMenu}</Excalidraw>;
+      };
+
+      const { container } = await render(<CustomExcalidraw />);
+      //open menu
+      toggleMenu(container);
+
+      expect(h.state.theme).toBe(THEME.LIGHT);
+
+      expect(
+        queryByTestId(container, "toggle-dark-mode")?.textContent,
+      ).toContain(t("buttons.darkMode"));
+
+      fireEvent.click(queryByTestId(container, "toggle-dark-mode")!);
+
+      expect(
+        queryByTestId(container, "toggle-dark-mode")?.textContent,
+      ).toContain(t("buttons.lightMode"));
+    });
+  });
 });