feat(packages/excalidraw): support nested dropdown menu (#10749)

Co-authored-by: Barnabás Molnár <38168628+barnabasmolnar@users.noreply.github.com>
This commit is contained in:
David Luzar
2026-02-08 22:27:34 +01:00
committed by GitHub
parent b43260d97b
commit 063533aede
46 changed files with 1265 additions and 431 deletions
+1
View File
@@ -106,6 +106,7 @@ export const CLASSES = {
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
FRAME_NAME: "frame-name",
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
};
export const FONT_SIZES = {
+1 -1
View File
@@ -1,6 +1,6 @@
import clsx from "clsx";
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import {
CLASSES,
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import clsx from "clsx";
import { useRef, useEffect } from "react";
@@ -0,0 +1,31 @@
import { KEYS } from "@excalidraw/common";
import { Excalidraw } from "../..";
import { Keyboard } from "../../tests/helpers/ui";
import { act, render } from "../../tests/test-utils";
describe("FontPicker", () => {
it("should be able to open font picker", async () => {
(global as any).ResizeObserver =
(global as any).ResizeObserver ||
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const { queryByTestId } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
Keyboard.keyPress(KEYS.T);
const fontPickerTrigger = queryByTestId("font-family-show-fonts");
expect(fontPickerTrigger).not.toBeNull();
act(() => {
fontPickerTrigger!.click();
});
});
});
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react";
@@ -30,10 +30,12 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
import {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import {
FontFamilyCodeIcon,
FontFamilyHeadingIcon,
@@ -269,45 +271,74 @@ export const FontPickerList = React.memo(
[filteredFonts, sceneFamilies],
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
key={font.value}
icon={font.icon}
value={font.value}
order={index}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
const FontPickerListItem = ({
font,
order,
}: {
font: FontDescriptor;
order: number;
}) => {
const ref = useRef<HTMLButtonElement>(null);
const isHovered = font.value === hoveredFont?.value;
const isSelected = font.value === selectedFontFamily;
useEffect(() => {
if (!isHovered) {
return;
}
>
{font.text}
</DropdownMenuItem>
);
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" });
}
}, [isHovered, order]);
return (
<button
ref={ref}
type="button"
value={font.value}
className={getDropdownMenuItemClassName("", isSelected, isHovered)}
title={font.text}
// allow to tab between search and selected font
tabIndex={isSelected ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
<MenuItemContent
icon={font.icon}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
>
{font.text}
</MenuItemContent>
</button>
);
};
const groups = [];
if (sceneFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map(renderFont)}
{sceneFilteredFonts.map((font, index) => (
<FontPickerListItem key={font.value} font={font} order={index} />
))}
</DropdownMenuGroup>,
);
}
@@ -315,9 +346,13 @@ export const FontPickerList = React.memo(
if (availableFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) =>
renderFont(font, index + sceneFilteredFonts.length),
)}
{availableFilteredFonts.map((font, index) => (
<FontPickerListItem
key={font.value}
font={font}
order={index + sceneFilteredFonts.length}
/>
))}
</DropdownMenuGroup>,
);
}
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useEffect } from "react";
@@ -126,9 +126,10 @@
.dropdown-menu-container {
width: 196px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
--box-shadow: var(--library-dropdown-shadow);
}
}
@@ -375,7 +375,7 @@ export const MobileToolBar = ({
)}
{/* Other Shapes */}
<DropdownMenu open={isOtherShapesMenuOpen} placement="top">
<DropdownMenu open={isOtherShapesMenuOpen}>
<DropdownMenu.Trigger
className={clsx(
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
@@ -403,6 +403,7 @@ export const MobileToolBar = ({
onClickOutside={() => setIsOtherShapesMenuOpen(false)}
onSelect={() => setIsOtherShapesMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
align="start"
>
{!showTextToolOutside && (
<DropdownMenu.Item
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { type ReactNode } from "react";
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { Tabs as RadixTabs } from "radix-ui";
import type { SidebarTabName } from "../../types";
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { Tabs as RadixTabs } from "radix-ui";
import type { SidebarTabName } from "../../types";
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { Tabs as RadixTabs } from "radix-ui";
export const SidebarTabTriggers = ({
children,
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { Tabs as RadixTabs } from "radix-ui";
import { useUIAppState } from "../../context/ui-appState";
import { useExcalidrawSetAppState } from "../App";
@@ -1,4 +1,4 @@
@import "../../../css/variables.module.scss";
@use "../../../css/variables.module.scss" as *;
$verticalBreakpoint: 861px;
@@ -52,11 +52,7 @@ export const ChatHistoryMenu = ({
>
{historyIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={onClose}
onSelect={onClose}
placement="bottom"
>
<DropdownMenu.Content onClickOutside={onClose} onSelect={onClose}>
<>
{savedChats.map((chat) => (
<DropdownMenu.ItemCustom
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTab = ({
tab,
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTabTrigger = ({
children,
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTabTriggers = ({
children,
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { Tabs as RadixTabs } from "radix-ui";
import { useRef } from "react";
import { isMemberOf } from "@excalidraw/common";
@@ -3,7 +3,7 @@ import clsx from "clsx";
import { capitalizeString } from "@excalidraw/common";
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import { trackEvent } from "../analytics";
+1 -1
View File
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useLayoutEffect } from "react";
@@ -2,24 +2,35 @@
.excalidraw {
.dropdown-menu {
position: absolute;
top: 2.5rem;
margin-top: 0.5rem;
max-width: 16rem;
z-index: 1;
&--placement-top {
top: auto;
bottom: 100%;
margin-top: 0;
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-dropdown {
&.main-menu {
min-width: 232px;
margin-top: 0;
margin-bottom: 0;
@@ -32,10 +43,6 @@
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
max-height: calc(
100svh - var(--editor-container-padding) * 2 - 2.25rem
);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
transition: box-shadow 0.5s ease-in-out;
@@ -51,14 +58,25 @@
.dropdown-menu-container {
background-color: var(--island-bg-color);
overflow-y: auto;
--gap: 2;
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%;
@@ -115,11 +133,9 @@
.dropdown-menu-item {
height: 2rem;
margin: 1px;
padding: 0 0.5rem;
width: calc(100% - 2px);
background-color: transparent;
border: 1px solid transparent;
border: none;
align-items: center;
cursor: pointer;
border-radius: var(--border-radius-md);
@@ -162,7 +178,7 @@
&:active {
background-color: var(--button-hover-bg);
border-color: var(--color-brand-active);
box-shadow: 0 0 0 1px var(--color-brand-active);
}
svg {
@@ -223,7 +239,7 @@
}
&:active {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
&[disabled] {
@@ -235,7 +251,7 @@
}
&:active {
border-color: transparent;
box-shadow: none;
}
@at-root .excalidraw.theme--dark#{&} {
@@ -1,11 +1,16 @@
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 {
getMenuContentComponent,
@@ -17,34 +22,35 @@ import "./DropdownMenu.scss";
const DropdownMenu = ({
children,
open,
placement,
}: {
children?: React.ReactNode;
open: boolean;
placement?: "top" | "bottom";
}) => {
const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children);
// clone the MenuContentComp to pass the placement prop
const MenuContentCompWithPlacement =
const MenuContentWithState =
MenuContentComp && React.isValidElement(MenuContentComp)
? React.cloneElement(MenuContentComp as React.ReactElement<any>, {
placement,
})
? React.cloneElement(
MenuContentComp as React.ReactElement<
React.ComponentProps<typeof DropdownMenuContent>
>,
{ open },
)
: MenuContentComp;
return (
<div
className="dropdown-menu-container"
style={{
// remove this div from box layout
display: "contents",
}}
>
{MenuTriggerComp}
{open && MenuContentCompWithPlacement}
</div>
<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>
);
};
@@ -55,6 +61,7 @@ DropdownMenu.ItemLink = DropdownMenuItemLink;
DropdownMenu.ItemCustom = DropdownMenuItemCustom;
DropdownMenu.Group = DropdownMenuGroup;
DropdownMenu.Separator = MenuSeparator;
DropdownMenu.Sub = DropdownMenuSub;
export default DropdownMenu;
@@ -1,7 +1,9 @@
import clsx from "clsx";
import React, { useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { EVENT, KEYS } from "@excalidraw/common";
import { CLASSES, EVENT, KEYS } from "@excalidraw/common";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useStable } from "../../hooks/useStable";
@@ -16,8 +18,9 @@ const MenuContent = ({
onClickOutside,
className = "",
onSelect,
open = true,
align = "end",
style,
placement = "bottom",
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
@@ -26,26 +29,36 @@ const MenuContent = ({
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
open?: boolean;
style?: React.CSSProperties;
placement?: "top" | "bottom";
align?: "start" | "center" | "end";
}) => {
const editorInterface = useEditorInterface();
const menuRef = useRef<HTMLDivElement>(null);
const callbacksRef = useStable({ onClickOutside });
useOutsideClick(menuRef, (event) => {
// prevents closing if clicking on the trigger button
if (
!menuRef.current
?.closest(".dropdown-menu-container")
?.contains(event.target)
) {
callbacksRef.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],
),
);
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.stopImmediatePropagation();
@@ -63,35 +76,33 @@ const MenuContent = ({
return () => {
document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
};
}, [callbacksRef]);
}, [callbacksRef, open]);
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": editorInterface.formFactor === "phone",
"dropdown-menu--placement-top": placement === "top",
}).trim();
return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<div
<DropdownMenuPrimitive.Content
ref={menuRef}
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}
style={{ zIndex: 2 }}
>
<Island className="dropdown-menu-container" padding={2}>
{children}
</Island>
)}
</div>
</DropdownMenuPrimitive.Content>
</DropdownMenuContentPropsContext.Provider>
);
};
@@ -1,78 +1,63 @@
import React, { useEffect, useRef } from "react";
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 MenuItemContent from "./DropdownMenuItemContent";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
useHandleDropdownMenuItemSelect,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
import type { JSX } from "react";
const DropdownMenuItem = ({
icon,
badge,
value,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
badge,
...rest
}: {
icon?: JSX.Element;
badge?: React.ReactNode;
value?: string | number | undefined;
order?: number;
onSelect?: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
badge?: React.ReactNode;
} & 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]);
} & Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onSelect" | "onClick"
>) => {
const handleSelect = useHandleDropdownMenuItemSelect(onSelect);
return (
<button
{...rest}
ref={ref}
value={value}
onClick={handleClick}
className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]}
<DropdownMenuPrimitive.Item
className="radix-menu-item"
onSelect={handleSelect}
asChild
>
<MenuItemContent
textStyle={textStyle}
icon={icon}
shortcut={shortcut}
badge={badge}
<button
{...rest}
value={value}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
>
{children}
</MenuItemContent>
</button>
<MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
{children}
</MenuItemContent>
</button>
</DropdownMenuPrimitive.Item>
);
};
DropdownMenuItem.displayName = "DropdownMenuItem";
@@ -27,9 +27,7 @@ const DropdownMenuItemContentRadio = <T,>({
return (
<>
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
<label className="dropdown-menu-item__text" htmlFor={name}>
{children}
</label>
<label className="dropdown-menu-item__text">{children}</label>
<RadioGroup
name={name}
value={value}
@@ -1,9 +1,11 @@
import React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import MenuItemContent from "./DropdownMenuItemContent";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
useHandleDropdownMenuItemSelect,
} from "./common";
import type { JSX } from "react";
@@ -28,23 +30,28 @@ const DropdownMenuItemLink = ({
onSelect?: (event: Event) => void;
rel?: string;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
const handleSelect = useHandleDropdownMenuItemSelect(onSelect);
return (
// eslint-disable-next-line react/jsx-no-target-blank
<a
{...rest}
href={href}
target="_blank"
rel={rel || "noopener"}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
<DropdownMenuPrimitive.Item
className="radix-menu-item"
onSelect={handleSelect}
asChild
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</a>
<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>
);
};
@@ -5,7 +5,7 @@ const MenuSeparator = () => (
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
margin: "6px 0",
flex: "0 0 auto",
}}
/>
@@ -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;
@@ -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";
@@ -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";
@@ -1,5 +1,7 @@
import clsx from "clsx";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useEditorInterface } from "../App";
const MenuTrigger = ({
@@ -23,7 +25,7 @@ const MenuTrigger = ({
},
).trim();
return (
<button
<DropdownMenuPrimitive.Trigger
className={classNames}
onClick={onToggle}
type="button"
@@ -32,7 +34,7 @@ const MenuTrigger = ({
{...rest}
>
{children}
</button>
</DropdownMenuPrimitive.Trigger>
);
};
@@ -1,6 +1,6 @@
import React, { useContext } from "react";
import { EVENT, composeEventHandlers } from "@excalidraw/common";
import { composeEventHandlers } from "@excalidraw/common";
export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void;
@@ -11,28 +11,17 @@ export const getDropdownMenuItemClassName = (
selected = false,
hovered = false,
) => {
return `dropdown-menu-item dropdown-menu-item-base ${className}
${selected ? "dropdown-menu-item--selected" : ""} ${
hovered ? "dropdown-menu-item--hovered" : ""
}`.trim();
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,
export const useHandleDropdownMenuItemSelect = (
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);
}
return composeEventHandlers(onSelect, (event) => {
DropdownMenuContentProps.onSelect?.(event);
});
};
@@ -1,6 +1,6 @@
import React from "react";
export const getMenuTriggerComponent = (children: React.ReactNode) => {
const getMenuComponent = (component: string) => (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
@@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuTrigger",
child.type.displayName === component,
);
if (!comp) {
return null;
@@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
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;
};
export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
export const getSubMenuTriggerComponent = getMenuComponent(
"DropdownMenuSubTrigger",
);
export const getSubMenuContentComponent = getMenuComponent(
"DropdownMenuSubContent",
);
+12
View File
@@ -2396,3 +2396,15 @@ export const presentationIcon = createIcon(
</g>,
tablerIconProps,
);
// empty placeholder icon (used for alignment in menus)
export const emptyIcon = <div style={{ width: "1rem", height: "1rem" }} />;
//tabler-icons: chevron-right
export const chevronRight = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="9 6 15 12 9 18" />
</g>,
tablerIconProps,
);
@@ -306,10 +306,14 @@ export const ChangeCanvasBackground = () => {
return null;
}
return (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ marginTop: "0.75rem" }}>
<div
data-testid="canvas-background-label"
style={{ fontSize: ".75rem", marginBottom: ".5rem" }}
style={{
fontSize: "0.875rem",
marginBottom: "0.25rem",
marginLeft: "0.5rem",
}}
>
{t("labels.canvasBackground")}
</div>
@@ -8,6 +8,7 @@ import { t } from "../../i18n";
import { useEditorInterface, useExcalidrawSetAppState } from "../App";
import { UserList } from "../UserList";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { HamburgerMenuIcon } from "../icons";
@@ -52,12 +53,8 @@ const MainMenu = Object.assign(
onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null });
})}
placement="bottom"
className={
editorInterface.formFactor === "phone"
? "main-menu-dropdown"
: ""
}
className="main-menu"
align="start"
>
{children}
{editorInterface.formFactor === "phone" &&
@@ -84,6 +81,7 @@ const MainMenu = Object.assign(
ItemCustom: DropdownMenu.ItemCustom,
Group: DropdownMenu.Group,
Separator: DropdownMenu.Separator,
Sub: DropdownMenuSub,
DefaultItems,
},
);
+16
View File
@@ -16,6 +16,7 @@
--zIndex-ui-context-menu: 90;
--zIndex-ui-styles-popup: 100;
--zIndex-ui-top: 100;
--zIndex-ui-main-menu: 110;
--zIndex-ui-library: 120;
--zIndex-modal: 1000;
@@ -223,6 +224,18 @@ body.excalidraw-cursor-resize * {
box-shadow: 0 0 0 1px var(--color-brand-hover);
}
// radix doesn't allow differntiating between hover and keyboard active
// states (it's forcing :focus on both).
//
// proper handling would be to disable :focus-visible by default, and enable
// on keyboard arrows (it'd then have to be disabled again, e.g. on keydown
// or container focus)
//
// alas, that is left for another day
[data-radix-collection-item]:focus-visible {
box-shadow: none !important;
}
.buttonList {
.ToolIcon__icon {
all: unset !important;
@@ -670,6 +683,9 @@ body.excalidraw-cursor-resize * {
}
}
.main-menu {
z-index: var(--zIndex-ui-main-menu);
}
.main-menu-trigger {
@include filledButtonOnCanvas;
}
+1 -2
View File
@@ -85,8 +85,7 @@
"@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rfc3",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-tabs": "1.1.3",
"radix-ui": "1.4.3",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
@@ -2,15 +2,31 @@
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
<div
class="dropdown-menu"
aria-labelledby="radix-:r7t:"
aria-orientation="vertical"
class="dropdown-menu main-menu"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="bottom"
data-state="open"
data-testid="dropdown-menu"
dir="ltr"
id="radix-:r7u:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
>
<div
class="Island dropdown-menu-container"
style="--padding: 2; z-index: 2;"
style="--padding: 2;"
>
<button
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
class="dropdown-menu-item__text"
@@ -23,9 +39,13 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
</div>
</button>
<a
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
href="blog.excalidaw.com"
rel="noopener"
rel="noopener noopener"
role="menuitem"
tabindex="-1"
target="_blank"
>
<div
@@ -49,8 +69,12 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
</div>
<button
aria-label="Help"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
data-testid="help-menu-item"
role="menuitem"
tabindex="-1"
title="Help"
>
<div
@@ -114,17 +138,33 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
<div
class="dropdown-menu"
aria-labelledby="radix-:rq:"
aria-orientation="vertical"
class="dropdown-menu main-menu"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="bottom"
data-state="open"
data-testid="dropdown-menu"
dir="ltr"
id="radix-:rr:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
>
<div
class="Island dropdown-menu-container"
style="--padding: 2; z-index: 2;"
style="--padding: 2;"
>
<button
aria-label="Open"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
data-testid="load-button"
role="menuitem"
tabindex="-1"
title="Open"
>
<div
@@ -164,8 +204,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
aria-label="Save to..."
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
data-testid="json-export-button"
role="menuitem"
tabindex="-1"
title="Save to..."
>
<div
@@ -200,8 +244,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
aria-label="Export image..."
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
data-testid="image-export-button"
role="menuitem"
tabindex="-1"
title="Export image..."
>
<div
@@ -265,8 +313,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
aria-label="Find on canvas"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
data-testid="search-menu-button"
role="menuitem"
tabindex="-1"
title="Find on canvas"
>
<div
@@ -318,8 +370,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
aria-label="Help"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
data-testid="help-menu-item"
role="menuitem"
tabindex="-1"
title="Help"
>
<div
@@ -379,8 +435,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
aria-label="Reset the canvas"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
data-testid="clear-canvas-button"
role="menuitem"
tabindex="-1"
title="Reset the canvas"
>
<div
@@ -414,7 +474,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div>
</button>
<div
style="height: 1px; margin: .5rem 0px; flex: 0 0 auto;"
style="height: 1px; margin: 6px 0px; flex: 0 0 auto;"
/>
<div
class="dropdown-menu-group "
@@ -426,9 +486,13 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</p>
<a
aria-label="GitHub"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
href="https://github.com/excalidraw/excalidraw"
rel="noopener"
rel="noopener noopener"
role="menuitem"
tabindex="-1"
target="_blank"
title="GitHub"
>
@@ -464,9 +528,13 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</a>
<a
aria-label="X"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
href="https://x.com/excalidraw"
rel="noopener"
rel="noopener noopener"
role="menuitem"
tabindex="-1"
target="_blank"
title="X"
>
@@ -514,9 +582,13 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</a>
<a
aria-label="Discord"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
href="https://discord.gg/UexuTaE"
rel="noopener"
rel="noopener noopener"
role="menuitem"
tabindex="-1"
target="_blank"
title="Discord"
>
@@ -558,12 +630,16 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</a>
</div>
<div
style="height: 1px; margin: .5rem 0px; flex: 0 0 auto;"
style="height: 1px; margin: 6px 0px; flex: 0 0 auto;"
/>
<button
aria-label="Dark mode"
class="dropdown-menu-item dropdown-menu-item-base"
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical"
data-radix-collection-item=""
data-testid="toggle-dark-mode"
role="menuitem"
tabindex="-1"
title="Dark mode"
>
<div
@@ -603,11 +679,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div>
</button>
<div
style="margin-top: 0.5rem;"
style="margin-top: 0.75rem;"
>
<div
data-testid="canvas-background-label"
style="font-size: .75rem; margin-bottom: .5rem;"
style="font-size: 0.875rem; margin-bottom: 0.25rem; margin-left: 0.5rem;"
>
Canvas background
</div>
@@ -683,7 +759,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
style="width: 1px; height: 1rem; margin: 0px auto;"
/>
<button
aria-controls="radix-:r0:"
aria-controls="radix-:r16:"
aria-expanded="false"
aria-haspopup="dialog"
aria-label="Canvas background"
@@ -1550,7 +1550,6 @@ describe("textWysiwyg", () => {
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
).toBe(VERTICAL_ALIGN.BOTTOM);
// Attempt to Bind 2nd text using text tool
UI.clickTool("text");
mouse.clickAt(