Browse Source

feat: close MainMenu and Library dropdown on item select (#6152)

David Luzar 2 years ago
parent
commit
1db078a3dc

+ 35 - 0
src/components/ActiveConfirmDialog.tsx

@@ -0,0 +1,35 @@
+import { atom, useAtom } from "jotai";
+import { actionClearCanvas } from "../actions";
+import { t } from "../i18n";
+import { useExcalidrawActionManager } from "./App";
+import ConfirmDialog from "./ConfirmDialog";
+
+export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
+
+export const ActiveConfirmDialog = () => {
+  const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
+    activeConfirmDialogAtom,
+  );
+  const actionManager = useExcalidrawActionManager();
+
+  if (!activeConfirmDialog) {
+    return null;
+  }
+
+  if (activeConfirmDialog === "clearCanvas") {
+    return (
+      <ConfirmDialog
+        onConfirm={() => {
+          actionManager.executeAction(actionClearCanvas);
+          setActiveConfirmDialog(null);
+        }}
+        onCancel={() => setActiveConfirmDialog(null)}
+        title={t("clearCanvasDialog.title")}
+      >
+        <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
+      </ConfirmDialog>
+    );
+  }
+
+  return null;
+};

+ 2 - 0
src/components/LayerUI.tsx

@@ -50,6 +50,7 @@ import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
 import { jotaiScope } from "../jotai";
 import { useAtom } from "jotai";
 import { useAtom } from "jotai";
 import MainMenu from "./main-menu/MainMenu";
 import MainMenu from "./main-menu/MainMenu";
+import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
 import { HandButton } from "./HandButton";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
 import { isHandToolActive } from "../appState";
 
 
@@ -394,6 +395,7 @@ const LayerUI = ({
           }}
           }}
         />
         />
       )}
       )}
+      <ActiveConfirmDialog />
       {renderImageExportDialog()}
       {renderImageExportDialog()}
       {renderJSONExportDialog()}
       {renderJSONExportDialog()}
       {appState.pasteDialog.shown && (
       {appState.pasteDialog.shown && (

+ 1 - 0
src/components/LibraryMenuHeaderContent.tsx

@@ -187,6 +187,7 @@ export const LibraryMenuHeader: React.FC<{
         </DropdownMenu.Trigger>
         </DropdownMenu.Trigger>
         <DropdownMenu.Content
         <DropdownMenu.Content
           onClickOutside={() => setIsLibraryMenuOpen(false)}
           onClickOutside={() => setIsLibraryMenuOpen(false)}
+          onSelect={() => setIsLibraryMenuOpen(false)}
           className="library-menu"
           className="library-menu"
         >
         >
           {!itemsSelected && (
           {!itemsSelected && (

+ 31 - 20
src/components/dropdownMenu/DropdownMenuContent.tsx

@@ -4,16 +4,23 @@ import { Island } from "../Island";
 import { useDevice } from "../App";
 import { useDevice } from "../App";
 import clsx from "clsx";
 import clsx from "clsx";
 import Stack from "../Stack";
 import Stack from "../Stack";
+import React from "react";
+import { DropdownMenuContentPropsContext } from "./common";
 
 
 const MenuContent = ({
 const MenuContent = ({
   children,
   children,
   onClickOutside,
   onClickOutside,
   className = "",
   className = "",
+  onSelect,
   style,
   style,
 }: {
 }: {
   children?: React.ReactNode;
   children?: React.ReactNode;
   onClickOutside?: () => void;
   onClickOutside?: () => void;
   className?: string;
   className?: string;
+  /**
+   * Called when any menu item is selected (clicked on).
+   */
+  onSelect?: (event: Event) => void;
   style?: React.CSSProperties;
   style?: React.CSSProperties;
 }) => {
 }) => {
   const device = useDevice();
   const device = useDevice();
@@ -24,28 +31,32 @@ const MenuContent = ({
   const classNames = clsx(`dropdown-menu ${className}`, {
   const classNames = clsx(`dropdown-menu ${className}`, {
     "dropdown-menu--mobile": device.isMobile,
     "dropdown-menu--mobile": device.isMobile,
   }).trim();
   }).trim();
+
   return (
   return (
-    <div
-      ref={menuRef}
-      className={classNames}
-      style={style}
-      data-testid="dropdown-menu"
-    >
-      {/* the zIndex ensures this menu has higher stacking order,
+    <DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
+      <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 */}
     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>
+        {device.isMobile ? (
+          <Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
+        ) : (
+          <Island
+            className="dropdown-menu-container"
+            padding={2}
+            style={{ zIndex: 1 }}
+          >
+            {children}
+          </Island>
+        )}
+      </div>
+    </DropdownMenuContentPropsContext.Provider>
   );
   );
 };
 };
-export default MenuContent;
 MenuContent.displayName = "DropdownMenuContent";
 MenuContent.displayName = "DropdownMenuContent";
+
+export default MenuContent;

+ 9 - 7
src/components/dropdownMenu/DropdownMenuItem.tsx

@@ -1,10 +1,10 @@
 import React from "react";
 import React from "react";
+import {
+  getDrodownMenuItemClassName,
+  useHandleDropdownMenuItemClick,
+} from "./common";
 import MenuItemContent from "./DropdownMenuItemContent";
 import MenuItemContent from "./DropdownMenuItemContent";
 
 
-export const getDrodownMenuItemClassName = (className = "") => {
-  return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
-};
-
 const DropdownMenuItem = ({
 const DropdownMenuItem = ({
   icon,
   icon,
   onSelect,
   onSelect,
@@ -14,15 +14,17 @@ const DropdownMenuItem = ({
   ...rest
   ...rest
 }: {
 }: {
   icon?: JSX.Element;
   icon?: JSX.Element;
-  onSelect: () => void;
+  onSelect: (event: Event) => void;
   children: React.ReactNode;
   children: React.ReactNode;
   shortcut?: string;
   shortcut?: string;
   className?: string;
   className?: string;
-} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
+} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
+  const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
+
   return (
   return (
     <button
     <button
       {...rest}
       {...rest}
-      onClick={onSelect}
+      onClick={handleClick}
       type="button"
       type="button"
       className={getDrodownMenuItemClassName(className)}
       className={getDrodownMenuItemClassName(className)}
       title={rest.title ?? rest["aria-label"]}
       title={rest.title ?? rest["aria-label"]}

+ 11 - 2
src/components/dropdownMenu/DropdownMenuItemLink.tsx

@@ -1,20 +1,28 @@
 import MenuItemContent from "./DropdownMenuItemContent";
 import MenuItemContent from "./DropdownMenuItemContent";
 import React from "react";
 import React from "react";
-import { getDrodownMenuItemClassName } from "./DropdownMenuItem";
+import {
+  getDrodownMenuItemClassName,
+  useHandleDropdownMenuItemClick,
+} from "./common";
+
 const DropdownMenuItemLink = ({
 const DropdownMenuItemLink = ({
   icon,
   icon,
   shortcut,
   shortcut,
   href,
   href,
   children,
   children,
+  onSelect,
   className = "",
   className = "",
   ...rest
   ...rest
 }: {
 }: {
+  href: string;
   icon?: JSX.Element;
   icon?: JSX.Element;
   children: React.ReactNode;
   children: React.ReactNode;
   shortcut?: string;
   shortcut?: string;
   className?: string;
   className?: string;
-  href: string;
+  onSelect?: (event: Event) => void;
 } & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
 } & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
+  const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
+
   return (
   return (
     <a
     <a
       {...rest}
       {...rest}
@@ -23,6 +31,7 @@ const DropdownMenuItemLink = ({
       rel="noreferrer"
       rel="noreferrer"
       className={getDrodownMenuItemClassName(className)}
       className={getDrodownMenuItemClassName(className)}
       title={rest.title ?? rest["aria-label"]}
       title={rest.title ?? rest["aria-label"]}
+      onClick={handleClick}
     >
     >
       <MenuItemContent icon={icon} shortcut={shortcut}>
       <MenuItemContent icon={icon} shortcut={shortcut}>
         {children}
         {children}

+ 31 - 0
src/components/dropdownMenu/common.ts

@@ -0,0 +1,31 @@
+import React, { useContext } from "react";
+import { EVENT } from "../../constants";
+import { composeEventHandlers } from "../../utils";
+
+export const DropdownMenuContentPropsContext = React.createContext<{
+  onSelect?: (event: Event) => void;
+}>({});
+
+export const getDrodownMenuItemClassName = (className = "") => {
+  return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
+};
+
+export const useHandleDropdownMenuItemClick = (
+  origOnClick:
+    | React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>
+    | undefined,
+  onSelect: ((event: Event) => void) | undefined,
+) => {
+  const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext);
+
+  return composeEventHandlers(origOnClick, (event) => {
+    const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, {
+      bubbles: true,
+      cancelable: true,
+    });
+    onSelect?.(itemSelectEvent);
+    if (!itemSelectEvent.defaultPrevented) {
+      DropdownMenuContentProps.onSelect?.(itemSelectEvent);
+    }
+  });
+};

+ 14 - 31
src/components/main-menu/DefaultItems.tsx

@@ -28,9 +28,9 @@ import {
 } from "../../actions";
 } from "../../actions";
 
 
 import "./DefaultItems.scss";
 import "./DefaultItems.scss";
-import { useState } from "react";
-import ConfirmDialog from "../ConfirmDialog";
 import clsx from "clsx";
 import clsx from "clsx";
+import { useSetAtom } from "jotai";
+import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
 
 
 export const LoadScene = () => {
 export const LoadScene = () => {
   // FIXME Hack until we tie "t" to lang state
   // FIXME Hack until we tie "t" to lang state
@@ -122,41 +122,22 @@ export const ClearCanvas = () => {
   // FIXME Hack until we tie "t" to lang state
   // FIXME Hack until we tie "t" to lang state
   // eslint-disable-next-line
   // eslint-disable-next-line
   const appState = useExcalidrawAppState();
   const appState = useExcalidrawAppState();
+  const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
   const actionManager = useExcalidrawActionManager();
   const actionManager = useExcalidrawActionManager();
 
 
-  const [showDialog, setShowDialog] = useState(false);
-  const toggleDialog = () => setShowDialog(!showDialog);
-
   if (!actionManager.isActionEnabled(actionClearCanvas)) {
   if (!actionManager.isActionEnabled(actionClearCanvas)) {
     return null;
     return null;
   }
   }
 
 
   return (
   return (
-    <>
-      <DropdownMenuItem
-        icon={TrashIcon}
-        onSelect={toggleDialog}
-        data-testid="clear-canvas-button"
-        aria-label={t("buttons.clearReset")}
-      >
-        {t("buttons.clearReset")}
-      </DropdownMenuItem>
-
-      {/* FIXME this should live outside MainMenu so it stays open
-          if menu is closed */}
-      {showDialog && (
-        <ConfirmDialog
-          onConfirm={() => {
-            actionManager.executeAction(actionClearCanvas);
-            toggleDialog();
-          }}
-          onCancel={toggleDialog}
-          title={t("clearCanvasDialog.title")}
-        >
-          <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
-        </ConfirmDialog>
-      )}
-    </>
+    <DropdownMenuItem
+      icon={TrashIcon}
+      onSelect={() => setActiveConfirmDialog("clearCanvas")}
+      data-testid="clear-canvas-button"
+      aria-label={t("buttons.clearReset")}
+    >
+      {t("buttons.clearReset")}
+    </DropdownMenuItem>
   );
   );
 };
 };
 ClearCanvas.displayName = "ClearCanvas";
 ClearCanvas.displayName = "ClearCanvas";
@@ -171,7 +152,9 @@ export const ToggleTheme = () => {
 
 
   return (
   return (
     <DropdownMenuItem
     <DropdownMenuItem
-      onSelect={() => {
+      onSelect={(event) => {
+        // do not close the menu when changing theme
+        event.preventDefault();
         return actionManager.executeAction(actionToggleTheme);
         return actionManager.executeAction(actionToggleTheme);
       }}
       }}
       icon={appState.theme === "dark" ? SunIcon : MoonIcon}
       icon={appState.theme === "dark" ? SunIcon : MoonIcon}

+ 18 - 2
src/components/main-menu/MainMenu.tsx

@@ -11,14 +11,25 @@ import * as DefaultItems from "./DefaultItems";
 import { UserList } from "../UserList";
 import { UserList } from "../UserList";
 import { t } from "../../i18n";
 import { t } from "../../i18n";
 import { HamburgerMenuIcon } from "../icons";
 import { HamburgerMenuIcon } from "../icons";
+import { composeEventHandlers } from "../../utils";
 
 
-const MainMenu = ({ children }: { children?: React.ReactNode }) => {
+const MainMenu = ({
+  children,
+  onSelect,
+}: {
+  children?: React.ReactNode;
+  /**
+   * Called when any menu item is selected (clicked on).
+   */
+  onSelect?: (event: Event) => void;
+}) => {
   const device = useDevice();
   const device = useDevice();
   const appState = useExcalidrawAppState();
   const appState = useExcalidrawAppState();
   const setAppState = useExcalidrawSetAppState();
   const setAppState = useExcalidrawSetAppState();
   const onClickOutside = device.isMobile
   const onClickOutside = device.isMobile
     ? undefined
     ? undefined
     : () => setAppState({ openMenu: null });
     : () => setAppState({ openMenu: null });
+
   return (
   return (
     <DropdownMenu open={appState.openMenu === "canvas"}>
     <DropdownMenu open={appState.openMenu === "canvas"}>
       <DropdownMenu.Trigger
       <DropdownMenu.Trigger
@@ -30,7 +41,12 @@ const MainMenu = ({ children }: { children?: React.ReactNode }) => {
       >
       >
         {HamburgerMenuIcon}
         {HamburgerMenuIcon}
       </DropdownMenu.Trigger>
       </DropdownMenu.Trigger>
-      <DropdownMenu.Content onClickOutside={onClickOutside}>
+      <DropdownMenu.Content
+        onClickOutside={onClickOutside}
+        onSelect={composeEventHandlers(onSelect, () => {
+          setAppState({ openMenu: null });
+        })}
+      >
         {children}
         {children}
         {device.isMobile && appState.collaborators.size > 0 && (
         {device.isMobile && appState.collaborators.size > 0 && (
           <fieldset className="UserList-Wrapper">
           <fieldset className="UserList-Wrapper">

+ 1 - 0
src/constants.ts

@@ -62,6 +62,7 @@ export enum EVENT {
   SCROLL = "scroll",
   SCROLL = "scroll",
   // custom events
   // custom events
   EXCALIDRAW_LINK = "excalidraw-link",
   EXCALIDRAW_LINK = "excalidraw-link",
+  MENU_ITEM_SELECT = "menu.itemSelect",
 }
 }
 
 
 export const ENV = {
 export const ENV = {

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

@@ -11,6 +11,12 @@ The change should be grouped under one of the below section and must contain PR
 Please add the latest change on the top under the correct section.
 Please add the latest change on the top under the correct section.
 -->
 -->
 
 
+## Unreleased
+
+### Features
+
+- `MainMenu`, `MainMenu.Item`, and `MainMenu.ItemLink` components now all support `onSelect(event: Event): void` callback. If you call `event.preventDefault()`, it will prevent the menu from closing when an item is selected (clicked on). [#6152](https://github.com/excalidraw/excalidraw/pull/6152)
+
 ## 0.14.1 (2023-01-16)
 ## 0.14.1 (2023-01-16)
 
 
 ### Fixes
 ### Fixes

+ 19 - 0
src/utils.ts

@@ -743,3 +743,22 @@ export const isShallowEqual = <T extends Record<string, any>>(
   }
   }
   return aKeys.every((key) => objA[key] === objB[key]);
   return aKeys.every((key) => objA[key] === objB[key]);
 };
 };
+
+// taken from Radix UI
+// https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
+export const composeEventHandlers = <E>(
+  originalEventHandler?: (event: E) => void,
+  ourEventHandler?: (event: E) => void,
+  { checkForDefaultPrevented = true } = {},
+) => {
+  return function handleEvent(event: E) {
+    originalEventHandler?.(event);
+
+    if (
+      !checkForDefaultPrevented ||
+      !(event as unknown as Event).defaultPrevented
+    ) {
+      return ourEventHandler?.(event);
+    }
+  };
+};