summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/main-menu
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/main-menu')
-rw-r--r--packages/excalidraw/components/main-menu/DefaultItems.scss21
-rw-r--r--packages/excalidraw/components/main-menu/DefaultItems.tsx391
-rw-r--r--packages/excalidraw/components/main-menu/MainMenu.tsx84
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;