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:
co-authored by
Barnabás Molnár
parent
b43260d97b
commit
063533aede
@@ -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,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",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user