Pārlūkot izejas kodu

feat: new Menu Component API (#6034)

* feat: new Menu Component API

* allow valid children types

* introduce menu group to group items

* Add lang footer

* use display name

* displayName

* define types inside

* fix default menu

* add json export to menu

* fix

* simplify expression

* put open menu into own compo to optimize perf

So that we don't rerun `useOutsideClickHook` (and rebind event listeners
all the time)

* naming tweaks

* rename MenuComponents->MenuDefaultItems and export default items from Menu.Items

* import Menu.scss in Menu.tsx

* move menu scss to excal app

* Don't filter children inside menu group

* move E+ out of socials

* support style prop for MenuItem and MenuGroup

* Support header in menu group and add Excalidraw links header for default items in social section

* rename header to title

* fix padding for lang

* render menu in mobile

* review fixes

* tweaks

* Export collaborators and show in mobile menu

* revert .env

* lint :p

* again lint

* show correct actions in view mode for mobile

* Whitelist Collaborators Comp

* mobile styling

* padding

* don't show nerds when menu open in mobile

* lint :(

* hide shortcuts

* refactor userlist to support mobile and keep a wrapper comp for excal app

* use only UserList

* render only on mobile for default items

* remove unused hooks

* Show collab button in menu when onCollabButtonClick present and hide export when UIOptions.canvasActions.export is false

* fix tests

* lint

* inject userlist inside menu on mobile

* revert userlist

* move menu socials to default menu

* fix collab

* use meny in library

* Make Menu generic and create hamburgemenu for public excal menu and use menu in library as well

* use appState.openMenu for mobile

* fix tests

* styling fixes and support style and class name in menu content

* fix test

* rename MenuDefaultItems->DefaultItems

* move footer css to its own comp

* rename HamburgerMenu -> MainMenu

* rename menu -> dropdownMenu and update classes, onClick->onToggle

* close main menu when dialog closes

* by bye filtering

* update docs

* fix lint

* update example, docs for useDevice and footer in mobile, rename menu ->DropDownMenu everywhere

* spec

* remove isMenuOpenAtom and set openMenu as canvas for main menu, render decreases in specs :)

* [temp] remove cyclic depenedency to fix build

* hack- update appstate to sync lang change

* Add more specs

* wip: rewrite MainMenu footer

* fix margin

* fix snaps

* not needed as lang list no more imported

* simplify custom footer rendering

* Add DropdownMenuItemLink and DropdownMenuItemCustom and update API, docs

* fix `MainMenu.ItemCustom`

* naming

* use onSelect and base class for custom items

* fix lint

* fix snap

* use custom item for lang

* update docs

* fix

* properly use `MainMenu.ItemCustom` for `LanguageList`

* add margin top to custom items

* flex

Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi 2 gadi atpakaļ
vecāks
revīzija
8420aecb34
54 mainītis faili ar 1883 papildinājumiem un 1918 dzēšanām
  1. 13 9
      src/actions/actionCanvas.tsx
  2. 14 10
      src/actions/actionExport.tsx
  3. 7 5
      src/actions/actionMenu.tsx
  4. 5 5
      src/components/ActiveFile.tsx
  5. 78 71
      src/components/App.tsx
  6. 7 5
      src/components/ClearCanvas.tsx
  7. 7 6
      src/components/CollabButton.tsx
  8. 8 9
      src/components/ConfirmDialog.tsx
  9. 10 6
      src/components/Dialog.tsx
  10. 7 16
      src/components/JSONExportDialog.tsx
  11. 0 10
      src/components/LayerUI.scss
  12. 39 104
      src/components/LayerUI.tsx
  13. 23 0
      src/components/LibraryMenu.scss
  14. 76 72
      src/components/LibraryMenuHeaderContent.tsx
  15. 0 85
      src/components/Menu.scss
  16. 0 37
      src/components/MenuItem.tsx
  17. 0 53
      src/components/MenuUtils.tsx
  18. 7 87
      src/components/MobileMenu.tsx
  19. 0 18
      src/components/Sidebar/Sidebar.scss
  20. 4 24
      src/components/UserList.tsx
  21. 1 17
      src/components/WelcomeScreen.tsx
  22. 127 0
      src/components/dropdownMenu/DropdownMenu.scss
  23. 43 0
      src/components/dropdownMenu/DropdownMenu.tsx
  24. 51 0
      src/components/dropdownMenu/DropdownMenuContent.tsx
  25. 23 0
      src/components/dropdownMenu/DropdownMenuGroup.tsx
  26. 45 0
      src/components/dropdownMenu/DropdownMenuItem.tsx
  27. 23 0
      src/components/dropdownMenu/DropdownMenuItemContent.tsx
  28. 23 0
      src/components/dropdownMenu/DropdownMenuItemCustom.tsx
  29. 42 0
      src/components/dropdownMenu/DropdownMenuItemLink.tsx
  30. 14 0
      src/components/dropdownMenu/DropdownMenuSeparator.tsx
  31. 37 0
      src/components/dropdownMenu/DropdownMenuTrigger.tsx
  32. 35 0
      src/components/dropdownMenu/dropdownMenuUtils.ts
  33. 4 5
      src/components/footer/Footer.tsx
  34. 10 0
      src/components/footer/FooterCenter.scss
  35. 2 1
      src/components/footer/FooterCenter.tsx
  36. 174 0
      src/components/mainMenu/DefaultItems.tsx
  37. 56 0
      src/components/mainMenu/MainMenu.tsx
  38. 14 0
      src/css/styles.scss
  39. 15 17
      src/excalidraw-app/components/LanguageList.tsx
  40. 23 1
      src/excalidraw-app/index.scss
  41. 43 2
      src/excalidraw-app/index.tsx
  42. 2 0
      src/packages/excalidraw/CHANGELOG.md
  43. 236 0
      src/packages/excalidraw/README.md
  44. 0 5
      src/packages/excalidraw/example/App.scss
  45. 39 53
      src/packages/excalidraw/example/App.tsx
  46. 65 0
      src/packages/excalidraw/example/CustomFooter.tsx
  47. 20 0
      src/packages/excalidraw/example/MobileFooter.tsx
  48. 3 0
      src/packages/excalidraw/index.tsx
  49. 2 2
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  50. 235 1083
      src/tests/packages/__snapshots__/excalidraw.test.tsx.snap
  51. 162 97
      src/tests/packages/excalidraw.test.tsx
  52. 1 1
      src/tests/regressionTests.test.tsx
  53. 6 0
      src/tests/test-utils.ts
  54. 2 2
      src/types.ts

+ 13 - 9
src/actions/actionCanvas.tsx

@@ -23,7 +23,7 @@ import { newElementWith } from "../element/mutateElement";
 import { getDefaultAppState, isEraserActive } from "../appState";
 import ClearCanvas from "../components/ClearCanvas";
 import clsx from "clsx";
-import MenuItem from "../components/MenuItem";
+import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
 import { getShortcutFromShortcutName } from "./shortcuts";
 
 export const actionChangeViewBackgroundColor = register({
@@ -299,19 +299,23 @@ export const actionToggleTheme = register({
     };
   },
   PanelComponent: ({ appState, updateData }) => (
-    <MenuItem
-      label={
-        appState.theme === "dark"
-          ? t("buttons.lightMode")
-          : t("buttons.darkMode")
-      }
-      onClick={() => {
+    <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,
 });

+ 14 - 10
src/actions/actionExport.tsx

@@ -19,7 +19,7 @@ import { ActiveFile } from "../components/ActiveFile";
 import { isImageFileHandle } from "../data/blob";
 import { nativeFileSystemSupported } from "../data/filesystem";
 import { Theme } from "../element/types";
-import MenuItem from "../components/MenuItem";
+import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
 import { getShortcutFromShortcutName } from "./shortcuts";
 
 export const actionChangeProjectName = register({
@@ -247,15 +247,19 @@ export const actionLoadScene = register({
     }
   },
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
-  PanelComponent: ({ updateData }) => (
-    <MenuItem
-      label={t("buttons.load")}
-      icon={LoadIcon}
-      onClick={updateData}
-      dataTestId="load-button"
-      shortcut={getShortcutFromShortcutName("loadScene")}
-    />
-  ),
+  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({

+ 7 - 5
src/actions/actionMenu.tsx

@@ -6,7 +6,7 @@ import { register } from "./register";
 import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
 import { KEYS } from "../keys";
 import { HelpButton } from "../components/HelpButton";
-import MenuItem from "../components/MenuItem";
+import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
 
 export const actionToggleCanvasMenu = register({
   name: "toggleCanvasMenu",
@@ -90,13 +90,15 @@ export const actionShortcuts = register({
   },
   PanelComponent: ({ updateData, isInHamburgerMenu }) =>
     isInHamburgerMenu ? (
-      <MenuItem
-        label={t("helpDialog.title")}
+      <DropdownMenuItem
         dataTestId="help-menu-item"
         icon={HelpIcon}
-        onClick={updateData}
+        onSelect={updateData}
         shortcut="?"
-      />
+        ariaLabel={t("helpDialog.title")}
+      >
+        {t("helpDialog.title")}
+      </DropdownMenuItem>
     ) : (
       <HelpButton title={t("helpDialog.title")} onClick={updateData} />
     ),

+ 5 - 5
src/components/ActiveFile.tsx

@@ -5,7 +5,7 @@ import { save } from "../components/icons";
 import { t } from "../i18n";
 
 import "./ActiveFile.scss";
-import MenuItem from "./MenuItem";
+import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
 
 type ActiveFileProps = {
   fileName?: string;
@@ -13,11 +13,11 @@ type ActiveFileProps = {
 };
 
 export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
-  <MenuItem
-    label={`${t("buttons.save")}`}
+  <DropdownMenuItem
     shortcut={getShortcutFromShortcutName("saveScene")}
     dataTestId="save-button"
-    onClick={onSave}
+    onSelect={onSave}
     icon={save}
-  />
+    ariaLabel={`${t("buttons.save")}`}
+  >{`${t("buttons.save")}`}</DropdownMenuItem>
 );

+ 78 - 71
src/components/App.tsx

@@ -272,13 +272,9 @@ import {
   isLocalLink,
 } from "../element/Hyperlink";
 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);
-
 const deviceContextInitialValue = {
   isSmScreen: false,
   isMobile: false,
@@ -289,7 +285,7 @@ const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
 DeviceContext.displayName = "DeviceContext";
 export const useDevice = () => useContext<Device>(DeviceContext);
 
-const ExcalidrawContainerContext = React.createContext<{
+export const ExcalidrawContainerContext = React.createContext<{
   container: HTMLDivElement | null;
   id: string | null;
 }>({ container: null, id: null });
@@ -316,12 +312,19 @@ const ExcalidrawSetAppStateContext = React.createContext<
 >(() => {});
 ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";
 
+const ExcalidrawActionManagerContext = React.createContext<
+  ActionManager | { renderAction: ActionManager["renderAction"] }
+>({ renderAction: () => null });
+ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
+
 export const useExcalidrawElements = () =>
   useContext(ExcalidrawElementsContext);
 export const useExcalidrawAppState = () =>
   useContext(ExcalidrawAppStateContext);
 export const useExcalidrawSetAppState = () =>
   useContext(ExcalidrawSetAppStateContext);
+export const useExcalidrawActionManager = () =>
+  useContext(ExcalidrawActionManagerContext);
 
 let didTapTwice: boolean = false;
 let tappedTwiceTimer = 0;
@@ -559,75 +562,79 @@ class App extends React.Component<AppProps, AppState> {
                 <ExcalidrawElementsContext.Provider
                   value={this.scene.getNonDeletedElements()}
                 >
-                  <LayerUI
-                    canvas={this.canvas}
-                    appState={this.state}
-                    files={this.files}
-                    setAppState={this.setAppState}
-                    actionManager={this.actionManager}
-                    elements={this.scene.getNonDeletedElements()}
-                    onCollabButtonClick={onCollabButtonClick}
-                    onLockToggle={this.toggleLock}
-                    onPenModeToggle={this.togglePenMode}
-                    onInsertElements={(elements) =>
-                      this.addElementsFromPasteOrLibrary({
-                        elements,
-                        position: "center",
-                        files: null,
-                      })
-                    }
-                    langCode={getLanguage().code}
-                    isCollaborating={this.props.isCollaborating}
-                    renderTopRightUI={renderTopRightUI}
-                    renderCustomStats={renderCustomStats}
-                    renderCustomSidebar={this.props.renderSidebar}
-                    showExitZenModeBtn={
-                      typeof this.props?.zenModeEnabled === "undefined" &&
-                      this.state.zenModeEnabled
-                    }
-                    libraryReturnUrl={this.props.libraryReturnUrl}
-                    UIOptions={this.props.UIOptions}
-                    focusContainer={this.focusContainer}
-                    library={this.library}
-                    id={this.id}
-                    onImageAction={this.onImageAction}
-                    renderWelcomeScreen={
-                      this.state.showWelcomeScreen &&
-                      this.state.activeTool.type === "selection" &&
-                      !this.scene.getElementsIncludingDeleted().length
-                    }
+                  <ExcalidrawActionManagerContext.Provider
+                    value={this.actionManager}
                   >
-                    {this.props.children}
-                  </LayerUI>
-                  <div className="excalidraw-textEditorContainer" />
-                  <div className="excalidraw-contextMenuContainer" />
-                  {selectedElement.length === 1 &&
-                    !this.state.contextMenu &&
-                    this.state.showHyperlinkPopup && (
-                      <Hyperlink
-                        key={selectedElement[0].id}
-                        element={selectedElement[0]}
-                        setAppState={this.setAppState}
-                        onLinkOpen={this.props.onLinkOpen}
+                    <LayerUI
+                      canvas={this.canvas}
+                      appState={this.state}
+                      files={this.files}
+                      setAppState={this.setAppState}
+                      actionManager={this.actionManager}
+                      elements={this.scene.getNonDeletedElements()}
+                      onCollabButtonClick={onCollabButtonClick}
+                      onLockToggle={this.toggleLock}
+                      onPenModeToggle={this.togglePenMode}
+                      onInsertElements={(elements) =>
+                        this.addElementsFromPasteOrLibrary({
+                          elements,
+                          position: "center",
+                          files: null,
+                        })
+                      }
+                      langCode={getLanguage().code}
+                      isCollaborating={this.props.isCollaborating}
+                      renderTopRightUI={renderTopRightUI}
+                      renderCustomStats={renderCustomStats}
+                      renderCustomSidebar={this.props.renderSidebar}
+                      showExitZenModeBtn={
+                        typeof this.props?.zenModeEnabled === "undefined" &&
+                        this.state.zenModeEnabled
+                      }
+                      libraryReturnUrl={this.props.libraryReturnUrl}
+                      UIOptions={this.props.UIOptions}
+                      focusContainer={this.focusContainer}
+                      library={this.library}
+                      id={this.id}
+                      onImageAction={this.onImageAction}
+                      renderWelcomeScreen={
+                        this.state.showWelcomeScreen &&
+                        this.state.activeTool.type === "selection" &&
+                        !this.scene.getElementsIncludingDeleted().length
+                      }
+                    >
+                      {this.props.children}
+                    </LayerUI>
+                    <div className="excalidraw-textEditorContainer" />
+                    <div className="excalidraw-contextMenuContainer" />
+                    {selectedElement.length === 1 &&
+                      !this.state.contextMenu &&
+                      this.state.showHyperlinkPopup && (
+                        <Hyperlink
+                          key={selectedElement[0].id}
+                          element={selectedElement[0]}
+                          setAppState={this.setAppState}
+                          onLinkOpen={this.props.onLinkOpen}
+                        />
+                      )}
+                    {this.state.toast !== null && (
+                      <Toast
+                        message={this.state.toast.message}
+                        onClose={() => this.setToast(null)}
+                        duration={this.state.toast.duration}
+                        closable={this.state.toast.closable}
                       />
                     )}
-                  {this.state.toast !== null && (
-                    <Toast
-                      message={this.state.toast.message}
-                      onClose={() => this.setToast(null)}
-                      duration={this.state.toast.duration}
-                      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>
+                    {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>
+                  </ExcalidrawActionManagerContext.Provider>
                 </ExcalidrawElementsContext.Provider>{" "}
               </ExcalidrawAppStateContext.Provider>
             </ExcalidrawSetAppStateContext.Provider>

+ 7 - 5
src/components/ClearCanvas.tsx

@@ -3,7 +3,7 @@ import { t } from "../i18n";
 import { TrashIcon } from "./icons";
 
 import ConfirmDialog from "./ConfirmDialog";
-import MenuItem from "./MenuItem";
+import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
 
 const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
   const [showDialog, setShowDialog] = useState(false);
@@ -13,12 +13,14 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
 
   return (
     <>
-      <MenuItem
-        label={t("buttons.clearReset")}
+      <DropdownMenuItem
         icon={TrashIcon}
-        onClick={toggleDialog}
+        onSelect={toggleDialog}
         dataTestId="clear-canvas-button"
-      />
+        ariaLabel={t("buttons.clearReset")}
+      >
+        {t("buttons.clearReset")}
+      </DropdownMenuItem>
 
       {showDialog && (
         <ConfirmDialog

+ 7 - 6
src/components/CollabButton.tsx

@@ -2,7 +2,7 @@ import { t } from "../i18n";
 import { UsersIcon } from "./icons";
 
 import "./CollabButton.scss";
-import MenuItem from "./MenuItem";
+import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
 import clsx from "clsx";
 
 const CollabButton = ({
@@ -19,13 +19,14 @@ const CollabButton = ({
   return (
     <>
       {isInHamburgerMenu ? (
-        <MenuItem
-          label={t("labels.liveCollaboration")}
+        <DropdownMenuItem
           dataTestId="collab-button"
           icon={UsersIcon}
-          onClick={onClick}
-          isCollaborating={isCollaborating}
-        />
+          onSelect={onClick}
+          ariaLabel={t("labels.liveCollaboration")}
+        >
+          {t("labels.liveCollaboration")}
+        </DropdownMenuItem>
       ) : (
         <button
           className={clsx("collab-button", { active: isCollaborating })}

+ 8 - 9
src/components/ConfirmDialog.tsx

@@ -3,9 +3,9 @@ import { Dialog, DialogProps } from "./Dialog";
 
 import "./ConfirmDialog.scss";
 import DialogActionButton from "./DialogActionButton";
-import { isMenuOpenAtom } from "./App";
-import { isDropdownOpenAtom } from "./App";
 import { useSetAtom } from "jotai";
+import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
+import { useExcalidrawSetAppState } from "./App";
 
 interface Props extends Omit<DialogProps, "onCloseRequest"> {
   onConfirm: () => void;
@@ -23,9 +23,8 @@ const ConfirmDialog = (props: Props) => {
     className = "",
     ...rest
   } = props;
-
-  const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
-  const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
+  const setAppState = useExcalidrawSetAppState();
+  const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
 
   return (
     <Dialog
@@ -39,16 +38,16 @@ const ConfirmDialog = (props: Props) => {
         <DialogActionButton
           label={cancelText}
           onClick={() => {
-            setIsMenuOpen(false);
-            setIsDropdownOpen(false);
+            setAppState({ openMenu: null });
+            setIsLibraryMenuOpen(false);
             onCancel();
           }}
         />
         <DialogActionButton
           label={confirmText}
           onClick={() => {
-            setIsMenuOpen(false);
-            setIsDropdownOpen(false);
+            setAppState({ openMenu: null });
+            setIsLibraryMenuOpen(false);
             onConfirm();
           }}
           actionType="danger"

+ 10 - 6
src/components/Dialog.tsx

@@ -2,7 +2,11 @@ import clsx from "clsx";
 import React, { useEffect, useState } from "react";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
 import { t } from "../i18n";
-import { useExcalidrawContainer, useDevice } from "../components/App";
+import {
+  useExcalidrawContainer,
+  useDevice,
+  useExcalidrawSetAppState,
+} from "../components/App";
 import { KEYS } from "../keys";
 import "./Dialog.scss";
 import { back, CloseIcon } from "./icons";
@@ -10,8 +14,8 @@ import { Island } from "./Island";
 import { Modal } from "./Modal";
 import { AppState } from "../types";
 import { queryFocusableElements } from "../utils";
-import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
 import { useSetAtom } from "jotai";
+import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
 
 export interface DialogProps {
   children: React.ReactNode;
@@ -67,12 +71,12 @@ export const Dialog = (props: DialogProps) => {
     return () => islandNode.removeEventListener("keydown", handleKeyDown);
   }, [islandNode, props.autofocus]);
 
-  const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
-  const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
+  const setAppState = useExcalidrawSetAppState();
+  const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
 
   const onClose = () => {
-    setIsMenuOpen(false);
-    setIsDropdownOpen(false);
+    setAppState({ openMenu: null });
+    setIsLibraryMenuOpen(false);
     (lastActiveElement as HTMLElement).focus();
     props.onCloseRequest();
   };

+ 7 - 16
src/components/JSONExportDialog.tsx

@@ -1,10 +1,10 @@
-import React, { useState } from "react";
+import React from "react";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 
 import { AppState, ExportOpts, BinaryFiles } from "../types";
 import { Dialog } from "./Dialog";
-import { ExportIcon, exportToFileIcon, LinkIcon } from "./icons";
+import { exportToFileIcon, LinkIcon } from "./icons";
 import { ToolButton } from "./ToolButton";
 import { actionSaveFileToDisk } from "../actions/actionExport";
 import { Card } from "./Card";
@@ -14,7 +14,6 @@ import { nativeFileSystemSupported } from "../data/filesystem";
 import { trackEvent } from "../analytics";
 import { ActionManager } from "../actions/manager";
 import { getFrame } from "../utils";
-import MenuItem from "./MenuItem";
 
 export type ExportCB = (
   elements: readonly NonDeletedExcalidrawElement[],
@@ -94,6 +93,7 @@ export const JSONExportDialog = ({
   actionManager,
   exportOpts,
   canvas,
+  setAppState,
 }: {
   elements: readonly NonDeletedExcalidrawElement[];
   appState: AppState;
@@ -101,24 +101,15 @@ export const JSONExportDialog = ({
   actionManager: ActionManager;
   exportOpts: ExportOpts;
   canvas: HTMLCanvasElement | null;
+  setAppState: React.Component<any, AppState>["setState"];
 }) => {
-  const [modalIsShown, setModalIsShown] = useState(false);
-
   const handleClose = React.useCallback(() => {
-    setModalIsShown(false);
-  }, []);
+    setAppState({ openDialog: null });
+  }, [setAppState]);
 
   return (
     <>
-      <MenuItem
-        icon={ExportIcon}
-        label={t("buttons.export")}
-        onClick={() => {
-          setModalIsShown(true);
-        }}
-        dataTestId="json-export-button"
-      />
-      {modalIsShown && (
+      {appState.openDialog === "jsonExport" && (
         <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
           <JSONExportModal
             elements={elements}

+ 0 - 10
src/components/LayerUI.scss

@@ -80,16 +80,6 @@
       }
     }
 
-    .layer-ui__wrapper__footer-center {
-      pointer-events: none;
-      & > * {
-        pointer-events: all;
-      }
-
-      display: flex;
-      width: 100%;
-      justify-content: flex-start;
-    }
     .layer-ui__wrapper__footer-left,
     .layer-ui__wrapper__footer-right,
     .disable-zen-mode--visible {

+ 39 - 104
src/components/LayerUI.tsx

@@ -41,26 +41,17 @@ import "./LayerUI.scss";
 import "./Toolbar.scss";
 import { PenModeButton } from "./PenModeButton";
 import { trackEvent } from "../analytics";
-import { isMenuOpenAtom, useDevice } from "../components/App";
+import { useDevice } from "../components/App";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions/actionToggleStats";
 import Footer from "./footer/Footer";
-import {
-  ExportImageIcon,
-  HamburgerMenuIcon,
-  WelcomeScreenMenuArrow,
-  WelcomeScreenTopToolbarArrow,
-} from "./icons";
-import { MenuLinks, Separator } from "./MenuUtils";
-import { useOutsideClickHook } from "../hooks/useOutsideClick";
+import { WelcomeScreenMenuArrow, WelcomeScreenTopToolbarArrow } from "./icons";
 import WelcomeScreen from "./WelcomeScreen";
 import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
 import { useAtom } from "jotai";
-import { LanguageList } from "../excalidraw-app/components/LanguageList";
 import WelcomeScreenDecor from "./WelcomeScreenDecor";
-import { getShortcutFromShortcutName } from "../actions/shortcuts";
-import MenuItem from "./MenuItem";
+import MainMenu from "./mainMenu/MainMenu";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -103,7 +94,6 @@ const LayerUI = ({
   showExitZenModeBtn,
   isCollaborating,
   renderTopRightUI,
-
   renderCustomStats,
   renderCustomSidebar,
   libraryReturnUrl,
@@ -133,6 +123,7 @@ const LayerUI = ({
         actionManager={actionManager}
         exportOpts={UIOptions.canvasActions.export}
         canvas={canvas}
+        setAppState={setAppState}
       />
     );
   };
@@ -186,9 +177,35 @@ const LayerUI = ({
     );
   };
 
-  const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom);
-  const menuRef = useOutsideClickHook(() => setIsMenuOpen(false));
-
+  const renderMenu = () => {
+    return (
+      childrenComponents.Menu || (
+        <MainMenu>
+          <MainMenu.DefaultItems.LoadScene />
+          <MainMenu.DefaultItems.SaveToActiveFile />
+          {UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
+          {UIOptions.canvasActions.saveAsImage && (
+            <MainMenu.DefaultItems.SaveAsImage />
+          )}
+          {onCollabButtonClick && (
+            <MainMenu.DefaultItems.LiveCollaboration
+              onSelect={onCollabButtonClick}
+              isCollaborating={isCollaborating}
+            />
+          )}
+          <MainMenu.DefaultItems.Help />
+          <MainMenu.DefaultItems.ClearCanvas />
+          <MainMenu.Separator />
+          <MainMenu.Group title="Excalidraw links">
+            <MainMenu.DefaultItems.Socials />
+          </MainMenu.Group>
+          <MainMenu.Separator />
+          <MainMenu.DefaultItems.ToggleTheme />
+          <MainMenu.DefaultItems.ChangeCanvasBackground />
+        </MainMenu>
+      )
+    );
+  };
   const renderCanvasActions = () => (
     <div style={{ position: "relative" }}>
       <WelcomeScreenDecor
@@ -199,87 +216,7 @@ const LayerUI = ({
           <div>{t("welcomeScreen.menuHints")}</div>
         </div>
       </WelcomeScreenDecor>
-
-      <button
-        data-prevent-outside-click
-        className={clsx("menu-button", "zen-mode-transition", {
-          "transition-left": appState.zenModeEnabled,
-        })}
-        onClick={() => setIsMenuOpen(!isMenuOpen)}
-        type="button"
-        data-testid="menu-button"
-      >
-        {HamburgerMenuIcon}
-      </button>
-
-      {isMenuOpen && (
-        <div
-          ref={menuRef}
-          style={{ position: "absolute", top: "100%", marginTop: ".25rem" }}
-        >
-          <Section heading="canvasActions">
-            {/* the zIndex ensures this menu has higher stacking order,
-         see https://github.com/excalidraw/excalidraw/pull/1445 */}
-            <Island
-              className="menu-container"
-              padding={2}
-              style={{ zIndex: 1 }}
-            >
-              {!appState.viewModeEnabled &&
-                actionManager.renderAction("loadScene")}
-              {/* // TODO barnabasmolnar/editor-redesign  */}
-              {/* is this fine here? */}
-              {appState.fileHandle &&
-                actionManager.renderAction("saveToActiveFile")}
-              {renderJSONExportDialog()}
-              {UIOptions.canvasActions.saveAsImage && (
-                <MenuItem
-                  label={t("buttons.exportImage")}
-                  icon={ExportImageIcon}
-                  dataTestId="image-export-button"
-                  onClick={() => setAppState({ openDialog: "imageExport" })}
-                  shortcut={getShortcutFromShortcutName("imageExport")}
-                />
-              )}
-              {onCollabButtonClick && (
-                <CollabButton
-                  isCollaborating={isCollaborating}
-                  collaboratorCount={appState.collaborators.size}
-                  onClick={onCollabButtonClick}
-                />
-              )}
-              {actionManager.renderAction("toggleShortcuts", undefined, true)}
-              {!appState.viewModeEnabled &&
-                actionManager.renderAction("clearCanvas")}
-              <Separator />
-              <MenuLinks />
-              <Separator />
-              <div
-                style={{
-                  display: "flex",
-                  flexDirection: "column",
-                  rowGap: ".5rem",
-                }}
-              >
-                <div>{actionManager.renderAction("toggleTheme")}</div>
-                <div style={{ padding: "0 0.625rem" }}>
-                  <LanguageList style={{ width: "100%" }} />
-                </div>
-                {!appState.viewModeEnabled && (
-                  <div>
-                    <div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
-                      {t("labels.canvasBackground")}
-                    </div>
-                    <div style={{ padding: "0 0.625rem" }}>
-                      {actionManager.renderAction("changeViewBackgroundColor")}
-                    </div>
-                  </div>
-                )}
-              </div>
-            </Island>
-          </Section>
-        </div>
-      )}
+      {renderMenu()}
     </div>
   );
 
@@ -410,10 +347,7 @@ const LayerUI = ({
               },
             )}
           >
-            <UserList
-              collaborators={appState.collaborators}
-              actionManager={actionManager}
-            />
+            <UserList collaborators={appState.collaborators} />
             {onCollabButtonClick && (
               <CollabButton
                 isInHamburgerMenu={false}
@@ -466,6 +400,7 @@ const LayerUI = ({
         />
       )}
       {renderImageExportDialog()}
+      {renderJSONExportDialog()}
       {appState.pasteDialog.shown && (
         <PasteChartDialog
           setAppState={setAppState}
@@ -497,6 +432,7 @@ const LayerUI = ({
           renderCustomStats={renderCustomStats}
           renderSidebars={renderSidebars}
           device={device}
+          renderMenu={renderMenu}
         />
       )}
 
@@ -525,9 +461,8 @@ const LayerUI = ({
               appState={appState}
               actionManager={actionManager}
               showExitZenModeBtn={showExitZenModeBtn}
-            >
-              {childrenComponents.FooterCenter}
-            </Footer>
+              footerCenter={childrenComponents.FooterCenter}
+            />
 
             {appState.showStats && (
               <Stats

+ 23 - 0
src/components/LibraryMenu.scss

@@ -129,4 +129,27 @@
       padding-right: 0;
     }
   }
+
+  .layer-ui__sidebar__header .dropdown-menu {
+    &.dropdown-menu--mobile {
+      top: 100%;
+    }
+    .dropdown-menu-container {
+      --gap: 0;
+      z-index: 1;
+      position: absolute;
+      top: 100%;
+      left: 0;
+
+      :root[dir="rtl"] & {
+        right: 0;
+        left: auto;
+      }
+
+      width: 196px;
+      box-shadow: var(--library-dropdown-shadow);
+      border-radius: var(--border-radius-lg);
+      padding: 0.25rem 0.5rem;
+    }
+  }
 }

+ 76 - 72
src/components/LibraryMenuHeaderContent.tsx

@@ -13,14 +13,15 @@ import {
 import { ToolButton } from "./ToolButton";
 import { fileOpen } from "../data/filesystem";
 import { muteFSAbortError } from "../utils";
-import { useAtom } from "jotai";
+import { atom, useAtom } from "jotai";
 import { jotaiScope } from "../jotai";
 import ConfirmDialog from "./ConfirmDialog";
 import PublishLibrary from "./PublishLibrary";
 import { Dialog } from "./Dialog";
-import { useOutsideClickHook } from "../hooks/useOutsideClick";
-import MenuItem from "./MenuItem";
-import { isDropdownOpenAtom } from "./App";
+
+import DropdownMenu from "./dropdownMenu/DropdownMenu";
+
+export const isLibraryMenuOpenAtom = atom(false);
 
 const getSelectedItems = (
   libraryItems: LibraryItems,
@@ -45,7 +46,9 @@ export const LibraryMenuHeader: React.FC<{
   appState,
 }) => {
   const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
-
+  const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
+    isLibraryMenuOpenAtom,
+  );
   const renderRemoveLibAlert = useCallback(() => {
     const content = selectedItems.length
       ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -173,85 +176,86 @@ export const LibraryMenuHeader: React.FC<{
       });
   };
 
-  const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom);
-  const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false));
-
-  return (
-    <div style={{ position: "relative" }}>
-      <button
-        type="button"
-        className="Sidebar__dropdown-btn"
-        data-prevent-outside-click
-        onClick={() => setIsDropdownOpen(!isDropdownOpen)}
-      >
-        {DotsIcon}
-      </button>
-
-      {selectedItems.length > 0 && (
-        <div className="library-actions-counter">{selectedItems.length}</div>
-      )}
-
-      {isDropdownOpen && (
-        <div
-          className="Sidebar__dropdown-content menu-container"
-          ref={dropdownRef}
+  const renderLibraryMenu = () => {
+    return (
+      <DropdownMenu open={isLibraryMenuOpen}>
+        <DropdownMenu.Trigger
+          className="Sidebar__dropdown-btn"
+          onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
+        >
+          {DotsIcon}
+        </DropdownMenu.Trigger>
+        <DropdownMenu.Content
+          onClickOutside={() => setIsLibraryMenuOpen(false)}
+          className="library-menu"
         >
           {!itemsSelected && (
-            <MenuItem
-              label={t("buttons.load")}
+            <DropdownMenu.Item
+              onSelect={onLibraryImport}
               icon={LoadIcon}
               dataTestId="lib-dropdown--load"
-              onClick={onLibraryImport}
-            />
+            >
+              {t("buttons.load")}
+            </DropdownMenu.Item>
           )}
-          {showRemoveLibAlert && renderRemoveLibAlert()}
-          {showPublishLibraryDialog && (
-            <PublishLibrary
-              onClose={() => setShowPublishLibraryDialog(false)}
-              libraryItems={getSelectedItems(
-                libraryItemsData.libraryItems,
-                selectedItems,
-              )}
-              appState={appState}
-              onSuccess={(data) =>
-                onPublishLibSuccess(data, libraryItemsData.libraryItems)
-              }
-              onError={(error) => window.alert(error)}
-              updateItemsInStorage={() =>
-                library.setLibrary(libraryItemsData.libraryItems)
-              }
-              onRemove={(id: string) =>
-                onSelectItems(selectedItems.filter((_id) => _id !== id))
-              }
-            />
+          {!!items.length && (
+            <DropdownMenu.Item
+              onSelect={onLibraryExport}
+              icon={ExportIcon}
+              dataTestId="lib-dropdown--export"
+            >
+              {t("buttons.export")}
+            </DropdownMenu.Item>
           )}
-          {publishLibSuccess && renderPublishSuccess()}
           {!!items.length && (
-            <>
-              <MenuItem
-                label={t("buttons.export")}
-                icon={ExportIcon}
-                onClick={onLibraryExport}
-                dataTestId="lib-dropdown--export"
-              />
-              <MenuItem
-                label={resetLabel}
-                icon={TrashIcon}
-                onClick={() => setShowRemoveLibAlert(true)}
-                dataTestId="lib-dropdown--remove"
-              />
-            </>
+            <DropdownMenu.Item
+              onSelect={() => setShowRemoveLibAlert(true)}
+              icon={TrashIcon}
+            >
+              {resetLabel}
+            </DropdownMenu.Item>
           )}
           {itemsSelected && (
-            <MenuItem
-              label={t("buttons.publishLibrary")}
+            <DropdownMenu.Item
               icon={publishIcon}
-              dataTestId="lib-dropdown--publish"
-              onClick={() => setShowPublishLibraryDialog(true)}
-            />
+              onSelect={() => setShowPublishLibraryDialog(true)}
+              dataTestId="lib-dropdown--remove"
+            >
+              {t("buttons.publishLibrary")}
+            </DropdownMenu.Item>
+          )}
+        </DropdownMenu.Content>
+      </DropdownMenu>
+    );
+  };
+  return (
+    <div style={{ position: "relative" }}>
+      {renderLibraryMenu()}
+      {selectedItems.length > 0 && (
+        <div className="library-actions-counter">{selectedItems.length}</div>
+      )}
+      {showRemoveLibAlert && renderRemoveLibAlert()}
+      {showPublishLibraryDialog && (
+        <PublishLibrary
+          onClose={() => setShowPublishLibraryDialog(false)}
+          libraryItems={getSelectedItems(
+            libraryItemsData.libraryItems,
+            selectedItems,
           )}
-        </div>
+          appState={appState}
+          onSuccess={(data) =>
+            onPublishLibSuccess(data, libraryItemsData.libraryItems)
+          }
+          onError={(error) => window.alert(error)}
+          updateItemsInStorage={() =>
+            library.setLibrary(libraryItemsData.libraryItems)
+          }
+          onRemove={(id: string) =>
+            onSelectItems(selectedItems.filter((_id) => _id !== id))
+          }
+        />
       )}
+      {publishLibSuccess && renderPublishSuccess()}
     </div>
   );
 };

+ 0 - 85
src/components/Menu.scss

@@ -1,85 +0,0 @@
-@import "../css/variables.module";
-
-.excalidraw {
-  .menu-container {
-    background-color: #fff !important;
-    max-height: calc(100vh - 150px);
-    overflow-y: auto;
-  }
-
-  .menu-button {
-    @include outlineButtonStyles;
-    background-color: var(--island-bg-color);
-    width: var(--lg-button-size);
-    height: var(--lg-button-size);
-
-    svg {
-      width: var(--lg-icon-size);
-      height: var(--lg-icon-size);
-    }
-  }
-
-  .menu-item {
-    display: flex;
-    background-color: transparent;
-    border: 0;
-    align-items: center;
-    padding: 0 0.625rem;
-    height: 2rem;
-    column-gap: 0.625rem;
-    font-size: 0.875rem;
-    color: var(--color-gray-100);
-    cursor: pointer;
-    border-radius: var(--border-radius-md);
-    width: 100%;
-    box-sizing: border-box;
-    font-weight: normal;
-    font-family: inherit;
-
-    @media screen and (min-width: 1921px) {
-      height: 2.25rem;
-    }
-
-    &__text {
-      text-overflow: ellipsis;
-      overflow: hidden;
-      white-space: nowrap;
-    }
-
-    &__shortcut {
-      margin-inline-start: auto;
-      opacity: 0.5;
-    }
-
-    &:hover {
-      background-color: var(--button-hover);
-      text-decoration: none;
-    }
-
-    svg {
-      width: 1rem;
-      height: 1rem;
-      display: block;
-    }
-
-    &.active-collab {
-      background-color: #ecfdf5;
-      color: #064e3c;
-    }
-  }
-
-  &.theme--dark {
-    .menu-item {
-      color: var(--color-gray-40);
-
-      &.active-collab {
-        background-color: #064e3c;
-        color: #ecfdf5;
-      }
-    }
-
-    .menu-container {
-      background-color: var(--color-gray-90) !important;
-    }
-  }
-}

+ 0 - 37
src/components/MenuItem.tsx

@@ -1,37 +0,0 @@
-import clsx from "clsx";
-import "./Menu.scss";
-
-interface MenuProps {
-  icon: JSX.Element;
-  onClick: () => void;
-  label: string;
-  dataTestId: string;
-  shortcut?: string;
-  isCollaborating?: boolean;
-}
-
-const MenuItem = ({
-  icon,
-  onClick,
-  label,
-  dataTestId,
-  shortcut,
-  isCollaborating,
-}: MenuProps) => {
-  return (
-    <button
-      className={clsx("menu-item", { "active-collab": isCollaborating })}
-      aria-label={label}
-      onClick={onClick}
-      data-testid={dataTestId}
-      title={label}
-      type="button"
-    >
-      <div className="menu-item__icon">{icon}</div>
-      <div className="menu-item__text">{label}</div>
-      {shortcut && <div className="menu-item__shortcut">{shortcut}</div>}
-    </button>
-  );
-};
-
-export default MenuItem;

+ 0 - 53
src/components/MenuUtils.tsx

@@ -1,53 +0,0 @@
-import { GithubIcon, DiscordIcon, PlusPromoIcon, TwitterIcon } from "./icons";
-
-export const MenuLinks = () => (
-  <>
-    <a
-      href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
-      target="_blank"
-      rel="noreferrer"
-      className="menu-item"
-      style={{ color: "var(--color-promo)" }}
-    >
-      <div className="menu-item__icon">{PlusPromoIcon}</div>
-      <div className="menu-item__text">Excalidraw+</div>
-    </a>
-    <a
-      className="menu-item"
-      href="https://github.com/excalidraw/excalidraw"
-      target="_blank"
-      rel="noopener noreferrer"
-    >
-      <div className="menu-item__icon">{GithubIcon}</div>
-      <div className="menu-item__text">GitHub</div>
-    </a>
-    <a
-      className="menu-item"
-      target="_blank"
-      href="https://discord.gg/UexuTaE"
-      rel="noopener noreferrer"
-    >
-      <div className="menu-item__icon">{DiscordIcon}</div>
-      <div className="menu-item__text">Discord</div>
-    </a>
-    <a
-      className="menu-item"
-      target="_blank"
-      href="https://twitter.com/excalidraw"
-      rel="noopener noreferrer"
-    >
-      <div className="menu-item__icon">{TwitterIcon}</div>
-      <div className="menu-item__text">Twitter</div>
-    </a>
-  </>
-);
-
-export const Separator = () => (
-  <div
-    style={{
-      height: "1px",
-      backgroundColor: "var(--default-border-color)",
-      margin: ".5rem 0",
-    }}
-  />
-);

+ 7 - 87
src/components/MobileMenu.tsx

@@ -11,18 +11,13 @@ import { HintViewer } from "./HintViewer";
 import { calculateScrollCenter } from "../scene";
 import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 import { Section } from "./Section";
-import CollabButton from "./CollabButton";
 import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 import { LockButton } from "./LockButton";
-import { UserList } from "./UserList";
 import { LibraryButton } from "./LibraryButton";
 import { PenModeButton } from "./PenModeButton";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
-import { MenuLinks, Separator } from "./MenuUtils";
 import WelcomeScreen from "./WelcomeScreen";
-import MenuItem from "./MenuItem";
-import { ExportImageIcon } from "./icons";
 
 type MobileMenuProps = {
   appState: AppState;
@@ -46,16 +41,14 @@ type MobileMenuProps = {
   renderSidebars: () => JSX.Element | null;
   device: Device;
   renderWelcomeScreen?: boolean;
+  renderMenu: () => React.ReactNode;
 };
 
 export const MobileMenu = ({
   appState,
   elements,
   actionManager,
-  renderJSONExportDialog,
-  renderImageExportDialog,
   setAppState,
-  onCollabButtonClick,
   onLockToggle,
   onPenModeToggle,
   canvas,
@@ -66,6 +59,7 @@ export const MobileMenu = ({
   renderSidebars,
   device,
   renderWelcomeScreen,
+  renderMenu,
 }: MobileMenuProps) => {
   const renderToolbar = () => {
     return (
@@ -147,16 +141,12 @@ export const MobileMenu = ({
 
   const renderAppToolbar = () => {
     if (appState.viewModeEnabled) {
-      return (
-        <div className="App-toolbar-content">
-          {actionManager.renderAction("toggleCanvasMenu")}
-        </div>
-      );
+      return <div className="App-toolbar-content">{renderMenu()}</div>;
     }
 
     return (
       <div className="App-toolbar-content">
-        {actionManager.renderAction("toggleCanvasMenu")}
+        {renderMenu()}
         {actionManager.renderAction("toggleEditMenu")}
         {actionManager.renderAction("undo")}
         {actionManager.renderAction("redo")}
@@ -168,58 +158,6 @@ export const MobileMenu = ({
     );
   };
 
-  const renderCanvasActions = () => {
-    if (appState.viewModeEnabled) {
-      return (
-        <>
-          {renderJSONExportDialog()}
-          <MenuItem
-            label={t("buttons.exportImage")}
-            icon={ExportImageIcon}
-            dataTestId="image-export-button"
-            onClick={() => setAppState({ openDialog: "imageExport" })}
-          />
-          {renderImageExportDialog()}
-        </>
-      );
-    }
-    return (
-      <>
-        {!appState.viewModeEnabled && actionManager.renderAction("loadScene")}
-        {renderJSONExportDialog()}
-        {renderImageExportDialog()}
-        <MenuItem
-          label={t("buttons.exportImage")}
-          icon={ExportImageIcon}
-          dataTestId="image-export-button"
-          onClick={() => setAppState({ openDialog: "imageExport" })}
-        />
-        {onCollabButtonClick && (
-          <CollabButton
-            isCollaborating={isCollaborating}
-            collaboratorCount={appState.collaborators.size}
-            onClick={onCollabButtonClick}
-          />
-        )}
-        {actionManager.renderAction("toggleShortcuts", undefined, true)}
-        {!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")}
-        <Separator />
-        <MenuLinks />
-        <Separator />
-        {!appState.viewModeEnabled && (
-          <div style={{ marginBottom: ".5rem" }}>
-            <div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
-              {t("labels.canvasBackground")}
-            </div>
-            <div style={{ padding: "0 0.625rem" }}>
-              {actionManager.renderAction("changeViewBackgroundColor")}
-            </div>
-          </div>
-        )}
-        {actionManager.renderAction("toggleTheme")}
-      </>
-    );
-  };
   return (
     <>
       {renderSidebars()}
@@ -244,27 +182,9 @@ export const MobileMenu = ({
         }}
       >
         <Island padding={0}>
-          {appState.openMenu === "canvas" ? (
-            <Section className="App-mobile-menu" heading="canvasActions">
-              <div className="panelColumn">
-                <Stack.Col gap={2}>
-                  {renderCanvasActions()}
-                  {appState.collaborators.size > 0 && (
-                    <fieldset>
-                      <legend>{t("labels.collaborators")}</legend>
-                      <UserList
-                        mobile
-                        collaborators={appState.collaborators}
-                        actionManager={actionManager}
-                      />
-                    </fieldset>
-                  )}
-                </Stack.Col>
-              </div>
-            </Section>
-          ) : appState.openMenu === "shape" &&
-            !appState.viewModeEnabled &&
-            showSelectedShapeActions(appState, elements) ? (
+          {appState.openMenu === "shape" &&
+          !appState.viewModeEnabled &&
+          showSelectedShapeActions(appState, elements) ? (
             <Section className="App-mobile-menu" heading="selectedShapeActions">
               <SelectedShapeActions
                 appState={appState}

+ 0 - 18
src/components/Sidebar/Sidebar.scss

@@ -3,24 +3,6 @@
 
 .excalidraw {
   .Sidebar {
-    &__dropdown-content {
-      z-index: 1;
-      position: absolute;
-      top: 100%;
-      left: 0;
-
-      :root[dir="rtl"] & {
-        right: 0;
-        left: auto;
-      }
-
-      margin-top: 0.25rem;
-      width: 180px;
-      box-shadow: var(--library-dropdown-shadow);
-      border-radius: var(--border-radius-lg);
-      padding: 0.25rem 0.5rem;
-    }
-
     &__close-btn,
     &__pin-btn,
     &__dropdown-btn {

+ 4 - 24
src/components/UserList.tsx

@@ -4,16 +4,16 @@ import React from "react";
 import clsx from "clsx";
 import { AppState, Collaborator } from "../types";
 import { Tooltip } from "./Tooltip";
-import { ActionManager } from "../actions/manager";
+import { useExcalidrawActionManager } from "./App";
 
 export const UserList: React.FC<{
   className?: string;
   mobile?: boolean;
   collaborators: AppState["collaborators"];
-  actionManager: ActionManager;
-}> = ({ className, mobile, collaborators, actionManager }) => {
-  const uniqueCollaborators = new Map<string, Collaborator>();
+}> = ({ className, mobile, collaborators }) => {
+  const actionManager = useExcalidrawActionManager();
 
+  const uniqueCollaborators = new Map<string, Collaborator>();
   collaborators.forEach((collaborator, socketId) => {
     uniqueCollaborators.set(
       // filter on user id, else fall back on unique socketId
@@ -44,26 +44,6 @@ export const UserList: React.FC<{
         );
       });
 
-  // TODO barnabasmolnar/editor-redesign
-  // probably remove before shipping :)
-  // 20 fake collaborators; for easy, convenient debug purposes ˇˇ
-  // const avatars = Array.from({ length: 20 }).map((_, index) => {
-  //   const avatarJSX = actionManager.renderAction("goToCollaborator", [
-  //     index.toString(),
-  //     {
-  //       username: `User ${index}`,
-  //     },
-  //   ]);
-
-  //   return mobile ? (
-  //     <Tooltip label={`User ${index}`} key={index}>
-  //       {avatarJSX}
-  //     </Tooltip>
-  //   ) : (
-  //     <React.Fragment key={index}>{avatarJSX}</React.Fragment>
-  //   );
-  // });
-
   return (
     <div className={clsx("UserList", className, { UserList_mobile: mobile })}>
       {avatars}

+ 1 - 17
src/components/WelcomeScreen.tsx

@@ -1,18 +1,10 @@
-import { useAtom } from "jotai";
 import { actionLoadScene, actionShortcuts } from "../actions";
 import { ActionManager } from "../actions/manager";
 import { getShortcutFromShortcutName } from "../actions/shortcuts";
 import { isExcalidrawPlusSignedUser } from "../constants";
-import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab";
 import { t } from "../i18n";
 import { AppState } from "../types";
-import {
-  ExcalLogo,
-  HelpIcon,
-  LoadIcon,
-  PlusPromoIcon,
-  UsersIcon,
-} from "./icons";
+import { ExcalLogo, HelpIcon, LoadIcon, PlusPromoIcon } from "./icons";
 import "./WelcomeScreen.scss";
 
 const WelcomeScreenItem = ({
@@ -64,8 +56,6 @@ const WelcomeScreen = ({
   appState: AppState;
   actionManager: ActionManager;
 }) => {
-  const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
-
   let subheadingJSX;
 
   if (isExcalidrawPlusSignedUser) {
@@ -110,12 +100,6 @@ const WelcomeScreen = ({
           />
         )}
         <WelcomeScreenItem
-          label={t("labels.liveCollaboration")}
-          shortcut={null}
-          onClick={() => setCollabDialogShown(true)}
-          icon={UsersIcon}
-        />
-        <WelcomeScreenItem
           onClick={() => actionManager.executeAction(actionShortcuts)}
           label={t("helpDialog.title")}
           shortcut="?"

+ 127 - 0
src/components/dropdownMenu/DropdownMenu.scss

@@ -0,0 +1,127 @@
+@import "../../css/variables.module";
+
+.excalidraw {
+  .dropdown-menu {
+    position: absolute;
+    top: 100%;
+    margin-top: 0.25rem;
+
+    &--mobile {
+      bottom: 55px;
+      top: auto;
+      left: 0;
+      width: 100%;
+      display: flex;
+      flex-direction: column;
+      row-gap: 0.75rem;
+
+      .dropdown-menu-container {
+        padding: 8px 8px;
+        box-sizing: border-box;
+        background-color: var(--island-bg-color);
+        box-shadow: var(--shadow-island);
+        border-radius: var(--border-radius-lg);
+        position: relative;
+        transition: box-shadow 0.5s ease-in-out;
+
+        &.zen-mode {
+          box-shadow: none;
+        }
+      }
+    }
+
+    .dropdown-menu-container {
+      background-color: #fff !important;
+      max-height: calc(100vh - 150px);
+      overflow-y: auto;
+      --gap: 2;
+    }
+
+    .dropdown-menu-item-base {
+      display: flex;
+      padding: 0 0.625rem;
+      column-gap: 0.625rem;
+      font-size: 0.875rem;
+      color: var(--color-gray-100);
+      width: 100%;
+      box-sizing: border-box;
+      font-weight: normal;
+      font-family: inherit;
+    }
+
+    .dropdown-menu-item {
+      background-color: transparent;
+      border: 0;
+      align-items: center;
+      height: 2rem;
+      cursor: pointer;
+      border-radius: var(--border-radius-md);
+
+      @media screen and (min-width: 1921px) {
+        height: 2.25rem;
+      }
+
+      &__text {
+        text-overflow: ellipsis;
+        overflow: hidden;
+        white-space: nowrap;
+      }
+
+      &__shortcut {
+        margin-inline-start: auto;
+        opacity: 0.5;
+      }
+
+      &:hover {
+        background-color: var(--button-hover);
+        text-decoration: none;
+      }
+
+      svg {
+        width: 1rem;
+        height: 1rem;
+        display: block;
+      }
+    }
+
+    .dropdown-menu-item-custom {
+      margin-top: 0.5rem;
+    }
+
+    .dropdown-menu-group-title {
+      font-size: 14px;
+      text-align: left;
+      margin: 10px 0;
+      font-weight: 500;
+    }
+  }
+  &.theme--dark {
+    .dropdown-menu-item {
+      color: var(--color-gray-40);
+    }
+
+    .dropdown-menu-container {
+      background-color: var(--color-gray-90) !important;
+    }
+  }
+
+  .dropdown-menu-button {
+    @include outlineButtonStyles;
+    background-color: var(--island-bg-color);
+    width: var(--lg-button-size);
+    height: var(--lg-button-size);
+
+    svg {
+      width: var(--lg-icon-size);
+      height: var(--lg-icon-size);
+    }
+
+    &--mobile {
+      border: none;
+      margin: 0;
+      padding: 0;
+      width: var(--default-button-size);
+      height: var(--default-button-size);
+    }
+  }
+}

+ 43 - 0
src/components/dropdownMenu/DropdownMenu.tsx

@@ -0,0 +1,43 @@
+import React from "react";
+import DropdownMenuTrigger from "./DropdownMenuTrigger";
+import DropdownMenuItem from "./DropdownMenuItem";
+import MenuSeparator from "./DropdownMenuSeparator";
+import DropdownMenuGroup from "./DropdownMenuGroup";
+import DropdownMenuContent from "./DropdownMenuContent";
+import DropdownMenuItemLink from "./DropdownMenuItemLink";
+import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
+import {
+  getMenuContentComponent,
+  getMenuTriggerComponent,
+} from "./dropdownMenuUtils";
+
+import "./DropdownMenu.scss";
+
+const DropdownMenu = ({
+  children,
+  open,
+}: {
+  children?: React.ReactNode;
+  open: boolean;
+}) => {
+  const MenuTriggerComp = getMenuTriggerComponent(children);
+  const MenuContentComp = getMenuContentComponent(children);
+  return (
+    <>
+      {MenuTriggerComp}
+      {open && MenuContentComp}
+    </>
+  );
+};
+
+DropdownMenu.Trigger = DropdownMenuTrigger;
+DropdownMenu.Content = DropdownMenuContent;
+DropdownMenu.Item = DropdownMenuItem;
+DropdownMenu.ItemLink = DropdownMenuItemLink;
+DropdownMenu.ItemCustom = DropdownMenuItemCustom;
+DropdownMenu.Group = DropdownMenuGroup;
+DropdownMenu.Separator = MenuSeparator;
+
+export default DropdownMenu;
+
+DropdownMenu.displayName = "DropdownMenu";

+ 51 - 0
src/components/dropdownMenu/DropdownMenuContent.tsx

@@ -0,0 +1,51 @@
+import { useOutsideClickHook } from "../../hooks/useOutsideClick";
+import { Island } from "../Island";
+
+import { useDevice } from "../App";
+import clsx from "clsx";
+import Stack from "../Stack";
+
+const MenuContent = ({
+  children,
+  onClickOutside,
+  className = "",
+  style,
+}: {
+  children?: React.ReactNode;
+  onClickOutside?: () => void;
+  className?: string;
+  style?: React.CSSProperties;
+}) => {
+  const device = useDevice();
+  const menuRef = useOutsideClickHook(() => {
+    onClickOutside?.();
+  });
+
+  const classNames = clsx(`dropdown-menu ${className}`, {
+    "dropdown-menu--mobile": device.isMobile,
+  }).trim();
+  return (
+    <div
+      ref={menuRef}
+      className={classNames}
+      style={style}
+      data-testid="dropdown-menu"
+    >
+      {/* the zIndex ensures this menu has higher stacking order,
+    see https://github.com/excalidraw/excalidraw/pull/1445 */}
+      {device.isMobile ? (
+        <Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
+      ) : (
+        <Island
+          className="dropdown-menu-container"
+          padding={2}
+          style={{ zIndex: 1 }}
+        >
+          {children}
+        </Island>
+      )}
+    </div>
+  );
+};
+export default MenuContent;
+MenuContent.displayName = "DropdownMenuContent";

+ 23 - 0
src/components/dropdownMenu/DropdownMenuGroup.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+
+const MenuGroup = ({
+  children,
+  className = "",
+  style,
+  title,
+}: {
+  children: React.ReactNode;
+  className?: string;
+  style?: React.CSSProperties;
+  title?: string;
+}) => {
+  return (
+    <div className={`dropdown-menu-group ${className}`} style={style}>
+      {title && <p className="dropdown-menu-group-title">{title}</p>}
+      {children}
+    </div>
+  );
+};
+
+export default MenuGroup;
+MenuGroup.displayName = "DropdownMenuGroup";

+ 45 - 0
src/components/dropdownMenu/DropdownMenuItem.tsx

@@ -0,0 +1,45 @@
+import React from "react";
+import MenuItemContent from "./DropdownMenuItemContent";
+
+export const getDrodownMenuItemClassName = (className = "") => {
+  return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
+};
+
+const DropdownMenuItem = ({
+  icon,
+  onSelect,
+  children,
+  dataTestId,
+  shortcut,
+  className,
+  style,
+  ariaLabel,
+}: {
+  icon?: JSX.Element;
+  onSelect: () => void;
+  children: React.ReactNode;
+  dataTestId?: string;
+  shortcut?: string;
+  className?: string;
+  style?: React.CSSProperties;
+  ariaLabel?: string;
+}) => {
+  return (
+    <button
+      aria-label={ariaLabel}
+      onClick={onSelect}
+      data-testid={dataTestId}
+      title={ariaLabel}
+      type="button"
+      className={getDrodownMenuItemClassName(className)}
+      style={style}
+    >
+      <MenuItemContent icon={icon} shortcut={shortcut}>
+        {children}
+      </MenuItemContent>
+    </button>
+  );
+};
+
+export default DropdownMenuItem;
+DropdownMenuItem.displayName = "DropdownMenuItem";

+ 23 - 0
src/components/dropdownMenu/DropdownMenuItemContent.tsx

@@ -0,0 +1,23 @@
+import { useDevice } from "../App";
+
+const MenuItemContent = ({
+  icon,
+  shortcut,
+  children,
+}: {
+  icon?: JSX.Element;
+  shortcut?: string;
+  children: React.ReactNode;
+}) => {
+  const device = useDevice();
+  return (
+    <>
+      <div className="dropdown-menu-item__icon">{icon}</div>
+      <div className="dropdown-menu-item__text">{children}</div>
+      {shortcut && !device.isMobile && (
+        <div className="dropdown-menu-item__shortcut">{shortcut}</div>
+      )}
+    </>
+  );
+};
+export default MenuItemContent;

+ 23 - 0
src/components/dropdownMenu/DropdownMenuItemCustom.tsx

@@ -0,0 +1,23 @@
+const DropdownMenuItemCustom = ({
+  children,
+  className = "",
+  style,
+  dataTestId,
+}: {
+  children: React.ReactNode;
+  className?: string;
+  style?: React.CSSProperties;
+  dataTestId?: string;
+}) => {
+  return (
+    <div
+      className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
+      style={style}
+      data-testid={dataTestId}
+    >
+      {children}
+    </div>
+  );
+};
+
+export default DropdownMenuItemCustom;

+ 42 - 0
src/components/dropdownMenu/DropdownMenuItemLink.tsx

@@ -0,0 +1,42 @@
+import MenuItemContent from "./DropdownMenuItemContent";
+import React from "react";
+import { getDrodownMenuItemClassName } from "./DropdownMenuItem";
+const DropdownMenuItemLink = ({
+  icon,
+  dataTestId,
+  shortcut,
+  href,
+  children,
+  className = "",
+  style,
+  ariaLabel,
+}: {
+  icon?: JSX.Element;
+  children: React.ReactNode;
+  dataTestId?: string;
+  shortcut?: string;
+  className?: string;
+  href: string;
+  style?: React.CSSProperties;
+  ariaLabel?: string;
+}) => {
+  return (
+    <a
+      href={href}
+      target="_blank"
+      rel="noreferrer"
+      className={getDrodownMenuItemClassName(className)}
+      style={style}
+      data-testid={dataTestId}
+      title={ariaLabel}
+      aria-label={ariaLabel}
+    >
+      <MenuItemContent icon={icon} shortcut={shortcut}>
+        {children}
+      </MenuItemContent>
+    </a>
+  );
+};
+
+export default DropdownMenuItemLink;
+DropdownMenuItemLink.displayName = "DropdownMenuItemLink";

+ 14 - 0
src/components/dropdownMenu/DropdownMenuSeparator.tsx

@@ -0,0 +1,14 @@
+import React from "react";
+
+const MenuSeparator = () => (
+  <div
+    style={{
+      height: "1px",
+      backgroundColor: "var(--default-border-color)",
+      margin: ".5rem 0",
+    }}
+  />
+);
+
+export default MenuSeparator;
+MenuSeparator.displayName = "DropdownMenuSeparator";

+ 37 - 0
src/components/dropdownMenu/DropdownMenuTrigger.tsx

@@ -0,0 +1,37 @@
+import clsx from "clsx";
+import { useDevice, useExcalidrawAppState } from "../App";
+
+const MenuTrigger = ({
+  className = "",
+  children,
+  onToggle,
+}: {
+  className?: string;
+  children: React.ReactNode;
+  onToggle: () => void;
+}) => {
+  const appState = useExcalidrawAppState();
+  const device = useDevice();
+  const classNames = clsx(
+    `dropdown-menu-button ${className}`,
+    "zen-mode-transition",
+    {
+      "transition-left": appState.zenModeEnabled,
+      "dropdown-menu-button--mobile": device.isMobile,
+    },
+  ).trim();
+  return (
+    <button
+      data-prevent-outside-click
+      className={classNames}
+      onClick={onToggle}
+      type="button"
+      data-testid="dropdown-menu-button"
+    >
+      {children}
+    </button>
+  );
+};
+
+export default MenuTrigger;
+MenuTrigger.displayName = "DropdownMenuTrigger";

+ 35 - 0
src/components/dropdownMenu/dropdownMenuUtils.ts

@@ -0,0 +1,35 @@
+import React from "react";
+
+export const getMenuTriggerComponent = (children: React.ReactNode) => {
+  const comp = React.Children.toArray(children).find(
+    (child) =>
+      React.isValidElement(child) &&
+      typeof child.type !== "string" &&
+      //@ts-ignore
+      child?.type.displayName &&
+      //@ts-ignore
+      child.type.displayName === "DropdownMenuTrigger",
+  );
+  if (!comp) {
+    return null;
+  }
+  //@ts-ignore
+  return comp;
+};
+
+export const getMenuContentComponent = (children: React.ReactNode) => {
+  const comp = React.Children.toArray(children).find(
+    (child) =>
+      React.isValidElement(child) &&
+      typeof child.type !== "string" &&
+      //@ts-ignore
+      child?.type.displayName &&
+      //@ts-ignore
+      child.type.displayName === "DropdownMenuContent",
+  );
+  if (!comp) {
+    return null;
+  }
+  //@ts-ignore
+  return comp;
+};

+ 4 - 5
src/components/footer/Footer.tsx

@@ -1,7 +1,7 @@
 import clsx from "clsx";
 import { ActionManager } from "../../actions/manager";
 import { t } from "../../i18n";
-import { AppState } from "../../types";
+import { AppState, UIChildrenComponents } from "../../types";
 import {
   ExitZenModeAction,
   FinalizeAction,
@@ -13,20 +13,19 @@ import { WelcomeScreenHelpArrow } from "../icons";
 import { Section } from "../Section";
 import Stack from "../Stack";
 import WelcomeScreenDecor from "../WelcomeScreenDecor";
-import FooterCenter from "./FooterCenter";
 
 const Footer = ({
   appState,
   actionManager,
   showExitZenModeBtn,
   renderWelcomeScreen,
-  children,
+  footerCenter,
 }: {
   appState: AppState;
   actionManager: ActionManager;
   showExitZenModeBtn: boolean;
   renderWelcomeScreen: boolean;
-  children?: React.ReactNode;
+  footerCenter: UIChildrenComponents["FooterCenter"];
 }) => {
   const device = useDevice();
   const showFinalize =
@@ -71,7 +70,7 @@ const Footer = ({
           </Section>
         </Stack.Col>
       </div>
-      <FooterCenter>{children}</FooterCenter>
+      {footerCenter}
       <div
         className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
           "transition-right disable-pointerEvents": appState.zenModeEnabled,

+ 10 - 0
src/components/footer/FooterCenter.scss

@@ -0,0 +1,10 @@
+.footer-center {
+  pointer-events: none;
+  & > * {
+    pointer-events: all;
+  }
+
+  display: flex;
+  width: 100%;
+  justify-content: flex-start;
+}

+ 2 - 1
src/components/footer/FooterCenter.tsx

@@ -1,11 +1,12 @@
 import clsx from "clsx";
 import { useExcalidrawAppState } from "../App";
+import "./FooterCenter.scss";
 
 const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
   const appState = useExcalidrawAppState();
   return (
     <div
-      className={clsx("layer-ui__wrapper__footer-center zen-mode-transition", {
+      className={clsx("footer-center zen-mode-transition", {
         "layer-ui__wrapper__footer-left--transition-bottom":
           appState.zenModeEnabled,
       })}

+ 174 - 0
src/components/mainMenu/DefaultItems.tsx

@@ -0,0 +1,174 @@
+import clsx from "clsx";
+import { getShortcutFromShortcutName } from "../../actions/shortcuts";
+import { t } from "../../i18n";
+import {
+  useExcalidrawAppState,
+  useExcalidrawSetAppState,
+  useExcalidrawActionManager,
+} from "../App";
+import { ExportIcon, ExportImageIcon, UsersIcon } from "../icons";
+import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
+import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
+import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
+
+export const LoadScene = () => {
+  const appState = useExcalidrawAppState();
+  const actionManager = useExcalidrawActionManager();
+  if (appState.viewModeEnabled) {
+    return null;
+  }
+  return actionManager.renderAction("loadScene");
+};
+LoadScene.displayName = "LoadScene";
+
+export const SaveToActiveFile = () => {
+  const appState = useExcalidrawAppState();
+  const actionManager = useExcalidrawActionManager();
+  if (!appState.fileHandle) {
+    return null;
+  }
+  return actionManager.renderAction("saveToActiveFile");
+};
+SaveToActiveFile.displayName = "SaveToActiveFile";
+
+export const SaveAsImage = () => {
+  const setAppState = useExcalidrawSetAppState();
+  // Hack until we tie "t" to lang state
+  // eslint-disable-next-line
+  const appState = useExcalidrawAppState();
+  return (
+    <DropdownMenuItem
+      icon={ExportImageIcon}
+      dataTestId="image-export-button"
+      onSelect={() => setAppState({ openDialog: "imageExport" })}
+      shortcut={getShortcutFromShortcutName("imageExport")}
+      ariaLabel={t("buttons.exportImage")}
+    >
+      {t("buttons.exportImage")}
+    </DropdownMenuItem>
+  );
+};
+SaveAsImage.displayName = "SaveAsImage";
+
+export const Help = () => {
+  // Hack until we tie "t" to lang state
+  // eslint-disable-next-line
+  const appState = useExcalidrawAppState();
+
+  const actionManager = useExcalidrawActionManager();
+  return actionManager.renderAction("toggleShortcuts", undefined, true);
+};
+Help.displayName = "Help";
+
+export const ClearCanvas = () => {
+  const appState = useExcalidrawAppState();
+  const actionManager = useExcalidrawActionManager();
+
+  if (appState.viewModeEnabled) {
+    return null;
+  }
+  return actionManager.renderAction("clearCanvas");
+};
+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");
+};
+ToggleTheme.displayName = "ToggleTheme";
+
+export const ChangeCanvasBackground = () => {
+  const appState = useExcalidrawAppState();
+  const actionManager = useExcalidrawActionManager();
+
+  if (appState.viewModeEnabled) {
+    return null;
+  }
+  return (
+    <div style={{ marginTop: "0.5rem" }}>
+      <div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
+        {t("labels.canvasBackground")}
+      </div>
+      <div style={{ padding: "0 0.625rem" }}>
+        {actionManager.renderAction("changeViewBackgroundColor")}
+      </div>
+    </div>
+  );
+};
+ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
+
+export const Export = () => {
+  // Hack until we tie "t" to lang state
+  // eslint-disable-next-line
+  const appState = useExcalidrawAppState();
+  const setAppState = useExcalidrawSetAppState();
+  return (
+    <DropdownMenuItem
+      icon={ExportIcon}
+      onSelect={() => {
+        setAppState({ openDialog: "jsonExport" });
+      }}
+      dataTestId="json-export-button"
+      ariaLabel={t("buttons.export")}
+    >
+      {t("buttons.export")}
+    </DropdownMenuItem>
+  );
+};
+Export.displayName = "Export";
+
+export const Socials = () => (
+  <>
+    <DropdownMenuItemLink
+      icon={GithubIcon}
+      href="https://github.com/excalidraw/excalidraw"
+      ariaLabel="GitHub"
+    >
+      GitHub
+    </DropdownMenuItemLink>
+    <DropdownMenuItemLink
+      icon={DiscordIcon}
+      href="https://discord.gg/UexuTaE"
+      ariaLabel="Discord"
+    >
+      Discord
+    </DropdownMenuItemLink>
+    <DropdownMenuItemLink
+      icon={TwitterIcon}
+      href="https://twitter.com/excalidraw"
+      ariaLabel="Twitter"
+    >
+      Twitter
+    </DropdownMenuItemLink>
+  </>
+);
+Socials.displayName = "Socials";
+
+export const LiveCollaboration = ({
+  onSelect,
+  isCollaborating,
+}: {
+  onSelect: () => void;
+  isCollaborating: boolean;
+}) => {
+  // Hack until we tie "t" to lang state
+  // eslint-disable-next-line
+  const appState = useExcalidrawAppState();
+  return (
+    <DropdownMenuItem
+      dataTestId="collab-button"
+      icon={UsersIcon}
+      className={clsx({
+        "active-collab": isCollaborating,
+      })}
+      onSelect={onSelect}
+    >
+      {t("labels.liveCollaboration")}
+    </DropdownMenuItem>
+  );
+};
+
+LiveCollaboration.displayName = "LiveCollaboration";

+ 56 - 0
src/components/mainMenu/MainMenu.tsx

@@ -0,0 +1,56 @@
+import React from "react";
+import {
+  useDevice,
+  useExcalidrawAppState,
+  useExcalidrawSetAppState,
+} from "../App";
+import DropdownMenu from "../dropdownMenu/DropdownMenu";
+
+import * as DefaultItems from "./DefaultItems";
+
+import { UserList } from "../UserList";
+import { t } from "../../i18n";
+import { HamburgerMenuIcon } from "../icons";
+
+const MainMenu = ({ children }: { children?: React.ReactNode }) => {
+  const device = useDevice();
+  const appState = useExcalidrawAppState();
+  const setAppState = useExcalidrawSetAppState();
+  const onClickOutside = device.isMobile
+    ? undefined
+    : () => setAppState({ openMenu: null });
+  return (
+    <DropdownMenu open={appState.openMenu === "canvas"}>
+      <DropdownMenu.Trigger
+        onToggle={() => {
+          setAppState({
+            openMenu: appState.openMenu === "canvas" ? null : "canvas",
+          });
+        }}
+      >
+        {HamburgerMenuIcon}
+      </DropdownMenu.Trigger>
+      <DropdownMenu.Content onClickOutside={onClickOutside}>
+        {children}
+        {device.isMobile && appState.collaborators.size > 0 && (
+          <fieldset className="UserList-Wrapper">
+            <legend>{t("labels.collaborators")}</legend>
+            <UserList mobile={true} collaborators={appState.collaborators} />
+          </fieldset>
+        )}
+      </DropdownMenu.Content>
+    </DropdownMenu>
+  );
+};
+
+MainMenu.Trigger = DropdownMenu.Trigger;
+MainMenu.Item = DropdownMenu.Item;
+MainMenu.ItemLink = DropdownMenu.ItemLink;
+MainMenu.ItemCustom = DropdownMenu.ItemCustom;
+MainMenu.Group = DropdownMenu.Group;
+MainMenu.Separator = DropdownMenu.Separator;
+MainMenu.DefaultItems = DefaultItems;
+
+export default MainMenu;
+
+MainMenu.displayName = "Menu";

+ 14 - 0
src/css/styles.scss

@@ -569,6 +569,20 @@
       display: none;
     }
   }
+  .UserList-Wrapper {
+    margin: 0;
+    padding: 0;
+    border: none;
+    text-align: left;
+
+    legend {
+      display: block;
+      font-size: 0.75rem;
+      font-weight: 400;
+      margin: 0 0 0.25rem;
+      padding: 0;
+    }
+  }
 }
 
 .ErrorSplash.excalidraw {

+ 15 - 17
src/excalidraw-app/components/LanguageList.tsx

@@ -8,23 +8,21 @@ export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
   const [langCode, setLangCode] = useAtom(langCodeAtom);
 
   return (
-    <React.Fragment>
-      <select
-        className="dropdown-select dropdown-select__language"
-        onChange={({ target }) => setLangCode(target.value)}
-        value={langCode}
-        aria-label={i18n.t("buttons.selectLanguage")}
-        style={style}
-      >
-        <option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
-          {i18n.defaultLang.label}
+    <select
+      className="dropdown-select dropdown-select__language"
+      onChange={({ target }) => setLangCode(target.value)}
+      value={langCode}
+      aria-label={i18n.t("buttons.selectLanguage")}
+      style={style}
+    >
+      <option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
+        {i18n.defaultLang.label}
+      </option>
+      {languages.map((lang) => (
+        <option key={lang.code} value={lang.code}>
+          {lang.label}
         </option>
-        {languages.map((lang) => (
-          <option key={lang.code} value={lang.code}>
-            {lang.label}
-          </option>
-        ))}
-      </select>
-    </React.Fragment>
+      ))}
+    </select>
   );
 };

+ 23 - 1
src/excalidraw-app/index.scss

@@ -4,7 +4,7 @@
   &.theme--dark {
     --color-primary-contrast-offset: #726dff; // to offset Chubb illusion
   }
-  .layer-ui__wrapper .layer-ui__wrapper__footer-center {
+  .footer-center {
     justify-content: flex-end;
     margin-top: auto;
     margin-bottom: auto;
@@ -24,7 +24,29 @@
       height: 1.2rem;
     }
   }
+
+  .dropdown-menu-container {
+    .dropdown-menu-item {
+      &.active-collab {
+        background-color: #ecfdf5;
+        color: #064e3c;
+      }
+      &.ExcalidrawPlus {
+        color: var(--color-promo);
+      }
+    }
+  }
+
+  &.theme--dark {
+    .dropdown-menu-item {
+      &.active-collab {
+        background-color: #064e3c;
+        color: #ecfdf5;
+      }
+    }
+  }
 }
+
 .excalidraw-app.is-collaborating {
   [data-testid="clear-canvas-button"] {
     display: none;

+ 43 - 2
src/excalidraw-app/index.tsx

@@ -21,7 +21,12 @@ import {
 } from "../element/types";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
 import { t } from "../i18n";
-import { Excalidraw, defaultLang, Footer } from "../packages/excalidraw/index";
+import {
+  Excalidraw,
+  defaultLang,
+  Footer,
+  MainMenu,
+} from "../packages/excalidraw/index";
 import {
   AppState,
   LibraryItems,
@@ -79,8 +84,11 @@ import { reconcileElements } from "./collab/reconciliation";
 import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
 import { EncryptedIcon } from "./components/EncryptedIcon";
 import { ExcalidrawPlusAppLink } from "./components/ExcalidrawPlusAppLink";
+import { LanguageList } from "./components/LanguageList";
+import { PlusPromoIcon } from "../components/icons";
 
 polyfill();
+
 window.EXCALIDRAW_THROTTLE_RENDER = true;
 
 const languageDetector = new LanguageDetector();
@@ -229,7 +237,6 @@ export const langCodeAtom = atom(
 const ExcalidrawWrapper = () => {
   const [errorMessage, setErrorMessage] = useState("");
   const [langCode, setLangCode] = useAtom(langCodeAtom);
-
   // initial state
   // ---------------------------------------------------------------------------
 
@@ -594,6 +601,39 @@ const ExcalidrawWrapper = () => {
     localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
   };
 
+  const renderMenu = () => {
+    return (
+      <MainMenu>
+        <MainMenu.DefaultItems.LoadScene />
+        <MainMenu.DefaultItems.SaveToActiveFile />
+        <MainMenu.DefaultItems.Export />
+        <MainMenu.DefaultItems.SaveAsImage />
+        <MainMenu.DefaultItems.LiveCollaboration
+          isCollaborating={isCollaborating}
+          onSelect={() => setCollabDialogShown(true)}
+        />
+
+        <MainMenu.DefaultItems.Help />
+        <MainMenu.DefaultItems.ClearCanvas />
+        <MainMenu.Separator />
+        <MainMenu.ItemLink
+          icon={PlusPromoIcon}
+          href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
+          className="ExcalidrawPlus"
+        >
+          Excalidraw+
+        </MainMenu.ItemLink>
+        <MainMenu.DefaultItems.Socials />
+        <MainMenu.Separator />
+        <MainMenu.DefaultItems.ToggleTheme />
+        <MainMenu.ItemCustom>
+          <LanguageList style={{ width: "100%" }} />
+        </MainMenu.ItemCustom>
+        <MainMenu.DefaultItems.ChangeCanvasBackground />
+      </MainMenu>
+    );
+  };
+
   return (
     <div
       style={{ height: "100%" }}
@@ -640,6 +680,7 @@ const ExcalidrawWrapper = () => {
         autoFocus={true}
         theme={theme}
       >
+        {renderMenu()}
         <Footer>
           <div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
             <ExcalidrawPlusAppLink />

+ 2 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- Expose component API for the Excalidraw main menu [#6034](https://github.com/excalidraw/excalidraw/pull/6034), You can read more about its usage [here](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#MainMenu)
+
 - Render Footer as a component instead of render prop [#5970](https://github.com/excalidraw/excalidraw/pull/5970). You can read more about its usage [here](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#Footer)
 
 #### BREAKING CHANGE

+ 236 - 0
src/packages/excalidraw/README.md

@@ -405,6 +405,195 @@ const App = () => {
 };
 ```
 
+This will only for `Desktop` devices.
+
+For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
+
+```js
+import { useDevice, Footer } from "@excalidraw/excalidraw";
+
+const MobileFooter = ({
+}) => {
+  const device = useDevice();
+  if (device.isMobile) {
+    return (
+      <Footer>
+       <button
+        className="custom-footer"
+        onClick={() => alert("This is custom footer in mobile menu")}
+      >
+        {" "}
+        custom footer{" "}
+      </button>
+      </Footer>
+    );
+  }
+  return null;
+
+};
+const App = () => {
+  <Excalidraw>
+    <MainMenu>
+      <MainMenu.Item onSelect={() => window.alert("Item1")}> Item1 </MainMenu.Item>
+      <MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </>
+      <MobileFooter/>
+    </MainMenu>
+  </Excalidraw>
+}
+
+```
+
+You can visit the[ example](https://ehlz3.csb.app/) for working demo.
+
+#### MainMenu
+
+By default Excalidraw will render the `MainMenu` with default options. If you want to customise the `MainMenu`, you can pass the `MainMenu` component with the list options. You can visit [codesandbox example](https://ehlz3.csb.app/) for a working demo.
+
+**Usage**
+
+```js
+import { MainMenu } from "@excalidraw/excalidraw";
+const App = () => {
+  <Excalidraw>
+    <MainMenu>
+      <MainMenu.Item onSelect={() => window.alert("Item1")}> Item1 </MainMenu.Item>
+      <MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </>
+    </MainMenu>
+  </Excalidraw>
+}
+```
+
+**MainMenu**
+
+This is the `MainMenu` component which you need to import to render the menu with custom options.
+
+**MainMenu.Item**
+
+To render an item, its recommended to use `MainMenu.Item`.
+
+| Prop | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| `onSelect` | `Function` | Yes | `undefined` | The handler is triggered when the item is selected. |
+| `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item |
+| `icon` | `JSX.Element` | No | `undefined` | The icon used in the menu item |
+| `shortcut` | `string` | No | `undefined` | The shortcut to be shown for the menu item |
+| `className` | `string` | No | "" | The class names to be added to the menu item |
+| `style` | `React.CSSProperties` | No | `undefined` | The inline styles to be added to the menu item |
+| `ariaLabel` | `string` | `undefined` | No | The `aria-label` to be added to the item for accessibility |
+| `dataTestId` | `string` | `undefined` | No | The `data-testid` to be added to the item. |
+
+**MainMenu.ItemLink**
+
+To render an item as a link, its recommended to use `MainMenu.ItemLink`.
+
+**Usage**
+
+```js
+import { MainMenu } from "@excalidraw/excalidraw";
+const App = () => {
+  <Excalidraw>
+    <MainMenu>
+      <MainMenu.ItemLink href="https://google.com">Google</MainMenu.ItemLink>
+      <MainMenu.ItemLink href="https://excalidraw.com">
+        Excalidraw
+      </MainMenu.ItemLink>
+    </MainMenu>
+  </Excalidraw>;
+};
+```
+
+| Prop | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| `href` | `string` | Yes | `undefined` | The `href` attribute to be added to the `anchor` element. |
+| `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item |
+| `icon` | `JSX.Element` | No | `undefined` | The icon used in the menu item |
+| `shortcut` | `string` | No | `undefined` | The shortcut to be shown for the menu item |
+| `className` | `string` | No | "" | The class names to be added to the menu item |
+| `style` | `React.CSSProperties` | No | `undefined` | The inline styles to be added to the menu item |
+| `ariaLabel` | `string` | No | `undefined` | The `aria-label` to be added to the item for accessibility |
+| `dataTestId` | `string` | No | `undefined` | The `data-testid` to be added to the item. |
+
+**MainMenu.ItemCustom**
+
+To render a custom item, you can use `MainMenu.ItemCustom`.
+
+**Usage**
+
+```js
+import { MainMenu } from "@excalidraw/excalidraw";
+const App = () => {
+  <Excalidraw>
+    <MainMenu>
+      <MainMenu.ItemCustom>
+        <button
+          style={{ height: "2rem" }}
+          onClick={() => window.alert("custom menu item")}
+        >
+          {" "}
+          custom item
+        </button>
+      </MainMenu.ItemCustom>
+    </MainMenu>
+  </Excalidraw>;
+};
+```
+
+| Prop | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item |
+| `className` | `string` | No | "" | The class names to be added to the menu item |
+| `style` | `React.CSSProperties` | No | `undefined` | The inline styles to be added to the menu item |
+| `dataTestId` | `string` | No | `undefined` | The `data-testid` to be added to the item. |
+
+**MainMenu.DefaultItems**
+
+For the items which are shown in the menu in [excalidraw.com](https://excalidraw.com), you can use `MainMenu.DefaultItems`
+
+```js
+import { MainMenu } from "@excalidraw/excalidraw";
+const App = () => {
+  <Excalidraw>
+    <MainMenu>
+      <MainMenu.DefaultItems.Socials/>
+      <MainMenu.DefaultItems.Export/>
+      <MainMenu.Item onSelect={() => window.alert("Item1")}> Item1 </MainMenu.Item>
+      <MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </>
+    </MainMenu>
+  </Excalidraw>
+}
+```
+
+Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/src/components/mainMenu/DefaultItems.tsx) of the default items.
+
+**MainMenu.Group**
+
+To Group item in the main menu, you can use `MainMenu.Group`
+
+```js
+import { MainMenu } from "@excalidraw/excalidraw";
+const App = () => {
+  <Excalidraw>
+    <MainMenu>
+      <MainMenu.Group title="Excalidraw items">
+        <MainMenu.DefaultItems.Socials/>
+        <MainMenu.DefaultItems.Export/>
+      </MainMenu.Group>
+      <MainMenu.Group title="custom items">
+        <MainMenu.Item onSelect={() => window.alert("Item1")}> Item1 </MainMenu.Item>
+        <MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </>
+      </MainMenu.Group>
+    </MainMenu>
+  </Excalidraw>
+}
+```
+
+| Prop | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| `children ` | `React.ReactNode` | Yes | `undefined` | The content of the `Menu Group` |
+| `title` | `string` | No | `undefined` | The `title` for the grouped items |
+| `className` | `string` | No | "" | The `classname` to be added to the group |
+| `style` | `React.CSsSProperties` | No | `undefined` | The inline `styles` to be added to the group |
+
 ### Props
 
 | Name | Type | Default | Description |
@@ -1369,6 +1558,53 @@ viewportCoordsToSceneCoords({clientX: number, clientY: number}, appState: <a hre
 
 This function returns equivalent scene coords for the provided viewport coords in params.
 
+#### useDevice
+
+This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component
+
+```js
+import { useDevice, Footer } from "@excalidraw/excalidraw";
+
+const MobileFooter = ({
+}) => {
+  const device = useDevice();
+  if (device.isMobile) {
+    return (
+      <Footer>
+       <button
+        className="custom-footer"
+        onClick={() => alert("This is custom footer in mobile menu")}
+      >
+        {" "}
+        custom footer{" "}
+      </button>
+      </Footer>
+    );
+  }
+  return null;
+
+};
+const App = () => {
+  <Excalidraw>
+    <MainMenu>
+      <MainMenu.Item> Item1 </MainMenu.Item>
+      <MainMenu.Item> Item 2 </>
+      <MobileFooter/>
+    </MainMenu>
+  </Excalidraw>
+}
+
+```
+
+The `device` has the following `attributes`
+
+| Name | Type | Description |
+| --- | --- | --- |
+| `isSmScreen` | `boolean` | Set to `true` when the device small screen is small (Width < `640px` ) |
+| `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
+| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
+| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
+
 ### Exported constants
 
 #### `FONT_FAMILY`

+ 0 - 5
src/packages/excalidraw/example/App.scss

@@ -73,9 +73,4 @@
   .custom-element {
     padding: 0.1rem;
   }
-
-  &.excalidraw-container .layer-ui__wrapper .layer-ui__wrapper__footer-center {
-    // Remove once we stop importing langauge list from excalidraw app
-    justify-content: flex-start;
-  }
 }

+ 39 - 53
src/packages/excalidraw/example/App.tsx

@@ -28,6 +28,8 @@ import {
 } from "../../../types";
 import { NonDeletedExcalidrawElement } from "../../../element/types";
 import { ImportedLibraryData } from "../../../data/types";
+import CustomFooter from "./CustomFooter";
+import MobileFooter from "./MobileFooter";
 
 declare global {
   interface Window {
@@ -69,24 +71,9 @@ const {
   restoreElements,
   Sidebar,
   Footer,
+  MainMenu,
 } = window.ExcalidrawLib;
 
-const COMMENT_SVG = (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    width="24"
-    height="24"
-    viewBox="0 0 24 24"
-    fill="none"
-    stroke="currentColor"
-    strokeWidth="2"
-    strokeLinecap="round"
-    strokeLinejoin="round"
-    className="feather feather-message-circle"
-  >
-    <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
-  </svg>
-);
 const COMMENT_ICON_DIMENSION = 32;
 const COMMENT_INPUT_HEIGHT = 50;
 const COMMENT_INPUT_WIDTH = 150;
@@ -343,6 +330,7 @@ export default function App() {
       }
     });
   };
+
   const renderCommentIcons = () => {
     return Object.values(commentIcons).map((commentIcon) => {
       if (!excalidrawAPI) {
@@ -495,6 +483,35 @@ export default function App() {
     );
   };
 
+  const renderMenu = () => {
+    return (
+      <MainMenu>
+        <MainMenu.DefaultItems.SaveAsImage />
+        <MainMenu.DefaultItems.Export />
+        <MainMenu.Separator />
+        {isCollaborating && (
+          <MainMenu.DefaultItems.LiveCollaboration
+            onSelect={() => window.alert("You clicked on collab button")}
+            isCollaborating={isCollaborating}
+          />
+        )}
+        <MainMenu.Group title="Excalidraw links">
+          <MainMenu.DefaultItems.Socials />
+        </MainMenu.Group>
+        <MainMenu.Separator />
+        <MainMenu.ItemCustom>
+          <button
+            style={{ height: "2rem" }}
+            onClick={() => window.alert("custom menu item")}
+          >
+            custom item
+          </button>
+        </MainMenu.ItemCustom>
+        <MainMenu.DefaultItems.Help />
+        {excalidrawAPI && <MobileFooter excalidrawAPI={excalidrawAPI} />}
+      </MainMenu>
+    );
+  };
   return (
     <div className="App" ref={appRef}>
       <h1> Excalidraw Example</h1>
@@ -675,43 +692,12 @@ export default function App() {
             onScrollChange={rerenderCommentIcons}
             renderSidebar={renderSidebar}
           >
-            <Footer>
-              <button
-                className="custom-element"
-                onClick={() => {
-                  excalidrawAPI?.setActiveTool({
-                    type: "custom",
-                    customType: "comment",
-                  });
-                  const url = `data:${MIME_TYPES.svg},${encodeURIComponent(
-                    `<svg
-              xmlns="http://www.w3.org/2000/svg"
-              width="24"
-              height="24"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              stroke-width="2"
-              stroke-linecap="round"
-              stroke-linejoin="round"
-              class="feather feather-message-circle"
-            >
-              <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
-            </svg>`,
-                  )}`;
-                  excalidrawAPI?.setCursor(`url(${url}), auto`);
-                }}
-              >
-                {COMMENT_SVG}
-              </button>
-              <button
-                className="custom-footer"
-                onClick={() => alert("This is dummy footer")}
-              >
-                {" "}
-                custom footer{" "}
-              </button>
-            </Footer>
+            {excalidrawAPI && (
+              <Footer>
+                <CustomFooter excalidrawAPI={excalidrawAPI} />
+              </Footer>
+            )}
+            {renderMenu()}
           </Excalidraw>
           {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
           {comment && renderComment()}

+ 65 - 0
src/packages/excalidraw/example/CustomFooter.tsx

@@ -0,0 +1,65 @@
+import { ExcalidrawImperativeAPI } from "../../../types";
+import { MIME_TYPES } from "../entry";
+const COMMENT_SVG = (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="24"
+    height="24"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    strokeWidth="2"
+    strokeLinecap="round"
+    strokeLinejoin="round"
+    className="feather feather-message-circle"
+  >
+    <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
+  </svg>
+);
+const CustomFooter = ({
+  excalidrawAPI,
+}: {
+  excalidrawAPI: ExcalidrawImperativeAPI;
+}) => {
+  return (
+    <>
+      <button
+        className="custom-element"
+        onClick={() => {
+          excalidrawAPI?.setActiveTool({
+            type: "custom",
+            customType: "comment",
+          });
+          const url = `data:${MIME_TYPES.svg},${encodeURIComponent(
+            `<svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="24"
+    height="24"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    stroke-width="2"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+    class="feather feather-message-circle"
+  >
+    <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
+  </svg>`,
+          )}`;
+          excalidrawAPI?.setCursor(`url(${url}), auto`);
+        }}
+      >
+        {COMMENT_SVG}
+      </button>
+      <button
+        className="custom-footer"
+        onClick={() => alert("This is dummy footer")}
+      >
+        {" "}
+        custom footer{" "}
+      </button>
+    </>
+  );
+};
+
+export default CustomFooter;

+ 20 - 0
src/packages/excalidraw/example/MobileFooter.tsx

@@ -0,0 +1,20 @@
+import { ExcalidrawImperativeAPI } from "../../../types";
+import CustomFooter from "./CustomFooter";
+const { useDevice, Footer } = window.ExcalidrawLib;
+
+const MobileFooter = ({
+  excalidrawAPI,
+}: {
+  excalidrawAPI: ExcalidrawImperativeAPI;
+}) => {
+  const device = useDevice();
+  if (device.isMobile) {
+    return (
+      <Footer>
+        <CustomFooter excalidrawAPI={excalidrawAPI} />
+      </Footer>
+    );
+  }
+  return null;
+};
+export default MobileFooter;

+ 3 - 0
src/packages/excalidraw/index.tsx

@@ -11,6 +11,7 @@ import { DEFAULT_UI_OPTIONS } from "../../constants";
 import { Provider } from "jotai";
 import { jotaiScope, jotaiStore } from "../../jotai";
 import Footer from "../../components/footer/FooterCenter";
+import MainMenu from "../../components/mainMenu/MainMenu";
 
 const ExcalidrawBase = (props: ExcalidrawProps) => {
   const {
@@ -239,3 +240,5 @@ export {
 
 export { Sidebar } from "../../components/Sidebar/Sidebar";
 export { Footer };
+export { MainMenu };
+export { useDevice } from "../../components/App";

+ 2 - 2
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -14613,7 +14613,7 @@ Object {
   "offsetLeft": 0,
   "offsetTop": 0,
   "openDialog": null,
-  "openMenu": null,
+  "openMenu": "canvas",
   "openPopup": null,
   "openSidebar": null,
   "pasteDialog": Object {
@@ -14672,7 +14672,7 @@ Object {
 
 exports[`regression tests rerenders UI on language change: [end of test] number of elements 1`] = `0`;
 
-exports[`regression tests rerenders UI on language change: [end of test] number of renders 1`] = `10`;
+exports[`regression tests rerenders UI on language change: [end of test] number of renders 1`] = `11`;
 
 exports[`regression tests shift click on selected element should deselect it on pointer up: [end of test] appState 1`] = `
 Object {

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

@@ -1,28 +1,23 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide any UI element when canvasActions is "undefined" 1`] = `
-<section
-  aria-labelledby="test-id-canvasActions-title"
+exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu with default items when "UIOPtions" is "undefined" 1`] = `
+<div
+  class="dropdown-menu"
+  data-testid="dropdown-menu"
 >
-  <h2
-    class="visually-hidden"
-    id="test-id-canvasActions-title"
-  >
-    Canvas actions
-  </h2>
   <div
-    class="Island menu-container"
+    class="Island dropdown-menu-container"
     style="--padding: 2; z-index: 1;"
   >
     <button
       aria-label="Open"
-      class="menu-item"
+      class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="load-button"
       title="Open"
       type="button"
     >
       <div
-        class="menu-item__icon"
+        class="dropdown-menu-item__icon"
       >
         <svg
           aria-hidden="true"
@@ -42,25 +37,25 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
         </svg>
       </div>
       <div
-        class="menu-item__text"
+        class="dropdown-menu-item__text"
       >
         Open
       </div>
       <div
-        class="menu-item__shortcut"
+        class="dropdown-menu-item__shortcut"
       >
         Ctrl+O
       </div>
     </button>
     <button
       aria-label="Save to..."
-      class="menu-item"
+      class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="json-export-button"
       title="Save to..."
       type="button"
     >
       <div
-        class="menu-item__icon"
+        class="dropdown-menu-item__icon"
       >
         <svg
           aria-hidden="true"
@@ -80,20 +75,20 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
         </svg>
       </div>
       <div
-        class="menu-item__text"
+        class="dropdown-menu-item__text"
       >
         Save to...
       </div>
     </button>
     <button
       aria-label="Export image..."
-      class="menu-item"
+      class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="image-export-button"
       title="Export image..."
       type="button"
     >
       <div
-        class="menu-item__icon"
+        class="dropdown-menu-item__icon"
       >
         <svg
           aria-hidden="true"
@@ -137,25 +132,25 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
         </svg>
       </div>
       <div
-        class="menu-item__text"
+        class="dropdown-menu-item__text"
       >
         Export image...
       </div>
       <div
-        class="menu-item__shortcut"
+        class="dropdown-menu-item__shortcut"
       >
         Ctrl+Shift+E
       </div>
     </button>
     <button
       aria-label="Help"
-      class="menu-item"
+      class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="help-menu-item"
       title="Help"
       type="button"
     >
       <div
-        class="menu-item__icon"
+        class="dropdown-menu-item__icon"
       >
         <svg
           aria-hidden="true"
@@ -195,25 +190,25 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
         </svg>
       </div>
       <div
-        class="menu-item__text"
+        class="dropdown-menu-item__text"
       >
         Help
       </div>
       <div
-        class="menu-item__shortcut"
+        class="dropdown-menu-item__shortcut"
       >
         ?
       </div>
     </button>
     <button
       aria-label="Reset the canvas"
-      class="menu-item"
+      class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="clear-canvas-button"
       title="Reset the canvas"
       type="button"
     >
       <div
-        class="menu-item__icon"
+        class="dropdown-menu-item__icon"
       >
         <svg
           aria-hidden="true"
@@ -233,7 +228,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
         </svg>
       </div>
       <div
-        class="menu-item__text"
+        class="dropdown-menu-item__text"
       >
         Reset the canvas
       </div>
@@ -241,71 +236,144 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
     <div
       style="height: 1px; margin: .5rem 0px;"
     />
-    <a
-      class="menu-item"
-      href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
-      rel="noreferrer"
-      target="_blank"
+    <div
+      class="dropdown-menu-group "
     >
-      <div
-        class="menu-item__icon"
+      <p
+        class="dropdown-menu-group-title"
+      >
+        Excalidraw links
+      </p>
+      <a
+        aria-label="GitHub"
+        class="dropdown-menu-item dropdown-menu-item-base"
+        href="https://github.com/excalidraw/excalidraw"
+        rel="noreferrer"
+        target="_blank"
+        title="GitHub"
       >
-        <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"
+        <div
+          class="dropdown-menu-item__icon"
         >
-          <g
-            stroke-width="1.5"
-          >
-            <path
-              d="M0 0h24v24H0z"
-              fill="none"
-              stroke="none"
-            />
-            <rect
-              height="4"
-              rx="1"
-              width="18"
-              x="3"
-              y="8"
-            />
-            <line
-              x1="12"
-              x2="12"
-              y1="8"
-              y2="21"
-            />
-            <path
-              d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
-            />
-            <path
-              d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
-            />
-          </g>
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
+          <svg
+            aria-hidden="true"
+            class=""
+            fill="none"
+            focusable="false"
+            role="img"
+            stroke="currentColor"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            viewBox="0 0 20 20"
+          >
+            <path
+              d="M7.5 15.833c-3.583 1.167-3.583-2.083-5-2.5m10 4.167v-2.917c0-.833.083-1.166-.417-1.666 2.334-.25 4.584-1.167 4.584-5a3.833 3.833 0 0 0-1.084-2.667 3.5 3.5 0 0 0-.083-2.667s-.917-.25-2.917 1.084a10.25 10.25 0 0 0-5.166 0C5.417 2.333 4.5 2.583 4.5 2.583a3.5 3.5 0 0 0-.083 2.667 3.833 3.833 0 0 0-1.084 2.667c0 3.833 2.25 4.75 4.584 5-.5.5-.5 1-.417 1.666V17.5"
+              stroke-width="1.25"
+            />
+          </svg>
+        </div>
+        <div
+          class="dropdown-menu-item__text"
+        >
+          GitHub
+        </div>
+      </a>
+      <a
+        aria-label="Discord"
+        class="dropdown-menu-item dropdown-menu-item-base"
+        href="https://discord.gg/UexuTaE"
+        rel="noreferrer"
+        target="_blank"
+        title="Discord"
       >
-        Excalidraw+
-      </div>
-    </a>
-    <a
-      class="menu-item"
-      href="https://github.com/excalidraw/excalidraw"
-      rel="noopener noreferrer"
-      target="_blank"
+        <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"
+            viewBox="0 0 20 20"
+          >
+            <g
+              stroke-width="1.25"
+            >
+              <path
+                d="M7.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM12.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM6.25 6.25c2.917-.833 4.583-.833 7.5 0M5.833 13.75c2.917.833 5.417.833 8.334 0"
+              />
+              <path
+                d="M12.917 14.167c0 .833 1.25 2.5 1.666 2.5 1.25 0 2.361-1.39 2.917-2.5.556-1.39.417-4.861-1.25-9.584-1.214-.846-2.5-1.116-3.75-1.25l-.833 2.084M7.083 14.167c0 .833-1.13 2.5-1.526 2.5-1.191 0-2.249-1.39-2.778-2.5-.529-1.39-.397-4.861 1.19-9.584 1.157-.846 2.318-1.116 3.531-1.25l.833 2.084"
+              />
+            </g>
+          </svg>
+        </div>
+        <div
+          class="dropdown-menu-item__text"
+        >
+          Discord
+        </div>
+      </a>
+      <a
+        aria-label="Twitter"
+        class="dropdown-menu-item dropdown-menu-item-base"
+        href="https://twitter.com/excalidraw"
+        rel="noreferrer"
+        target="_blank"
+        title="Twitter"
+      >
+        <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.25"
+            >
+              <path
+                d="M0 0h24v24H0z"
+                fill="none"
+                stroke="none"
+              />
+              <path
+                d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
+              />
+            </g>
+          </svg>
+        </div>
+        <div
+          class="dropdown-menu-item__text"
+        >
+          Twitter
+        </div>
+      </a>
+    </div>
+    <div
+      style="height: 1px; margin: .5rem 0px;"
+    />
+    <button
+      aria-label="Dark mode"
+      class="dropdown-menu-item dropdown-menu-item-base"
+      data-testid="toggle-dark-mode"
+      title="Dark mode"
+      type="button"
     >
       <div
-        class="menu-item__icon"
+        class="dropdown-menu-item__icon"
       >
         <svg
           aria-hidden="true"
@@ -319,454 +387,130 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
           viewBox="0 0 20 20"
         >
           <path
-            d="M7.5 15.833c-3.583 1.167-3.583-2.083-5-2.5m10 4.167v-2.917c0-.833.083-1.166-.417-1.666 2.334-.25 4.584-1.167 4.584-5a3.833 3.833 0 0 0-1.084-2.667 3.5 3.5 0 0 0-.083-2.667s-.917-.25-2.917 1.084a10.25 10.25 0 0 0-5.166 0C5.417 2.333 4.5 2.583 4.5 2.583a3.5 3.5 0 0 0-.083 2.667 3.833 3.833 0 0 0-1.084 2.667c0 3.833 2.25 4.75 4.584 5-.5.5-.5 1-.417 1.666V17.5"
-            stroke-width="1.25"
+            clip-rule="evenodd"
+            d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
+            stroke="currentColor"
           />
         </svg>
       </div>
       <div
-        class="menu-item__text"
-      >
-        GitHub
-      </div>
-    </a>
-    <a
-      class="menu-item"
-      href="https://discord.gg/UexuTaE"
-      rel="noopener noreferrer"
-      target="_blank"
-    >
-      <div
-        class="menu-item__icon"
+        class="dropdown-menu-item__text"
       >
-        <svg
-          aria-hidden="true"
-          class=""
-          fill="none"
-          focusable="false"
-          role="img"
-          stroke="currentColor"
-          stroke-linecap="round"
-          stroke-linejoin="round"
-          viewBox="0 0 20 20"
-        >
-          <g
-            stroke-width="1.25"
-          >
-            <path
-              d="M7.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM12.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM6.25 6.25c2.917-.833 4.583-.833 7.5 0M5.833 13.75c2.917.833 5.417.833 8.334 0"
-            />
-            <path
-              d="M12.917 14.167c0 .833 1.25 2.5 1.666 2.5 1.25 0 2.361-1.39 2.917-2.5.556-1.39.417-4.861-1.25-9.584-1.214-.846-2.5-1.116-3.75-1.25l-.833 2.084M7.083 14.167c0 .833-1.13 2.5-1.526 2.5-1.191 0-2.249-1.39-2.778-2.5-.529-1.39-.397-4.861 1.19-9.584 1.157-.846 2.318-1.116 3.531-1.25l.833 2.084"
-            />
-          </g>
-        </svg>
+        Dark mode
       </div>
       <div
-        class="menu-item__text"
+        class="dropdown-menu-item__shortcut"
       >
-        Discord
+        Shift+Alt+D
       </div>
-    </a>
-    <a
-      class="menu-item"
-      href="https://twitter.com/excalidraw"
-      rel="noopener noreferrer"
-      target="_blank"
+    </button>
+    <div
+      style="margin-top: 0.5rem;"
     >
       <div
-        class="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.25"
-          >
-            <path
-              d="M0 0h24v24H0z"
-              fill="none"
-              stroke="none"
-            />
-            <path
-              d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
-            />
-          </g>
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
+        style="font-size: .75rem; margin-bottom: .5rem;"
       >
-        Twitter
-      </div>
-    </a>
-    <div
-      style="height: 1px; margin: .5rem 0px;"
-    />
-    <div
-      style="display: flex; flex-direction: column; row-gap: .5rem;"
-    >
-      <div>
-        <button
-          aria-label="Dark mode"
-          class="menu-item"
-          data-testid="toggle-dark-mode"
-          title="Dark mode"
-          type="button"
-        >
-          <div
-            class="menu-item__icon"
-          >
-            <svg
-              aria-hidden="true"
-              class=""
-              fill="none"
-              focusable="false"
-              role="img"
-              stroke="currentColor"
-              stroke-linecap="round"
-              stroke-linejoin="round"
-              viewBox="0 0 20 20"
-            >
-              <path
-                clip-rule="evenodd"
-                d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
-                stroke="currentColor"
-              />
-            </svg>
-          </div>
-          <div
-            class="menu-item__text"
-          >
-            Dark mode
-          </div>
-          <div
-            class="menu-item__shortcut"
-          >
-            Shift+Alt+D
-          </div>
-        </button>
+        Canvas background
       </div>
       <div
         style="padding: 0px 0.625rem;"
       >
-        <select
-          aria-label="Select language"
-          class="dropdown-select dropdown-select__language"
-          style="width: 100%;"
-        >
-          <option
-            value="en"
-          >
-            English
-          </option>
-          <option
-            value="id-ID"
-          >
-            Bahasa Indonesia
-          </option>
-          <option
-            value="ca-ES"
-          >
-            Català
-          </option>
-          <option
-            value="de-DE"
-          >
-            Deutsch
-          </option>
-          <option
-            value="en"
-          >
-            English
-          </option>
-          <option
-            value="es-ES"
-          >
-            Español
-          </option>
-          <option
-            value="eu-ES"
-          >
-            Euskara
-          </option>
-          <option
-            value="fr-FR"
-          >
-            Français
-          </option>
-          <option
-            value="gl-ES"
-          >
-            Galego
-          </option>
-          <option
-            value="it-IT"
-          >
-            Italiano
-          </option>
-          <option
-            value="ku-TR"
-          >
-            Kurdî
-          </option>
-          <option
-            value="lv-LV"
-          >
-            Latviešu
-          </option>
-          <option
-            value="hu-HU"
-          >
-            Magyar
-          </option>
-          <option
-            value="nl-NL"
-          >
-            Nederlands
-          </option>
-          <option
-            value="nb-NO"
-          >
-            Norsk bokmål
-          </option>
-          <option
-            value="nn-NO"
-          >
-            Norsk nynorsk
-          </option>
-          <option
-            value="oc-FR"
-          >
-            Occitan
-          </option>
-          <option
-            value="pl-PL"
-          >
-            Polski
-          </option>
-          <option
-            value="pt-PT"
-          >
-            Português
-          </option>
-          <option
-            value="pt-BR"
-          >
-            Português Brasileiro
-          </option>
-          <option
-            value="ro-RO"
-          >
-            Română
-          </option>
-          <option
-            value="sk-SK"
-          >
-            Slovenčina
-          </option>
-          <option
-            value="sl-SI"
-          >
-            Slovenščina
-          </option>
-          <option
-            value="fi-FI"
-          >
-            Suomi
-          </option>
-          <option
-            value="sv-SE"
-          >
-            Svenska
-          </option>
-          <option
-            value="kab-KAB"
-          >
-            Taqbaylit
-          </option>
-          <option
-            value="tr-TR"
-          >
-            Türkçe
-          </option>
-          <option
-            value="el-GR"
-          >
-            Ελληνικά
-          </option>
-          <option
-            value="ru-RU"
-          >
-            Русский
-          </option>
-          <option
-            value="uk-UA"
-          >
-            Українська
-          </option>
-          <option
-            value="he-IL"
-          >
-            עברית
-          </option>
-          <option
-            value="ar-SA"
-          >
-            العربية
-          </option>
-          <option
-            value="fa-IR"
-          >
-            فارسی
-          </option>
-          <option
-            value="mr-IN"
-          >
-            मराठी
-          </option>
-          <option
-            value="ja-JP"
-          >
-            日本語
-          </option>
-          <option
-            value="zh-CN"
-          >
-            简体中文
-          </option>
-          <option
-            value="zh-TW"
-          >
-            繁體中文
-          </option>
-          <option
-            value="ko-KR"
-          >
-            한국어
-          </option>
-        </select>
-      </div>
-      <div>
-        <div
-          style="font-size: .75rem; margin-bottom: .5rem;"
-        >
-          Canvas background
-        </div>
         <div
-          style="padding: 0px 0.625rem;"
+          style="position: relative;"
         >
-          <div
-            style="position: relative;"
-          >
-            <div>
+          <div>
+            <div
+              class="color-picker-control-container"
+            >
               <div
-                class="color-picker-control-container"
+                class="color-picker-label-swatch-container"
+              >
+                <button
+                  aria-label="Canvas background"
+                  class="color-picker-label-swatch"
+                  style="--swatch-color: #ffffff;"
+                />
+              </div>
+              <label
+                class="color-input-container"
               >
                 <div
-                  class="color-picker-label-swatch-container"
+                  class="color-picker-hash"
                 >
-                  <button
-                    aria-label="Canvas background"
-                    class="color-picker-label-swatch"
-                    style="--swatch-color: #ffffff;"
-                  />
+                  #
                 </div>
-                <label
-                  class="color-input-container"
-                >
-                  <div
-                    class="color-picker-hash"
-                  >
-                    #
-                  </div>
-                  <input
-                    aria-label="Canvas background"
-                    class="color-picker-input"
-                    spellcheck="false"
-                    value="ffffff"
-                  />
-                </label>
-              </div>
+                <input
+                  aria-label="Canvas background"
+                  class="color-picker-input"
+                  spellcheck="false"
+                  value="ffffff"
+                />
+              </label>
             </div>
           </div>
         </div>
       </div>
     </div>
   </div>
-</section>
+</div>
 `;
 
-exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when the UIOptions prop is "undefined" 1`] = `
-<section
-  aria-labelledby="test-id-canvasActions-title"
+exports[`<Excalidraw/> should render main menu with host menu items if passed from host 1`] = `
+<div
+  class="dropdown-menu"
+  data-testid="dropdown-menu"
 >
-  <h2
-    class="visually-hidden"
-    id="test-id-canvasActions-title"
-  >
-    Canvas actions
-  </h2>
   <div
-    class="Island menu-container"
+    class="Island dropdown-menu-container"
     style="--padding: 2; z-index: 1;"
   >
     <button
-      aria-label="Open"
-      class="menu-item"
-      data-testid="load-button"
-      title="Open"
+      class="dropdown-menu-item dropdown-menu-item-base"
       type="button"
     >
       <div
-        class="menu-item__icon"
-      >
-        <svg
-          aria-hidden="true"
-          class=""
-          fill="none"
-          focusable="false"
-          role="img"
-          stroke="currentColor"
-          stroke-linecap="round"
-          stroke-linejoin="round"
-          viewBox="0 0 20 20"
-        >
-          <path
-            d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
-            stroke-width="1.25"
-          />
-        </svg>
-      </div>
+        class="dropdown-menu-item__icon"
+      />
       <div
-        class="menu-item__text"
+        class="dropdown-menu-item__text"
       >
-        Open
+        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="menu-item__shortcut"
+        class="dropdown-menu-item__text"
       >
-        Ctrl+O
+        Excalidraw blog
       </div>
-    </button>
+    </a>
+    <div
+      class="dropdown-menu-item-base dropdown-menu-item-custom"
+    >
+      <button
+        style="height: 2rem;"
+      >
+         
+        custom menu item
+      </button>
+    </div>
     <button
-      aria-label="Save to..."
-      class="menu-item"
-      data-testid="json-export-button"
-      title="Save to..."
+      aria-label="Help"
+      class="dropdown-menu-item dropdown-menu-item-base"
+      data-testid="help-menu-item"
+      title="Help"
       type="button"
     >
       <div
-        class="menu-item__icon"
+        class="dropdown-menu-item__icon"
       >
         <svg
           aria-hidden="true"
@@ -777,103 +521,8 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
           stroke="currentColor"
           stroke-linecap="round"
           stroke-linejoin="round"
-          viewBox="0 0 20 20"
-        >
-          <path
-            d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
-            stroke-width="1.25"
-          />
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
-      >
-        Save to...
-      </div>
-    </button>
-    <button
-      aria-label="Export image..."
-      class="menu-item"
-      data-testid="image-export-button"
-      title="Export image..."
-      type="button"
-    >
-      <div
-        class="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.25"
-          >
-            <path
-              d="M0 0h24v24H0z"
-              fill="none"
-              stroke="none"
-            />
-            <path
-              d="M15 8h.01"
-            />
-            <path
-              d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
-            />
-            <path
-              d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
-            />
-            <path
-              d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
-            />
-            <path
-              d="M19 16v6"
-            />
-            <path
-              d="M22 19l-3 3l-3 -3"
-            />
-          </g>
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
-      >
-        Export image...
-      </div>
-      <div
-        class="menu-item__shortcut"
-      >
-        Ctrl+Shift+E
-      </div>
-    </button>
-    <button
-      aria-label="Help"
-      class="menu-item"
-      data-testid="help-menu-item"
-      title="Help"
-      type="button"
-    >
-      <div
-        class="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"
+          stroke-width="2"
+          viewBox="0 0 24 24"
         >
           <g
             stroke-width="1.5"
@@ -901,513 +550,16 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
         </svg>
       </div>
       <div
-        class="menu-item__text"
+        class="dropdown-menu-item__text"
       >
         Help
       </div>
       <div
-        class="menu-item__shortcut"
+        class="dropdown-menu-item__shortcut"
       >
         ?
       </div>
     </button>
-    <button
-      aria-label="Reset the canvas"
-      class="menu-item"
-      data-testid="clear-canvas-button"
-      title="Reset the canvas"
-      type="button"
-    >
-      <div
-        class="menu-item__icon"
-      >
-        <svg
-          aria-hidden="true"
-          class=""
-          fill="none"
-          focusable="false"
-          role="img"
-          stroke="currentColor"
-          stroke-linecap="round"
-          stroke-linejoin="round"
-          viewBox="0 0 20 20"
-        >
-          <path
-            d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
-            stroke-width="1.25"
-          />
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
-      >
-        Reset the canvas
-      </div>
-    </button>
-    <div
-      style="height: 1px; margin: .5rem 0px;"
-    />
-    <a
-      class="menu-item"
-      href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
-      rel="noreferrer"
-      target="_blank"
-    >
-      <div
-        class="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"
-            />
-            <rect
-              height="4"
-              rx="1"
-              width="18"
-              x="3"
-              y="8"
-            />
-            <line
-              x1="12"
-              x2="12"
-              y1="8"
-              y2="21"
-            />
-            <path
-              d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
-            />
-            <path
-              d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
-            />
-          </g>
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
-      >
-        Excalidraw+
-      </div>
-    </a>
-    <a
-      class="menu-item"
-      href="https://github.com/excalidraw/excalidraw"
-      rel="noopener noreferrer"
-      target="_blank"
-    >
-      <div
-        class="menu-item__icon"
-      >
-        <svg
-          aria-hidden="true"
-          class=""
-          fill="none"
-          focusable="false"
-          role="img"
-          stroke="currentColor"
-          stroke-linecap="round"
-          stroke-linejoin="round"
-          viewBox="0 0 20 20"
-        >
-          <path
-            d="M7.5 15.833c-3.583 1.167-3.583-2.083-5-2.5m10 4.167v-2.917c0-.833.083-1.166-.417-1.666 2.334-.25 4.584-1.167 4.584-5a3.833 3.833 0 0 0-1.084-2.667 3.5 3.5 0 0 0-.083-2.667s-.917-.25-2.917 1.084a10.25 10.25 0 0 0-5.166 0C5.417 2.333 4.5 2.583 4.5 2.583a3.5 3.5 0 0 0-.083 2.667 3.833 3.833 0 0 0-1.084 2.667c0 3.833 2.25 4.75 4.584 5-.5.5-.5 1-.417 1.666V17.5"
-            stroke-width="1.25"
-          />
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
-      >
-        GitHub
-      </div>
-    </a>
-    <a
-      class="menu-item"
-      href="https://discord.gg/UexuTaE"
-      rel="noopener noreferrer"
-      target="_blank"
-    >
-      <div
-        class="menu-item__icon"
-      >
-        <svg
-          aria-hidden="true"
-          class=""
-          fill="none"
-          focusable="false"
-          role="img"
-          stroke="currentColor"
-          stroke-linecap="round"
-          stroke-linejoin="round"
-          viewBox="0 0 20 20"
-        >
-          <g
-            stroke-width="1.25"
-          >
-            <path
-              d="M7.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM12.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM6.25 6.25c2.917-.833 4.583-.833 7.5 0M5.833 13.75c2.917.833 5.417.833 8.334 0"
-            />
-            <path
-              d="M12.917 14.167c0 .833 1.25 2.5 1.666 2.5 1.25 0 2.361-1.39 2.917-2.5.556-1.39.417-4.861-1.25-9.584-1.214-.846-2.5-1.116-3.75-1.25l-.833 2.084M7.083 14.167c0 .833-1.13 2.5-1.526 2.5-1.191 0-2.249-1.39-2.778-2.5-.529-1.39-.397-4.861 1.19-9.584 1.157-.846 2.318-1.116 3.531-1.25l.833 2.084"
-            />
-          </g>
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
-      >
-        Discord
-      </div>
-    </a>
-    <a
-      class="menu-item"
-      href="https://twitter.com/excalidraw"
-      rel="noopener noreferrer"
-      target="_blank"
-    >
-      <div
-        class="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.25"
-          >
-            <path
-              d="M0 0h24v24H0z"
-              fill="none"
-              stroke="none"
-            />
-            <path
-              d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
-            />
-          </g>
-        </svg>
-      </div>
-      <div
-        class="menu-item__text"
-      >
-        Twitter
-      </div>
-    </a>
-    <div
-      style="height: 1px; margin: .5rem 0px;"
-    />
-    <div
-      style="display: flex; flex-direction: column; row-gap: .5rem;"
-    >
-      <div>
-        <button
-          aria-label="Dark mode"
-          class="menu-item"
-          data-testid="toggle-dark-mode"
-          title="Dark mode"
-          type="button"
-        >
-          <div
-            class="menu-item__icon"
-          >
-            <svg
-              aria-hidden="true"
-              class=""
-              fill="none"
-              focusable="false"
-              role="img"
-              stroke="currentColor"
-              stroke-linecap="round"
-              stroke-linejoin="round"
-              viewBox="0 0 20 20"
-            >
-              <path
-                clip-rule="evenodd"
-                d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
-                stroke="currentColor"
-              />
-            </svg>
-          </div>
-          <div
-            class="menu-item__text"
-          >
-            Dark mode
-          </div>
-          <div
-            class="menu-item__shortcut"
-          >
-            Shift+Alt+D
-          </div>
-        </button>
-      </div>
-      <div
-        style="padding: 0px 0.625rem;"
-      >
-        <select
-          aria-label="Select language"
-          class="dropdown-select dropdown-select__language"
-          style="width: 100%;"
-        >
-          <option
-            value="en"
-          >
-            English
-          </option>
-          <option
-            value="id-ID"
-          >
-            Bahasa Indonesia
-          </option>
-          <option
-            value="ca-ES"
-          >
-            Català
-          </option>
-          <option
-            value="de-DE"
-          >
-            Deutsch
-          </option>
-          <option
-            value="en"
-          >
-            English
-          </option>
-          <option
-            value="es-ES"
-          >
-            Español
-          </option>
-          <option
-            value="eu-ES"
-          >
-            Euskara
-          </option>
-          <option
-            value="fr-FR"
-          >
-            Français
-          </option>
-          <option
-            value="gl-ES"
-          >
-            Galego
-          </option>
-          <option
-            value="it-IT"
-          >
-            Italiano
-          </option>
-          <option
-            value="ku-TR"
-          >
-            Kurdî
-          </option>
-          <option
-            value="lv-LV"
-          >
-            Latviešu
-          </option>
-          <option
-            value="hu-HU"
-          >
-            Magyar
-          </option>
-          <option
-            value="nl-NL"
-          >
-            Nederlands
-          </option>
-          <option
-            value="nb-NO"
-          >
-            Norsk bokmål
-          </option>
-          <option
-            value="nn-NO"
-          >
-            Norsk nynorsk
-          </option>
-          <option
-            value="oc-FR"
-          >
-            Occitan
-          </option>
-          <option
-            value="pl-PL"
-          >
-            Polski
-          </option>
-          <option
-            value="pt-PT"
-          >
-            Português
-          </option>
-          <option
-            value="pt-BR"
-          >
-            Português Brasileiro
-          </option>
-          <option
-            value="ro-RO"
-          >
-            Română
-          </option>
-          <option
-            value="sk-SK"
-          >
-            Slovenčina
-          </option>
-          <option
-            value="sl-SI"
-          >
-            Slovenščina
-          </option>
-          <option
-            value="fi-FI"
-          >
-            Suomi
-          </option>
-          <option
-            value="sv-SE"
-          >
-            Svenska
-          </option>
-          <option
-            value="kab-KAB"
-          >
-            Taqbaylit
-          </option>
-          <option
-            value="tr-TR"
-          >
-            Türkçe
-          </option>
-          <option
-            value="el-GR"
-          >
-            Ελληνικά
-          </option>
-          <option
-            value="ru-RU"
-          >
-            Русский
-          </option>
-          <option
-            value="uk-UA"
-          >
-            Українська
-          </option>
-          <option
-            value="he-IL"
-          >
-            עברית
-          </option>
-          <option
-            value="ar-SA"
-          >
-            العربية
-          </option>
-          <option
-            value="fa-IR"
-          >
-            فارسی
-          </option>
-          <option
-            value="mr-IN"
-          >
-            मराठी
-          </option>
-          <option
-            value="ja-JP"
-          >
-            日本語
-          </option>
-          <option
-            value="zh-CN"
-          >
-            简体中文
-          </option>
-          <option
-            value="zh-TW"
-          >
-            繁體中文
-          </option>
-          <option
-            value="ko-KR"
-          >
-            한국어
-          </option>
-        </select>
-      </div>
-      <div>
-        <div
-          style="font-size: .75rem; margin-bottom: .5rem;"
-        >
-          Canvas background
-        </div>
-        <div
-          style="padding: 0px 0.625rem;"
-        >
-          <div
-            style="position: relative;"
-          >
-            <div>
-              <div
-                class="color-picker-control-container"
-              >
-                <div
-                  class="color-picker-label-swatch-container"
-                >
-                  <button
-                    aria-label="Canvas background"
-                    class="color-picker-label-swatch"
-                    style="--swatch-color: #ffffff;"
-                  />
-                </div>
-                <label
-                  class="color-input-container"
-                >
-                  <div
-                    class="color-picker-hash"
-                  >
-                    #
-                  </div>
-                  <input
-                    aria-label="Canvas background"
-                    class="color-picker-input"
-                    spellcheck="false"
-                    value="ffffff"
-                  />
-                </label>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
   </div>
-</section>
+</div>
 `;

+ 162 - 97
src/tests/packages/excalidraw.test.tsx

@@ -1,5 +1,5 @@
-import { fireEvent, GlobalTestState, render } from "../test-utils";
-import { Excalidraw, Footer } from "../../packages/excalidraw/index";
+import { fireEvent, GlobalTestState, toggleMenu, render } from "../test-utils";
+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";
@@ -7,6 +7,12 @@ import { t } from "../../i18n";
 const { h } = window;
 
 describe("<Excalidraw/>", () => {
+  afterEach(() => {
+    const menu = document.querySelector(".dropdown-menu");
+    if (menu) {
+      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 />);
@@ -56,9 +62,7 @@ describe("<Excalidraw/>", () => {
         <div>This is a custom footer</div>
       </Excalidraw>,
     );
-    expect(
-      container.querySelector(".layer-ui__wrapper__footer-center"),
-    ).toBeEmptyDOMElement();
+    expect(container.querySelector(".footer-center")).toBe(null);
 
     // Footer passed hence it will render the footer
     ({ container } = await render(
@@ -68,12 +72,17 @@ describe("<Excalidraw/>", () => {
         </Footer>
       </Excalidraw>,
     ));
-    expect(
-      container.querySelector(".layer-ui__wrapper__footer-center")?.innerHTML,
-    ).toMatchInlineSnapshot(
-      `"<div class=\\"layer-ui__wrapper__footer-center zen-mode-transition\\"><div>This is a custom footer</div></div>"`,
-    );
+    expect(container.querySelector(".footer-center")).toMatchInlineSnapshot(`
+      <div
+        class="footer-center zen-mode-transition"
+      >
+        <div>
+          This is a custom footer
+        </div>
+      </div>
+    `);
   });
+
   describe("Test gridModeEnabled prop", () => {
     it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => {
       const { container } = await render(<Excalidraw />);
@@ -112,98 +121,51 @@ describe("<Excalidraw/>", () => {
     });
   });
 
-  describe("Test theme prop", () => {
-    it("should show the theme toggle by default", async () => {
-      const { container } = await render(<Excalidraw />);
-
-      expect(h.state.theme).toBe(THEME.LIGHT);
-
-      queryByTestId(container, "menu-button")!.click();
-      const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
-      expect(darkModeToggle).toBeTruthy();
-    });
-
-    it("should not show theme toggle when the theme prop is defined", async () => {
-      const { container } = await render(<Excalidraw theme="dark" />);
-      expect(h.state.theme).toBe(THEME.DARK);
-      expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
-    });
-
-    it("should show theme mode toggle when `UIOptions.canvasActions.toggleTheme` is true", async () => {
-      const { container } = await render(
-        <Excalidraw
-          theme={THEME.DARK}
-          UIOptions={{ canvasActions: { toggleTheme: true } }}
-        />,
-      );
-      expect(h.state.theme).toBe(THEME.DARK);
-      const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
-      expect(darkModeToggle).toBeTruthy();
-    });
-
-    it("should not show theme toggle when `UIOptions.canvasActions.toggleTheme` is false", async () => {
-      const { container } = await render(
-        <Excalidraw
-          UIOptions={{ canvasActions: { toggleTheme: false } }}
-          theme={THEME.DARK}
-        />,
-      );
-      expect(h.state.theme).toBe(THEME.DARK);
-      const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
-      expect(darkModeToggle).toBeFalsy();
-    });
-  });
-
-  describe("Test name prop", () => {
-    it('should allow editing name when the name prop is "undefined"', async () => {
-      const { container } = await render(<Excalidraw />);
-
-      fireEvent.click(queryByTestId(container, "image-export-button")!);
-      const textInput: HTMLInputElement | null = document.querySelector(
-        ".ExportDialog .ProjectName .TextInput",
-      );
-      expect(textInput?.value).toContain(`${t("labels.untitled")}`);
-      expect(textInput?.nodeName).toBe("INPUT");
-    });
-
-    it('should set the name and not allow editing when the name prop is present"', async () => {
-      const name = "test";
-      const { container } = await render(<Excalidraw name={name} />);
-
-      await fireEvent.click(queryByTestId(container, "image-export-button")!);
-      const textInput = document.querySelector(
-        ".ExportDialog .ProjectName .TextInput--readonly",
-      );
-      expect(textInput?.textContent).toEqual(name);
-      expect(textInput?.nodeName).toBe("SPAN");
-    });
+  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", () => {
-    it('should not hide any UI element when the UIOptions prop is "undefined"', async () => {
-      await render(<Excalidraw />);
-
-      const canvasActions = document.querySelector(
-        'section[aria-labelledby="test-id-canvasActions-title"]',
-      );
-
-      expect(canvasActions).toMatchSnapshot();
-    });
-
     describe("Test canvasActions", () => {
-      it('should not hide any UI element when canvasActions is "undefined"', async () => {
-        await render(<Excalidraw UIOptions={{}} />);
-        const canvasActions = document.querySelector(
-          'section[aria-labelledby="test-id-canvasActions-title"]',
+      it('should render menu with default items when "UIOPtions" is "undefined"', async () => {
+        const { container } = await render(
+          <Excalidraw UIOptions={undefined} />,
         );
-        expect(canvasActions).toMatchSnapshot();
+        //open menu
+        toggleMenu(container);
+        expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
       });
 
       it("should hide clear canvas button when clearCanvas is false", async () => {
         const { container } = await render(
           <Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
         );
-
+        //open menu
+        toggleMenu(container);
         expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
       });
 
@@ -211,7 +173,8 @@ describe("<Excalidraw/>", () => {
         const { container } = await render(
           <Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
         );
-
+        //open menu
+        toggleMenu(container);
         expect(queryByTestId(container, "json-export-button")).toBeNull();
       });
 
@@ -219,7 +182,8 @@ describe("<Excalidraw/>", () => {
         const { container } = await render(
           <Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
         );
-
+        //open menu
+        toggleMenu(container);
         expect(queryByTestId(container, "image-export-button")).toBeNull();
       });
 
@@ -237,7 +201,8 @@ describe("<Excalidraw/>", () => {
             UIOptions={{ canvasActions: { export: { saveFileToDisk: false } } }}
           />,
         );
-
+        //open menu
+        toggleMenu(container);
         expect(queryByTestId(container, "save-as-button")).toBeNull();
       });
 
@@ -247,7 +212,8 @@ describe("<Excalidraw/>", () => {
             UIOptions={{ canvasActions: { saveToActiveFile: false } }}
           />,
         );
-
+        //open menu
+        toggleMenu(container);
         expect(queryByTestId(container, "save-button")).toBeNull();
       });
 
@@ -257,7 +223,8 @@ describe("<Excalidraw/>", () => {
             UIOptions={{ canvasActions: { changeViewBackgroundColor: false } }}
           />,
         );
-
+        //open menu
+        toggleMenu(container);
         expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
       });
 
@@ -265,12 +232,110 @@ describe("<Excalidraw/>", () => {
         const { container } = await render(
           <Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />,
         );
-
+        //open menu
+        toggleMenu(container);
         expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
       });
+
+      it("should not render default items in custom menu even if passed if the prop in `canvasActions` is set to false", async () => {
+        const { container } = await render(
+          <Excalidraw UIOptions={{ canvasActions: { loadScene: false } }}>
+            <MainMenu>
+              <MainMenu.ItemCustom>
+                <button
+                  style={{ height: "2rem" }}
+                  onClick={() => window.alert("custom menu item")}
+                >
+                  {" "}
+                  custom item
+                </button>
+              </MainMenu.ItemCustom>
+              <MainMenu.DefaultItems.LoadScene />
+            </MainMenu>
+          </Excalidraw>,
+        );
+        //open menu
+        toggleMenu(container);
+        // load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false`
+        expect(queryByTestId(container, "load-button")).toBeNull();
+      });
     });
   });
 
+  describe("Test theme prop", () => {
+    it("should show the theme toggle by default", async () => {
+      const { container } = await render(<Excalidraw />);
+      expect(h.state.theme).toBe(THEME.LIGHT);
+      //open menu
+      toggleMenu(container);
+      const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
+      expect(darkModeToggle).toBeTruthy();
+    });
+
+    it("should not show theme toggle when the theme prop is defined", async () => {
+      const { container } = await render(<Excalidraw theme={THEME.DARK} />);
+
+      expect(h.state.theme).toBe(THEME.DARK);
+      //open menu
+      toggleMenu(container);
+      expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
+    });
+
+    it("should show theme mode toggle when `UIOptions.canvasActions.toggleTheme` is true", async () => {
+      const { container } = await render(
+        <Excalidraw
+          theme={THEME.DARK}
+          UIOptions={{ canvasActions: { toggleTheme: true } }}
+        />,
+      );
+      expect(h.state.theme).toBe(THEME.DARK);
+      //open menu
+      toggleMenu(container);
+      const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
+      expect(darkModeToggle).toBeTruthy();
+    });
+
+    it("should not show theme toggle when `UIOptions.canvasActions.toggleTheme` is false", async () => {
+      const { container } = await render(
+        <Excalidraw
+          UIOptions={{ canvasActions: { toggleTheme: false } }}
+          theme={THEME.DARK}
+        />,
+      );
+      expect(h.state.theme).toBe(THEME.DARK);
+      //open menu
+      toggleMenu(container);
+      const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
+      expect(darkModeToggle).toBeFalsy();
+    });
+  });
+
+  describe("Test name prop", () => {
+    it('should allow editing name when the name prop is "undefined"', async () => {
+      const { container } = await render(<Excalidraw />);
+      //open menu
+      toggleMenu(container);
+      fireEvent.click(queryByTestId(container, "image-export-button")!);
+      const textInput: HTMLInputElement | null = document.querySelector(
+        ".ExportDialog .ProjectName .TextInput",
+      );
+      expect(textInput?.value).toContain(`${t("labels.untitled")}`);
+      expect(textInput?.nodeName).toBe("INPUT");
+    });
+
+    it('should set the name and not allow editing when the name prop is present"', async () => {
+      const name = "test";
+      const { container } = await render(<Excalidraw name={name} />);
+      //open menu
+      toggleMenu(container);
+      await fireEvent.click(queryByTestId(container, "image-export-button")!);
+      const textInput = document.querySelector(
+        ".ExportDialog .ProjectName .TextInput--readonly",
+      );
+      expect(textInput?.textContent).toEqual(name);
+      expect(textInput?.nodeName).toBe("SPAN");
+    });
+  });
   describe("Test autoFocus prop", () => {
     it("should not focus when autoFocus is false", async () => {
       const { container } = await render(<Excalidraw />);

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

@@ -446,7 +446,7 @@ describe("regression tests", () => {
     UI.clickTool("rectangle");
     // english lang should display `thin` label
     expect(screen.queryByTitle(/thin/i)).not.toBeNull();
-    fireEvent.click(document.querySelector(".menu-button")!);
+    fireEvent.click(document.querySelector(".dropdown-menu-button")!);
 
     fireEvent.change(document.querySelector(".dropdown-select__language")!, {
       target: { value: "de-DE" },

+ 6 - 0
src/tests/test-utils.ts

@@ -6,6 +6,7 @@ import {
   RenderResult,
   RenderOptions,
   waitFor,
+  fireEvent,
 } from "@testing-library/react";
 
 import * as toolQueries from "./queries/toolQueries";
@@ -184,3 +185,8 @@ export const assertSelectedElements = (
   expect(selectedElementIds.length).toBe(ids.length);
   expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
 };
+
+export const toggleMenu = (container: HTMLElement) => {
+  // open menu
+  fireEvent.click(container.querySelector(".dropdown-menu-button")!);
+};

+ 2 - 2
src/types.ts

@@ -161,7 +161,7 @@ export type AppState = {
     | "strokeColorPicker"
     | null;
   openSidebar: "library" | "customSidebar" | null;
-  openDialog: "imageExport" | "help" | null;
+  openDialog: "imageExport" | "help" | "jsonExport" | null;
   isSidebarDocked: boolean;
 
   lastPointerDownWith: PointerType;
@@ -517,7 +517,7 @@ export type Device = Readonly<{
 }>;
 
 export type UIChildrenComponents = {
-  [k in "FooterCenter"]?:
+  [k in "FooterCenter" | "Menu"]?:
     | React.ReactPortal
     | React.ReactElement<unknown, string | React.JSXElementConstructor<any>>;
 };