snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
@use "../../css/variables.module" as *;
.excalidraw {
.dropdown-menu {
max-width: 20rem;
z-index: 1;
&--placement-top {
margin-bottom: 0.5rem;
}
&__submenu-trigger {
&[aria-expanded="true"] {
.dropdown-menu-item {
background-color: var(--button-hover-bg);
}
}
}
&__submenu-trigger-icon {
margin-left: auto;
opacity: 0.5;
svg g {
stroke-width: 2;
}
}
&--mobile {
width: 100%;
row-gap: 0.75rem;
// When main menu is in the top toolbar, position relative to trigger
&.main-menu {
min-width: 232px;
margin-top: 0;
margin-bottom: 0;
@media screen and (orientation: landscape) {
max-width: 232px;
}
}
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
border-radius: var(--border-radius-lg);
position: relative;
transition: box-shadow 0.5s ease-in-out;
display: flex;
flex-direction: column;
overflow-y: auto;
&.zen-mode {
box-shadow: none;
}
}
}
.dropdown-menu-container {
background-color: var(--island-bg-color);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
box-shadow: var(--box-shadow, var(--shadow-island));
max-height: calc(100svh - var(--editor-container-padding) * 2 - 2.25rem);
@at-root .excalidraw.theme--dark#{&} {
box-shadow: var(--box-shadow, var(--shadow-island)),
0 0 0 1px rgba(0, 0, 0, 0.15);
}
}
.dropdown-menu-item-base {
display: flex;
column-gap: 0.625rem;
padding: 0 0.5rem;
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;
padding: 0 0.5rem;
background-color: transparent;
border: none;
align-items: center;
cursor: pointer;
border-radius: var(--border-radius-md);
flex: 1 0 auto;
@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);
box-shadow: 0 0 0 1px var(--color-brand-active);
}
&[disabled] {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
&:hover {
background-color: transparent;
}
&:active {
background-color: transparent;
box-shadow: none;
}
}
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 {
box-shadow: 0 0 0 1px var(--color-primary);
}
&[disabled] {
cursor: not-allowed;
&:hover {
--background: var(--color-surface-mid);
background-color: var(--background);
}
&:active {
box-shadow: none;
}
@at-root .excalidraw.theme--dark#{&} {
&:hover {
--background: var(--color-surface-high);
}
}
}
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);
}
}
}

View File

@@ -0,0 +1,59 @@
import React from "react";
import { KEYS } from "@excalidraw/common";
import { Excalidraw } from "../../index";
import { Keyboard } from "../../tests/helpers/ui";
import {
render,
waitFor,
getByTestId,
fireEvent,
} from "../../tests/test-utils";
describe("Test <DropdownMenu/>", () => {
it("closes on Escape", 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);
});
});
it.each([
["main menu", '[data-testid="main-menu-trigger"]'],
["more tools", ".App-toolbar__extra-tools-trigger"],
])(
"closes %s on outside pointer and touch events after reopening",
async (_, triggerSelector) => {
const { container } = await render(<Excalidraw />);
const trigger = container.querySelector<HTMLElement>(triggerSelector)!;
const canvas = container.querySelector<HTMLCanvasElement>("canvas")!;
const toolbar = getByTestId(container, "toolbar-rectangle");
for (const outsideTarget of [canvas, toolbar]) {
for (const event of [fireEvent.pointerDown, fireEvent.touchStart]) {
fireEvent.click(trigger);
await waitFor(() => {
expect(
container.querySelector('[data-testid="dropdown-menu"]'),
).not.toBeNull();
});
event(outsideTarget);
await waitFor(() => {
expect(
container.querySelector('[data-testid="dropdown-menu"]'),
).toBeNull();
});
}
}
},
);
});

View File

@@ -0,0 +1,70 @@
import React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { CLASSES } from "@excalidraw/common";
import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuItem from "./DropdownMenuItem";
import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
import DropdownMenuItemLink from "./DropdownMenuItemLink";
import MenuSeparator from "./DropdownMenuSeparator";
import DropdownMenuSub from "./DropdownMenuSub";
import DropdownMenuTrigger from "./DropdownMenuTrigger";
import DropdownMenuItemCheckbox from "./DropdownMenuItemCheckbox";
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);
const MenuContentWithState =
MenuContentComp && React.isValidElement(MenuContentComp)
? React.cloneElement(
MenuContentComp as React.ReactElement<
React.ComponentProps<typeof DropdownMenuContent>
>,
{ open },
)
: MenuContentComp;
return (
<DropdownMenuPrimitive.Root open={open} modal={false}>
<div
className={CLASSES.DROPDOWN_MENU_EVENT_WRAPPER}
style={{
// remove this div from box layout
display: "contents",
}}
>
{MenuTriggerComp}
{MenuContentWithState}
</div>
</DropdownMenuPrimitive.Root>
);
};
DropdownMenu.Trigger = DropdownMenuTrigger;
DropdownMenu.Content = DropdownMenuContent;
DropdownMenu.Item = DropdownMenuItem;
DropdownMenu.ItemCheckbox = DropdownMenuItemCheckbox;
DropdownMenu.ItemLink = DropdownMenuItemLink;
DropdownMenu.ItemCustom = DropdownMenuItemCustom;
DropdownMenu.Group = DropdownMenuGroup;
DropdownMenu.Separator = MenuSeparator;
DropdownMenu.Sub = DropdownMenuSub;
export default DropdownMenu;
DropdownMenu.displayName = "DropdownMenu";

View File

@@ -0,0 +1,117 @@
import clsx from "clsx";
import React, { useCallback, useEffect, useMemo } from "react";
import { CLASSES, EVENT, KEYS } from "@excalidraw/common";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useCallbackRefState } from "../../hooks/useCallbackRefState";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useStable } from "../../hooks/useStable";
import { useEditorInterface } from "../App";
import { Island } from "../Island";
import Stack from "../Stack";
import { DropdownMenuContentPropsContext } from "./common";
const MenuContent = ({
children,
onClickOutside,
className = "",
onSelect,
open = true,
align = "end",
style,
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
className?: string;
/**
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
open?: boolean;
style?: React.CSSProperties;
align?: "start" | "center" | "end";
}) => {
const editorInterface = useEditorInterface();
const [menuNode, setMenuNode] = useCallbackRefState<HTMLDivElement>();
// Radix mounts the content lazily. Rebind outside-click listeners when the
// node becomes available so they attach to its owner document.
const menuRef = useMemo(() => ({ current: menuNode }), [menuNode]);
const callbacksRef = useStable({ onClickOutside });
useOutsideClick(
menuRef,
useCallback(
(event) => {
// prevents closing if clicking on the trigger button
if (
!menuRef.current
?.closest(`.${CLASSES.DROPDOWN_MENU_EVENT_WRAPPER}`)
?.contains(event.target)
) {
callbacksRef.onClickOutside?.();
}
},
[callbacksRef, menuRef],
),
);
useEffect(() => {
if (!open || !menuNode) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.preventDefault();
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,
};
const ownerDocument = menuNode.ownerDocument;
ownerDocument.addEventListener(EVENT.KEYDOWN, onKeyDown, option);
return () => {
ownerDocument.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
};
}, [callbacksRef, open, menuNode]);
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": editorInterface.formFactor === "phone",
}).trim();
return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<DropdownMenuPrimitive.Content
ref={setMenuNode}
className={classNames}
style={style}
data-testid="dropdown-menu"
align={align}
sideOffset={8}
onCloseAutoFocus={(event: Event) => event.preventDefault()}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
{editorInterface.formFactor === "phone" ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island className="dropdown-menu-container" padding={2}>
{children}
</Island>
)}
</DropdownMenuPrimitive.Content>
</DropdownMenuContentPropsContext.Provider>
);
};
MenuContent.displayName = "DropdownMenuContent";
export default MenuContent;

View File

@@ -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";

View File

@@ -0,0 +1,119 @@
import React from "react";
import { THEME } from "@excalidraw/common";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { useExcalidrawAppState } from "../App";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemSelect,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
import type { JSX } from "react";
export type DropdownMenuItemProps = {
icon?: JSX.Element;
badge?: React.ReactNode;
value?: string | number | undefined;
onSelect?: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
selected?: boolean;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">;
const DropdownMenuItem = ({
icon,
badge,
value,
children,
shortcut,
className,
selected,
onSelect,
...rest
}: DropdownMenuItemProps) => {
const handleSelect = useHandleDropdownMenuItemSelect(onSelect);
return (
<DropdownMenuPrimitive.Item
className="radix-menu-item"
onSelect={handleSelect}
asChild
>
<button
{...rest}
value={value}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
{children}
</MenuItemContent>
</button>
</DropdownMenuPrimitive.Item>
);
};
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;

View File

@@ -0,0 +1,15 @@
import { checkIcon, emptyIcon } from "../icons";
import DropdownMenuItem from "./DropdownMenuItem";
import type { DropdownMenuItemProps } from "./DropdownMenuItem";
const DropdownMenuItemCheckbox = (
props: Omit<DropdownMenuItemProps, "icon"> & { checked: boolean },
) => {
return (
<DropdownMenuItem {...props} icon={props.checked ? checkIcon : emptyIcon} />
);
};
export default DropdownMenuItemCheckbox;

View File

@@ -0,0 +1,34 @@
import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import type { JSX } from "react";
const MenuItemContent = ({
textStyle,
icon,
shortcut,
children,
badge,
}: {
icon?: JSX.Element;
shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode;
badge?: React.ReactNode;
}) => {
const editorInterface = useEditorInterface();
return (
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</div>
{badge && <div className="dropdown-menu-item__badge">{badge}</div>}
{shortcut && editorInterface.formFactor !== "phone" && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export default MenuItemContent;

View File

@@ -0,0 +1,55 @@
import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
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;
icon?: React.ReactNode;
};
const DropdownMenuItemContentRadio = <T,>({
value,
shortcut,
onChange,
choices,
children,
name,
icon,
}: Props<T>) => {
const editorInterface = useEditorInterface();
return (
<>
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<RadioGroup
name={name}
value={value}
onChange={onChange}
choices={choices}
/>
</div>
{shortcut && editorInterface.formFactor !== "phone" && (
<div className="dropdown-menu-item__shortcut dropdown-menu-item__shortcut--orphaned">
{shortcut}
</div>
)}
</>
);
};
DropdownMenuItemContentRadio.displayName = "DropdownMenuItemContentRadio";
export default DropdownMenuItemContentRadio;

View File

@@ -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;

View File

@@ -0,0 +1,59 @@
import React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import MenuItemContent from "./DropdownMenuItemContent";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemSelect,
} from "./common";
import type { JSX } from "react";
const DropdownMenuItemLink = ({
icon,
shortcut,
href,
children,
onSelect,
className = "",
selected,
rel = "noopener",
...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 handleSelect = useHandleDropdownMenuItemSelect(onSelect);
return (
// eslint-disable-next-line react/jsx-no-target-blank
<DropdownMenuPrimitive.Item
className="radix-menu-item"
onSelect={handleSelect}
asChild
>
<a
{...rest}
href={href}
target="_blank"
rel={`noopener ${rel}`}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</a>
</DropdownMenuPrimitive.Item>
);
};
export default DropdownMenuItemLink;
DropdownMenuItemLink.displayName = "DropdownMenuItemLink";

View File

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

View File

@@ -0,0 +1,26 @@
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import DropdownMenuSubContent from "./DropdownMenuSubContent";
import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger";
import {
getSubMenuContentComponent,
getSubMenuTriggerComponent,
} from "./dropdownMenuUtils";
const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => {
const MenuTriggerComp = getSubMenuTriggerComponent(children);
const MenuContentComp = getSubMenuContentComponent(children);
return (
<DropdownMenuPrimitive.Sub>
{MenuTriggerComp}
{MenuContentComp}
</DropdownMenuPrimitive.Sub>
);
};
DropdownMenuSub.Trigger = DropdownMenuSubTrigger;
DropdownMenuSub.Content = DropdownMenuSubContent;
DropdownMenuSub.displayName = "DropdownMenuSub";
export default DropdownMenuSub;

View File

@@ -0,0 +1,71 @@
import clsx from "clsx";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useCallback, useState } from "react";
import { useEditorInterface } from "../App";
import { Island } from "../Island";
import Stack from "../Stack";
const BASE_ALIGN_OFFSET = -4;
const BASE_SIDE_OFFSET = 4;
const DropdownMenuSubContent = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const editorInterface = useEditorInterface();
const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, {
"dropdown-menu--mobile": editorInterface.formFactor === "phone",
}).trim();
const callbacksRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const parentContainer = node.closest(".dropdown-menu-container");
const parentRect = parentContainer?.getBoundingClientRect();
if (parentRect) {
const menuWidth = node.getBoundingClientRect().width;
const viewportWidth = window.innerWidth;
const spaceRemaining = viewportWidth - parentRect.right;
if (spaceRemaining < menuWidth + 20) {
setSideOffset(spaceRemaining - menuWidth + BASE_ALIGN_OFFSET);
setAlignOffset(BASE_ALIGN_OFFSET + 8);
}
}
}
}, []);
const [sideOffset, setSideOffset] = useState(BASE_SIDE_OFFSET);
const [alignOffset, setAlignOffset] = useState(BASE_ALIGN_OFFSET);
return (
<DropdownMenuPrimitive.SubContent
className={classNames}
sideOffset={sideOffset}
alignOffset={alignOffset}
collisionPadding={8}
ref={callbacksRef}
>
{editorInterface.formFactor === "phone" ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</DropdownMenuPrimitive.SubContent>
);
};
export default DropdownMenuSubContent;
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";

View File

@@ -0,0 +1,38 @@
import React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { chevronRight } from "../icons";
import { getDropdownMenuItemClassName } from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
import type { JSX } from "react";
const DropdownMenuSubTrigger = ({
children,
icon,
shortcut,
className,
}: {
children: React.ReactNode;
icon?: JSX.Element;
shortcut?: string;
className?: string;
}) => {
return (
<DropdownMenuPrimitive.SubTrigger
className={`${getDropdownMenuItemClassName(
className,
)} dropdown-menu__submenu-trigger`}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
<div className="dropdown-menu__submenu-trigger-icon">{chevronRight}</div>
</DropdownMenuPrimitive.SubTrigger>
);
};
export default DropdownMenuSubTrigger;
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";

View File

@@ -0,0 +1,42 @@
import clsx from "clsx";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useEditorInterface } from "../App";
const MenuTrigger = ({
className = "",
children,
onToggle,
title,
...rest
}: {
className?: string;
children: React.ReactNode;
onToggle: () => void;
title?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const editorInterface = useEditorInterface();
const classNames = clsx(
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"dropdown-menu-button--mobile": editorInterface.formFactor === "phone",
},
).trim();
return (
<DropdownMenuPrimitive.Trigger
className={classNames}
onClick={onToggle}
type="button"
data-testid="dropdown-menu-button"
title={title}
{...rest}
>
{children}
</DropdownMenuPrimitive.Trigger>
);
};
export default MenuTrigger;
MenuTrigger.displayName = "DropdownMenuTrigger";

View File

@@ -0,0 +1,27 @@
import React, { useContext } from "react";
import { composeEventHandlers } from "@excalidraw/common";
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 useHandleDropdownMenuItemSelect = (
onSelect: ((event: Event) => void) | undefined,
) => {
const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext);
return composeEventHandlers(onSelect, (event) => {
DropdownMenuContentProps.onSelect?.(event);
});
};

View File

@@ -0,0 +1,27 @@
import React from "react";
const getMenuComponent = (component: string) => (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 === component,
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
export const getSubMenuTriggerComponent = getMenuComponent(
"DropdownMenuSubTrigger",
);
export const getSubMenuContentComponent = getMenuComponent(
"DropdownMenuSubContent",
);