Compare commits

..

1 Commits

Author SHA1 Message Date
Márk Tolmács
f55ecb96cc
fix: Mobile arrow point drag broken (#9998)
* fix: Mobile bound arrow point drag broken

* fix:Check real point
2025-09-19 19:41:03 +02:00
25 changed files with 549 additions and 1529 deletions

View File

@ -10,13 +10,7 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type === "rectangle" || type !== "image" && type !== "frame" && type !== "magicframe";
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,10 +348,7 @@ export const actionChangeStrokeColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={ compactMode={appState.stylesPanelMode === "compact"}
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@ -431,10 +428,7 @@ export const actionChangeBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={ compactMode={appState.stylesPanelMode === "compact"}
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@ -537,7 +531,9 @@ 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"
@ -594,7 +590,9 @@ 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"
@ -647,7 +645,9 @@ 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,8 +776,7 @@ 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,
); );
@ -1041,7 +1040,7 @@ export const actionChangeFontFamily = register({
return result; return result;
}, },
PanelComponent: ({ elements, appState, app, updateData }) => { PanelComponent: ({ elements, appState, app, updateData, data }) => {
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
@ -1118,7 +1117,7 @@ export const actionChangeFontFamily = register({
}, []); }, []);
return ( return (
<> <fieldset>
{appState.stylesPanelMode === "full" && ( {appState.stylesPanelMode === "full" && (
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
)} )}
@ -1126,7 +1125,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 !== "full"} compactMode={appState.stylesPanelMode === "compact"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => { () => {
@ -1138,8 +1137,7 @@ 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,
); );
}} }}
@ -1215,8 +1213,7 @@ 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
@ -1224,7 +1221,7 @@ export const actionChangeFontFamily = register({
} }
}} }}
/> />
</> </fieldset>
); );
}, },
}); });
@ -1317,8 +1314,7 @@ 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,
); );
@ -1417,8 +1413,7 @@ 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,
); );
@ -1683,8 +1678,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("changeArrowhead")}
{renderAction("changeArrowType")} {renderAction("changeArrowType")}
{renderAction("changeArrowhead")}
</div> </div>
); );
}, },

View File

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

View File

@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useState } from "react"; import { useState } from "react";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { import {
@ -78,8 +78,6 @@ import {
DotsHorizontalIcon, DotsHorizontalIcon,
} from "./icons"; } from "./icons";
import { Island } from "./Island";
import type { import type {
AppClassProperties, AppClassProperties,
AppProps, AppProps,
@ -307,19 +305,27 @@ export const SelectedShapeActions = ({
); );
}; };
const CombinedShapeProperties = ({ export const CompactShapeActions = ({
appState, appState,
elementsMap,
renderAction, renderAction,
app,
setAppState, setAppState,
targetElements,
container,
}: { }: {
targetElements: ExcalidrawElement[];
appState: UIAppState; appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
container: HTMLDivElement | null;
}) => { }) => {
const targetElements = getTargetElements(elementsMap, appState);
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const { container } = useExcalidrawContainer();
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const showFillIcons = const showFillIcons =
(hasBackground(appState.activeTool.type) && (hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) || !isTransparent(appState.currentItemBackgroundColor)) ||
@ -328,20 +334,56 @@ const CombinedShapeProperties = ({
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
); );
const showShowCombinedProperties = const showLinkIcon = targetElements.length === 1;
showFillIcons ||
const showLineEditorAction =
!appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
const showAlignActions = alignActionsPredicate(appState, app);
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
isSingleElementBoundContainer = true;
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
return (
<div className="compact-shape-actions">
{/* Stroke Color */}
{canChangeStrokeColor(appState, targetElements) && (
<div className={clsx("compact-action-item")}>
{renderAction("changeStrokeColor")}
</div>
)}
{/* Background Color */}
{canChangeBackgroundColor(appState, targetElements) && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
</div>
)}
{/* Combined Properties (Fill, Stroke, Opacity) */}
{(showFillIcons ||
hasStrokeWidth(appState.activeTool.type) || hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeWidth(element.type)) || targetElements.some((element) => hasStrokeWidth(element.type)) ||
hasStrokeStyle(appState.activeTool.type) || hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type)) || targetElements.some((element) => hasStrokeStyle(element.type)) ||
canChangeRoundness(appState.activeTool.type) || canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) => canChangeRoundness(element.type)); targetElements.some((element) => canChangeRoundness(element.type))) && (
if (!showShowCombinedProperties) {
return null;
}
return (
<div className="compact-action-item"> <div className="compact-action-item">
<Popover.Root <Popover.Root
open={appState.openPopup === "compactStrokeStyles"} open={appState.openPopup === "compactStrokeStyles"}
@ -407,33 +449,11 @@ const CombinedShapeProperties = ({
)} )}
</Popover.Root> </Popover.Root>
</div> </div>
); )}
};
const CombinedArrowProperties = ({ {/* Combined Arrow Properties */}
appState, {(toolIsArrow(appState.activeTool.type) ||
renderAction, targetElements.some((element) => toolIsArrow(element.type))) && (
setAppState,
targetElements,
container,
app,
}: {
targetElements: ExcalidrawElement[];
appState: UIAppState;
renderAction: ActionManager["renderAction"];
setAppState: React.Component<any, AppState>["setState"];
container: HTMLDivElement | null;
app: AppClassProperties;
}) => {
const showShowArrowProperties =
toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type));
if (!showShowArrowProperties) {
return null;
}
return (
<div className="compact-action-item"> <div className="compact-action-item">
<Popover.Root <Popover.Root
open={appState.openPopup === "compactArrowProperties"} open={appState.openPopup === "compactArrowProperties"}
@ -504,27 +524,22 @@ const CombinedArrowProperties = ({
)} )}
</Popover.Root> </Popover.Root>
</div> </div>
); )}
};
const CombinedTextProperties = ({ {/* Linear Editor */}
appState, {showLineEditorAction && (
renderAction, <div className="compact-action-item">
setAppState, {renderAction("toggleLinearEditor")}
targetElements, </div>
container, )}
elementsMap,
}: {
appState: UIAppState;
renderAction: ActionManager["renderAction"];
setAppState: React.Component<any, AppState>["setState"];
targetElements: ExcalidrawElement[];
container: HTMLDivElement | null;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
}) => {
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
return ( {/* Text Properties */}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<div className="compact-action-item"> <div className="compact-action-item">
<Popover.Root <Popover.Root
open={appState.openPopup === "compactTextProperties"} open={appState.openPopup === "compactTextProperties"}
@ -592,49 +607,25 @@ const CombinedTextProperties = ({
)} )}
</Popover.Root> </Popover.Root>
</div> </div>
); </>
}; )}
const CombinedExtraActions = ({ {/* Dedicated Copy Button */}
appState, {!isEditingTextOrNewElement && targetElements.length > 0 && (
renderAction, <div className="compact-action-item">
targetElements, {renderAction("duplicateSelection")}
setAppState, </div>
container, )}
app,
}: {
appState: UIAppState;
targetElements: ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
setAppState: React.Component<any, AppState>["setState"];
container: HTMLDivElement | null;
app: AppClassProperties;
}) => {
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
const showLinkIcon = targetElements.length === 1;
const showAlignActions = alignActionsPredicate(appState, app);
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
isSingleElementBoundContainer = true;
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; {/* Dedicated Delete Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
if (isEditingTextOrNewElement || targetElements.length === 0) { {/* Combined Other Actions */}
return null; {!isEditingTextOrNewElement && targetElements.length > 0 && (
}
return (
<div className="compact-action-item"> <div className="compact-action-item">
<Popover.Root <Popover.Root
open={appState.openPopup === "compactOtherProperties"} open={appState.openPopup === "compactOtherProperties"}
@ -671,6 +662,7 @@ const CombinedExtraActions = ({
container={container} container={container}
style={{ style={{
maxWidth: "12rem", maxWidth: "12rem",
// center the popover content
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}} }}
@ -739,278 +731,8 @@ const CombinedExtraActions = ({
)} )}
</Popover.Root> </Popover.Root>
</div> </div>
);
};
const LinearEditorAction = ({
appState,
renderAction,
targetElements,
}: {
appState: UIAppState;
targetElements: ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
}) => {
const showLineEditorAction =
!appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
if (!showLineEditorAction) {
return null;
}
return (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
);
};
export const CompactShapeActions = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const { container } = useExcalidrawContainer();
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const showLineEditorAction =
!appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
return (
<div className="compact-shape-actions">
{/* Stroke Color */}
{canChangeStrokeColor(appState, targetElements) && (
<div
className={clsx("compact-action-item")}
style={{
marginRight: 4,
}}
>
{renderAction("changeStrokeColor")}
</div>
)} )}
{/* Background Color */}
{canChangeBackgroundColor(appState, targetElements) && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
</div> </div>
)}
<CombinedShapeProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
targetElements={targetElements}
container={container}
/>
<CombinedArrowProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
targetElements={targetElements}
container={container}
app={app}
/>
{/* Linear Editor */}
{showLineEditorAction && (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
)}
{/* Text Properties */}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<CombinedTextProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
targetElements={targetElements}
container={container}
elementsMap={elementsMap}
/>
</>
)}
{/* Dedicated Copy Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("duplicateSelection")}
</div>
)}
{/* Dedicated Delete Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
<CombinedExtraActions
appState={appState}
renderAction={renderAction}
targetElements={targetElements}
setAppState={setAppState}
container={container}
app={app}
/>
</div>
);
};
export const MobileShapeActions = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const { container } = useExcalidrawContainer();
const mobileActionsRef = useRef<HTMLDivElement>(null);
const width = mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0;
const WIDTH = 26;
const GAP = 8;
// max 6 actions + undo
const MIN_WIDTH = 7 * WIDTH + 6 * GAP;
const ADDITIONAL_WIDTH = WIDTH + GAP;
const showDelete = width >= MIN_WIDTH;
const showDuplicate = width >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
const showRedo = width >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH;
return (
<Island
className="compact-shape-actions mobile-shape-actions"
style={{
flexDirection: "row",
boxShadow: "none",
backgroundColor: "transparent",
padding: 0,
margin: "0 0.25rem",
zIndex: 2,
height: WIDTH * 1.75,
alignItems: "center",
gap: GAP,
}}
ref={mobileActionsRef}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: GAP,
flex: 1,
}}
>
{canChangeStrokeColor(appState, targetElements) && (
<div className={clsx("compact-action-item")}>
{renderAction("changeStrokeColor")}
</div>
)}
{canChangeBackgroundColor(appState, targetElements) && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
</div>
)}
<CombinedShapeProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
targetElements={targetElements}
container={container}
/>
{/* Combined Arrow Properties */}
<CombinedArrowProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
targetElements={targetElements}
container={container}
app={app}
/>
{/* Linear Editor */}
<LinearEditorAction
appState={appState}
renderAction={renderAction}
targetElements={targetElements}
/>
{/* Text Properties */}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<CombinedTextProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
targetElements={targetElements}
container={container}
elementsMap={elementsMap}
/>
</>
)}
{showDuplicate && renderAction("duplicateSelection")}
{showDelete && renderAction("deleteSelectedElements")}
{/* Combined Other Actions */}
<CombinedExtraActions
appState={appState}
renderAction={renderAction}
targetElements={targetElements}
setAppState={setAppState}
container={container}
app={app}
/>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: GAP,
}}
>
{renderAction("undo")}
{showRedo && renderAction("redo")}
</div>
</Island>
); );
}; };

View File

@ -2484,8 +2484,6 @@ 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",
}); });
@ -6487,10 +6485,6 @@ 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
@ -7254,6 +7248,16 @@ class App extends React.Component<AppProps, AppState> {
selectedElements.length === 1 && selectedElements.length === 1 &&
!this.state.selectedLinearElement?.isEditing && !this.state.selectedLinearElement?.isEditing &&
!isElbowArrow(selectedElements[0]) && !isElbowArrow(selectedElements[0]) &&
!(
isLineElement(selectedElements[0]) &&
LinearElementEditor.getPointIndexUnderCursor(
selectedElements[0],
elementsMap,
this.state.zoom,
pointerDownState.origin.x,
pointerDownState.origin.y,
) !== -1
) &&
!( !(
this.state.selectedLinearElement && this.state.selectedLinearElement &&
this.state.selectedLinearElement.hoverPointIndex !== -1 this.state.selectedLinearElement.hoverPointIndex !== -1

View File

@ -1,8 +1,5 @@
.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;
@ -33,17 +30,6 @@
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" | "mobile"; type LogoSize = "xs" | "small" | "normal" | "large" | "custom";
interface LogoProps { interface LogoProps {
size?: LogoSize; size?: LogoSize;

View File

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

View File

@ -338,13 +338,11 @@ export const FontPickerList = React.memo(
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement} preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
{app.state.stylesPanelMode === "full" && (
<QuickSearch <QuickSearch
ref={inputRef} ref={inputRef}
placeholder={t("quickSearch.placeholder")} placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)} 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,13 +11,11 @@ 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();
@ -39,8 +37,6 @@ export const FontPickerTrigger = ({
}} }}
style={{ style={{
border: "none", border: "none",
width: compactMode ? "1.625rem" : undefined,
height: compactMode ? "1.625rem" : undefined,
}} }}
/> />
</div> </div>

View File

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

View File

@ -582,10 +582,13 @@ 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,23 +1,32 @@
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 { MobileShapeActions } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } 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";
@ -29,6 +38,7 @@ 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"];
@ -36,7 +46,9 @@ 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;
@ -47,10 +59,14 @@ 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,
@ -62,15 +78,64 @@ export const MobileMenu = ({
} = useTunnels(); } = useTunnels();
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<MobileToolBar <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} appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app} app={app}
onHandToolToggle={onHandToolToggle}
/> />
</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 renderAppToolbar = () => {
if ( if (
appState.viewModeEnabled || appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector" appState.openDialog?.name === "elementLinkSelector"
@ -82,28 +147,18 @@ export const MobileMenu = ({
); );
} }
const topRightUI = renderTopRightUI?.(true, appState);
return ( return (
<div <div className="App-toolbar-content">
className="App-toolbar-content"
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 16,
}}
>
<MainMenuTunnel.Out /> <MainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
<div>
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
</div> </div>
{topRightUI ? topRightUI : <DefaultSidebarTriggerTunnel.Out />}
</div> </div>
); );
}; };
@ -111,28 +166,33 @@ export const MobileMenu = ({
return ( return (
<> <>
{renderSidebars()} {renderSidebars()}
<FixedSideContainer side="top" className="App-top-bar"> {!appState.viewModeEnabled &&
{renderAppTopBar()} appState.openDialog?.name !== "elementLinkSelector" &&
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />} renderToolbar()}
</FixedSideContainer>
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN, marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}} }}
> >
<MobileShapeActions <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} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app} app={app}
setAppState={setAppState}
/> />
</Section>
<Island className="App-toolbar"> ) : null}
{!appState.viewModeEnabled && <footer className="App-toolbar">
appState.openDialog?.name !== "elementLinkSelector" && {renderAppToolbar()}
renderToolbar()}
{appState.scrolledOutside && {appState.scrolledOutside &&
!appState.openMenu && !appState.openMenu &&
!appState.openSidebar && ( !appState.openSidebar && (
@ -148,6 +208,7 @@ export const MobileMenu = ({
{t("buttons.scrollBackToContent")} {t("buttons.scrollBackToContent")}
</button> </button>
)} )}
</footer>
</Island> </Island>
</div> </div>
</> </>

View File

@ -1,74 +0,0 @@
@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

@ -1,422 +0,0 @@
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

@ -1,194 +0,0 @@
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,30 +6,11 @@
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,27 +17,16 @@ 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 && MenuContentCompWithPlacement} {open && MenuContentComp}
</> </>
); );
}; };

View File

@ -17,7 +17,6 @@ const MenuContent = ({
className = "", className = "",
onSelect, onSelect,
style, style,
placement = "bottom",
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
onClickOutside?: () => void; onClickOutside?: () => void;
@ -27,7 +26,6 @@ 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);
@ -60,7 +58,6 @@ 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,8 +53,6 @@ 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,28 +235,27 @@ 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;
// account for margins top: 0;
width: calc(100% - 28px);
max-width: 400px;
bottom: 0; bottom: 0;
left: 50%; left: 0;
transform: translateX(-50%); right: 0;
--bar-padding: calc(4 * var(--space-factor)); --bar-padding: calc(4 * var(--space-factor));
z-index: 4; z-index: 4;
display: flex; display: flex;
flex-direction: column; align-items: flex-end;
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);
@ -264,8 +263,7 @@ body.excalidraw-cursor-resize * {
} }
.App-toolbar { .App-toolbar {
display: flex; width: 100%;
justify-content: center;
.eraser { .eraser {
&.ToolIcon:hover { &.ToolIcon:hover {
@ -280,7 +278,14 @@ body.excalidraw-cursor-resize * {
.App-toolbar-content { .App-toolbar-content {
display: flex; display: flex;
flex-direction: column; align-items: center;
justify-content: space-between;
padding: 8px;
.dropdown-menu--mobile {
bottom: 55px;
top: auto;
}
} }
.App-mobile-menu { .App-mobile-menu {

View File

@ -43,10 +43,6 @@
--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": null, "openPopup": "elementBackground",
"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": null, "openPopup": "elementStroke",
"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": null, "openPopup": "elementBackground",
"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": null, "openPopup": "elementBackground",
"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" | "mobile"; stylesPanelMode: "compact" | "full";
} }
export type SearchMatch = { export type SearchMatch = {