feat: close MainMenu and Library dropdown on item select (#6152)
This commit is contained in:
parent
d4afd66268
commit
1db078a3dc
35
src/components/ActiveConfirmDialog.tsx
Normal file
35
src/components/ActiveConfirmDialog.tsx
Normal file
@ -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;
|
||||||
|
};
|
@ -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 && (
|
||||||
|
@ -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 && (
|
||||||
|
@ -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
|
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
|
||||||
ref={menuRef}
|
<div
|
||||||
className={classNames}
|
ref={menuRef}
|
||||||
style={style}
|
className={classNames}
|
||||||
data-testid="dropdown-menu"
|
style={style}
|
||||||
>
|
data-testid="dropdown-menu"
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
>
|
||||||
|
{/* 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 ? (
|
{device.isMobile ? (
|
||||||
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||||
) : (
|
) : (
|
||||||
<Island
|
<Island
|
||||||
className="dropdown-menu-container"
|
className="dropdown-menu-container"
|
||||||
padding={2}
|
padding={2}
|
||||||
style={{ zIndex: 1 }}
|
style={{ zIndex: 1 }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Island>
|
</Island>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</DropdownMenuContentPropsContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default MenuContent;
|
|
||||||
MenuContent.displayName = "DropdownMenuContent";
|
MenuContent.displayName = "DropdownMenuContent";
|
||||||
|
|
||||||
|
export default MenuContent;
|
||||||
|
@ -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"]}
|
||||||
|
@ -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
src/components/dropdownMenu/common.ts
Normal file
31
src/components/dropdownMenu/common.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -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
|
||||||
<DropdownMenuItem
|
icon={TrashIcon}
|
||||||
icon={TrashIcon}
|
onSelect={() => setActiveConfirmDialog("clearCanvas")}
|
||||||
onSelect={toggleDialog}
|
data-testid="clear-canvas-button"
|
||||||
data-testid="clear-canvas-button"
|
aria-label={t("buttons.clearReset")}
|
||||||
aria-label={t("buttons.clearReset")}
|
>
|
||||||
>
|
{t("buttons.clearReset")}
|
||||||
{t("buttons.clearReset")}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{/* FIXME this should live outside MainMenu so it stays open
|
|
||||||
if menu is closed */}
|
|
||||||
{showDialog && (
|
|
||||||
<ConfirmDialog
|
|
||||||
onConfirm={() => {
|
|
||||||
actionManager.executeAction(actionClearCanvas);
|
|
||||||
toggleDialog();
|
|
||||||
}}
|
|
||||||
onCancel={toggleDialog}
|
|
||||||
title={t("clearCanvasDialog.title")}
|
|
||||||
>
|
|
||||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
|
||||||
</ConfirmDialog>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
ClearCanvas.displayName = "ClearCanvas";
|
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}
|
||||||
|
@ -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">
|
||||||
|
@ -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 = {
|
||||||
|
@ -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
src/utils.ts
19
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user