-
- {actionManager.renderAction("toggleEditMenu")}
- {actionManager.renderAction(
- appState.multiElement ? "finalize" : "duplicateSelection",
- )}
- {actionManager.renderAction("deleteSelectedElements")}
-
- {actionManager.renderAction("undo")}
- {actionManager.renderAction("redo")}
-
+
+ {topLeftUI}
+ {topRightUI}
);
};
+ const renderToolbar = () => {
+ return (
+
+ );
+ };
+
return (
<>
{renderSidebars()}
- {!appState.viewModeEnabled &&
- appState.openDialog?.name !== "elementLinkSelector" &&
- renderToolbar()}
+ {/* welcome screen, bottom bar, and top bar all have the same z-index */}
+ {/* ordered in this reverse order so that top bar is on top */}
+
+ {renderWelcomeScreen && }
+
+
-
- {appState.openMenu === "shape" &&
- !appState.viewModeEnabled &&
- appState.openDialog?.name !== "elementLinkSelector" &&
- showSelectedShapeActions(appState, elements) ? (
-
- ) : null}
-
+
+
+
+ {!appState.viewModeEnabled &&
+ appState.openDialog?.name !== "elementLinkSelector" &&
+ renderToolbar()}
+ {appState.scrolledOutside &&
+ !appState.openMenu &&
+ !appState.openSidebar && (
+
+ )}
+
+
+ {renderAppTopBar()}
+
>
);
};
diff --git a/packages/excalidraw/components/MobileToolBar.scss b/packages/excalidraw/components/MobileToolBar.scss
new file mode 100644
index 0000000000..b936c70ebd
--- /dev/null
+++ b/packages/excalidraw/components/MobileToolBar.scss
@@ -0,0 +1,78 @@
+@import "open-color/open-color.scss";
+@import "../css/variables.module.scss";
+
+.excalidraw {
+ .mobile-toolbar {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ padding: 0px;
+ gap: 4px;
+ border-radius: var(--space-factor);
+ overflow-x: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ justify-content: space-between;
+ }
+
+ .mobile-toolbar::-webkit-scrollbar {
+ display: none;
+ }
+
+ .mobile-toolbar .ToolIcon {
+ min-width: 2rem;
+ min-height: 2rem;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+
+ .ToolIcon__icon {
+ width: 2.25rem;
+ height: 2.25rem;
+
+ &:hover {
+ background-color: transparent;
+ }
+ }
+
+ &.active {
+ background: var(
+ --color-surface-primary-container,
+ var(--island-bg-color)
+ );
+ border-color: var(--button-active-border, var(--color-primary-darkest));
+ }
+
+ svg {
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+
+ .mobile-toolbar .App-toolbar__extra-tools-dropdown {
+ min-width: 160px;
+ z-index: var(--zIndex-layerUI);
+ }
+
+ .mobile-toolbar-separator {
+ width: 1px;
+ height: 24px;
+ background: var(--default-border-color);
+ margin: 0 2px;
+ flex-shrink: 0;
+ }
+
+ .mobile-toolbar-undo {
+ display: flex;
+ align-items: center;
+ }
+
+ .mobile-toolbar-undo .ToolIcon {
+ min-width: 32px;
+ min-height: 32px;
+ width: 32px;
+ height: 32px;
+ }
+}
diff --git a/packages/excalidraw/components/MobileToolBar.tsx b/packages/excalidraw/components/MobileToolBar.tsx
new file mode 100644
index 0000000000..bc52c01b71
--- /dev/null
+++ b/packages/excalidraw/components/MobileToolBar.tsx
@@ -0,0 +1,474 @@
+import { useState, useEffect, useRef } from "react";
+import clsx from "clsx";
+
+import { KEYS, capitalizeString } from "@excalidraw/common";
+
+import { trackEvent } from "../analytics";
+
+import { t } from "../i18n";
+
+import { isHandToolActive } from "../appState";
+
+import { useTunnels } from "../context/tunnels";
+
+import { HandButton } from "./HandButton";
+import { ToolButton } from "./ToolButton";
+import DropdownMenu from "./dropdownMenu/DropdownMenu";
+import { ToolPopover } from "./ToolPopover";
+
+import {
+ SelectionIcon,
+ FreedrawIcon,
+ EraserIcon,
+ RectangleIcon,
+ ArrowIcon,
+ extraToolsIcon,
+ DiamondIcon,
+ EllipseIcon,
+ LineIcon,
+ TextIcon,
+ ImageIcon,
+ frameToolIcon,
+ EmbedIcon,
+ laserPointerToolIcon,
+ LassoIcon,
+ mermaidLogoIcon,
+ MagicIcon,
+} from "./icons";
+
+import "./ToolIcon.scss";
+import "./MobileToolBar.scss";
+
+import type { AppClassProperties, ToolType, UIAppState } from "../types";
+
+const SHAPE_TOOLS = [
+ {
+ type: "rectangle",
+ icon: RectangleIcon,
+ title: capitalizeString(t("toolBar.rectangle")),
+ },
+ {
+ type: "diamond",
+ icon: DiamondIcon,
+ title: capitalizeString(t("toolBar.diamond")),
+ },
+ {
+ type: "ellipse",
+ icon: EllipseIcon,
+ title: capitalizeString(t("toolBar.ellipse")),
+ },
+] as const;
+
+const SELECTION_TOOLS = [
+ {
+ type: "selection",
+ icon: SelectionIcon,
+ title: capitalizeString(t("toolBar.selection")),
+ },
+ {
+ type: "lasso",
+ icon: LassoIcon,
+ title: capitalizeString(t("toolBar.lasso")),
+ },
+] as const;
+
+const LINEAR_ELEMENT_TOOLS = [
+ {
+ type: "arrow",
+ icon: ArrowIcon,
+ title: capitalizeString(t("toolBar.arrow")),
+ },
+ { type: "line", icon: LineIcon, title: capitalizeString(t("toolBar.line")) },
+] as const;
+
+type MobileToolBarProps = {
+ app: AppClassProperties;
+ onHandToolToggle: () => void;
+ setAppState: React.Component
["setState"];
+};
+
+export const MobileToolBar = ({
+ app,
+ onHandToolToggle,
+ setAppState,
+}: MobileToolBarProps) => {
+ const activeTool = app.state.activeTool;
+ const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false);
+ const [lastActiveGenericShape, setLastActiveGenericShape] = useState<
+ "rectangle" | "diamond" | "ellipse"
+ >("rectangle");
+ const [lastActiveLinearElement, setLastActiveLinearElement] = useState<
+ "arrow" | "line"
+ >("arrow");
+
+ const toolbarRef = useRef(null);
+
+ // keep lastActiveGenericShape in sync with active tool if user switches via other UI
+ useEffect(() => {
+ if (
+ activeTool.type === "rectangle" ||
+ activeTool.type === "diamond" ||
+ activeTool.type === "ellipse"
+ ) {
+ setLastActiveGenericShape(activeTool.type);
+ }
+ }, [activeTool.type]);
+
+ // keep lastActiveLinearElement in sync with active tool if user switches via other UI
+ useEffect(() => {
+ if (activeTool.type === "arrow" || activeTool.type === "line") {
+ setLastActiveLinearElement(activeTool.type);
+ }
+ }, [activeTool.type]);
+
+ const frameToolSelected = activeTool.type === "frame";
+ const laserToolSelected = activeTool.type === "laser";
+ const embeddableToolSelected = activeTool.type === "embeddable";
+
+ const { TTDDialogTriggerTunnel } = useTunnels();
+
+ const handleToolChange = (toolType: string, pointerType?: string) => {
+ if (app.state.activeTool.type !== toolType) {
+ trackEvent("toolbar", toolType, "ui");
+ }
+
+ if (toolType === "selection") {
+ if (app.state.activeTool.type === "selection") {
+ // Toggle selection tool behavior if needed
+ } else {
+ app.setActiveTool({ type: "selection" });
+ }
+ } else {
+ app.setActiveTool({ type: toolType as ToolType });
+ }
+ };
+
+ const toolbarWidth =
+ toolbarRef.current?.getBoundingClientRect()?.width ?? 0 - 8;
+ const WIDTH = 36;
+ const GAP = 4;
+
+ // hand, selection, freedraw, eraser, rectangle, arrow, others
+ const MIN_TOOLS = 7;
+ const MIN_WIDTH = MIN_TOOLS * WIDTH + (MIN_TOOLS - 1) * GAP;
+ const ADDITIONAL_WIDTH = WIDTH + GAP;
+
+ const showTextToolOutside = toolbarWidth >= MIN_WIDTH + 1 * ADDITIONAL_WIDTH;
+ const showImageToolOutside = toolbarWidth >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
+ const showFrameToolOutside = toolbarWidth >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH;
+
+ const extraTools = [
+ "text",
+ "frame",
+ "embeddable",
+ "laser",
+ "magicframe",
+ ].filter((tool) => {
+ if (showImageToolOutside && tool === "image") {
+ return false;
+ }
+ if (showFrameToolOutside && tool === "frame") {
+ return false;
+ }
+ return true;
+ });
+ const extraToolSelected = extraTools.includes(activeTool.type);
+ const extraIcon = extraToolSelected
+ ? activeTool.type === "frame"
+ ? frameToolIcon
+ : activeTool.type === "embeddable"
+ ? EmbedIcon
+ : activeTool.type === "laser"
+ ? laserPointerToolIcon
+ : activeTool.type === "text"
+ ? TextIcon
+ : activeTool.type === "magicframe"
+ ? MagicIcon
+ : extraToolsIcon
+ : extraToolsIcon;
+
+ return (
+
+ {/* Hand Tool */}
+
+
+ {/* Selection Tool */}
+
{
+ if (type === "selection" || type === "lasso") {
+ app.setActiveTool({ type });
+ setAppState({
+ preferredSelectionTool: { type, initialized: true },
+ });
+ }
+ }}
+ displayedOption={
+ SELECTION_TOOLS.find(
+ (tool) => tool.type === app.state.preferredSelectionTool.type,
+ ) || SELECTION_TOOLS[0]
+ }
+ />
+
+ {/* Free Draw */}
+ handleToolChange("freedraw")}
+ />
+
+ {/* Eraser */}
+ handleToolChange("eraser")}
+ />
+
+ {/* Rectangle */}
+ {
+ if (
+ type === "rectangle" ||
+ type === "diamond" ||
+ type === "ellipse"
+ ) {
+ setLastActiveGenericShape(type);
+ app.setActiveTool({ type });
+ }
+ }}
+ displayedOption={
+ SHAPE_TOOLS.find((tool) => tool.type === lastActiveGenericShape) ||
+ SHAPE_TOOLS[0]
+ }
+ />
+
+ {/* Arrow/Line */}
+ {
+ if (type === "arrow" || type === "line") {
+ setLastActiveLinearElement(type);
+ app.setActiveTool({ type });
+ }
+ }}
+ displayedOption={
+ LINEAR_ELEMENT_TOOLS.find(
+ (tool) => tool.type === lastActiveLinearElement,
+ ) || LINEAR_ELEMENT_TOOLS[0]
+ }
+ />
+
+ {/* Text Tool */}
+ {showTextToolOutside && (
+ handleToolChange("text")}
+ />
+ )}
+
+ {/* Image */}
+ {showImageToolOutside && (
+ handleToolChange("image")}
+ />
+ )}
+
+ {/* Frame Tool */}
+ {showFrameToolOutside && (
+ handleToolChange("frame")}
+ />
+ )}
+
+ {/* Other Shapes */}
+
+ {
+ setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen);
+ setAppState({ openMenu: null, openPopup: null });
+ }}
+ title={t("toolBar.extraTools")}
+ style={{
+ width: WIDTH,
+ height: WIDTH,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ {extraIcon}
+
+ setIsOtherShapesMenuOpen(false)}
+ onSelect={() => setIsOtherShapesMenuOpen(false)}
+ className="App-toolbar__extra-tools-dropdown"
+ >
+ {!showTextToolOutside && (
+ app.setActiveTool({ type: "text" })}
+ icon={TextIcon}
+ shortcut={KEYS.T.toLocaleUpperCase()}
+ data-testid="toolbar-text"
+ selected={activeTool.type === "text"}
+ >
+ {t("toolBar.text")}
+
+ )}
+
+ {!showImageToolOutside && (
+ app.setActiveTool({ type: "image" })}
+ icon={ImageIcon}
+ data-testid="toolbar-image"
+ selected={activeTool.type === "image"}
+ >
+ {t("toolBar.image")}
+
+ )}
+ {!showFrameToolOutside && (
+ app.setActiveTool({ type: "frame" })}
+ icon={frameToolIcon}
+ shortcut={KEYS.F.toLocaleUpperCase()}
+ data-testid="toolbar-frame"
+ selected={frameToolSelected}
+ >
+ {t("toolBar.frame")}
+
+ )}
+ app.setActiveTool({ type: "embeddable" })}
+ icon={EmbedIcon}
+ data-testid="toolbar-embeddable"
+ selected={embeddableToolSelected}
+ >
+ {t("toolBar.embeddable")}
+
+ app.setActiveTool({ type: "laser" })}
+ icon={laserPointerToolIcon}
+ data-testid="toolbar-laser"
+ selected={laserToolSelected}
+ shortcut={KEYS.K.toLocaleUpperCase()}
+ >
+ {t("toolBar.laser")}
+
+
+ Generate
+
+ {app.props.aiEnabled !== false && }
+ app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
+ icon={mermaidLogoIcon}
+ data-testid="toolbar-embeddable"
+ >
+ {t("toolBar.mermaidToExcalidraw")}
+
+ {app.props.aiEnabled !== false && app.plugins.diagramToCode && (
+ <>
+ app.onMagicframeToolSelect()}
+ icon={MagicIcon}
+ data-testid="toolbar-magicframe"
+ >
+ {t("toolBar.magicframe")}
+ AI
+
+ >
+ )}
+
+
+
+ );
+};
diff --git a/packages/excalidraw/components/Popover.tsx b/packages/excalidraw/components/Popover.tsx
index 4864b37d16..d8ee76aa62 100644
--- a/packages/excalidraw/components/Popover.tsx
+++ b/packages/excalidraw/components/Popover.tsx
@@ -3,6 +3,8 @@ import { unstable_batchedUpdates } from "react-dom";
import { KEYS, queryFocusableElements } from "@excalidraw/common";
+import clsx from "clsx";
+
import "./Popover.scss";
type Props = {
@@ -15,6 +17,7 @@ type Props = {
offsetTop?: number;
viewportWidth?: number;
viewportHeight?: number;
+ className?: string;
};
export const Popover = ({
@@ -27,6 +30,7 @@ export const Popover = ({
offsetTop = 0,
viewportWidth = window.innerWidth,
viewportHeight = window.innerHeight,
+ className,
}: Props) => {
const popoverRef = useRef(null);
@@ -146,7 +150,7 @@ export const Popover = ({
}, [onCloseRequest]);
return (
-
+
{children}
);
diff --git a/packages/excalidraw/components/PropertiesPopover.tsx b/packages/excalidraw/components/PropertiesPopover.tsx
index d4437b3858..ccedd87a02 100644
--- a/packages/excalidraw/components/PropertiesPopover.tsx
+++ b/packages/excalidraw/components/PropertiesPopover.tsx
@@ -40,6 +40,8 @@ export const PropertiesPopover = React.forwardRef<
ref,
) => {
const device = useDevice();
+ const isMobilePortrait =
+ device.editor.isMobile && !device.viewport.isLandscape;
return (
@@ -47,20 +49,14 @@ export const PropertiesPopover = React.forwardRef<
ref={ref}
className={clsx("focus-visible-none", className)}
data-prevent-outside-click
- side={
- device.editor.isMobile && !device.viewport.isLandscape
- ? "bottom"
- : "right"
- }
- align={
- device.editor.isMobile && !device.viewport.isLandscape
- ? "center"
- : "start"
- }
+ side={isMobilePortrait ? "bottom" : "right"}
+ align={isMobilePortrait ? "center" : "start"}
alignOffset={-16}
sideOffset={20}
+ collisionBoundary={container ?? undefined}
style={{
- zIndex: "var(--zIndex-popup)",
+ zIndex: "var(--zIndex-ui-styles-popup)",
+ marginLeft: device.editor.isMobile ? "0.5rem" : undefined,
}}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx
index 076b303d70..cdc038dac3 100644
--- a/packages/excalidraw/components/PublishLibrary.tsx
+++ b/packages/excalidraw/components/PublishLibrary.tsx
@@ -518,7 +518,7 @@ const PublishLibrary = ({
diff --git a/packages/excalidraw/components/Sidebar/Sidebar.scss b/packages/excalidraw/components/Sidebar/Sidebar.scss
index c7776d1c69..2fba020ca9 100644
--- a/packages/excalidraw/components/Sidebar/Sidebar.scss
+++ b/packages/excalidraw/components/Sidebar/Sidebar.scss
@@ -9,7 +9,7 @@
top: 0;
bottom: 0;
right: 0;
- z-index: 5;
+ z-index: var(--zIndex-ui-library);
margin: 0;
padding: 0;
box-sizing: border-box;
diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx
index d08ba5f597..5f0ca487f2 100644
--- a/packages/excalidraw/components/Sidebar/Sidebar.tsx
+++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx
@@ -9,7 +9,13 @@ import React, {
useCallback,
} from "react";
-import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common";
+import {
+ CLASSES,
+ EVENT,
+ isDevEnv,
+ KEYS,
+ updateObject,
+} from "@excalidraw/common";
import { useUIAppState } from "../../context/ui-appState";
import { atom, useSetAtom } from "../../editor-jotai";
@@ -137,7 +143,11 @@ export const SidebarInner = forwardRef(
return (
diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx
index 6e8bf374ce..706a6abe52 100644
--- a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx
+++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx
@@ -30,7 +30,11 @@ export const SidebarTrigger = ({
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const isOpen = event.target.checked;
- setAppState({ openSidebar: isOpen ? { name, tab } : null });
+ setAppState({
+ openSidebar: isOpen ? { name, tab } : null,
+ openMenu: null,
+ openPopup: null,
+ });
onToggle?.(isOpen);
}}
checked={appState.openSidebar?.name === name}
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx
index 05cad640b8..21a6f16948 100644
--- a/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx
@@ -1,4 +1,4 @@
-import { getShortcutKey } from "@excalidraw/common";
+import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
export const TTDDialogSubmitShortcut = () => {
return (
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx
index 833b659fe2..0d5c62f331 100644
--- a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx
@@ -1,12 +1,11 @@
import { trackEvent } from "../../analytics";
import { useTunnels } from "../../context/tunnels";
-import { t } from "../../i18n";
+import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState } from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { brainIcon } from "../icons";
-import type { ReactNode } from "react";
-import type { JSX } from "react";
+import type { JSX, ReactNode } from "react";
export const TTDDialogTrigger = ({
children,
@@ -15,6 +14,7 @@ export const TTDDialogTrigger = ({
children?: ReactNode;
icon?: JSX.Element;
}) => {
+ const { t } = useI18n();
const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState();
diff --git a/packages/excalidraw/components/TextField.scss b/packages/excalidraw/components/TextField.scss
index c46cd2fe8c..fefea7e802 100644
--- a/packages/excalidraw/components/TextField.scss
+++ b/packages/excalidraw/components/TextField.scss
@@ -12,6 +12,10 @@
--ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant);
+ &.theme--dark {
+ --ExcTextField--border: var(--color-border-outline-variant);
+ }
+
.ExcTextField {
position: relative;
diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx
index d6bc315b18..4e724aceda 100644
--- a/packages/excalidraw/components/TextField.tsx
+++ b/packages/excalidraw/components/TextField.tsx
@@ -28,6 +28,7 @@ type TextFieldProps = {
className?: string;
placeholder?: string;
isRedacted?: boolean;
+ type?: "text" | "search";
} & ({ value: string } | { defaultValue: string });
export const TextField = forwardRef(
@@ -43,6 +44,7 @@ export const TextField = forwardRef(
isRedacted = false,
icon,
className,
+ type,
...rest
},
ref,
@@ -96,6 +98,7 @@ export const TextField = forwardRef(
ref={innerRef}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
+ type={type}
/>
{isRedacted && (
> Test UIOptions prop > Test canvasActions > should rende