From 063533aede8365bf18b8f08ec949a1e93697c17d Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sun, 8 Feb 2026 22:27:34 +0100 Subject: [PATCH] feat(packages/excalidraw): support nested dropdown menu (#10749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Barnabás Molnár <38168628+barnabasmolnar@users.noreply.github.com> --- excalidraw-app/components/AppMainMenu.tsx | 2 +- packages/common/src/constants.ts | 1 + packages/excalidraw/components/Actions.tsx | 2 +- .../components/ColorPicker/ColorPicker.tsx | 2 +- .../components/FontPicker/FontPicker.test.tsx | 31 + .../components/FontPicker/FontPicker.tsx | 2 +- .../components/FontPicker/FontPickerList.tsx | 107 ++- .../FontPicker/FontPickerTrigger.tsx | 2 +- packages/excalidraw/components/IconPicker.tsx | 2 +- .../excalidraw/components/LibraryMenu.scss | 3 +- .../excalidraw/components/MobileToolBar.tsx | 3 +- .../components/PropertiesPopover.tsx | 2 +- .../components/Sidebar/SidebarTab.tsx | 2 +- .../components/Sidebar/SidebarTabTrigger.tsx | 2 +- .../components/Sidebar/SidebarTabTriggers.tsx | 2 +- .../components/Sidebar/SidebarTabs.tsx | 2 +- .../components/TTDDialog/Chat/Chat.scss | 2 +- .../TTDDialog/Chat/ChatHistoryMenu.tsx | 6 +- .../components/TTDDialog/TTDDialogTab.tsx | 2 +- .../TTDDialog/TTDDialogTabTrigger.tsx | 2 +- .../TTDDialog/TTDDialogTabTriggers.tsx | 2 +- .../components/TTDDialog/TTDDialogTabs.tsx | 2 +- .../excalidraw/components/ToolPopover.tsx | 2 +- packages/excalidraw/components/UserList.tsx | 2 +- .../components/dropdownMenu/DropdownMenu.scss | 54 +- .../components/dropdownMenu/DropdownMenu.tsx | 43 +- .../dropdownMenu/DropdownMenuContent.tsx | 57 +- .../dropdownMenu/DropdownMenuItem.tsx | 67 +- .../DropdownMenuItemContentRadio.tsx | 4 +- .../dropdownMenu/DropdownMenuItemLink.tsx | 35 +- .../dropdownMenu/DropdownMenuSeparator.tsx | 2 +- .../dropdownMenu/DropdownMenuSub.tsx | 26 + .../dropdownMenu/DropdownMenuSubContent.tsx | 71 ++ .../dropdownMenu/DropdownMenuSubTrigger.tsx | 38 + .../dropdownMenu/DropdownMenuTrigger.tsx | 6 +- .../components/dropdownMenu/common.ts | 25 +- .../dropdownMenu/dropdownMenuUtils.ts | 28 +- packages/excalidraw/components/icons.tsx | 12 + .../components/main-menu/DefaultItems.tsx | 8 +- .../components/main-menu/MainMenu.tsx | 10 +- packages/excalidraw/css/styles.scss | 16 + packages/excalidraw/package.json | 3 +- .../MermaidToExcalidraw.test.tsx.snap | 2 +- .../__snapshots__/excalidraw.test.tsx.snap | 128 ++- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 1 - yarn.lock | 873 ++++++++++++++---- 46 files changed, 1265 insertions(+), 431 deletions(-) create mode 100644 packages/excalidraw/components/FontPicker/FontPicker.test.tsx create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index cd0aca2683..e51d1763b8 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{ {isDevEnv() && ( { + onSelect={() => { if (window.visualDebug) { delete window.visualDebug; saveDebugState({ enabled: false }); diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 03978584cb..4ff50335ef 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -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 = { diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 18791f8f9b..d9f3415d64 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -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, diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 5de89f7590..f76c109280 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -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"; diff --git a/packages/excalidraw/components/FontPicker/FontPicker.test.tsx b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx new file mode 100644 index 0000000000..ab92464fd5 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx @@ -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( + , + ); + + Keyboard.keyPress(KEYS.T); + + const fontPickerTrigger = queryByTestId("font-family-show-fonts"); + + expect(fontPickerTrigger).not.toBeNull(); + + act(() => { + fontPickerTrigger!.click(); + }); + }); +}); diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index c52286a173..4325147e17 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -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"; diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index 79dca68ce0..e5e7b85aea 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -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) => ( - { - wrappedOnSelect(Number(e.currentTarget.value)); - }} - onMouseMove={() => { - if (hoveredFont?.value !== font.value) { - onHover(font.value); - } - }} - badge={ - font.badge && ( - - {font.badge.placeholder} - - ) + const FontPickerListItem = ({ + font, + order, + }: { + font: FontDescriptor; + order: number; + }) => { + const ref = useRef(null); + const isHovered = font.value === hoveredFont?.value; + const isSelected = font.value === selectedFontFamily; + + useEffect(() => { + if (!isHovered) { + return; } - > - {font.text} - - ); + 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 ( + + ); + }; const groups = []; if (sceneFilteredFonts.length) { groups.push( - {sceneFilteredFonts.map(renderFont)} + {sceneFilteredFonts.map((font, index) => ( + + ))} , ); } @@ -315,9 +346,13 @@ export const FontPickerList = React.memo( if (availableFilteredFonts.length) { groups.push( - {availableFilteredFonts.map((font, index) => - renderFont(font, index + sceneFilteredFonts.length), - )} + {availableFilteredFonts.map((font, index) => ( + + ))} , ); } diff --git a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx index ede3a50e06..eea1cdb8d1 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx @@ -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"; diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index fab4f109b8..0d644ca7e9 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -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"; diff --git a/packages/excalidraw/components/LibraryMenu.scss b/packages/excalidraw/components/LibraryMenu.scss index 6133bb2ecf..b0892a07f7 100644 --- a/packages/excalidraw/components/LibraryMenu.scss +++ b/packages/excalidraw/components/LibraryMenu.scss @@ -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); } } diff --git a/packages/excalidraw/components/MobileToolBar.tsx b/packages/excalidraw/components/MobileToolBar.tsx index 9cd351d2ee..e439061217 100644 --- a/packages/excalidraw/components/MobileToolBar.tsx +++ b/packages/excalidraw/components/MobileToolBar.tsx @@ -375,7 +375,7 @@ export const MobileToolBar = ({ )} {/* Other Shapes */} - + setIsOtherShapesMenuOpen(false)} onSelect={() => setIsOtherShapesMenuOpen(false)} className="App-toolbar__extra-tools-dropdown" + align="start" > {!showTextToolOutside && ( {historyIcon} - + <> {savedChats.map((chat) => ( { 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, { - placement, - }) + ? React.cloneElement( + MenuContentComp as React.ReactElement< + React.ComponentProps + >, + { open }, + ) : MenuContentComp; return ( -
- {MenuTriggerComp} - {open && MenuContentCompWithPlacement} -
+ +
+ {MenuTriggerComp} + {MenuContentWithState} +
+
); }; @@ -55,6 +61,7 @@ DropdownMenu.ItemLink = DropdownMenuItemLink; DropdownMenu.ItemCustom = DropdownMenuItemCustom; DropdownMenu.Group = DropdownMenuGroup; DropdownMenu.Separator = MenuSeparator; +DropdownMenu.Sub = DropdownMenuSub; export default DropdownMenu; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index f92c9df327..79be24b969 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -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(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 ( -
event.preventDefault()} > {/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} {editorInterface.formFactor === "phone" ? ( {children} ) : ( - + {children} )} -
+
); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx index 0f227a0bbd..7cc10b95d4 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx @@ -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, "onSelect">) => { - const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect); - const ref = useRef(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, + "onSelect" | "onClick" +>) => { + const handleSelect = useHandleDropdownMenuItemSelect(onSelect); return ( - + + {children} + + + ); }; DropdownMenuItem.displayName = "DropdownMenuItem"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index d8177c50e0..4f2986c30e 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -27,9 +27,7 @@ const DropdownMenuItemContentRadio = ({ return ( <>
- + void; rel?: string; } & React.AnchorHTMLAttributes) => { - const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); + const handleSelect = useHandleDropdownMenuItemSelect(onSelect); return ( // eslint-disable-next-line react/jsx-no-target-blank - - - {children} - - + + + {children} + + + ); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx index 1c6d19521d..4f880c691a 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx @@ -5,7 +5,7 @@ const MenuSeparator = () => ( style={{ height: "1px", backgroundColor: "var(--default-border-color)", - margin: ".5rem 0", + margin: "6px 0", flex: "0 0 auto", }} /> diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx new file mode 100644 index 0000000000..0ba328b189 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx @@ -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 ( + + {MenuTriggerComp} + {MenuContentComp} + + ); +}; + +DropdownMenuSub.Trigger = DropdownMenuSubTrigger; +DropdownMenuSub.Content = DropdownMenuSubContent; + +DropdownMenuSub.displayName = "DropdownMenuSub"; + +export default DropdownMenuSub; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx new file mode 100644 index 0000000000..ef5debef57 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx @@ -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 ( + + {editorInterface.formFactor === "phone" ? ( + {children} + ) : ( + + {children} + + )} + + ); +}; + +export default DropdownMenuSubContent; +DropdownMenuSubContent.displayName = "DropdownMenuSubContent"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx new file mode 100644 index 0000000000..579d4979a7 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx @@ -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 ( + + + {children} + +
{chevronRight}
+
+ ); +}; + +export default DropdownMenuSubTrigger; +DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx index e1f3ef202f..469eb002b8 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx @@ -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 ( - + ); }; diff --git a/packages/excalidraw/components/dropdownMenu/common.ts b/packages/excalidraw/components/dropdownMenu/common.ts index feca5c4060..27342a28f6 100644 --- a/packages/excalidraw/components/dropdownMenu/common.ts +++ b/packages/excalidraw/components/dropdownMenu/common.ts @@ -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 - | 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); }); }; diff --git a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts index 10d91fb856..82e8ccf591 100644 --- a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts +++ b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts @@ -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", +); diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 8caf6cc163..f5ce947d7a 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -2396,3 +2396,15 @@ export const presentationIcon = createIcon( , tablerIconProps, ); + +// empty placeholder icon (used for alignment in menus) +export const emptyIcon =
; + +//tabler-icons: chevron-right +export const chevronRight = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 29a2761a10..2a00a79a71 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -306,10 +306,14 @@ export const ChangeCanvasBackground = () => { return null; } return ( -
+
{t("labels.canvasBackground")}
diff --git a/packages/excalidraw/components/main-menu/MainMenu.tsx b/packages/excalidraw/components/main-menu/MainMenu.tsx index d028231328..3755f1f0d4 100644 --- a/packages/excalidraw/components/main-menu/MainMenu.tsx +++ b/packages/excalidraw/components/main-menu/MainMenu.tsx @@ -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, }, ); diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index 5557d50313..e0d42fa716 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -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; } diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index c7d8693d86..2f12bf6041 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -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", diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap index 1d92ee37e0..f07f054977 100644 --- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Test > should open mermaid popup when active tool is mermaid 1`] = ` -"