Explorar el Código

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

David Luzar hace 2 años
padre
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 { useAtom } from "jotai";
 import MainMenu from "./main-menu/MainMenu";
+import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
 
@@ -394,6 +395,7 @@ const LayerUI = ({
           }}
         />
       )}
+      <ActiveConfirmDialog />
       {renderImageExportDialog()}
       {renderJSONExportDialog()}
       {appState.pasteDialog.shown && (

+ 1 - 0
src/components/LibraryMenuHeaderContent.tsx

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

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

@@ -4,16 +4,23 @@ import { Island } from "../Island";
 import { useDevice } from "../App";
 import clsx from "clsx";
 import Stack from "../Stack";
+import React from "react";
+import { DropdownMenuContentPropsContext } from "./common";
 
 const MenuContent = ({
   children,
   onClickOutside,
   className = "",
+  onSelect,
   style,
 }: {
   children?: React.ReactNode;
   onClickOutside?: () => void;
   className?: string;
+  /**
+   * Called when any menu item is selected (clicked on).
+   */
+  onSelect?: (event: Event) => void;
   style?: React.CSSProperties;
 }) => {
   const device = useDevice();
@@ -24,28 +31,32 @@ const MenuContent = ({
   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,
+    <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 */}
-      {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";
+
+export default MenuContent;

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

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

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

@@ -1,20 +1,28 @@
 import MenuItemContent from "./DropdownMenuItemContent";
 import React from "react";
-import { getDrodownMenuItemClassName } from "./DropdownMenuItem";
+import {
+  getDrodownMenuItemClassName,
+  useHandleDropdownMenuItemClick,
+} from "./common";
+
 const DropdownMenuItemLink = ({
   icon,
   shortcut,
   href,
   children,
+  onSelect,
   className = "",
   ...rest
 }: {
+  href: string;
   icon?: JSX.Element;
   children: React.ReactNode;
   shortcut?: string;
   className?: string;
-  href: string;
+  onSelect?: (event: Event) => void;
 } & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
+  const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
+
   return (
     <a
       {...rest}
@@ -23,6 +31,7 @@ const DropdownMenuItemLink = ({
       rel="noreferrer"
       className={getDrodownMenuItemClassName(className)}
       title={rest.title ?? rest["aria-label"]}
+      onClick={handleClick}
     >
       <MenuItemContent icon={icon} shortcut={shortcut}>
         {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";
 
 import "./DefaultItems.scss";
-import { useState } from "react";
-import ConfirmDialog from "../ConfirmDialog";
 import clsx from "clsx";
+import { useSetAtom } from "jotai";
+import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
 
 export const LoadScene = () => {
   // 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
   // eslint-disable-next-line
   const appState = useExcalidrawAppState();
+  const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
   const actionManager = useExcalidrawActionManager();
 
-  const [showDialog, setShowDialog] = useState(false);
-  const toggleDialog = () => setShowDialog(!showDialog);
-
   if (!actionManager.isActionEnabled(actionClearCanvas)) {
     return null;
   }
 
   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";
@@ -171,7 +152,9 @@ export const ToggleTheme = () => {
 
   return (
     <DropdownMenuItem
-      onSelect={() => {
+      onSelect={(event) => {
+        // do not close the menu when changing theme
+        event.preventDefault();
         return actionManager.executeAction(actionToggleTheme);
       }}
       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 { t } from "../../i18n";
 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 appState = useExcalidrawAppState();
   const setAppState = useExcalidrawSetAppState();
   const onClickOutside = device.isMobile
     ? undefined
     : () => setAppState({ openMenu: null });
+
   return (
     <DropdownMenu open={appState.openMenu === "canvas"}>
       <DropdownMenu.Trigger
@@ -30,7 +41,12 @@ const MainMenu = ({ children }: { children?: React.ReactNode }) => {
       >
         {HamburgerMenuIcon}
       </DropdownMenu.Trigger>
-      <DropdownMenu.Content onClickOutside={onClickOutside}>
+      <DropdownMenu.Content
+        onClickOutside={onClickOutside}
+        onSelect={composeEventHandlers(onSelect, () => {
+          setAppState({ openMenu: null });
+        })}
+      >
         {children}
         {device.isMobile && appState.collaborators.size > 0 && (
           <fieldset className="UserList-Wrapper">

+ 1 - 0
src/constants.ts

@@ -62,6 +62,7 @@ export enum EVENT {
   SCROLL = "scroll",
   // custom events
   EXCALIDRAW_LINK = "excalidraw-link",
+  MENU_ITEM_SELECT = "menu.itemSelect",
 }
 
 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.
 -->
 
+## 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)
 
 ### 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]);
 };
+
+// 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);
+    }
+  };
+};