summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/dropdownMenu
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/dropdownMenu
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/dropdownMenu')
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenu.scss218
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx26
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx43
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx88
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuGroup.tsx23
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx123
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx28
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx51
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuItemCustom.tsx25
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuItemLink.tsx49
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx14
-rw-r--r--packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx40
-rw-r--r--packages/excalidraw/components/dropdownMenu/common.ts38
-rw-r--r--packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts35
14 files changed, 801 insertions, 0 deletions
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss
new file mode 100644
index 0000000..e48f6d7
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss
@@ -0,0 +1,218 @@
+@import "../../css/variables.module.scss";
+
+.excalidraw {
+ .dropdown-menu {
+ position: absolute;
+ top: 100%;
+ margin-top: 0.5rem;
+
+ &--mobile {
+ left: 0;
+ width: 100%;
+ 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: var(--island-bg-color);
+ max-height: calc(100vh - 150px);
+ overflow-y: auto;
+ --gap: 2;
+ }
+
+ .dropdown-menu-item-base {
+ display: flex;
+ column-gap: 0.625rem;
+ font-size: 0.875rem;
+ color: var(--color-on-surface);
+ width: 100%;
+ box-sizing: border-box;
+ font-weight: 400;
+ font-family: inherit;
+ }
+
+ &.manual-hover {
+ // disable built-in hover due to keyboard navigation
+ .dropdown-menu-item {
+ &:hover {
+ background-color: transparent;
+ }
+
+ &--hovered {
+ background-color: var(--button-hover-bg) !important;
+ }
+
+ &--selected {
+ background-color: var(--color-primary-light) !important;
+ }
+ }
+ }
+
+ &.fonts {
+ margin-top: 1rem;
+ // display max 7 items per list, where each has 2rem (2.25) height and 1px margin top & bottom
+ // count in 2 groups, where each allocates 1.3*0.75rem font-size and 0.5rem margin bottom, plus one extra 1rem margin top
+ max-height: calc(7 * (2rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem);
+
+ @media screen and (min-width: 1921px) {
+ max-height: calc(
+ 7 * (2.25rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem
+ );
+ }
+
+ .dropdown-menu-item-base {
+ display: inline-flex;
+ }
+
+ .dropdown-menu-group:not(:first-child) {
+ margin-top: 1rem;
+ }
+
+ .dropdown-menu-group-title {
+ font-size: 0.75rem;
+ text-align: left;
+ font-weight: 400;
+ margin: 0 0 0.5rem;
+ line-height: 1.3;
+ }
+ }
+
+ .dropdown-menu-item {
+ height: 2rem;
+ margin: 1px;
+ padding: 0 0.5rem;
+ width: calc(100% - 2px);
+ background-color: transparent;
+ border: 1px solid transparent;
+ align-items: center;
+ cursor: pointer;
+ border-radius: var(--border-radius-md);
+
+ @media screen and (min-width: 1921px) {
+ height: 2.25rem;
+ }
+
+ &__text {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ gap: 0.75rem;
+ }
+
+ &__shortcut {
+ margin-inline-start: auto;
+ opacity: 0.5;
+
+ &--orphaned {
+ text-align: right;
+ font-size: 0.875rem;
+ padding: 0 0.625rem;
+ }
+ }
+
+ &--selected {
+ background: var(--color-primary-light);
+ --icon-fill-color: var(--color-primary-darker);
+ }
+
+ &:hover {
+ background-color: var(--button-hover-bg);
+ text-decoration: none;
+ }
+
+ &:active {
+ background-color: var(--button-hover-bg);
+ border-color: var(--color-brand-active);
+ }
+
+ svg {
+ width: 1rem;
+ height: 1rem;
+ display: block;
+ }
+ }
+
+ .dropdown-menu-item-bare {
+ align-items: center;
+ height: 2rem;
+ justify-content: space-between;
+
+ @media screen and (min-width: 1921px) {
+ height: 2.25rem;
+ }
+
+ 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;
+ }
+ }
+
+ .dropdown-menu-button {
+ @include outlineButtonStyles;
+ width: var(--lg-button-size);
+ height: var(--lg-button-size);
+
+ --background: var(--color-surface-mid);
+
+ background-color: var(--background);
+
+ @at-root .excalidraw.theme--dark#{&} {
+ --background: var(--color-surface-high);
+ &:hover {
+ --background: #363541;
+ }
+ }
+
+ &:hover {
+ --background: var(--color-surface-high);
+ background-color: var(--background);
+ text-decoration: none;
+ }
+
+ &:active {
+ border-color: var(--color-primary);
+ }
+
+ 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);
+ }
+ }
+}
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx
new file mode 100644
index 0000000..0c8deec
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { Excalidraw } from "../../index";
+import { KEYS } from "../../keys";
+import { Keyboard } from "../../tests/helpers/ui";
+import {
+ render,
+ waitFor,
+ getByTestId,
+ fireEvent,
+} from "../../tests/test-utils";
+
+describe("Test <DropdownMenu/>", () => {
+ it("should", async () => {
+ const { container } = await render(<Excalidraw />);
+
+ expect(window.h.state.openMenu).toBe(null);
+
+ fireEvent.click(getByTestId(container, "main-menu-trigger"));
+ expect(window.h.state.openMenu).toBe("canvas");
+
+ await waitFor(() => {
+ Keyboard.keyDown(KEYS.ESCAPE);
+ expect(window.h.state.openMenu).toBe(null);
+ });
+ });
+});
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx
new file mode 100644
index 0000000..8f4ce43
--- /dev/null
+++ b/packages/excalidraw/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";
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx
new file mode 100644
index 0000000..a203124
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx
@@ -0,0 +1,88 @@
+import { Island } from "../Island";
+import { useDevice } from "../App";
+import clsx from "clsx";
+import Stack from "../Stack";
+import React, { useEffect, useRef } from "react";
+import { DropdownMenuContentPropsContext } from "./common";
+import { useOutsideClick } from "../../hooks/useOutsideClick";
+import { KEYS } from "../../keys";
+import { EVENT } from "../../constants";
+import { useStable } from "../../hooks/useStable";
+
+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();
+ const menuRef = useRef<HTMLDivElement>(null);
+
+ const callbacksRef = useStable({ onClickOutside });
+
+ useOutsideClick(menuRef, () => {
+ callbacksRef.onClickOutside?.();
+ });
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === KEYS.ESCAPE) {
+ event.stopImmediatePropagation();
+ callbacksRef.onClickOutside?.();
+ }
+ };
+
+ const option = {
+ // so that we can stop propagation of the event before it reaches
+ // event handlers that were bound before this one
+ capture: true,
+ };
+
+ document.addEventListener(EVENT.KEYDOWN, onKeyDown, option);
+ return () => {
+ document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
+ };
+ }, [callbacksRef]);
+
+ const classNames = clsx(`dropdown-menu ${className}`, {
+ "dropdown-menu--mobile": device.editor.isMobile,
+ }).trim();
+
+ return (
+ <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.editor.isMobile ? (
+ <Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
+ ) : (
+ <Island
+ className="dropdown-menu-container"
+ padding={2}
+ style={{ zIndex: 2 }}
+ >
+ {children}
+ </Island>
+ )}
+ </div>
+ </DropdownMenuContentPropsContext.Provider>
+ );
+};
+MenuContent.displayName = "DropdownMenuContent";
+
+export default MenuContent;
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuGroup.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuGroup.tsx
new file mode 100644
index 0000000..aa4b49a
--- /dev/null
+++ b/packages/excalidraw/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";
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx
new file mode 100644
index 0000000..1ff53f8
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx
@@ -0,0 +1,123 @@
+import type { JSX } from "react";
+import React, { useEffect, useRef } from "react";
+import {
+ getDropdownMenuItemClassName,
+ useHandleDropdownMenuItemClick,
+} from "./common";
+import MenuItemContent from "./DropdownMenuItemContent";
+import { useExcalidrawAppState } from "../App";
+import { THEME } from "../../constants";
+import type { ValueOf } from "../../utility-types";
+
+const DropdownMenuItem = ({
+ icon,
+ value,
+ order,
+ children,
+ shortcut,
+ className,
+ hovered,
+ selected,
+ textStyle,
+ onSelect,
+ onClick,
+ ...rest
+}: {
+ icon?: JSX.Element;
+ value?: string | number | undefined;
+ order?: number;
+ onSelect?: (event: Event) => void;
+ children: React.ReactNode;
+ shortcut?: string;
+ hovered?: boolean;
+ selected?: boolean;
+ textStyle?: React.CSSProperties;
+ className?: string;
+} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
+ const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
+ const ref = useRef<HTMLButtonElement>(null);
+
+ useEffect(() => {
+ if (hovered) {
+ if (order === 0) {
+ // scroll into the first item differently, so it's visible what is above (i.e. group title)
+ ref.current?.scrollIntoView({ block: "end" });
+ } else {
+ ref.current?.scrollIntoView({ block: "nearest" });
+ }
+ }
+ }, [hovered, order]);
+
+ return (
+ <button
+ {...rest}
+ ref={ref}
+ value={value}
+ onClick={handleClick}
+ className={getDropdownMenuItemClassName(className, selected, hovered)}
+ title={rest.title ?? rest["aria-label"]}
+ >
+ <MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
+ {children}
+ </MenuItemContent>
+ </button>
+ );
+};
+DropdownMenuItem.displayName = "DropdownMenuItem";
+
+export const DropDownMenuItemBadgeType = {
+ GREEN: "green",
+ RED: "red",
+ BLUE: "blue",
+} as const;
+
+export const DropDownMenuItemBadge = ({
+ type = DropDownMenuItemBadgeType.BLUE,
+ children,
+}: {
+ type?: ValueOf<typeof DropDownMenuItemBadgeType>;
+ children: React.ReactNode;
+}) => {
+ const { theme } = useExcalidrawAppState();
+ const style = {
+ display: "inline-flex",
+ marginLeft: "auto",
+ padding: "2px 4px",
+ borderRadius: 6,
+ fontSize: 9,
+ fontFamily: "Cascadia, monospace",
+ border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
+ };
+
+ switch (type) {
+ case DropDownMenuItemBadgeType.GREEN:
+ Object.assign(style, {
+ backgroundColor: "var(--background-color-badge)",
+ color: "var(--color-badge)",
+ });
+ break;
+ case DropDownMenuItemBadgeType.RED:
+ Object.assign(style, {
+ backgroundColor: "pink",
+ color: "darkred",
+ });
+ break;
+ case DropDownMenuItemBadgeType.BLUE:
+ default:
+ Object.assign(style, {
+ background: "var(--color-promo)",
+ color: "var(--color-surface-lowest)",
+ });
+ }
+
+ return (
+ <div className="DropDownMenuItemBadge" style={style}>
+ {children}
+ </div>
+ );
+};
+DropDownMenuItemBadge.displayName = "DropdownMenuItemBadge";
+
+DropdownMenuItem.Badge = DropDownMenuItemBadge;
+
+export default DropdownMenuItem;
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx
new file mode 100644
index 0000000..000b8c3
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx
@@ -0,0 +1,28 @@
+import type { JSX } from "react";
+import { useDevice } from "../App";
+
+const MenuItemContent = ({
+ textStyle,
+ icon,
+ shortcut,
+ children,
+}: {
+ icon?: JSX.Element;
+ shortcut?: string;
+ textStyle?: React.CSSProperties;
+ children: React.ReactNode;
+}) => {
+ const device = useDevice();
+ return (
+ <>
+ {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
+ <div style={textStyle} className="dropdown-menu-item__text">
+ {children}
+ </div>
+ {shortcut && !device.editor.isMobile && (
+ <div className="dropdown-menu-item__shortcut">{shortcut}</div>
+ )}
+ </>
+ );
+};
+export default MenuItemContent;
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx
new file mode 100644
index 0000000..14bfe1a
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx
@@ -0,0 +1,51 @@
+import { useDevice } from "../App";
+import { RadioGroup } from "../RadioGroup";
+
+type Props<T> = {
+ value: T;
+ shortcut?: string;
+ choices: {
+ value: T;
+ label: React.ReactNode;
+ ariaLabel?: string;
+ }[];
+ onChange: (value: T) => void;
+ children: React.ReactNode;
+ name: string;
+};
+
+const DropdownMenuItemContentRadio = <T,>({
+ value,
+ shortcut,
+ onChange,
+ choices,
+ children,
+ name,
+}: Props<T>) => {
+ const device = useDevice();
+
+ return (
+ <>
+ <div className="dropdown-menu-item-base dropdown-menu-item-bare">
+ <label className="dropdown-menu-item__text" htmlFor={name}>
+ {children}
+ </label>
+ <RadioGroup
+ name={name}
+ value={value}
+ onChange={onChange}
+ choices={choices}
+ />
+ </div>
+ {shortcut && !device.editor.isMobile && (
+ <div className="dropdown-menu-item__shortcut dropdown-menu-item__shortcut--orphaned">
+ {shortcut}
+ </div>
+ )}
+ </>
+ );
+};
+
+DropdownMenuItemContentRadio.displayName = "DropdownMenuItemContentRadio";
+
+export default DropdownMenuItemContentRadio;
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCustom.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCustom.tsx
new file mode 100644
index 0000000..795c5c7
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCustom.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+
+const DropdownMenuItemCustom = ({
+ children,
+ className = "",
+ selected,
+ ...rest
+}: {
+ children: React.ReactNode;
+ className?: string;
+ selected?: boolean;
+} & React.HTMLAttributes<HTMLDivElement>) => {
+ return (
+ <div
+ {...rest}
+ className={`dropdown-menu-item-base dropdown-menu-item-custom ${className} ${
+ selected ? `dropdown-menu-item--selected` : ``
+ }`.trim()}
+ >
+ {children}
+ </div>
+ );
+};
+
+export default DropdownMenuItemCustom;
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemLink.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemLink.tsx
new file mode 100644
index 0000000..2dbee75
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemLink.tsx
@@ -0,0 +1,49 @@
+import MenuItemContent from "./DropdownMenuItemContent";
+import type { JSX } from "react";
+import React from "react";
+import {
+ getDropdownMenuItemClassName,
+ useHandleDropdownMenuItemClick,
+} from "./common";
+
+const DropdownMenuItemLink = ({
+ icon,
+ shortcut,
+ href,
+ children,
+ onSelect,
+ className = "",
+ selected,
+ rel = "noreferrer",
+ ...rest
+}: {
+ href: string;
+ icon?: JSX.Element;
+ children: React.ReactNode;
+ shortcut?: string;
+ className?: string;
+ selected?: boolean;
+ onSelect?: (event: Event) => void;
+ rel?: string;
+} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
+ const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
+
+ return (
+ <a
+ {...rest}
+ href={href}
+ target="_blank"
+ rel="noreferrer"
+ className={getDropdownMenuItemClassName(className, selected)}
+ title={rest.title ?? rest["aria-label"]}
+ onClick={handleClick}
+ >
+ <MenuItemContent icon={icon} shortcut={shortcut}>
+ {children}
+ </MenuItemContent>
+ </a>
+ );
+};
+
+export default DropdownMenuItemLink;
+DropdownMenuItemLink.displayName = "DropdownMenuItemLink";
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx
new file mode 100644
index 0000000..ee41960
--- /dev/null
+++ b/packages/excalidraw/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";
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx
new file mode 100644
index 0000000..e7369ba
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx
@@ -0,0 +1,40 @@
+import clsx from "clsx";
+import { useDevice } from "../App";
+
+const MenuTrigger = ({
+ className = "",
+ children,
+ onToggle,
+ title,
+ ...rest
+}: {
+ className?: string;
+ children: React.ReactNode;
+ onToggle: () => void;
+ title?: string;
+} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
+ const device = useDevice();
+ const classNames = clsx(
+ `dropdown-menu-button ${className}`,
+ "zen-mode-transition",
+ {
+ "dropdown-menu-button--mobile": device.editor.isMobile,
+ },
+ ).trim();
+ return (
+ <button
+ data-prevent-outside-click
+ className={classNames}
+ onClick={onToggle}
+ type="button"
+ data-testid="dropdown-menu-button"
+ title={title}
+ {...rest}
+ >
+ {children}
+ </button>
+ );
+};
+
+export default MenuTrigger;
+MenuTrigger.displayName = "DropdownMenuTrigger";
diff --git a/packages/excalidraw/components/dropdownMenu/common.ts b/packages/excalidraw/components/dropdownMenu/common.ts
new file mode 100644
index 0000000..a2a46fc
--- /dev/null
+++ b/packages/excalidraw/components/dropdownMenu/common.ts
@@ -0,0 +1,38 @@
+import React, { useContext } from "react";
+import { EVENT } from "../../constants";
+import { composeEventHandlers } from "../../utils";
+
+export const DropdownMenuContentPropsContext = React.createContext<{
+ onSelect?: (event: Event) => void;
+}>({});
+
+export const getDropdownMenuItemClassName = (
+ className = "",
+ selected = false,
+ hovered = false,
+) => {
+ return `dropdown-menu-item dropdown-menu-item-base ${className}
+ ${selected ? "dropdown-menu-item--selected" : ""} ${
+ hovered ? "dropdown-menu-item--hovered" : ""
+ }`.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);
+ }
+ });
+};
diff --git a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts
new file mode 100644
index 0000000..10d91fb
--- /dev/null
+++ b/packages/excalidraw/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;
+};