diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 0a83be53..acfee472 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -23,7 +23,7 @@ import { newElementWith } from "../element/mutateElement"; import { getDefaultAppState, isEraserActive } from "../appState"; import ClearCanvas from "../components/ClearCanvas"; import clsx from "clsx"; -import MenuItem from "../components/MenuItem"; +import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem"; import { getShortcutFromShortcutName } from "./shortcuts"; export const actionChangeViewBackgroundColor = register({ @@ -299,19 +299,23 @@ export const actionToggleTheme = register({ }; }, PanelComponent: ({ appState, updateData }) => ( - { + { updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT); }} icon={appState.theme === "dark" ? SunIcon : MoonIcon} dataTestId="toggle-dark-mode" shortcut={getShortcutFromShortcutName("toggleTheme")} - /> + ariaLabel={ + appState.theme === "dark" + ? t("buttons.lightMode") + : t("buttons.darkMode") + } + > + {appState.theme === "dark" + ? t("buttons.lightMode") + : t("buttons.darkMode")} + ), keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, }); diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index fc91425d..e0f6c95f 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -19,7 +19,7 @@ import { ActiveFile } from "../components/ActiveFile"; import { isImageFileHandle } from "../data/blob"; import { nativeFileSystemSupported } from "../data/filesystem"; import { Theme } from "../element/types"; -import MenuItem from "../components/MenuItem"; +import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem"; import { getShortcutFromShortcutName } from "./shortcuts"; export const actionChangeProjectName = register({ @@ -247,15 +247,19 @@ export const actionLoadScene = register({ } }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, - PanelComponent: ({ updateData }) => ( - - ), + PanelComponent: ({ updateData }) => { + return ( + + {t("buttons.load")} + + ); + }, }); export const actionExportWithDarkMode = register({ diff --git a/src/actions/actionMenu.tsx b/src/actions/actionMenu.tsx index 769fd06d..f0337128 100644 --- a/src/actions/actionMenu.tsx +++ b/src/actions/actionMenu.tsx @@ -6,7 +6,7 @@ import { register } from "./register"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { KEYS } from "../keys"; import { HelpButton } from "../components/HelpButton"; -import MenuItem from "../components/MenuItem"; +import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem"; export const actionToggleCanvasMenu = register({ name: "toggleCanvasMenu", @@ -90,13 +90,15 @@ export const actionShortcuts = register({ }, PanelComponent: ({ updateData, isInHamburgerMenu }) => isInHamburgerMenu ? ( - + ariaLabel={t("helpDialog.title")} + > + {t("helpDialog.title")} + ) : ( ), diff --git a/src/components/ActiveFile.tsx b/src/components/ActiveFile.tsx index d6e7c8fa..ac9ee89b 100644 --- a/src/components/ActiveFile.tsx +++ b/src/components/ActiveFile.tsx @@ -5,7 +5,7 @@ import { save } from "../components/icons"; import { t } from "../i18n"; import "./ActiveFile.scss"; -import MenuItem from "./MenuItem"; +import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem"; type ActiveFileProps = { fileName?: string; @@ -13,11 +13,11 @@ type ActiveFileProps = { }; export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => ( - + ariaLabel={`${t("buttons.save")}`} + >{`${t("buttons.save")}`} ); diff --git a/src/components/App.tsx b/src/components/App.tsx index e058d36c..c0275dbf 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -272,13 +272,9 @@ import { isLocalLink, } from "../element/Hyperlink"; import { shouldShowBoundingBox } from "../element/transformHandles"; -import { atom } from "jotai"; import { Fonts } from "../scene/Fonts"; import { actionPaste } from "../actions/actionClipboard"; -export const isMenuOpenAtom = atom(false); -export const isDropdownOpenAtom = atom(false); - const deviceContextInitialValue = { isSmScreen: false, isMobile: false, @@ -289,7 +285,7 @@ const DeviceContext = React.createContext(deviceContextInitialValue); DeviceContext.displayName = "DeviceContext"; export const useDevice = () => useContext(DeviceContext); -const ExcalidrawContainerContext = React.createContext<{ +export const ExcalidrawContainerContext = React.createContext<{ container: HTMLDivElement | null; id: string | null; }>({ container: null, id: null }); @@ -316,12 +312,19 @@ const ExcalidrawSetAppStateContext = React.createContext< >(() => {}); ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext"; +const ExcalidrawActionManagerContext = React.createContext< + ActionManager | { renderAction: ActionManager["renderAction"] } +>({ renderAction: () => null }); +ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; + export const useExcalidrawElements = () => useContext(ExcalidrawElementsContext); export const useExcalidrawAppState = () => useContext(ExcalidrawAppStateContext); export const useExcalidrawSetAppState = () => useContext(ExcalidrawSetAppStateContext); +export const useExcalidrawActionManager = () => + useContext(ExcalidrawActionManagerContext); let didTapTwice: boolean = false; let tappedTwiceTimer = 0; @@ -559,75 +562,79 @@ class App extends React.Component { - - this.addElementsFromPasteOrLibrary({ - elements, - position: "center", - files: null, - }) - } - langCode={getLanguage().code} - isCollaborating={this.props.isCollaborating} - renderTopRightUI={renderTopRightUI} - renderCustomStats={renderCustomStats} - renderCustomSidebar={this.props.renderSidebar} - showExitZenModeBtn={ - typeof this.props?.zenModeEnabled === "undefined" && - this.state.zenModeEnabled - } - libraryReturnUrl={this.props.libraryReturnUrl} - UIOptions={this.props.UIOptions} - focusContainer={this.focusContainer} - library={this.library} - id={this.id} - onImageAction={this.onImageAction} - renderWelcomeScreen={ - this.state.showWelcomeScreen && - this.state.activeTool.type === "selection" && - !this.scene.getElementsIncludingDeleted().length - } + - {this.props.children} - -
-
- {selectedElement.length === 1 && - !this.state.contextMenu && - this.state.showHyperlinkPopup && ( - + this.addElementsFromPasteOrLibrary({ + elements, + position: "center", + files: null, + }) + } + langCode={getLanguage().code} + isCollaborating={this.props.isCollaborating} + renderTopRightUI={renderTopRightUI} + renderCustomStats={renderCustomStats} + renderCustomSidebar={this.props.renderSidebar} + showExitZenModeBtn={ + typeof this.props?.zenModeEnabled === "undefined" && + this.state.zenModeEnabled + } + libraryReturnUrl={this.props.libraryReturnUrl} + UIOptions={this.props.UIOptions} + focusContainer={this.focusContainer} + library={this.library} + id={this.id} + onImageAction={this.onImageAction} + renderWelcomeScreen={ + this.state.showWelcomeScreen && + this.state.activeTool.type === "selection" && + !this.scene.getElementsIncludingDeleted().length + } + > + {this.props.children} + +
+
+ {selectedElement.length === 1 && + !this.state.contextMenu && + this.state.showHyperlinkPopup && ( + + )} + {this.state.toast !== null && ( + this.setToast(null)} + duration={this.state.toast.duration} + closable={this.state.toast.closable} /> )} - {this.state.toast !== null && ( - this.setToast(null)} - duration={this.state.toast.duration} - closable={this.state.toast.closable} - /> - )} - {this.state.contextMenu && ( - - )} -
{this.renderCanvas()}
+ {this.state.contextMenu && ( + + )} +
{this.renderCanvas()}
+ {" "} diff --git a/src/components/ClearCanvas.tsx b/src/components/ClearCanvas.tsx index d6f8ee39..354838aa 100644 --- a/src/components/ClearCanvas.tsx +++ b/src/components/ClearCanvas.tsx @@ -3,7 +3,7 @@ import { t } from "../i18n"; import { TrashIcon } from "./icons"; import ConfirmDialog from "./ConfirmDialog"; -import MenuItem from "./MenuItem"; +import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem"; const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { const [showDialog, setShowDialog] = useState(false); @@ -13,12 +13,14 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { return ( <> - + ariaLabel={t("buttons.clearReset")} + > + {t("buttons.clearReset")} + {showDialog && ( {isInHamburgerMenu ? ( - + onSelect={onClick} + ariaLabel={t("labels.liveCollaboration")} + > + {t("labels.liveCollaboration")} + ) : (
- - - - {isMenuOpen && ( -
-
- {/* the zIndex ensures this menu has higher stacking order, - see https://github.com/excalidraw/excalidraw/pull/1445 */} - - {!appState.viewModeEnabled && - actionManager.renderAction("loadScene")} - {/* // TODO barnabasmolnar/editor-redesign */} - {/* is this fine here? */} - {appState.fileHandle && - actionManager.renderAction("saveToActiveFile")} - {renderJSONExportDialog()} - {UIOptions.canvasActions.saveAsImage && ( - setAppState({ openDialog: "imageExport" })} - shortcut={getShortcutFromShortcutName("imageExport")} - /> - )} - {onCollabButtonClick && ( - - )} - {actionManager.renderAction("toggleShortcuts", undefined, true)} - {!appState.viewModeEnabled && - actionManager.renderAction("clearCanvas")} - - - -
-
{actionManager.renderAction("toggleTheme")}
-
- -
- {!appState.viewModeEnabled && ( -
-
- {t("labels.canvasBackground")} -
-
- {actionManager.renderAction("changeViewBackgroundColor")} -
-
- )} -
-
-
-
- )} + {renderMenu()}
); @@ -410,10 +347,7 @@ const LayerUI = ({ }, )} > - + {onCollabButtonClick && ( )} {renderImageExportDialog()} + {renderJSONExportDialog()} {appState.pasteDialog.shown && ( )} @@ -525,9 +461,8 @@ const LayerUI = ({ appState={appState} actionManager={actionManager} showExitZenModeBtn={showExitZenModeBtn} - > - {childrenComponents.FooterCenter} - + footerCenter={childrenComponents.FooterCenter} + /> {appState.showStats && ( { const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); - + const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom( + isLibraryMenuOpenAtom, + ); const renderRemoveLibAlert = useCallback(() => { const content = selectedItems.length ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) @@ -173,85 +176,86 @@ export const LibraryMenuHeader: React.FC<{ }); }; - const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom); - const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false)); - + const renderLibraryMenu = () => { + return ( + + setIsLibraryMenuOpen(!isLibraryMenuOpen)} + > + {DotsIcon} + + setIsLibraryMenuOpen(false)} + className="library-menu" + > + {!itemsSelected && ( + + {t("buttons.load")} + + )} + {!!items.length && ( + + {t("buttons.export")} + + )} + {!!items.length && ( + setShowRemoveLibAlert(true)} + icon={TrashIcon} + > + {resetLabel} + + )} + {itemsSelected && ( + setShowPublishLibraryDialog(true)} + dataTestId="lib-dropdown--remove" + > + {t("buttons.publishLibrary")} + + )} + + + ); + }; return (
- - + {renderLibraryMenu()} {selectedItems.length > 0 && (
{selectedItems.length}
)} - - {isDropdownOpen && ( -
- {!itemsSelected && ( - + {showRemoveLibAlert && renderRemoveLibAlert()} + {showPublishLibraryDialog && ( + setShowPublishLibraryDialog(false)} + libraryItems={getSelectedItems( + libraryItemsData.libraryItems, + selectedItems, )} - {showRemoveLibAlert && renderRemoveLibAlert()} - {showPublishLibraryDialog && ( - setShowPublishLibraryDialog(false)} - libraryItems={getSelectedItems( - libraryItemsData.libraryItems, - selectedItems, - )} - appState={appState} - onSuccess={(data) => - onPublishLibSuccess(data, libraryItemsData.libraryItems) - } - onError={(error) => window.alert(error)} - updateItemsInStorage={() => - library.setLibrary(libraryItemsData.libraryItems) - } - onRemove={(id: string) => - onSelectItems(selectedItems.filter((_id) => _id !== id)) - } - /> - )} - {publishLibSuccess && renderPublishSuccess()} - {!!items.length && ( - <> - - setShowRemoveLibAlert(true)} - dataTestId="lib-dropdown--remove" - /> - - )} - {itemsSelected && ( - setShowPublishLibraryDialog(true)} - /> - )} -
+ appState={appState} + onSuccess={(data) => + onPublishLibSuccess(data, libraryItemsData.libraryItems) + } + onError={(error) => window.alert(error)} + updateItemsInStorage={() => + library.setLibrary(libraryItemsData.libraryItems) + } + onRemove={(id: string) => + onSelectItems(selectedItems.filter((_id) => _id !== id)) + } + /> )} + {publishLibSuccess && renderPublishSuccess()}
); }; diff --git a/src/components/Menu.scss b/src/components/Menu.scss deleted file mode 100644 index 6a22b0b8..00000000 --- a/src/components/Menu.scss +++ /dev/null @@ -1,85 +0,0 @@ -@import "../css/variables.module"; - -.excalidraw { - .menu-container { - background-color: #fff !important; - max-height: calc(100vh - 150px); - overflow-y: auto; - } - - .menu-button { - @include outlineButtonStyles; - background-color: var(--island-bg-color); - width: var(--lg-button-size); - height: var(--lg-button-size); - - svg { - width: var(--lg-icon-size); - height: var(--lg-icon-size); - } - } - - .menu-item { - display: flex; - background-color: transparent; - border: 0; - align-items: center; - padding: 0 0.625rem; - height: 2rem; - column-gap: 0.625rem; - font-size: 0.875rem; - color: var(--color-gray-100); - cursor: pointer; - border-radius: var(--border-radius-md); - width: 100%; - box-sizing: border-box; - font-weight: normal; - font-family: inherit; - - @media screen and (min-width: 1921px) { - height: 2.25rem; - } - - &__text { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - &__shortcut { - margin-inline-start: auto; - opacity: 0.5; - } - - &:hover { - background-color: var(--button-hover); - text-decoration: none; - } - - svg { - width: 1rem; - height: 1rem; - display: block; - } - - &.active-collab { - background-color: #ecfdf5; - color: #064e3c; - } - } - - &.theme--dark { - .menu-item { - color: var(--color-gray-40); - - &.active-collab { - background-color: #064e3c; - color: #ecfdf5; - } - } - - .menu-container { - background-color: var(--color-gray-90) !important; - } - } -} diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx deleted file mode 100644 index 8bfe3d75..00000000 --- a/src/components/MenuItem.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import clsx from "clsx"; -import "./Menu.scss"; - -interface MenuProps { - icon: JSX.Element; - onClick: () => void; - label: string; - dataTestId: string; - shortcut?: string; - isCollaborating?: boolean; -} - -const MenuItem = ({ - icon, - onClick, - label, - dataTestId, - shortcut, - isCollaborating, -}: MenuProps) => { - return ( - - ); -}; - -export default MenuItem; diff --git a/src/components/MenuUtils.tsx b/src/components/MenuUtils.tsx deleted file mode 100644 index 90cf0503..00000000 --- a/src/components/MenuUtils.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { GithubIcon, DiscordIcon, PlusPromoIcon, TwitterIcon } from "./icons"; - -export const MenuLinks = () => ( - <> - -
{PlusPromoIcon}
-
Excalidraw+
-
- -
{GithubIcon}
-
GitHub
-
- -
{DiscordIcon}
-
Discord
-
- -
{TwitterIcon}
-
Twitter
-
- -); - -export const Separator = () => ( -
-); diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 37b32cc0..5d77c340 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -11,18 +11,13 @@ import { HintViewer } from "./HintViewer"; import { calculateScrollCenter } from "../scene"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { Section } from "./Section"; -import CollabButton from "./CollabButton"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { LockButton } from "./LockButton"; -import { UserList } from "./UserList"; import { LibraryButton } from "./LibraryButton"; import { PenModeButton } from "./PenModeButton"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions"; -import { MenuLinks, Separator } from "./MenuUtils"; import WelcomeScreen from "./WelcomeScreen"; -import MenuItem from "./MenuItem"; -import { ExportImageIcon } from "./icons"; type MobileMenuProps = { appState: AppState; @@ -46,16 +41,14 @@ type MobileMenuProps = { renderSidebars: () => JSX.Element | null; device: Device; renderWelcomeScreen?: boolean; + renderMenu: () => React.ReactNode; }; export const MobileMenu = ({ appState, elements, actionManager, - renderJSONExportDialog, - renderImageExportDialog, setAppState, - onCollabButtonClick, onLockToggle, onPenModeToggle, canvas, @@ -66,6 +59,7 @@ export const MobileMenu = ({ renderSidebars, device, renderWelcomeScreen, + renderMenu, }: MobileMenuProps) => { const renderToolbar = () => { return ( @@ -147,16 +141,12 @@ export const MobileMenu = ({ const renderAppToolbar = () => { if (appState.viewModeEnabled) { - return ( -
- {actionManager.renderAction("toggleCanvasMenu")} -
- ); + return
{renderMenu()}
; } return (
- {actionManager.renderAction("toggleCanvasMenu")} + {renderMenu()} {actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("undo")} {actionManager.renderAction("redo")} @@ -168,58 +158,6 @@ export const MobileMenu = ({ ); }; - const renderCanvasActions = () => { - if (appState.viewModeEnabled) { - return ( - <> - {renderJSONExportDialog()} - setAppState({ openDialog: "imageExport" })} - /> - {renderImageExportDialog()} - - ); - } - return ( - <> - {!appState.viewModeEnabled && actionManager.renderAction("loadScene")} - {renderJSONExportDialog()} - {renderImageExportDialog()} - setAppState({ openDialog: "imageExport" })} - /> - {onCollabButtonClick && ( - - )} - {actionManager.renderAction("toggleShortcuts", undefined, true)} - {!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")} - - - - {!appState.viewModeEnabled && ( -
-
- {t("labels.canvasBackground")} -
-
- {actionManager.renderAction("changeViewBackgroundColor")} -
-
- )} - {actionManager.renderAction("toggleTheme")} - - ); - }; return ( <> {renderSidebars()} @@ -244,27 +182,9 @@ export const MobileMenu = ({ }} > - {appState.openMenu === "canvas" ? ( -
-
- - {renderCanvasActions()} - {appState.collaborators.size > 0 && ( -
- {t("labels.collaborators")} - -
- )} -
-
-
- ) : appState.openMenu === "shape" && - !appState.viewModeEnabled && - showSelectedShapeActions(appState, elements) ? ( + {appState.openMenu === "shape" && + !appState.viewModeEnabled && + showSelectedShapeActions(appState, elements) ? (
= ({ className, mobile, collaborators, actionManager }) => { - const uniqueCollaborators = new Map(); +}> = ({ className, mobile, collaborators }) => { + const actionManager = useExcalidrawActionManager(); + const uniqueCollaborators = new Map(); collaborators.forEach((collaborator, socketId) => { uniqueCollaborators.set( // filter on user id, else fall back on unique socketId @@ -44,26 +44,6 @@ export const UserList: React.FC<{ ); }); - // TODO barnabasmolnar/editor-redesign - // probably remove before shipping :) - // 20 fake collaborators; for easy, convenient debug purposes ˇˇ - // const avatars = Array.from({ length: 20 }).map((_, index) => { - // const avatarJSX = actionManager.renderAction("goToCollaborator", [ - // index.toString(), - // { - // username: `User ${index}`, - // }, - // ]); - - // return mobile ? ( - // - // {avatarJSX} - // - // ) : ( - // {avatarJSX} - // ); - // }); - return (
{avatars} diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index 66993d4d..b952d513 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -1,18 +1,10 @@ -import { useAtom } from "jotai"; import { actionLoadScene, actionShortcuts } from "../actions"; import { ActionManager } from "../actions/manager"; import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { isExcalidrawPlusSignedUser } from "../constants"; -import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab"; import { t } from "../i18n"; import { AppState } from "../types"; -import { - ExcalLogo, - HelpIcon, - LoadIcon, - PlusPromoIcon, - UsersIcon, -} from "./icons"; +import { ExcalLogo, HelpIcon, LoadIcon, PlusPromoIcon } from "./icons"; import "./WelcomeScreen.scss"; const WelcomeScreenItem = ({ @@ -64,8 +56,6 @@ const WelcomeScreen = ({ appState: AppState; actionManager: ActionManager; }) => { - const [, setCollabDialogShown] = useAtom(collabDialogShownAtom); - let subheadingJSX; if (isExcalidrawPlusSignedUser) { @@ -109,12 +99,6 @@ const WelcomeScreen = ({ icon={LoadIcon} /> )} - setCollabDialogShown(true)} - icon={UsersIcon} - /> actionManager.executeAction(actionShortcuts)} label={t("helpDialog.title")} diff --git a/src/components/dropdownMenu/DropdownMenu.scss b/src/components/dropdownMenu/DropdownMenu.scss new file mode 100644 index 00000000..28a81287 --- /dev/null +++ b/src/components/dropdownMenu/DropdownMenu.scss @@ -0,0 +1,127 @@ +@import "../../css/variables.module"; + +.excalidraw { + .dropdown-menu { + position: absolute; + top: 100%; + margin-top: 0.25rem; + + &--mobile { + bottom: 55px; + top: auto; + left: 0; + width: 100%; + display: flex; + flex-direction: column; + 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: #fff !important; + max-height: calc(100vh - 150px); + overflow-y: auto; + --gap: 2; + } + + .dropdown-menu-item-base { + display: flex; + padding: 0 0.625rem; + column-gap: 0.625rem; + font-size: 0.875rem; + color: var(--color-gray-100); + width: 100%; + box-sizing: border-box; + font-weight: normal; + font-family: inherit; + } + + .dropdown-menu-item { + background-color: transparent; + border: 0; + align-items: center; + height: 2rem; + cursor: pointer; + border-radius: var(--border-radius-md); + + @media screen and (min-width: 1921px) { + height: 2.25rem; + } + + &__text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &__shortcut { + margin-inline-start: auto; + opacity: 0.5; + } + + &:hover { + background-color: var(--button-hover); + text-decoration: none; + } + + 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; + } + } + &.theme--dark { + .dropdown-menu-item { + color: var(--color-gray-40); + } + + .dropdown-menu-container { + background-color: var(--color-gray-90) !important; + } + } + + .dropdown-menu-button { + @include outlineButtonStyles; + background-color: var(--island-bg-color); + width: var(--lg-button-size); + height: var(--lg-button-size); + + 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/src/components/dropdownMenu/DropdownMenu.tsx b/src/components/dropdownMenu/DropdownMenu.tsx new file mode 100644 index 00000000..8f4ce435 --- /dev/null +++ b/src/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/src/components/dropdownMenu/DropdownMenuContent.tsx b/src/components/dropdownMenu/DropdownMenuContent.tsx new file mode 100644 index 00000000..873a99d8 --- /dev/null +++ b/src/components/dropdownMenu/DropdownMenuContent.tsx @@ -0,0 +1,51 @@ +import { useOutsideClickHook } from "../../hooks/useOutsideClick"; +import { Island } from "../Island"; + +import { useDevice } from "../App"; +import clsx from "clsx"; +import Stack from "../Stack"; + +const MenuContent = ({ + children, + onClickOutside, + className = "", + style, +}: { + children?: React.ReactNode; + onClickOutside?: () => void; + className?: string; + style?: React.CSSProperties; +}) => { + const device = useDevice(); + const menuRef = useOutsideClickHook(() => { + onClickOutside?.(); + }); + + const classNames = clsx(`dropdown-menu ${className}`, { + "dropdown-menu--mobile": device.isMobile, + }).trim(); + return ( +
+ {/* the zIndex ensures this menu has higher stacking order, + see https://github.com/excalidraw/excalidraw/pull/1445 */} + {device.isMobile ? ( + {children} + ) : ( + + {children} + + )} +
+ ); +}; +export default MenuContent; +MenuContent.displayName = "DropdownMenuContent"; diff --git a/src/components/dropdownMenu/DropdownMenuGroup.tsx b/src/components/dropdownMenu/DropdownMenuGroup.tsx new file mode 100644 index 00000000..aa4b49ae --- /dev/null +++ b/src/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 ( +
+ {title &&

{title}

} + {children} +
+ ); +}; + +export default MenuGroup; +MenuGroup.displayName = "DropdownMenuGroup"; diff --git a/src/components/dropdownMenu/DropdownMenuItem.tsx b/src/components/dropdownMenu/DropdownMenuItem.tsx new file mode 100644 index 00000000..47e20166 --- /dev/null +++ b/src/components/dropdownMenu/DropdownMenuItem.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import MenuItemContent from "./DropdownMenuItemContent"; + +export const getDrodownMenuItemClassName = (className = "") => { + return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim(); +}; + +const DropdownMenuItem = ({ + icon, + onSelect, + children, + dataTestId, + shortcut, + className, + style, + ariaLabel, +}: { + icon?: JSX.Element; + onSelect: () => void; + children: React.ReactNode; + dataTestId?: string; + shortcut?: string; + className?: string; + style?: React.CSSProperties; + ariaLabel?: string; +}) => { + return ( + + ); +}; + +export default DropdownMenuItem; +DropdownMenuItem.displayName = "DropdownMenuItem"; diff --git a/src/components/dropdownMenu/DropdownMenuItemContent.tsx b/src/components/dropdownMenu/DropdownMenuItemContent.tsx new file mode 100644 index 00000000..7d02c634 --- /dev/null +++ b/src/components/dropdownMenu/DropdownMenuItemContent.tsx @@ -0,0 +1,23 @@ +import { useDevice } from "../App"; + +const MenuItemContent = ({ + icon, + shortcut, + children, +}: { + icon?: JSX.Element; + shortcut?: string; + children: React.ReactNode; +}) => { + const device = useDevice(); + return ( + <> +
{icon}
+
{children}
+ {shortcut && !device.isMobile && ( +
{shortcut}
+ )} + + ); +}; +export default MenuItemContent; diff --git a/src/components/dropdownMenu/DropdownMenuItemCustom.tsx b/src/components/dropdownMenu/DropdownMenuItemCustom.tsx new file mode 100644 index 00000000..5ed47c3b --- /dev/null +++ b/src/components/dropdownMenu/DropdownMenuItemCustom.tsx @@ -0,0 +1,23 @@ +const DropdownMenuItemCustom = ({ + children, + className = "", + style, + dataTestId, +}: { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; + dataTestId?: string; +}) => { + return ( +
+ {children} +
+ ); +}; + +export default DropdownMenuItemCustom; diff --git a/src/components/dropdownMenu/DropdownMenuItemLink.tsx b/src/components/dropdownMenu/DropdownMenuItemLink.tsx new file mode 100644 index 00000000..11661069 --- /dev/null +++ b/src/components/dropdownMenu/DropdownMenuItemLink.tsx @@ -0,0 +1,42 @@ +import MenuItemContent from "./DropdownMenuItemContent"; +import React from "react"; +import { getDrodownMenuItemClassName } from "./DropdownMenuItem"; +const DropdownMenuItemLink = ({ + icon, + dataTestId, + shortcut, + href, + children, + className = "", + style, + ariaLabel, +}: { + icon?: JSX.Element; + children: React.ReactNode; + dataTestId?: string; + shortcut?: string; + className?: string; + href: string; + style?: React.CSSProperties; + ariaLabel?: string; +}) => { + return ( + + + {children} + + + ); +}; + +export default DropdownMenuItemLink; +DropdownMenuItemLink.displayName = "DropdownMenuItemLink"; diff --git a/src/components/dropdownMenu/DropdownMenuSeparator.tsx b/src/components/dropdownMenu/DropdownMenuSeparator.tsx new file mode 100644 index 00000000..ee419607 --- /dev/null +++ b/src/components/dropdownMenu/DropdownMenuSeparator.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +const MenuSeparator = () => ( +
+); + +export default MenuSeparator; +MenuSeparator.displayName = "DropdownMenuSeparator"; diff --git a/src/components/dropdownMenu/DropdownMenuTrigger.tsx b/src/components/dropdownMenu/DropdownMenuTrigger.tsx new file mode 100644 index 00000000..100cef0e --- /dev/null +++ b/src/components/dropdownMenu/DropdownMenuTrigger.tsx @@ -0,0 +1,37 @@ +import clsx from "clsx"; +import { useDevice, useExcalidrawAppState } from "../App"; + +const MenuTrigger = ({ + className = "", + children, + onToggle, +}: { + className?: string; + children: React.ReactNode; + onToggle: () => void; +}) => { + const appState = useExcalidrawAppState(); + const device = useDevice(); + const classNames = clsx( + `dropdown-menu-button ${className}`, + "zen-mode-transition", + { + "transition-left": appState.zenModeEnabled, + "dropdown-menu-button--mobile": device.isMobile, + }, + ).trim(); + return ( + + ); +}; + +export default MenuTrigger; +MenuTrigger.displayName = "DropdownMenuTrigger"; diff --git a/src/components/dropdownMenu/dropdownMenuUtils.ts b/src/components/dropdownMenu/dropdownMenuUtils.ts new file mode 100644 index 00000000..10d91fb8 --- /dev/null +++ b/src/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; +}; diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx index cd28f7c2..94afee81 100644 --- a/src/components/footer/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { ActionManager } from "../../actions/manager"; import { t } from "../../i18n"; -import { AppState } from "../../types"; +import { AppState, UIChildrenComponents } from "../../types"; import { ExitZenModeAction, FinalizeAction, @@ -13,20 +13,19 @@ import { WelcomeScreenHelpArrow } from "../icons"; import { Section } from "../Section"; import Stack from "../Stack"; import WelcomeScreenDecor from "../WelcomeScreenDecor"; -import FooterCenter from "./FooterCenter"; const Footer = ({ appState, actionManager, showExitZenModeBtn, renderWelcomeScreen, - children, + footerCenter, }: { appState: AppState; actionManager: ActionManager; showExitZenModeBtn: boolean; renderWelcomeScreen: boolean; - children?: React.ReactNode; + footerCenter: UIChildrenComponents["FooterCenter"]; }) => { const device = useDevice(); const showFinalize = @@ -71,7 +70,7 @@ const Footer = ({
- {children} + {footerCenter}
* { + pointer-events: all; + } + + display: flex; + width: 100%; + justify-content: flex-start; +} diff --git a/src/components/footer/FooterCenter.tsx b/src/components/footer/FooterCenter.tsx index d271f145..9fb0acbd 100644 --- a/src/components/footer/FooterCenter.tsx +++ b/src/components/footer/FooterCenter.tsx @@ -1,11 +1,12 @@ import clsx from "clsx"; import { useExcalidrawAppState } from "../App"; +import "./FooterCenter.scss"; const FooterCenter = ({ children }: { children?: React.ReactNode }) => { const appState = useExcalidrawAppState(); return (
{ + const appState = useExcalidrawAppState(); + const actionManager = useExcalidrawActionManager(); + if (appState.viewModeEnabled) { + return null; + } + return actionManager.renderAction("loadScene"); +}; +LoadScene.displayName = "LoadScene"; + +export const SaveToActiveFile = () => { + const appState = useExcalidrawAppState(); + const actionManager = useExcalidrawActionManager(); + if (!appState.fileHandle) { + return null; + } + return actionManager.renderAction("saveToActiveFile"); +}; +SaveToActiveFile.displayName = "SaveToActiveFile"; + +export const SaveAsImage = () => { + const setAppState = useExcalidrawSetAppState(); + // Hack until we tie "t" to lang state + // eslint-disable-next-line + const appState = useExcalidrawAppState(); + return ( + setAppState({ openDialog: "imageExport" })} + shortcut={getShortcutFromShortcutName("imageExport")} + ariaLabel={t("buttons.exportImage")} + > + {t("buttons.exportImage")} + + ); +}; +SaveAsImage.displayName = "SaveAsImage"; + +export const Help = () => { + // Hack until we tie "t" to lang state + // eslint-disable-next-line + const appState = useExcalidrawAppState(); + + const actionManager = useExcalidrawActionManager(); + return actionManager.renderAction("toggleShortcuts", undefined, true); +}; +Help.displayName = "Help"; + +export const ClearCanvas = () => { + const appState = useExcalidrawAppState(); + const actionManager = useExcalidrawActionManager(); + + if (appState.viewModeEnabled) { + return null; + } + return actionManager.renderAction("clearCanvas"); +}; +ClearCanvas.displayName = "ClearCanvas"; + +export const ToggleTheme = () => { + // Hack until we tie "t" to lang state + // eslint-disable-next-line + const appState = useExcalidrawAppState(); + const actionManager = useExcalidrawActionManager(); + return actionManager.renderAction("toggleTheme"); +}; +ToggleTheme.displayName = "ToggleTheme"; + +export const ChangeCanvasBackground = () => { + const appState = useExcalidrawAppState(); + const actionManager = useExcalidrawActionManager(); + + if (appState.viewModeEnabled) { + return null; + } + return ( +
+
+ {t("labels.canvasBackground")} +
+
+ {actionManager.renderAction("changeViewBackgroundColor")} +
+
+ ); +}; +ChangeCanvasBackground.displayName = "ChangeCanvasBackground"; + +export const Export = () => { + // Hack until we tie "t" to lang state + // eslint-disable-next-line + const appState = useExcalidrawAppState(); + const setAppState = useExcalidrawSetAppState(); + return ( + { + setAppState({ openDialog: "jsonExport" }); + }} + dataTestId="json-export-button" + ariaLabel={t("buttons.export")} + > + {t("buttons.export")} + + ); +}; +Export.displayName = "Export"; + +export const Socials = () => ( + <> + + GitHub + + + Discord + + + Twitter + + +); +Socials.displayName = "Socials"; + +export const LiveCollaboration = ({ + onSelect, + isCollaborating, +}: { + onSelect: () => void; + isCollaborating: boolean; +}) => { + // Hack until we tie "t" to lang state + // eslint-disable-next-line + const appState = useExcalidrawAppState(); + return ( + + {t("labels.liveCollaboration")} + + ); +}; + +LiveCollaboration.displayName = "LiveCollaboration"; diff --git a/src/components/mainMenu/MainMenu.tsx b/src/components/mainMenu/MainMenu.tsx new file mode 100644 index 00000000..bf398a38 --- /dev/null +++ b/src/components/mainMenu/MainMenu.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { + useDevice, + useExcalidrawAppState, + 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"; + +const MainMenu = ({ children }: { children?: React.ReactNode }) => { + const device = useDevice(); + const appState = useExcalidrawAppState(); + const setAppState = useExcalidrawSetAppState(); + const onClickOutside = device.isMobile + ? undefined + : () => setAppState({ openMenu: null }); + return ( + + { + setAppState({ + openMenu: appState.openMenu === "canvas" ? null : "canvas", + }); + }} + > + {HamburgerMenuIcon} + + + {children} + {device.isMobile && appState.collaborators.size > 0 && ( +
+ {t("labels.collaborators")} + +
+ )} +
+
+ ); +}; + +MainMenu.Trigger = DropdownMenu.Trigger; +MainMenu.Item = DropdownMenu.Item; +MainMenu.ItemLink = DropdownMenu.ItemLink; +MainMenu.ItemCustom = DropdownMenu.ItemCustom; +MainMenu.Group = DropdownMenu.Group; +MainMenu.Separator = DropdownMenu.Separator; +MainMenu.DefaultItems = DefaultItems; + +export default MainMenu; + +MainMenu.displayName = "Menu"; diff --git a/src/css/styles.scss b/src/css/styles.scss index 7bceb1ce..cec2af2d 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -569,6 +569,20 @@ display: none; } } + .UserList-Wrapper { + margin: 0; + padding: 0; + border: none; + text-align: left; + + legend { + display: block; + font-size: 0.75rem; + font-weight: 400; + margin: 0 0 0.25rem; + padding: 0; + } + } } .ErrorSplash.excalidraw { diff --git a/src/excalidraw-app/components/LanguageList.tsx b/src/excalidraw-app/components/LanguageList.tsx index 375bde62..1b3606b5 100644 --- a/src/excalidraw-app/components/LanguageList.tsx +++ b/src/excalidraw-app/components/LanguageList.tsx @@ -8,23 +8,21 @@ export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { const [langCode, setLangCode] = useAtom(langCodeAtom); return ( - - setLangCode(target.value)} + value={langCode} + aria-label={i18n.t("buttons.selectLanguage")} + style={style} + > + + {languages.map((lang) => ( + - {languages.map((lang) => ( - - ))} - - + ))} + ); }; diff --git a/src/excalidraw-app/index.scss b/src/excalidraw-app/index.scss index 6b7874df..b9e693ec 100644 --- a/src/excalidraw-app/index.scss +++ b/src/excalidraw-app/index.scss @@ -4,7 +4,7 @@ &.theme--dark { --color-primary-contrast-offset: #726dff; // to offset Chubb illusion } - .layer-ui__wrapper .layer-ui__wrapper__footer-center { + .footer-center { justify-content: flex-end; margin-top: auto; margin-bottom: auto; @@ -24,7 +24,29 @@ height: 1.2rem; } } + + .dropdown-menu-container { + .dropdown-menu-item { + &.active-collab { + background-color: #ecfdf5; + color: #064e3c; + } + &.ExcalidrawPlus { + color: var(--color-promo); + } + } + } + + &.theme--dark { + .dropdown-menu-item { + &.active-collab { + background-color: #064e3c; + color: #ecfdf5; + } + } + } } + .excalidraw-app.is-collaborating { [data-testid="clear-canvas-button"] { display: none; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index b12a41e3..3b24291f 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -21,7 +21,12 @@ import { } from "../element/types"; import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { t } from "../i18n"; -import { Excalidraw, defaultLang, Footer } from "../packages/excalidraw/index"; +import { + Excalidraw, + defaultLang, + Footer, + MainMenu, +} from "../packages/excalidraw/index"; import { AppState, LibraryItems, @@ -79,8 +84,11 @@ import { reconcileElements } from "./collab/reconciliation"; import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; import { EncryptedIcon } from "./components/EncryptedIcon"; import { ExcalidrawPlusAppLink } from "./components/ExcalidrawPlusAppLink"; +import { LanguageList } from "./components/LanguageList"; +import { PlusPromoIcon } from "../components/icons"; polyfill(); + window.EXCALIDRAW_THROTTLE_RENDER = true; const languageDetector = new LanguageDetector(); @@ -229,7 +237,6 @@ export const langCodeAtom = atom( const ExcalidrawWrapper = () => { const [errorMessage, setErrorMessage] = useState(""); const [langCode, setLangCode] = useAtom(langCodeAtom); - // initial state // --------------------------------------------------------------------------- @@ -594,6 +601,39 @@ const ExcalidrawWrapper = () => { localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems); }; + const renderMenu = () => { + return ( + + + + + + setCollabDialogShown(true)} + /> + + + + + + Excalidraw+ + + + + + + + + + + ); + }; + return (
{ autoFocus={true} theme={theme} > + {renderMenu()} , )); - expect( - container.querySelector(".layer-ui__wrapper__footer-center")?.innerHTML, - ).toMatchInlineSnapshot( - `""`, - ); + expect(container.querySelector(".footer-center")).toMatchInlineSnapshot(` + + `); }); + describe("Test gridModeEnabled prop", () => { it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => { const { container } = await render(); @@ -112,98 +121,51 @@ describe("", () => { }); }); - describe("Test theme prop", () => { - it("should show the theme toggle by default", async () => { - const { container } = await render(); - - expect(h.state.theme).toBe(THEME.LIGHT); - - queryByTestId(container, "menu-button")!.click(); - const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); - expect(darkModeToggle).toBeTruthy(); - }); - - it("should not show theme toggle when the theme prop is defined", async () => { - const { container } = await render(); - expect(h.state.theme).toBe(THEME.DARK); - expect(queryByTestId(container, "toggle-dark-mode")).toBe(null); - }); - - it("should show theme mode toggle when `UIOptions.canvasActions.toggleTheme` is true", async () => { - const { container } = await render( - , - ); - expect(h.state.theme).toBe(THEME.DARK); - const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); - expect(darkModeToggle).toBeTruthy(); - }); - - it("should not show theme toggle when `UIOptions.canvasActions.toggleTheme` is false", async () => { - const { container } = await render( - , - ); - expect(h.state.theme).toBe(THEME.DARK); - const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); - expect(darkModeToggle).toBeFalsy(); - }); - }); - - describe("Test name prop", () => { - it('should allow editing name when the name prop is "undefined"', async () => { - const { container } = await render(); - - fireEvent.click(queryByTestId(container, "image-export-button")!); - const textInput: HTMLInputElement | null = document.querySelector( - ".ExportDialog .ProjectName .TextInput", - ); - expect(textInput?.value).toContain(`${t("labels.untitled")}`); - expect(textInput?.nodeName).toBe("INPUT"); - }); - - it('should set the name and not allow editing when the name prop is present"', async () => { - const name = "test"; - const { container } = await render(); - - await fireEvent.click(queryByTestId(container, "image-export-button")!); - const textInput = document.querySelector( - ".ExportDialog .ProjectName .TextInput--readonly", - ); - expect(textInput?.textContent).toEqual(name); - expect(textInput?.nodeName).toBe("SPAN"); - }); + it("should render main menu with host menu items if passed from host", async () => { + const { container } = await render( + + + window.alert("Clicked")}> + Click me + + + Excalidraw blog + + + + + + + , + ); + //open menu + toggleMenu(container); + expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot(); }); describe("Test UIOptions prop", () => { - it('should not hide any UI element when the UIOptions prop is "undefined"', async () => { - await render(); - - const canvasActions = document.querySelector( - 'section[aria-labelledby="test-id-canvasActions-title"]', - ); - - expect(canvasActions).toMatchSnapshot(); - }); - describe("Test canvasActions", () => { - it('should not hide any UI element when canvasActions is "undefined"', async () => { - await render(); - const canvasActions = document.querySelector( - 'section[aria-labelledby="test-id-canvasActions-title"]', + it('should render menu with default items when "UIOPtions" is "undefined"', async () => { + const { container } = await render( + , ); - expect(canvasActions).toMatchSnapshot(); + //open menu + toggleMenu(container); + expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot(); }); it("should hide clear canvas button when clearCanvas is false", async () => { const { container } = await render( , ); - + //open menu + toggleMenu(container); expect(queryByTestId(container, "clear-canvas-button")).toBeNull(); }); @@ -211,7 +173,8 @@ describe("", () => { const { container } = await render( , ); - + //open menu + toggleMenu(container); expect(queryByTestId(container, "json-export-button")).toBeNull(); }); @@ -219,7 +182,8 @@ describe("", () => { const { container } = await render( , ); - + //open menu + toggleMenu(container); expect(queryByTestId(container, "image-export-button")).toBeNull(); }); @@ -237,7 +201,8 @@ describe("", () => { UIOptions={{ canvasActions: { export: { saveFileToDisk: false } } }} />, ); - + //open menu + toggleMenu(container); expect(queryByTestId(container, "save-as-button")).toBeNull(); }); @@ -247,7 +212,8 @@ describe("", () => { UIOptions={{ canvasActions: { saveToActiveFile: false } }} />, ); - + //open menu + toggleMenu(container); expect(queryByTestId(container, "save-button")).toBeNull(); }); @@ -257,7 +223,8 @@ describe("", () => { UIOptions={{ canvasActions: { changeViewBackgroundColor: false } }} />, ); - + //open menu + toggleMenu(container); expect(queryByTestId(container, "canvas-background-picker")).toBeNull(); }); @@ -265,12 +232,110 @@ describe("", () => { const { container } = await render( , ); - + //open menu + toggleMenu(container); expect(queryByTestId(container, "toggle-dark-mode")).toBeNull(); }); + + it("should not render default items in custom menu even if passed if the prop in `canvasActions` is set to false", async () => { + const { container } = await render( + + + + + + + + , + ); + //open menu + toggleMenu(container); + // load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false` + expect(queryByTestId(container, "load-button")).toBeNull(); + }); }); }); + describe("Test theme prop", () => { + it("should show the theme toggle by default", async () => { + const { container } = await render(); + expect(h.state.theme).toBe(THEME.LIGHT); + //open menu + toggleMenu(container); + const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); + expect(darkModeToggle).toBeTruthy(); + }); + + it("should not show theme toggle when the theme prop is defined", async () => { + const { container } = await render(); + + expect(h.state.theme).toBe(THEME.DARK); + //open menu + toggleMenu(container); + expect(queryByTestId(container, "toggle-dark-mode")).toBe(null); + }); + + it("should show theme mode toggle when `UIOptions.canvasActions.toggleTheme` is true", async () => { + const { container } = await render( + , + ); + expect(h.state.theme).toBe(THEME.DARK); + //open menu + toggleMenu(container); + const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); + expect(darkModeToggle).toBeTruthy(); + }); + + it("should not show theme toggle when `UIOptions.canvasActions.toggleTheme` is false", async () => { + const { container } = await render( + , + ); + expect(h.state.theme).toBe(THEME.DARK); + //open menu + toggleMenu(container); + const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); + expect(darkModeToggle).toBeFalsy(); + }); + }); + + describe("Test name prop", () => { + it('should allow editing name when the name prop is "undefined"', async () => { + const { container } = await render(); + //open menu + toggleMenu(container); + fireEvent.click(queryByTestId(container, "image-export-button")!); + const textInput: HTMLInputElement | null = document.querySelector( + ".ExportDialog .ProjectName .TextInput", + ); + expect(textInput?.value).toContain(`${t("labels.untitled")}`); + expect(textInput?.nodeName).toBe("INPUT"); + }); + + it('should set the name and not allow editing when the name prop is present"', async () => { + const name = "test"; + const { container } = await render(); + //open menu + toggleMenu(container); + await fireEvent.click(queryByTestId(container, "image-export-button")!); + const textInput = document.querySelector( + ".ExportDialog .ProjectName .TextInput--readonly", + ); + expect(textInput?.textContent).toEqual(name); + expect(textInput?.nodeName).toBe("SPAN"); + }); + }); describe("Test autoFocus prop", () => { it("should not focus when autoFocus is false", async () => { const { container } = await render(); diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index d8e149d9..07a6a449 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -446,7 +446,7 @@ describe("regression tests", () => { UI.clickTool("rectangle"); // english lang should display `thin` label expect(screen.queryByTitle(/thin/i)).not.toBeNull(); - fireEvent.click(document.querySelector(".menu-button")!); + fireEvent.click(document.querySelector(".dropdown-menu-button")!); fireEvent.change(document.querySelector(".dropdown-select__language")!, { target: { value: "de-DE" }, diff --git a/src/tests/test-utils.ts b/src/tests/test-utils.ts index c7af2904..c33e80c7 100644 --- a/src/tests/test-utils.ts +++ b/src/tests/test-utils.ts @@ -6,6 +6,7 @@ import { RenderResult, RenderOptions, waitFor, + fireEvent, } from "@testing-library/react"; import * as toolQueries from "./queries/toolQueries"; @@ -184,3 +185,8 @@ export const assertSelectedElements = ( expect(selectedElementIds.length).toBe(ids.length); expect(selectedElementIds).toEqual(expect.arrayContaining(ids)); }; + +export const toggleMenu = (container: HTMLElement) => { + // open menu + fireEvent.click(container.querySelector(".dropdown-menu-button")!); +}; diff --git a/src/types.ts b/src/types.ts index 218ca4de..8ee85d27 100644 --- a/src/types.ts +++ b/src/types.ts @@ -161,7 +161,7 @@ export type AppState = { | "strokeColorPicker" | null; openSidebar: "library" | "customSidebar" | null; - openDialog: "imageExport" | "help" | null; + openDialog: "imageExport" | "help" | "jsonExport" | null; isSidebarDocked: boolean; lastPointerDownWith: PointerType; @@ -517,7 +517,7 @@ export type Device = Readonly<{ }>; export type UIChildrenComponents = { - [k in "FooterCenter"]?: + [k in "FooterCenter" | "Menu"]?: | React.ReactPortal | React.ReactElement>; };