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