feat: new mobile layout (#9996)

* compact bottom toolbar

* put menu trigger to top left

* add popup to switch between grouped tool types

* add a dedicated mobile toolbar

* update position for mobile

* fix active tool type

* add mobile mode as well

* mobile actions

* remove refactored popups

* excali logo mobile

* include mobile

* update mobile menu layout

* move selection and deletion back to right

* do not fill eraser

* fix styling

* fix active styling

* bigger buttons, smaller gaps

* fix other tools not opened

* fix: Style panel persistence and restore

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* move hidden action btns to extra popover

* fix dropdown overlapping with welcome screen

* replace custom popup with popover

* improve button styles

* swapping redo and delete

* always show undo & redo and improve styling

* change background

* toolbar styles

* no any

* persist perferred selection tool and align tablet as well

* add a renderTopLeftUI to props

* tweak border and bg

* show combined properties only when using suitable tools

* fix preferred tool

* new stroke icon

* hide color picker hot keys

* init preferred tool based on device

* fix main menu sizing

* fix welcome screen offset

* put text before image

* disable call highlight on buttons

* fix renderTopLeftUI

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di
2025-10-10 08:48:31 +11:00
committed by GitHub
parent 98e0cd9078
commit 416e8b3e42
47 changed files with 2407 additions and 678 deletions
+34 -36
View File
@@ -106,15 +106,15 @@
justify-content: center;
align-items: center;
min-height: 2.5rem;
pointer-events: auto;
--default-button-size: 2rem;
.compact-action-button {
width: 2rem;
height: 2rem;
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
border: none;
border-radius: var(--border-radius-lg);
background: transparent;
color: var(--color-on-surface);
cursor: pointer;
display: flex;
@@ -122,24 +122,20 @@
justify-content: center;
transition: all 0.2s ease;
background: var(--mobile-action-button-bg);
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
&.active {
background: var(
--color-surface-primary-container,
var(--mobile-action-button-bg)
);
}
&:active {
background: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
}
.compact-popover-content {
@@ -167,6 +163,19 @@
}
}
}
.ToolIcon {
.ToolIcon__icon {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
background: var(--mobile-action-button-bg);
&:hover {
background-color: transparent;
}
}
}
}
.compact-shape-actions-island {
@@ -174,29 +183,18 @@
overflow-x: hidden;
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
.mobile-shape-actions {
z-index: 999;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
background: transparent;
border-radius: var(--border-radius-lg);
box-shadow: none;
overflow: none;
scrollbar-width: none;
-ms-overflow-style: none;
}
.shape-actions-theme-scope {
File diff suppressed because it is too large Load Diff
+38 -19
View File
@@ -666,14 +666,9 @@ class App extends React.Component<AppProps, AppState> {
>();
onRemoveEventListenersEmitter = new Emitter<[]>();
defaultSelectionTool: "selection" | "lasso" = "selection";
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = isMobileOrTablet()
? ("lasso" as const)
: ("selection" as const);
const {
excalidrawAPI,
viewModeEnabled = false,
@@ -1527,7 +1522,7 @@ class App extends React.Component<AppProps, AppState> {
public render() {
const selectedElements = this.scene.getSelectedElements(this.state);
const { renderTopRightUI, renderCustomStats } = this.props;
const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props;
const sceneNonce = this.scene.getSceneNonce();
const { elementsMap, visibleElements } =
@@ -1613,6 +1608,7 @@ class App extends React.Component<AppProps, AppState> {
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
langCode={getLanguage().code}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
showExitZenModeBtn={
@@ -1625,7 +1621,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type ===
this.defaultSelectionTool &&
this.state.preferredSelectionTool.type &&
!this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length
}
@@ -2370,6 +2366,14 @@ class App extends React.Component<AppProps, AppState> {
deleteInvisibleElements: true,
});
const activeTool = scene.appState.activeTool;
if (!scene.appState.preferredSelectionTool.initialized) {
scene.appState.preferredSelectionTool = {
type: this.device.editor.isMobile ? "lasso" : "selection",
initialized: true,
};
}
scene.appState = {
...scene.appState,
theme: this.props.theme || scene.appState.theme,
@@ -2384,12 +2388,13 @@ class App extends React.Component<AppProps, AppState> {
activeTool.type === "selection"
? {
...activeTool,
type: this.defaultSelectionTool,
type: scene.appState.preferredSelectionTool.type,
}
: scene.appState.activeTool,
isLoading: false,
toast: this.state.toast,
};
if (initialData?.scrollToContent) {
scene.appState = {
...scene.appState,
@@ -2490,6 +2495,8 @@ class App extends React.Component<AppProps, AppState> {
// but not too narrow (> MQ_MAX_WIDTH_MOBILE)
this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet()
? "compact"
: this.isMobileBreakpoint(editorWidth, editorHeight)
? "mobile"
: "full",
});
@@ -3289,7 +3296,10 @@ class App extends React.Component<AppProps, AppState> {
await this.insertClipboardContent(data, filesList, isPlainPaste);
this.setActiveTool({ type: this.defaultSelectionTool }, true);
this.setActiveTool(
{ type: this.state.preferredSelectionTool.type },
true,
);
event?.preventDefault();
},
);
@@ -3435,7 +3445,7 @@ class App extends React.Component<AppProps, AppState> {
}
},
);
this.setActiveTool({ type: this.defaultSelectionTool }, true);
this.setActiveTool({ type: this.state.preferredSelectionTool.type }, true);
if (opts.fitToContent) {
this.scrollToContent(duplicatedElements, {
@@ -3647,7 +3657,7 @@ class App extends React.Component<AppProps, AppState> {
...updateActiveTool(
this.state,
prevState.activeTool.locked
? { type: this.defaultSelectionTool }
? { type: this.state.preferredSelectionTool.type }
: prevState.activeTool,
),
locked: !prevState.activeTool.locked,
@@ -3989,7 +3999,12 @@ class App extends React.Component<AppProps, AppState> {
}
if (appState) {
this.setState(appState);
this.setState({
...appState,
// keep existing stylesPanelMode as it needs to be preserved
// or set at startup
stylesPanelMode: this.state.stylesPanelMode,
} as Pick<AppState, K> | null);
}
if (elements) {
@@ -4653,7 +4668,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: this.defaultSelectionTool });
this.setActiveTool({ type: this.state.preferredSelectionTool.type });
} else {
this.setActiveTool({ type: "laser" });
}
@@ -5498,7 +5513,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
// we should only be able to double click when mode is selection
if (this.state.activeTool.type !== this.defaultSelectionTool) {
if (this.state.activeTool.type !== this.state.preferredSelectionTool.type) {
return;
}
@@ -6491,6 +6506,10 @@ class App extends React.Component<AppProps, AppState> {
this.setAppState({ snapLines: [] });
}
if (this.state.openPopup) {
this.setState({ openPopup: null });
}
this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs
@@ -7695,7 +7714,7 @@ class App extends React.Component<AppProps, AppState> {
if (!this.state.activeTool.locked) {
this.setState({
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
type: this.state.preferredSelectionTool.type,
}),
});
}
@@ -9409,7 +9428,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => ({
newElement: null,
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
type: this.state.preferredSelectionTool.type,
}),
selectedElementIds: makeNextSelectedElementIds(
{
@@ -10026,7 +10045,7 @@ class App extends React.Component<AppProps, AppState> {
newElement: null,
suggestedBindings: [],
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
type: this.state.preferredSelectionTool.type,
}),
});
} else {
@@ -10256,7 +10275,7 @@ class App extends React.Component<AppProps, AppState> {
{
newElement: null,
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
type: this.state.preferredSelectionTool.type,
}),
},
() => {
@@ -10720,7 +10739,7 @@ class App extends React.Component<AppProps, AppState> {
event.nativeEvent.pointerType === "pen" &&
// always allow if user uses a pen secondary button
event.button !== POINTER_BUTTON.SECONDARY)) &&
this.state.activeTool.type !== this.defaultSelectionTool
this.state.activeTool.type !== this.state.preferredSelectionTool.type
) {
return;
}
@@ -7,6 +7,12 @@
}
}
.color-picker__title {
padding: 0 0.5rem;
font-size: 0.875rem;
text-align: left;
}
.color-picker__heading {
padding: 0 0.5rem;
font-size: 0.75rem;
@@ -157,6 +163,15 @@
width: 1.625rem;
height: 1.625rem;
}
&.compact-sizing {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
}
&.mobile-border {
border: 1px solid var(--mobile-color-border);
}
}
.color-picker__button__hotkey-label {
@@ -19,7 +19,7 @@ import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
import { slashIcon, strokeIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
@@ -216,6 +216,11 @@ const ColorPickerPopupContent = ({
type={type}
elements={elements}
updateData={updateData}
showTitle={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
showHotKey={appState.stylesPanelMode !== "mobile"}
>
{colorInputJSX}
</Picker>
@@ -230,7 +235,7 @@ const ColorPickerTrigger = ({
label,
color,
type,
compactMode = false,
stylesPanelMode,
mode = "background",
onToggle,
editingTextElement,
@@ -238,7 +243,7 @@ const ColorPickerTrigger = ({
color: string | null;
label: string;
type: ColorPickerType;
compactMode?: boolean;
stylesPanelMode?: AppState["stylesPanelMode"];
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
@@ -263,6 +268,9 @@ const ColorPickerTrigger = ({
"is-transparent": !color || color === "transparent",
"has-outline":
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
"compact-sizing":
stylesPanelMode === "compact" || stylesPanelMode === "mobile",
"mobile-border": stylesPanelMode === "mobile",
})}
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
@@ -275,20 +283,10 @@ const ColorPickerTrigger = ({
onClick={handleClick}
>
<div className="color-picker__button-outline">{!color && slashIcon}</div>
{compactMode && color && (
<div className="color-picker__button-background">
{mode === "background" ? (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{backgroundIcon}
</span>
) : (
{(stylesPanelMode === "compact" || stylesPanelMode === "mobile") &&
color &&
mode === "stroke" && (
<div className="color-picker__button-background">
<span
style={{
color:
@@ -299,9 +297,8 @@ const ColorPickerTrigger = ({
>
{strokeIcon}
</span>
)}
</div>
)}
</div>
)}
</Popover.Trigger>
);
};
@@ -316,12 +313,15 @@ export const ColorPicker = ({
topPicks,
updateData,
appState,
compactMode = false,
}: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
const compactMode =
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile";
return (
<div>
<div
@@ -353,7 +353,7 @@ export const ColorPicker = ({
color={color}
label={label}
type={type}
compactMode={compactMode}
stylesPanelMode={appState.stylesPanelMode}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
@@ -37,8 +37,10 @@ interface PickerProps {
palette: ColorPaletteCustom;
updateData: (formData?: any) => void;
children?: React.ReactNode;
showTitle?: boolean;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
showHotKey?: boolean;
}
export const Picker = React.forwardRef(
@@ -51,11 +53,21 @@ export const Picker = React.forwardRef(
palette,
updateData,
children,
showTitle,
onEyeDropperToggle,
onEscape,
showHotKey = true,
}: PickerProps,
ref,
) => {
const title = showTitle
? type === "elementStroke"
? t("labels.stroke")
: type === "elementBackground"
? t("labels.background")
: null
: null;
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
@@ -154,6 +166,8 @@ export const Picker = React.forwardRef(
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
{title && <div className="color-picker__title">{title}</div>}
{!!customColors.length && (
<div>
<PickerHeading>
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef(
palette={palette}
onChange={onChange}
activeShade={activeShade}
showHotKey={showHotKey}
/>
</div>
<div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList color={color} onChange={onChange} palette={palette} />
<ShadeList
color={color}
onChange={onChange}
palette={palette}
showHotKey={showHotKey}
/>
</div>
{children}
</div>
@@ -20,6 +20,7 @@ interface PickerColorListProps {
color: string | null;
onChange: (color: string) => void;
activeShade: number;
showHotKey?: boolean;
}
const PickerColorList = ({
@@ -27,6 +28,7 @@ const PickerColorList = ({
color,
onChange,
activeShade,
showHotKey = true,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color,
@@ -82,7 +84,7 @@ const PickerColorList = ({
key={key}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={keybinding} />
{showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />}
</button>
);
})}
@@ -16,9 +16,15 @@ interface ShadeListProps {
color: string | null;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
showHotKey?: boolean;
}
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
export const ShadeList = ({
color,
onChange,
palette,
showHotKey,
}: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
@@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
}}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
{showHotKey && (
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
</button>
))}
</div>
@@ -1,5 +1,8 @@
.excalidraw {
.ExcalidrawLogo {
--logo-icon--mobile: 1rem;
--logo-text--mobile: 0.75rem;
--logo-icon--xs: 2rem;
--logo-text--xs: 1.5rem;
@@ -30,6 +33,17 @@
color: var(--color-logo-text);
}
&.is-mobile {
.ExcalidrawLogo-icon {
height: var(--logo-icon--mobile);
}
.ExcalidrawLogo-text {
height: var(--logo-text--mobile);
margin-left: 0.5rem;
}
}
&.is-xs {
.ExcalidrawLogo-icon {
height: var(--logo-icon--xs);
@@ -41,7 +41,7 @@ const LogoText = () => (
</svg>
);
type LogoSize = "xs" | "small" | "normal" | "large" | "custom";
type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile";
interface LogoProps {
size?: LogoSize;
@@ -106,6 +106,7 @@ export const FontPicker = React.memo(
<FontPickerTrigger
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
compactMode={compactMode}
/>
{isOpened && (
<FontPickerList
@@ -338,11 +338,13 @@ export const FontPickerList = React.memo(
onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
>
<QuickSearch
ref={inputRef}
placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)}
/>
{app.state.stylesPanelMode === "full" && (
<QuickSearch
ref={inputRef}
placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)}
/>
)}
<ScrollableList
className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")}
@@ -1,5 +1,7 @@
import * as Popover from "@radix-ui/react-popover";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
import type { FontFamilyValues } from "@excalidraw/element/types";
import { t } from "../../i18n";
@@ -11,14 +13,24 @@ import { useExcalidrawSetAppState } from "../App";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean;
compactMode?: boolean;
}
export const FontPickerTrigger = ({
selectedFontFamily,
isOpened = false,
compactMode = false,
}: FontPickerTriggerProps) => {
const setAppState = useExcalidrawSetAppState();
const compactStyle = compactMode
? {
...MOBILE_ACTION_BUTTON_BG,
width: "2rem",
height: "2rem",
}
: {};
return (
<Popover.Trigger asChild>
<div data-openpopup="fontFamily" className="properties-trigger">
@@ -37,6 +49,7 @@ export const FontPickerTrigger = ({
}}
style={{
border: "none",
...compactStyle,
}}
/>
</div>
@@ -18,7 +18,7 @@ type LockIconProps = {
export const HandButton = (props: LockIconProps) => {
return (
<ToolButton
className={clsx("Shape", { fillable: false })}
className={clsx("Shape", { fillable: false, active: props.checked })}
type="radio"
icon={handIcon}
name="editor-current-shape"
@@ -152,15 +152,13 @@ function Picker<T>({
);
};
const isMobile = device.editor.isMobile;
return (
<Popover.Content
side={
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
side={isMobile ? "right" : "bottom"}
align="start"
sideOffset={12}
sideOffset={isMobile ? 8 : 12}
style={{ zIndex: "var(--zIndex-popup)" }}
onKeyDown={handleKeyDown}
>
+4 -4
View File
@@ -91,6 +91,7 @@ interface LayerUIProps {
onPenModeToggle: AppClassProperties["togglePenMode"];
showExitZenModeBtn: boolean;
langCode: Language["code"];
renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
UIOptions: AppProps["UIOptions"];
@@ -149,6 +150,7 @@ const LayerUI = ({
onHandToolToggle,
onPenModeToggle,
showExitZenModeBtn,
renderTopLeftUI,
renderTopRightUI,
renderCustomStats,
UIOptions,
@@ -366,7 +368,7 @@ const LayerUI = ({
/>
<ShapesSwitcher
appState={appState}
setAppState={setAppState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
@@ -582,13 +584,11 @@ const LayerUI = ({
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions}
/>
+77 -132
View File
@@ -1,32 +1,23 @@
import React from "react";
import { showSelectedShapeActions } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { MobileShapeActions } from "./Actions";
import { MobileToolBar } from "./MobileToolBar";
import { FixedSideContainer } from "./FixedSideContainer";
import { HandButton } from "./HandButton";
import { HintViewer } from "./HintViewer";
import { Island } from "./Island";
import { LockButton } from "./LockButton";
import { PenModeButton } from "./PenModeButton";
import { Section } from "./Section";
import Stack from "./Stack";
import type { ActionManager } from "../actions/manager";
import type {
AppClassProperties,
AppProps,
AppState,
Device,
ExcalidrawProps,
UIAppState,
} from "../types";
import type { JSX } from "react";
@@ -38,7 +29,6 @@ type MobileMenuProps = {
renderImageExportDialog: () => React.ReactNode;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"];
@@ -46,9 +36,11 @@ type MobileMenuProps = {
isMobile: boolean,
appState: UIAppState,
) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderTopLeftUI?: (
isMobile: boolean,
appState: UIAppState,
) => JSX.Element | null;
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"];
app: AppClassProperties;
@@ -59,14 +51,10 @@ export const MobileMenu = ({
elements,
actionManager,
setAppState,
onLockToggle,
onHandToolToggle,
onPenModeToggle,
renderTopLeftUI,
renderTopRightUI,
renderCustomStats,
renderSidebars,
device,
renderWelcomeScreen,
UIOptions,
app,
@@ -76,141 +64,98 @@ export const MobileMenu = ({
MainMenuTunnel,
DefaultSidebarTriggerTunnel,
} = useTunnels();
const renderToolbar = () => {
return (
<FixedSideContainer side="top" className="App-top-bar">
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
<Section heading="shapes">
{(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1} className="App-toolbar-container">
<Island padding={1} className="App-toolbar App-toolbar--mobile">
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container">
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<DefaultSidebarTriggerTunnel.Out />
)}
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
</div>
</Stack.Row>
</Stack.Col>
)}
</Section>
<HintViewer
appState={appState}
isMobile={true}
device={device}
app={app}
/>
</FixedSideContainer>
const renderAppTopBar = () => {
const topRightUI = renderTopRightUI?.(true, appState) ?? (
<DefaultSidebarTriggerTunnel.Out />
);
const topLeftUI = (
<div className="excalidraw-ui-top-left">
{renderTopLeftUI?.(true, appState)}
<MainMenuTunnel.Out />
</div>
);
};
const renderAppToolbar = () => {
if (
appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector"
) {
return (
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
</div>
);
return <div className="App-toolbar-content">{topLeftUI}</div>;
}
return (
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
<div>
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
</div>
<div
className="App-toolbar-content"
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
{topLeftUI}
{topRightUI}
</div>
);
};
const renderToolbar = () => {
return (
<MobileToolBar
app={app}
onHandToolToggle={onHandToolToggle}
setAppState={setAppState}
/>
);
};
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 */}
<div className="App-welcome-screen">
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
</div>
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN,
}}
>
<Island padding={0}>
{appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Section>
) : null}
<footer className="App-toolbar">
{renderAppToolbar()}
{appState.scrolledOutside &&
!appState.openMenu &&
!appState.openSidebar && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
<MobileShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
setAppState={setAppState}
/>
<Island className="App-toolbar">
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
renderToolbar()}
{appState.scrolledOutside &&
!appState.openMenu &&
!appState.openSidebar && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</Island>
</div>
<FixedSideContainer side="top" className="App-top-bar">
{renderAppTopBar()}
</FixedSideContainer>
</>
);
};
@@ -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;
}
}
@@ -0,0 +1,471 @@
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<any, UIAppState>["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<HTMLDivElement>(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 (
<div className="mobile-toolbar" ref={toolbarRef}>
{/* Hand Tool */}
<HandButton
checked={isHandToolActive(app.state)}
onChange={onHandToolToggle}
title={t("toolBar.hand")}
isMobile
/>
{/* Selection Tool */}
<ToolPopover
app={app}
options={SELECTION_TOOLS}
activeTool={activeTool}
defaultOption={app.state.preferredSelectionTool.type}
namePrefix="selectionType"
title={capitalizeString(t("toolBar.selection"))}
data-testid="toolbar-selection"
onToolChange={(type: string) => {
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 */}
<ToolButton
className={clsx({
active: activeTool.type === "freedraw",
})}
type="radio"
icon={FreedrawIcon}
checked={activeTool.type === "freedraw"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.freedraw"))}`}
aria-label={capitalizeString(t("toolBar.freedraw"))}
data-testid="toolbar-freedraw"
onChange={() => handleToolChange("freedraw")}
/>
{/* Eraser */}
<ToolButton
className={clsx({
active: activeTool.type === "eraser",
})}
type="radio"
icon={EraserIcon}
checked={activeTool.type === "eraser"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.eraser"))}`}
aria-label={capitalizeString(t("toolBar.eraser"))}
data-testid="toolbar-eraser"
onChange={() => handleToolChange("eraser")}
/>
{/* Rectangle */}
<ToolPopover
app={app}
options={SHAPE_TOOLS}
activeTool={activeTool}
defaultOption={lastActiveGenericShape}
namePrefix="shapeType"
title={capitalizeString(
t(
lastActiveGenericShape === "rectangle"
? "toolBar.rectangle"
: lastActiveGenericShape === "diamond"
? "toolBar.diamond"
: lastActiveGenericShape === "ellipse"
? "toolBar.ellipse"
: "toolBar.rectangle",
),
)}
data-testid="toolbar-rectangle"
onToolChange={(type: string) => {
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 */}
<ToolPopover
app={app}
options={LINEAR_ELEMENT_TOOLS}
activeTool={activeTool}
defaultOption={lastActiveLinearElement}
namePrefix="linearElementType"
title={capitalizeString(
t(
lastActiveLinearElement === "arrow"
? "toolBar.arrow"
: "toolBar.line",
),
)}
data-testid="toolbar-arrow"
fillable={true}
onToolChange={(type: string) => {
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 && (
<ToolButton
className={clsx({
active: activeTool.type === "text",
})}
type="radio"
icon={TextIcon}
checked={activeTool.type === "text"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.text"))}`}
aria-label={capitalizeString(t("toolBar.text"))}
data-testid="toolbar-text"
onChange={() => handleToolChange("text")}
/>
)}
{/* Image */}
{showImageToolOutside && (
<ToolButton
className={clsx({
active: activeTool.type === "image",
})}
type="radio"
icon={ImageIcon}
checked={activeTool.type === "image"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.image"))}`}
aria-label={capitalizeString(t("toolBar.image"))}
data-testid="toolbar-image"
onChange={() => handleToolChange("image")}
/>
)}
{/* Frame Tool */}
{showFrameToolOutside && (
<ToolButton
className={clsx({ active: frameToolSelected })}
type="radio"
icon={frameToolIcon}
checked={frameToolSelected}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.frame"))}`}
aria-label={capitalizeString(t("toolBar.frame"))}
data-testid="toolbar-frame"
onChange={() => handleToolChange("frame")}
/>
)}
{/* Other Shapes */}
<DropdownMenu open={isOtherShapesMenuOpen} placement="top">
<DropdownMenu.Trigger
className={clsx(
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
{
"App-toolbar__extra-tools-trigger--selected":
extraToolSelected || isOtherShapesMenuOpen,
},
)}
onToggle={() => setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen)}
title={t("toolBar.extraTools")}
style={{
width: WIDTH,
height: WIDTH,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{extraIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsOtherShapesMenuOpen(false)}
onSelect={() => setIsOtherShapesMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
{!showTextToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "text" })}
icon={TextIcon}
shortcut={KEYS.T.toLocaleUpperCase()}
data-testid="toolbar-text"
selected={activeTool.type === "text"}
>
{t("toolBar.text")}
</DropdownMenu.Item>
)}
{!showImageToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "image" })}
icon={ImageIcon}
data-testid="toolbar-image"
selected={activeTool.type === "image"}
>
{t("toolBar.image")}
</DropdownMenu.Item>
)}
{!showFrameToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
)}
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "embeddable" })}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
<DropdownMenu.Item
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu>
</div>
);
};
@@ -0,0 +1,18 @@
@import "../css/variables.module.scss";
.excalidraw {
.tool-popover-content {
display: flex;
flex-direction: row;
gap: 0.25rem;
border-radius: 0.5rem;
background: var(--island-bg-color);
box-shadow: var(--shadow-island);
padding: 0.5rem;
z-index: var(--zIndex-layerUI);
}
&:focus {
outline: none;
}
}
@@ -0,0 +1,120 @@
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import { capitalizeString } from "@excalidraw/common";
import * as Popover from "@radix-ui/react-popover";
import { trackEvent } from "../analytics";
import { ToolButton } from "./ToolButton";
import "./ToolPopover.scss";
import type { AppClassProperties } from "../types";
type ToolOption = {
type: string;
icon: React.ReactNode;
title?: string;
};
type ToolPopoverProps = {
app: AppClassProperties;
options: readonly ToolOption[];
activeTool: { type: string };
defaultOption: string;
className?: string;
namePrefix: string;
title: string;
"data-testid": string;
onToolChange: (type: string) => void;
displayedOption: ToolOption;
fillable?: boolean;
};
export const ToolPopover = ({
app,
options,
activeTool,
defaultOption,
className = "Shape",
namePrefix,
title,
"data-testid": dataTestId,
onToolChange,
displayedOption,
fillable = false,
}: ToolPopoverProps) => {
const [isPopupOpen, setIsPopupOpen] = useState(false);
const currentType = activeTool.type;
const isActive = displayedOption.type === currentType;
const SIDE_OFFSET = 32 / 2 + 10;
// if currentType is not in options, close popup
if (!options.some((o) => o.type === currentType) && isPopupOpen) {
setIsPopupOpen(false);
}
// Close popover when user starts interacting with the canvas (pointer down)
useEffect(() => {
// app.onPointerDownEmitter emits when pointer down happens on canvas area
const unsubscribe = app.onPointerDownEmitter.on(() => {
setIsPopupOpen(false);
});
return () => unsubscribe?.();
}, [app]);
return (
<Popover.Root open={isPopupOpen}>
<Popover.Trigger asChild>
<ToolButton
className={clsx(className, {
fillable,
active: options.some((o) => o.type === activeTool.type),
})}
type="radio"
icon={displayedOption.icon}
checked={isActive}
name="editor-current-shape"
title={title}
aria-label={title}
data-testid={dataTestId}
onPointerDown={() => {
setIsPopupOpen((v) => !v);
onToolChange(defaultOption);
}}
/>
</Popover.Trigger>
<Popover.Content
className="tool-popover-content"
sideOffset={SIDE_OFFSET}
>
{options.map(({ type, icon, title }) => (
<ToolButton
className={clsx(className, {
active: currentType === type,
})}
key={type}
type="radio"
icon={icon}
checked={currentType === type}
name={`${namePrefix}-option`}
title={title || capitalizeString(type)}
keyBindingLabel=""
aria-label={title || capitalizeString(type)}
data-testid={`toolbar-${type}`}
onChange={() => {
if (app.state.activeTool.type !== type) {
trackEvent("toolbar", type, "ui");
}
app.setActiveTool({ type: type as any });
onToolChange?.(type);
}}
/>
))}
</Popover.Content>
</Popover.Root>
);
};
@@ -44,6 +44,10 @@
var(--button-active-border, var(--color-primary-darkest)) inset;
}
&:hover {
background-color: transparent;
}
&--selected,
&--selected:hover {
background: var(--color-primary-light);
@@ -3,24 +3,46 @@
.excalidraw {
.dropdown-menu {
position: absolute;
top: 100%;
top: 2.5rem;
margin-top: 0.5rem;
&--placement-top {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 0.5rem;
}
&--mobile {
left: 0;
width: 100%;
row-gap: 0.75rem;
// When main menu is in the top toolbar, position relative to trigger
&.main-menu-dropdown {
min-width: 232px;
max-width: calc(100vw - var(--editor-container-padding) * 2);
margin-top: 0;
margin-bottom: 0;
z-index: var(--zIndex-layerUI);
@media screen and (orientation: landscape) {
max-width: 232px;
}
}
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
// background-color: var(--island-bg-color);
max-height: calc(
100svh - var(--editor-container-padding) * 2 - 2.25rem
);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
transition: box-shadow 0.5s ease-in-out;
display: flex;
flex-direction: column;
overflow-y: auto;
&.zen-mode {
box-shadow: none;
@@ -30,7 +52,7 @@
.dropdown-menu-container {
background-color: var(--island-bg-color);
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
}
@@ -17,16 +17,27 @@ import "./DropdownMenu.scss";
const DropdownMenu = ({
children,
open,
placement,
}: {
children?: React.ReactNode;
open: boolean;
placement?: "top" | "bottom";
}) => {
const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children);
// clone the MenuContentComp to pass the placement prop
const MenuContentCompWithPlacement =
MenuContentComp && React.isValidElement(MenuContentComp)
? React.cloneElement(MenuContentComp as React.ReactElement<any>, {
placement,
})
: MenuContentComp;
return (
<>
{MenuTriggerComp}
{open && MenuContentComp}
{open && MenuContentCompWithPlacement}
</>
);
};
@@ -17,6 +17,7 @@ const MenuContent = ({
className = "",
onSelect,
style,
placement = "bottom",
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
@@ -26,6 +27,7 @@ const MenuContent = ({
*/
onSelect?: (event: Event) => void;
style?: React.CSSProperties;
placement?: "top" | "bottom";
}) => {
const device = useDevice();
const menuRef = useRef<HTMLDivElement>(null);
@@ -58,6 +60,7 @@ const MenuContent = ({
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.editor.isMobile,
"dropdown-menu--placement-top": placement === "top",
}).trim();
return (
+1 -13
View File
@@ -2319,22 +2319,10 @@ export const adjustmentsIcon = createIcon(
tablerIconProps,
);
export const backgroundIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 10l4 -4" />
<path d="M6 14l8 -8" />
<path d="M6 18l12 -12" />
<path d="M10 18l8 -8" />
<path d="M14 18l4 -4" />
</g>,
tablerIconProps,
);
export const strokeIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="6" width="12" height="12" fill="none" />
<path d="M6 10l4 -4 L6 14l8 -8 L6 18l12 -12 L10 18l8 -8 L14 18l4 -4" />
</g>,
tablerIconProps,
);
@@ -53,6 +53,8 @@ const MainMenu = Object.assign(
onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null });
})}
placement="bottom"
className={device.editor.isMobile ? "main-menu-dropdown" : ""}
>
{children}
{device.editor.isMobile && appState.collaborators.size > 0 && (
+1 -1
View File
@@ -89,7 +89,7 @@ export const SHAPES = [
] as const;
export const getToolbarTools = (app: AppClassProperties) => {
return app.defaultSelectionTool === "lasso"
return app.state.preferredSelectionTool.type === "lasso"
? ([
{
value: "lasso",
@@ -252,16 +252,12 @@
}
}
@media (max-height: 599px) {
&.excalidraw--mobile {
.welcome-screen-center {
margin-top: 4rem;
}
}
@media (min-height: 600px) and (max-height: 900px) {
.welcome-screen-center {
margin-top: 8rem;
margin-bottom: 2rem;
}
}
@media (max-height: 500px), (max-width: 320px) {
.welcome-screen-center {
display: none;