diff options
Diffstat (limited to 'packages/excalidraw/components/main-menu')
3 files changed, 496 insertions, 0 deletions
diff --git a/packages/excalidraw/components/main-menu/DefaultItems.scss b/packages/excalidraw/components/main-menu/DefaultItems.scss new file mode 100644 index 0000000..404df84 --- /dev/null +++ b/packages/excalidraw/components/main-menu/DefaultItems.scss @@ -0,0 +1,21 @@ +.excalidraw { + .ActiveFile { + .ActiveFile__fileName { + display: flex; + align-items: center; + + span { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 9.3em; + } + + svg { + width: 1.15em; + margin-inline-end: 0.3em; + transform: scaleY(0.9); + } + } + } +} diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx new file mode 100644 index 0000000..632ea4f --- /dev/null +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -0,0 +1,391 @@ +import { getShortcutFromShortcutName } from "../../actions/shortcuts"; +import { useI18n } from "../../i18n"; +import { + useExcalidrawSetAppState, + useExcalidrawActionManager, + useExcalidrawElements, + useAppProps, +} from "../App"; +import { + boltIcon, + DeviceDesktopIcon, + ExportIcon, + ExportImageIcon, + HelpIcon, + LoadIcon, + MoonIcon, + save, + searchIcon, + SunIcon, + TrashIcon, + usersIcon, +} from "../icons"; +import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons"; +import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem"; +import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; +import { + actionClearCanvas, + actionLoadScene, + actionSaveToActiveFile, + actionShortcuts, + actionToggleSearchMenu, + actionToggleTheme, +} from "../../actions"; +import clsx from "clsx"; +import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; +import { useSetAtom } from "../../editor-jotai"; +import { useUIAppState } from "../../context/ui-appState"; +import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState"; +import Trans from "../Trans"; +import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; +import { THEME } from "../../constants"; +import type { Theme } from "../../element/types"; +import { trackEvent } from "../../analytics"; +import "./DefaultItems.scss"; + +export const LoadScene = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const elements = useExcalidrawElements(); + + if (!actionManager.isActionEnabled(actionLoadScene)) { + return null; + } + + const handleSelect = async () => { + if ( + !elements.length || + (await openConfirmModal({ + title: t("overwriteConfirm.modal.loadFromFile.title"), + actionLabel: t("overwriteConfirm.modal.loadFromFile.button"), + color: "warning", + description: ( + <Trans + i18nKey="overwriteConfirm.modal.loadFromFile.description" + bold={(text) => <strong>{text}</strong>} + br={() => <br />} + /> + ), + })) + ) { + actionManager.executeAction(actionLoadScene); + } + }; + + return ( + <DropdownMenuItem + icon={LoadIcon} + onSelect={handleSelect} + data-testid="load-button" + shortcut={getShortcutFromShortcutName("loadScene")} + aria-label={t("buttons.load")} + > + {t("buttons.load")} + </DropdownMenuItem> + ); +}; +LoadScene.displayName = "LoadScene"; + +export const SaveToActiveFile = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + + if (!actionManager.isActionEnabled(actionSaveToActiveFile)) { + return null; + } + + return ( + <DropdownMenuItem + shortcut={getShortcutFromShortcutName("saveScene")} + data-testid="save-button" + onSelect={() => actionManager.executeAction(actionSaveToActiveFile)} + icon={save} + aria-label={`${t("buttons.save")}`} + >{`${t("buttons.save")}`}</DropdownMenuItem> + ); +}; +SaveToActiveFile.displayName = "SaveToActiveFile"; + +export const SaveAsImage = () => { + const setAppState = useExcalidrawSetAppState(); + const { t } = useI18n(); + return ( + <DropdownMenuItem + icon={ExportImageIcon} + data-testid="image-export-button" + onSelect={() => setAppState({ openDialog: { name: "imageExport" } })} + shortcut={getShortcutFromShortcutName("imageExport")} + aria-label={t("buttons.exportImage")} + > + {t("buttons.exportImage")} + </DropdownMenuItem> + ); +}; +SaveAsImage.displayName = "SaveAsImage"; + +export const CommandPalette = (opts?: { className?: string }) => { + const setAppState = useExcalidrawSetAppState(); + const { t } = useI18n(); + + return ( + <DropdownMenuItem + icon={boltIcon} + data-testid="command-palette-button" + onSelect={() => { + trackEvent("command_palette", "open", "menu"); + setAppState({ openDialog: { name: "commandPalette" } }); + }} + shortcut={getShortcutFromShortcutName("commandPalette")} + aria-label={t("commandPalette.title")} + className={opts?.className} + > + {t("commandPalette.title")} + </DropdownMenuItem> + ); +}; +CommandPalette.displayName = "CommandPalette"; + +export const SearchMenu = (opts?: { className?: string }) => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + + return ( + <DropdownMenuItem + icon={searchIcon} + data-testid="search-menu-button" + onSelect={() => { + actionManager.executeAction(actionToggleSearchMenu); + }} + shortcut={getShortcutFromShortcutName("searchMenu")} + aria-label={t("search.title")} + className={opts?.className} + > + {t("search.title")} + </DropdownMenuItem> + ); +}; +SearchMenu.displayName = "SearchMenu"; + +export const Help = () => { + const { t } = useI18n(); + + const actionManager = useExcalidrawActionManager(); + + return ( + <DropdownMenuItem + data-testid="help-menu-item" + icon={HelpIcon} + onSelect={() => actionManager.executeAction(actionShortcuts)} + shortcut="?" + aria-label={t("helpDialog.title")} + > + {t("helpDialog.title")} + </DropdownMenuItem> + ); +}; +Help.displayName = "Help"; + +export const ClearCanvas = () => { + const { t } = useI18n(); + + const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom); + const actionManager = useExcalidrawActionManager(); + + if (!actionManager.isActionEnabled(actionClearCanvas)) { + return null; + } + + return ( + <DropdownMenuItem + icon={TrashIcon} + onSelect={() => setActiveConfirmDialog("clearCanvas")} + data-testid="clear-canvas-button" + aria-label={t("buttons.clearReset")} + > + {t("buttons.clearReset")} + </DropdownMenuItem> + ); +}; +ClearCanvas.displayName = "ClearCanvas"; + +export const ToggleTheme = ( + props: + | { + allowSystemTheme: true; + theme: Theme | "system"; + onSelect: (theme: Theme | "system") => void; + } + | { + allowSystemTheme?: false; + onSelect?: (theme: Theme) => void; + }, +) => { + const { t } = useI18n(); + const appState = useUIAppState(); + const actionManager = useExcalidrawActionManager(); + const shortcut = getShortcutFromShortcutName("toggleTheme"); + + if (!actionManager.isActionEnabled(actionToggleTheme)) { + return null; + } + + if (props?.allowSystemTheme) { + return ( + <DropdownMenuItemContentRadio + name="theme" + value={props.theme} + onChange={(value: Theme | "system") => props.onSelect(value)} + choices={[ + { + value: THEME.LIGHT, + label: SunIcon, + ariaLabel: `${t("buttons.lightMode")} - ${shortcut}`, + }, + { + value: THEME.DARK, + label: MoonIcon, + ariaLabel: `${t("buttons.darkMode")} - ${shortcut}`, + }, + { + value: "system", + label: DeviceDesktopIcon, + ariaLabel: t("buttons.systemMode"), + }, + ]} + > + {t("labels.theme")} + </DropdownMenuItemContentRadio> + ); + } + + return ( + <DropdownMenuItem + onSelect={(event) => { + // do not close the menu when changing theme + event.preventDefault(); + + if (props?.onSelect) { + props.onSelect( + appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK, + ); + } else { + return actionManager.executeAction(actionToggleTheme); + } + }} + icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon} + data-testid="toggle-dark-mode" + shortcut={shortcut} + aria-label={ + appState.theme === THEME.DARK + ? t("buttons.lightMode") + : t("buttons.darkMode") + } + > + {appState.theme === THEME.DARK + ? t("buttons.lightMode") + : t("buttons.darkMode")} + </DropdownMenuItem> + ); +}; +ToggleTheme.displayName = "ToggleTheme"; + +export const ChangeCanvasBackground = () => { + const { t } = useI18n(); + const appState = useUIAppState(); + const actionManager = useExcalidrawActionManager(); + const appProps = useAppProps(); + + if ( + appState.viewModeEnabled || + !appProps.UIOptions.canvasActions.changeViewBackgroundColor + ) { + return null; + } + return ( + <div style={{ marginTop: "0.5rem" }}> + <div + data-testid="canvas-background-label" + style={{ fontSize: ".75rem", marginBottom: ".5rem" }} + > + {t("labels.canvasBackground")} + </div> + <div style={{ padding: "0 0.625rem" }}> + {actionManager.renderAction("changeViewBackgroundColor")} + </div> + </div> + ); +}; +ChangeCanvasBackground.displayName = "ChangeCanvasBackground"; + +export const Export = () => { + const { t } = useI18n(); + const setAppState = useExcalidrawSetAppState(); + return ( + <DropdownMenuItem + icon={ExportIcon} + onSelect={() => { + setAppState({ openDialog: { name: "jsonExport" } }); + }} + data-testid="json-export-button" + aria-label={t("buttons.export")} + > + {t("buttons.export")} + </DropdownMenuItem> + ); +}; +Export.displayName = "Export"; + +export const Socials = () => { + const { t } = useI18n(); + + return ( + <> + <DropdownMenuItemLink + icon={GithubIcon} + href="https://github.com/excalidraw/excalidraw" + aria-label="GitHub" + > + GitHub + </DropdownMenuItemLink> + <DropdownMenuItemLink + icon={XBrandIcon} + href="https://x.com/excalidraw" + aria-label="X" + > + {t("labels.followUs")} + </DropdownMenuItemLink> + <DropdownMenuItemLink + icon={DiscordIcon} + href="https://discord.gg/UexuTaE" + aria-label="Discord" + > + {t("labels.discordChat")} + </DropdownMenuItemLink> + </> + ); +}; +Socials.displayName = "Socials"; + +export const LiveCollaborationTrigger = ({ + onSelect, + isCollaborating, +}: { + onSelect: () => void; + isCollaborating: boolean; +}) => { + const { t } = useI18n(); + return ( + <DropdownMenuItem + data-testid="collab-button" + icon={usersIcon} + className={clsx({ + "active-collab": isCollaborating, + })} + onSelect={onSelect} + > + {t("labels.liveCollaboration")} + </DropdownMenuItem> + ); +}; + +LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger"; diff --git a/packages/excalidraw/components/main-menu/MainMenu.tsx b/packages/excalidraw/components/main-menu/MainMenu.tsx new file mode 100644 index 0000000..07afd3c --- /dev/null +++ b/packages/excalidraw/components/main-menu/MainMenu.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { useDevice, useExcalidrawSetAppState } from "../App"; +import DropdownMenu from "../dropdownMenu/DropdownMenu"; + +import * as DefaultItems from "./DefaultItems"; + +import { UserList } from "../UserList"; +import { t } from "../../i18n"; +import { HamburgerMenuIcon } from "../icons"; +import { withInternalFallback } from "../hoc/withInternalFallback"; +import { composeEventHandlers } from "../../utils"; +import { useTunnels } from "../../context/tunnels"; +import { useUIAppState } from "../../context/ui-appState"; + +const MainMenu = Object.assign( + withInternalFallback( + "MainMenu", + ({ + children, + onSelect, + }: { + children?: React.ReactNode; + /** + * Called when any menu item is selected (clicked on). + */ + onSelect?: (event: Event) => void; + }) => { + const { MainMenuTunnel } = useTunnels(); + const device = useDevice(); + const appState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + const onClickOutside = device.editor.isMobile + ? undefined + : () => setAppState({ openMenu: null }); + + return ( + <MainMenuTunnel.In> + <DropdownMenu open={appState.openMenu === "canvas"}> + <DropdownMenu.Trigger + onToggle={() => { + setAppState({ + openMenu: appState.openMenu === "canvas" ? null : "canvas", + }); + }} + data-testid="main-menu-trigger" + className="main-menu-trigger" + > + {HamburgerMenuIcon} + </DropdownMenu.Trigger> + <DropdownMenu.Content + onClickOutside={onClickOutside} + onSelect={composeEventHandlers(onSelect, () => { + setAppState({ openMenu: null }); + })} + > + {children} + {device.editor.isMobile && appState.collaborators.size > 0 && ( + <fieldset className="UserList-Wrapper"> + <legend>{t("labels.collaborators")}</legend> + <UserList + mobile={true} + collaborators={appState.collaborators} + userToFollow={appState.userToFollow?.socketId || null} + /> + </fieldset> + )} + </DropdownMenu.Content> + </DropdownMenu> + </MainMenuTunnel.In> + ); + }, + ), + { + Trigger: DropdownMenu.Trigger, + Item: DropdownMenu.Item, + ItemLink: DropdownMenu.ItemLink, + ItemCustom: DropdownMenu.ItemCustom, + Group: DropdownMenu.Group, + Separator: DropdownMenu.Separator, + DefaultItems, + }, +); + +export default MainMenu; |
