Compare commits

...

13 Commits

Author SHA1 Message Date
Ryan Di
3ca55e9a45 Merge branch 'master' into ryan-di/mobile/new-layout 2025-09-19 14:05:53 +10:00
Ryan Di
e6da5b213d update mobile menu layout 2025-09-19 14:03:52 +10:00
Ryan Di
62be53845e include mobile 2025-09-19 13:55:42 +10:00
Ryan Di
eebe491f4e excali logo mobile 2025-09-19 13:54:45 +10:00
Ryan Di
ffcc2e13d8 remove refactored popups 2025-09-19 13:53:58 +10:00
Ryan Di
ddb5dc313c mobile actions 2025-09-19 13:52:54 +10:00
Ryan Di
e064bc236f add mobile mode as well 2025-09-19 00:54:24 +10:00
Ryan Di
4492c81fa8 fix active tool type 2025-09-19 00:53:15 +10:00
Ryan Di
d28b63fcbb update position for mobile 2025-09-19 00:38:19 +10:00
Ryan Di
fd34f7437b add a dedicated mobile toolbar 2025-09-19 00:37:23 +10:00
Ryan Di
6935934417 add popup to switch between grouped tool types 2025-09-19 00:36:37 +10:00
Ryan Di
fb2975b0f2 put menu trigger to top left 2025-09-12 17:08:53 +10:00
Ryan Di
12644eb347 compact bottom toolbar 2025-09-12 14:20:57 +10:00
25 changed files with 1529 additions and 539 deletions

View File

@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame" && type !== "magicframe"; type === "rectangle" ||
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "text";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||

View File

@ -348,7 +348,10 @@ export const actionChangeStrokeColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"} compactMode={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@ -428,7 +431,10 @@ export const actionChangeBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"} compactMode={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@ -531,9 +537,7 @@ export const actionChangeStrokeWidth = register({
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && ( <legend>{t("labels.strokeWidth")}</legend>
<legend>{t("labels.strokeWidth")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="stroke-width" group="stroke-width"
@ -590,9 +594,7 @@ export const actionChangeSloppiness = register({
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && ( <legend>{t("labels.sloppiness")}</legend>
<legend>{t("labels.sloppiness")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="sloppiness" group="sloppiness"
@ -645,9 +647,7 @@ export const actionChangeStrokeStyle = register({
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && ( <legend>{t("labels.strokeStyle")}</legend>
<legend>{t("labels.strokeStyle")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="strokeStyle" group="strokeStyle"
@ -776,7 +776,8 @@ export const actionChangeFontSize = register({
onChange={(value) => { onChange={(value) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => updateData(value), () => updateData(value),
appState.stylesPanelMode === "compact", appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
data?.onPreventClose, data?.onPreventClose,
); );
@ -1040,7 +1041,7 @@ export const actionChangeFontFamily = register({
return result; return result;
}, },
PanelComponent: ({ elements, appState, app, updateData, data }) => { PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<ElementsMap>(new Map()); const cachedElementsRef = useRef<ElementsMap>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null); const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
@ -1117,7 +1118,7 @@ export const actionChangeFontFamily = register({
}, []); }, []);
return ( return (
<fieldset> <>
{appState.stylesPanelMode === "full" && ( {appState.stylesPanelMode === "full" && (
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
)} )}
@ -1125,7 +1126,7 @@ export const actionChangeFontFamily = register({
isOpened={appState.openPopup === "fontFamily"} isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={appState.stylesPanelMode === "compact"} compactMode={appState.stylesPanelMode !== "full"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => { () => {
@ -1137,7 +1138,8 @@ export const actionChangeFontFamily = register({
// defensive clear so immediate close won't abuse the cached elements // defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
}, },
appState.stylesPanelMode === "compact", appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
); );
}} }}
@ -1213,7 +1215,8 @@ export const actionChangeFontFamily = register({
// Refocus text editor when font picker closes if we were editing text // Refocus text editor when font picker closes if we were editing text
if ( if (
appState.stylesPanelMode === "compact" && (appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile") &&
appState.editingTextElement appState.editingTextElement
) { ) {
restoreCaretPosition(null); // Just refocus without saved position restoreCaretPosition(null); // Just refocus without saved position
@ -1221,7 +1224,7 @@ export const actionChangeFontFamily = register({
} }
}} }}
/> />
</fieldset> </>
); );
}, },
}); });
@ -1314,7 +1317,8 @@ export const actionChangeTextAlign = register({
onChange={(value) => { onChange={(value) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => updateData(value), () => updateData(value),
appState.stylesPanelMode === "compact", appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
data?.onPreventClose, data?.onPreventClose,
); );
@ -1413,7 +1417,8 @@ export const actionChangeVerticalAlign = register({
onChange={(value) => { onChange={(value) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => updateData(value), () => updateData(value),
appState.stylesPanelMode === "compact", appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
data?.onPreventClose, data?.onPreventClose,
); );
@ -1678,8 +1683,8 @@ export const actionChangeArrowProperties = register({
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => { PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return ( return (
<div className="selected-shape-actions"> <div className="selected-shape-actions">
{renderAction("changeArrowType")}
{renderAction("changeArrowhead")} {renderAction("changeArrowhead")}
{renderAction("changeArrowType")}
</div> </div>
); );
}, },

View File

@ -110,8 +110,8 @@
--default-button-size: 2rem; --default-button-size: 2rem;
.compact-action-button { .compact-action-button {
width: 2rem; width: 1.625rem;
height: 2rem; height: 1.625rem;
border: none; border: none;
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
background: transparent; background: transparent;
@ -167,6 +167,11 @@
} }
} }
} }
.ToolIcon__icon {
width: 1.625rem;
height: 1.625rem;
}
} }
.compact-shape-actions-island { .compact-shape-actions-island {
@ -199,6 +204,17 @@
} }
} }
.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;
}
.shape-actions-theme-scope { .shape-actions-theme-scope {
--button-border: transparent; --button-border: transparent;
--button-bg: var(--color-surface-mid); --button-bg: var(--color-surface-mid);

File diff suppressed because it is too large Load Diff

View File

@ -2484,6 +2484,8 @@ class App extends React.Component<AppProps, AppState> {
// but not too narrow (> MQ_MAX_WIDTH_MOBILE) // but not too narrow (> MQ_MAX_WIDTH_MOBILE)
this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet() this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet()
? "compact" ? "compact"
: this.isMobileBreakpoint(editorWidth, editorHeight)
? "mobile"
: "full", : "full",
}); });
@ -6485,6 +6487,10 @@ class App extends React.Component<AppProps, AppState> {
this.setAppState({ snapLines: [] }); this.setAppState({ snapLines: [] });
} }
if (this.state.openPopup) {
this.setState({ openPopup: null });
}
this.updateGestureOnPointerDown(event); this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs // if dragging element is freedraw and another pointerdown event occurs

View File

@ -1,5 +1,8 @@
.excalidraw { .excalidraw {
.ExcalidrawLogo { .ExcalidrawLogo {
--logo-icon--mobile: 1rem;
--logo-text--mobile: 0.75rem;
--logo-icon--xs: 2rem; --logo-icon--xs: 2rem;
--logo-text--xs: 1.5rem; --logo-text--xs: 1.5rem;
@ -30,6 +33,17 @@
color: var(--color-logo-text); 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 { &.is-xs {
.ExcalidrawLogo-icon { .ExcalidrawLogo-icon {
height: var(--logo-icon--xs); height: var(--logo-icon--xs);

View File

@ -41,7 +41,7 @@ const LogoText = () => (
</svg> </svg>
); );
type LogoSize = "xs" | "small" | "normal" | "large" | "custom"; type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile";
interface LogoProps { interface LogoProps {
size?: LogoSize; size?: LogoSize;

View File

@ -106,6 +106,7 @@ export const FontPicker = React.memo(
<FontPickerTrigger <FontPickerTrigger
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
isOpened={isOpened} isOpened={isOpened}
compactMode={compactMode}
/> />
{isOpened && ( {isOpened && (
<FontPickerList <FontPickerList

View File

@ -338,11 +338,13 @@ export const FontPickerList = React.memo(
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement} preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
<QuickSearch {app.state.stylesPanelMode === "full" && (
ref={inputRef} <QuickSearch
placeholder={t("quickSearch.placeholder")} ref={inputRef}
onChange={debounce(setSearchTerm, 20)} placeholder={t("quickSearch.placeholder")}
/> onChange={debounce(setSearchTerm, 20)}
/>
)}
<ScrollableList <ScrollableList
className="dropdown-menu fonts manual-hover" className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")} placeholder={t("fontList.empty")}

View File

@ -11,11 +11,13 @@ import { useExcalidrawSetAppState } from "../App";
interface FontPickerTriggerProps { interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null; selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean; isOpened?: boolean;
compactMode?: boolean;
} }
export const FontPickerTrigger = ({ export const FontPickerTrigger = ({
selectedFontFamily, selectedFontFamily,
isOpened = false, isOpened = false,
compactMode = false,
}: FontPickerTriggerProps) => { }: FontPickerTriggerProps) => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
@ -37,6 +39,8 @@ export const FontPickerTrigger = ({
}} }}
style={{ style={{
border: "none", border: "none",
width: compactMode ? "1.625rem" : undefined,
height: compactMode ? "1.625rem" : undefined,
}} }}
/> />
</div> </div>

View File

@ -152,15 +152,13 @@ function Picker<T>({
); );
}; };
const isMobile = device.editor.isMobile;
return ( return (
<Popover.Content <Popover.Content
side={ side={isMobile ? "right" : "bottom"}
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start" align="start"
sideOffset={12} sideOffset={isMobile ? 8 : 12}
style={{ zIndex: "var(--zIndex-popup)" }} style={{ zIndex: "var(--zIndex-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >

View File

@ -582,13 +582,10 @@ const LayerUI = ({
renderJSONExportDialog={renderJSONExportDialog} renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog} renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle} onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars} renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions} UIOptions={UIOptions}
/> />

View File

@ -1,32 +1,23 @@
import React from "react"; import React from "react";
import { showSelectedShapeActions } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; 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 { FixedSideContainer } from "./FixedSideContainer";
import { HandButton } from "./HandButton";
import { HintViewer } from "./HintViewer";
import { Island } from "./Island"; 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 { ActionManager } from "../actions/manager";
import type { import type {
AppClassProperties, AppClassProperties,
AppProps, AppProps,
AppState, AppState,
Device,
ExcalidrawProps,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import type { JSX } from "react"; import type { JSX } from "react";
@ -38,7 +29,6 @@ type MobileMenuProps = {
renderImageExportDialog: () => React.ReactNode; renderImageExportDialog: () => React.ReactNode;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"]; onPenModeToggle: AppClassProperties["togglePenMode"];
@ -46,9 +36,7 @@ type MobileMenuProps = {
isMobile: boolean, isMobile: boolean,
appState: UIAppState, appState: UIAppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
app: AppClassProperties; app: AppClassProperties;
@ -59,14 +47,10 @@ export const MobileMenu = ({
elements, elements,
actionManager, actionManager,
setAppState, setAppState,
onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle,
renderTopRightUI, renderTopRightUI,
renderCustomStats,
renderSidebars, renderSidebars,
device,
renderWelcomeScreen, renderWelcomeScreen,
UIOptions, UIOptions,
app, app,
@ -78,64 +62,15 @@ export const MobileMenu = ({
} = useTunnels(); } = useTunnels();
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<FixedSideContainer side="top" className="App-top-bar"> <MobileToolBar
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />} appState={appState}
<Section heading="shapes"> app={app}
{(heading: React.ReactNode) => ( onHandToolToggle={onHandToolToggle}
<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 renderAppToolbar = () => { const renderAppTopBar = () => {
if ( if (
appState.viewModeEnabled || appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector" appState.openDialog?.name === "elementLinkSelector"
@ -147,18 +82,28 @@ export const MobileMenu = ({
); );
} }
const topRightUI = renderTopRightUI?.(true, appState);
return ( return (
<div className="App-toolbar-content"> <div
<MainMenuTunnel.Out /> className="App-toolbar-content"
{actionManager.renderAction("toggleEditMenu")} style={{
{actionManager.renderAction( display: "flex",
appState.multiElement ? "finalize" : "duplicateSelection", flexDirection: "row",
)} justifyContent: "space-between",
{actionManager.renderAction("deleteSelectedElements")} }}
<div> >
{actionManager.renderAction("undo")} <div
{actionManager.renderAction("redo")} style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 16,
}}
>
<MainMenuTunnel.Out />
</div> </div>
{topRightUI ? topRightUI : <DefaultSidebarTriggerTunnel.Out />}
</div> </div>
); );
}; };
@ -166,49 +111,43 @@ export const MobileMenu = ({
return ( return (
<> <>
{renderSidebars()} {renderSidebars()}
{!appState.viewModeEnabled && <FixedSideContainer side="top" className="App-top-bar">
appState.openDialog?.name !== "elementLinkSelector" && {renderAppTopBar()}
renderToolbar()} {renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
</FixedSideContainer>
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2, marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}} }}
> >
<Island padding={0}> <MobileShapeActions
{appState.openMenu === "shape" && appState={appState}
!appState.viewModeEnabled && elementsMap={app.scene.getNonDeletedElementsMap()}
appState.openDialog?.name !== "elementLinkSelector" && renderAction={actionManager.renderAction}
showSelectedShapeActions(appState, elements) ? ( app={app}
<Section className="App-mobile-menu" heading="selectedShapeActions"> setAppState={setAppState}
<SelectedShapeActions />
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} <Island className="App-toolbar">
renderAction={actionManager.renderAction} {!appState.viewModeEnabled &&
app={app} appState.openDialog?.name !== "elementLinkSelector" &&
/> renderToolbar()}
</Section> {appState.scrolledOutside &&
) : null} !appState.openMenu &&
<footer className="App-toolbar"> !appState.openSidebar && (
{renderAppToolbar()} <button
{appState.scrolledOutside && type="button"
!appState.openMenu && className="scroll-back-to-content"
!appState.openSidebar && ( onClick={() => {
<button setAppState((appState) => ({
type="button" ...calculateScrollCenter(elements, appState),
className="scroll-back-to-content" }));
onClick={() => { }}
setAppState((appState) => ({ >
...calculateScrollCenter(elements, appState), {t("buttons.scrollBackToContent")}
})); </button>
}} )}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</Island> </Island>
</div> </div>
</> </>

View File

@ -0,0 +1,74 @@
@import "open-color/open-color.scss";
@import "../css/variables.module.scss";
.excalidraw {
.mobile-toolbar {
display: flex;
flex: 1;
align-items: center;
padding: 4px;
gap: 4px;
border-radius: var(--space-factor);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
justify-content: space-between;
@media screen and (min-width: 340px) {
gap: 6px;
}
@media screen and (min-width: 380px) {
gap: 8px;
}
}
.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: 2rem;
height: 2rem;
}
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;
}
}

View File

@ -0,0 +1,422 @@
import { useState, useEffect } 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 { ToolWithPopup } from "./ToolWithPopup";
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, 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 = {
appState: UIAppState;
app: AppClassProperties;
onHandToolToggle: () => void;
};
export const MobileToolBar = ({
appState,
app,
onHandToolToggle,
}: MobileToolBarProps) => {
const activeTool = appState.activeTool;
const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false);
const [lastActiveGenericShape, setLastActiveGenericShape] = useState<
"rectangle" | "diamond" | "ellipse"
>("rectangle");
const [lastActiveLinearElement, setLastActiveLinearElement] = useState<
"arrow" | "line"
>("arrow");
// 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 (appState.activeTool.type !== toolType) {
trackEvent("toolbar", toolType, "ui");
}
if (toolType === "selection") {
if (appState.activeTool.type === "selection") {
// Toggle selection tool behavior if needed
} else {
app.setActiveTool({ type: "selection" });
}
} else {
app.setActiveTool({ type: toolType as any });
}
};
const showTextToolOutside = appState.width >= 400;
const showFrameToolOutside = appState.width >= 440;
const extraTools = [
"text",
"frame",
"embeddable",
"laser",
"magicframe",
].filter((tool) => {
if (showTextToolOutside && tool === "text") {
return false;
}
if (showFrameToolOutside && tool === "frame") {
return false;
}
return true;
});
const extraToolSelected = extraTools.includes(appState.activeTool.type);
const extraIcon = extraToolSelected
? appState.activeTool.type === "frame"
? frameToolIcon
: appState.activeTool.type === "embeddable"
? EmbedIcon
: appState.activeTool.type === "laser"
? laserPointerToolIcon
: appState.activeTool.type === "text"
? TextIcon
: appState.activeTool.type === "magicframe"
? MagicIcon
: extraToolsIcon
: extraToolsIcon;
return (
<div className="mobile-toolbar">
{/* Hand Tool */}
<HandButton
checked={isHandToolActive(appState)}
onChange={onHandToolToggle}
title={t("toolBar.hand")}
isMobile
/>
{/* Selection Tool */}
<ToolWithPopup
app={app}
options={SELECTION_TOOLS}
activeTool={activeTool}
defaultOption={app.defaultSelectionTool}
className="Selection"
namePrefix="selectionType"
title={capitalizeString(t("toolBar.selection"))}
data-testid="toolbar-selection"
onToolChange={(type: string) => {
app.setActiveTool({ type: type as any });
app.defaultSelectionTool = type as any;
}}
getDisplayedOption={() =>
SELECTION_TOOLS.find(
(tool) => tool.type === app.defaultSelectionTool,
) || SELECTION_TOOLS[0]
}
isActive={
activeTool.type === "selection" || activeTool.type === "lasso"
}
/>
{/* Free Draw */}
<ToolButton
className={clsx("Shape", { fillable: false })}
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("Shape", { fillable: true })}
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 */}
<ToolWithPopup
app={app}
options={SHAPE_TOOLS}
activeTool={activeTool}
defaultOption={lastActiveGenericShape}
className="Shape"
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) => {
setLastActiveGenericShape(type as any);
app.setActiveTool({ type: type as any });
}}
getDisplayedOption={() =>
SHAPE_TOOLS.find((tool) => tool.type === lastActiveGenericShape) ||
SHAPE_TOOLS[0]
}
isActive={["rectangle", "diamond", "ellipse"].includes(activeTool.type)}
/>
{/* Arrow/Line */}
<ToolWithPopup
app={app}
options={LINEAR_ELEMENT_TOOLS}
activeTool={activeTool}
defaultOption={lastActiveLinearElement}
className="LinearElement"
namePrefix="linearElementType"
title={capitalizeString(
t(
lastActiveLinearElement === "arrow"
? "toolBar.arrow"
: "toolBar.line",
),
)}
data-testid="toolbar-arrow"
fillable={true}
onToolChange={(type: string) => {
setLastActiveLinearElement(type as any);
app.setActiveTool({ type: type as any });
}}
getDisplayedOption={() =>
LINEAR_ELEMENT_TOOLS.find(
(tool) => tool.type === lastActiveLinearElement,
) || LINEAR_ELEMENT_TOOLS[0]
}
isActive={["arrow", "line"].includes(activeTool.type)}
/>
{/* Image */}
<ToolButton
className={clsx("Shape", { fillable: false })}
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")}
/>
{/* Text Tool */}
{showTextToolOutside && (
<ToolButton
className={clsx("Shape", { fillable: false })}
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")}
/>
)}
{/* Frame Tool */}
{showFrameToolOutside && (
<ToolButton
className={clsx("Shape", { fillable: false })}
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--selected": extraToolSelected,
})}
onToggle={() => setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen)}
title={t("toolBar.extraTools")}
>
{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>
)}
{!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>
);
};

View File

@ -0,0 +1,194 @@
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { capitalizeString, CLASSES } from "@excalidraw/common";
import { trackEvent } from "../analytics";
import { ToolButton } from "./ToolButton";
import type { AppClassProperties } from "../types";
type ToolOption = {
type: string;
icon: React.ReactNode;
title?: string;
};
type ToolTypePopupProps = {
app: AppClassProperties;
triggerElement: HTMLElement | null;
isOpen: boolean;
onClose: () => void;
currentType: string;
onChange?: (type: string) => void;
options: readonly ToolOption[];
className?: string;
namePrefix: string;
};
export const ToolTypePopup = ({
app,
triggerElement,
isOpen,
onClose,
onChange,
currentType,
options,
className = "Shape",
namePrefix,
}: ToolTypePopupProps) => {
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen || !triggerElement) {
return;
}
const updatePosition = () => {
const triggerRect = triggerElement.getBoundingClientRect();
const panelRect = panelRef.current?.getBoundingClientRect();
const panelWidth = panelRect?.width ?? 0;
const panelHeight = panelRect?.height ?? 0;
setPanelPosition({
x: triggerRect.x - panelWidth / 2,
y: panelHeight + 8,
});
};
updatePosition();
// Outside click handling (capture pointer events for reliability on mobile)
const handlePointer = (event: PointerEvent) => {
const target = event.target as Node | null;
const panelEl = panelRef.current;
const triggerEl = triggerElement;
if (!target) {
onClose();
return;
}
const insidePanel = !!panelEl && panelEl.contains(target);
const onTrigger = !!triggerEl && triggerEl.contains(target);
if (!insidePanel && !onTrigger) {
onClose();
}
};
document.addEventListener("pointerdown", handlePointer, true);
document.addEventListener("pointerup", handlePointer, true);
return () => {
document.removeEventListener("pointerdown", handlePointer, true);
document.removeEventListener("pointerup", handlePointer, true);
};
}, [isOpen, triggerElement, onClose]);
if (!isOpen) {
return null;
}
return (
<div
ref={panelRef}
tabIndex={-1}
style={{
position: "fixed",
bottom: `${panelPosition.y}px`,
left: `${panelPosition.x}px`,
zIndex: 2,
}}
className={CLASSES.CONVERT_ELEMENT_TYPE_POPUP}
>
{options.map(({ type, icon, title }) => (
<ToolButton
className={className}
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 });
onChange?.(type);
}}
/>
))}
</div>
);
};
type ToolWithPopupProps = {
app: AppClassProperties;
options: readonly ToolOption[];
activeTool: { type: string };
defaultOption: string;
className?: string;
namePrefix: string;
title: string;
"data-testid": string;
onToolChange: (type: string) => void;
getDisplayedOption: () => ToolOption;
isActive: boolean;
fillable?: boolean;
};
export const ToolWithPopup = ({
app,
options,
activeTool,
defaultOption,
className = "Shape",
namePrefix,
title,
"data-testid": dataTestId,
onToolChange,
getDisplayedOption,
isActive,
fillable = false,
}: ToolWithPopupProps) => {
const [isPopupOpen, setIsPopupOpen] = useState(false);
const [triggerRef, setTriggerRef] = useState<HTMLElement | null>(null);
const displayedOption = getDisplayedOption();
return (
<div style={{ position: "relative" }}>
<div ref={setTriggerRef}>
<ToolButton
className={clsx(className, { fillable })}
type="radio"
icon={displayedOption.icon}
checked={isActive}
name="editor-current-shape"
title={title}
aria-label={title}
data-testid={dataTestId}
onPointerDown={() => {
setIsPopupOpen((val) => !val);
onToolChange(defaultOption);
}}
/>
</div>
<ToolTypePopup
app={app}
triggerElement={triggerRef}
isOpen={isPopupOpen}
onClose={() => setIsPopupOpen(false)}
options={options}
className={className}
namePrefix={namePrefix}
currentType={activeTool.type}
onChange={(type: string) => {
onToolChange(type);
}}
/>
</div>
);
};

View File

@ -6,11 +6,30 @@
top: 100%; top: 100%;
margin-top: 0.5rem; margin-top: 0.5rem;
&--placement-top {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 0.5rem;
}
&--mobile { &--mobile {
left: 0; left: 0;
width: 100%; width: 100%;
row-gap: 0.75rem; row-gap: 0.75rem;
// When main menu is in the top toolbar, position relative to trigger
&.main-menu-dropdown {
position: fixed;
left: 1rem;
top: 3.5rem;
min-width: 280px;
max-width: calc(100vw - 2rem);
margin-top: 0;
margin-bottom: 0;
z-index: var(--zIndex-layerUI);
}
.dropdown-menu-container { .dropdown-menu-container {
padding: 8px 8px; padding: 8px 8px;
box-sizing: border-box; box-sizing: border-box;

View File

@ -17,16 +17,27 @@ import "./DropdownMenu.scss";
const DropdownMenu = ({ const DropdownMenu = ({
children, children,
open, open,
placement,
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
open: boolean; open: boolean;
placement?: "top" | "bottom";
}) => { }) => {
const MenuTriggerComp = getMenuTriggerComponent(children); const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(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 ( return (
<> <>
{MenuTriggerComp} {MenuTriggerComp}
{open && MenuContentComp} {open && MenuContentCompWithPlacement}
</> </>
); );
}; };

View File

@ -17,6 +17,7 @@ const MenuContent = ({
className = "", className = "",
onSelect, onSelect,
style, style,
placement = "bottom",
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
onClickOutside?: () => void; onClickOutside?: () => void;
@ -26,6 +27,7 @@ const MenuContent = ({
*/ */
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
placement?: "top" | "bottom";
}) => { }) => {
const device = useDevice(); const device = useDevice();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -58,6 +60,7 @@ const MenuContent = ({
const classNames = clsx(`dropdown-menu ${className}`, { const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.editor.isMobile, "dropdown-menu--mobile": device.editor.isMobile,
"dropdown-menu--placement-top": placement === "top",
}).trim(); }).trim();
return ( return (

View File

@ -53,6 +53,8 @@ const MainMenu = Object.assign(
onSelect={composeEventHandlers(onSelect, () => { onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
})} })}
placement="bottom"
className={device.editor.isMobile ? "main-menu-dropdown" : ""}
> >
{children} {children}
{device.editor.isMobile && appState.collaborators.size > 0 && ( {device.editor.isMobile && appState.collaborators.size > 0 && (

View File

@ -235,27 +235,28 @@ body.excalidraw-cursor-resize * {
z-index: var(--zIndex-layerUI); z-index: var(--zIndex-layerUI);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
} }
.App-bottom-bar { .App-bottom-bar {
position: absolute; position: absolute;
top: 0; // account for margins
width: calc(100% - 28px);
max-width: 400px;
bottom: 0; bottom: 0;
left: 0; left: 50%;
right: 0; transform: translateX(-50%);
--bar-padding: calc(4 * var(--space-factor)); --bar-padding: calc(4 * var(--space-factor));
z-index: 4; z-index: 4;
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
pointer-events: none; pointer-events: none;
justify-content: center;
> .Island { > .Island {
width: 100%;
max-width: 100%;
min-width: 100%;
box-sizing: border-box; box-sizing: border-box;
max-height: 100%; max-height: 100%;
padding: 4px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
@ -263,7 +264,8 @@ body.excalidraw-cursor-resize * {
} }
.App-toolbar { .App-toolbar {
width: 100%; display: flex;
justify-content: center;
.eraser { .eraser {
&.ToolIcon:hover { &.ToolIcon:hover {
@ -278,14 +280,7 @@ body.excalidraw-cursor-resize * {
.App-toolbar-content { .App-toolbar-content {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between;
padding: 8px;
.dropdown-menu--mobile {
bottom: 55px;
top: auto;
}
} }
.App-mobile-menu { .App-mobile-menu {

View File

@ -43,6 +43,10 @@
--lg-icon-size: 1rem; --lg-icon-size: 1rem;
--editor-container-padding: 1rem; --editor-container-padding: 1rem;
@include isMobile {
--editor-container-padding: 0.75rem;
}
@media screen and (min-device-width: 1921px) { @media screen and (min-device-width: 1921px) {
--lg-button-size: 2.5rem; --lg-button-size: 2.5rem;
--lg-icon-size: 1.25rem; --lg-icon-size: 1.25rem;

View File

@ -6101,7 +6101,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"offsetTop": 0, "offsetTop": 0,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": "elementBackground", "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null, "originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
@ -11961,7 +11961,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"offsetTop": 0, "offsetTop": 0,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": "elementStroke", "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null, "originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
@ -15533,7 +15533,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"offsetTop": 0, "offsetTop": 0,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": "elementBackground", "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null, "originSnapOffset": null,
"pasteDialog": { "pasteDialog": {

View File

@ -7384,7 +7384,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"offsetTop": 0, "offsetTop": 0,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": "elementBackground", "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null, "originSnapOffset": null,
"pasteDialog": { "pasteDialog": {

View File

@ -448,7 +448,7 @@ export interface AppState {
lockedMultiSelections: { [groupId: string]: true }; lockedMultiSelections: { [groupId: string]: true };
/** properties sidebar mode - determines whether to show compact or complete sidebar */ /** properties sidebar mode - determines whether to show compact or complete sidebar */
stylesPanelMode: "compact" | "full"; stylesPanelMode: "compact" | "full" | "mobile";
} }
export type SearchMatch = { export type SearchMatch = {