From ddb5dc313cdf3568b5278b202640e750fca29705 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 19 Sep 2025 13:52:54 +1000 Subject: [PATCH] mobile actions --- packages/excalidraw/components/Actions.scss | 20 +- packages/excalidraw/components/Actions.tsx | 987 +++++++++++------- .../components/FontPicker/FontPicker.tsx | 1 + .../components/FontPicker/FontPickerList.tsx | 12 +- .../FontPicker/FontPickerTrigger.tsx | 4 + 5 files changed, 662 insertions(+), 362 deletions(-) diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index 93b5ef7c3..90f568811 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -110,8 +110,8 @@ --default-button-size: 2rem; .compact-action-button { - width: 2rem; - height: 2rem; + width: 1.625rem; + height: 1.625rem; border: none; border-radius: var(--border-radius-lg); background: transparent; @@ -167,6 +167,11 @@ } } } + + .ToolIcon__icon { + width: 1.625rem; + height: 1.625rem; + } } .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 { --button-border: transparent; --button-bg: var(--color-surface-mid); diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index f43a4925d..e3eaf6aee 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { useState } from "react"; +import { useRef, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { @@ -86,6 +86,7 @@ import type { AppState, } from "../types"; import type { ActionManager } from "../actions/manager"; +import { Island } from "./Island"; // Common CSS class combinations const PROPERTIES_CLASSES = clsx([ @@ -305,6 +306,467 @@ export const SelectedShapeActions = ({ ); }; +const CombinedShapeProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, +}: { + targetElements: ExcalidrawElement[]; + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; +}) => { + const showFillIcons = + (hasBackground(appState.activeTool.type) && + !isTransparent(appState.currentItemBackgroundColor)) || + targetElements.some( + (element) => + hasBackground(element.type) && !isTransparent(element.backgroundColor), + ); + + const showShowCombinedProperties = + showFillIcons || + hasStrokeWidth(appState.activeTool.type) || + targetElements.some((element) => hasStrokeWidth(element.type)) || + hasStrokeStyle(appState.activeTool.type) || + targetElements.some((element) => hasStrokeStyle(element.type)) || + canChangeRoundness(appState.activeTool.type) || + targetElements.some((element) => canChangeRoundness(element.type)); + + if (!showShowCombinedProperties) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactStrokeStyles" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {appState.openPopup === "compactStrokeStyles" && ( + {}} + > +
+ {showFillIcons && renderAction("changeFillStyle")} + {(hasStrokeWidth(appState.activeTool.type) || + targetElements.some((element) => + hasStrokeWidth(element.type), + )) && + renderAction("changeStrokeWidth")} + {(hasStrokeStyle(appState.activeTool.type) || + targetElements.some((element) => + hasStrokeStyle(element.type), + )) && ( + <> + {renderAction("changeStrokeStyle")} + {renderAction("changeSloppiness")} + + )} + {(canChangeRoundness(appState.activeTool.type) || + targetElements.some((element) => + canChangeRoundness(element.type), + )) && + renderAction("changeRoundness")} + {renderAction("changeOpacity")} +
+
+ )} +
+
+ ); +}; + +const CombinedArrowProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, + app, +}: { + targetElements: ExcalidrawElement[]; + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; + app: AppClassProperties; +}) => { + const showShowArrowProperties = + toolIsArrow(appState.activeTool.type) || + targetElements.some((element) => toolIsArrow(element.type)); + + if (!showShowArrowProperties) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactArrowProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {appState.openPopup === "compactArrowProperties" && ( + {}} + > + {renderAction("changeArrowProperties")} + + )} + +
+ ); +}; + +const CombinedTextProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, + elementsMap, +}: { + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + targetElements: ExcalidrawElement[]; + container: HTMLDivElement | null; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; +}) => { + const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(); + + return ( +
+ { + if (open) { + if (appState.editingTextElement) { + saveCaretPosition(); + } + setAppState({ openPopup: "compactTextProperties" }); + } else { + setAppState({ openPopup: null }); + if (appState.editingTextElement) { + restoreCaretPosition(); + } + } + }} + > + + + + {appState.openPopup === "compactTextProperties" && ( + { + // Refocus text editor when popover closes with caret restoration + if (appState.editingTextElement) { + restoreCaretPosition(); + } + }} + > +
+ {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && + renderAction("changeFontSize")} + {(appState.activeTool.type === "text" || + suppportsHorizontalAlign(targetElements, elementsMap)) && + renderAction("changeTextAlign")} + {shouldAllowVerticalAlign(targetElements, elementsMap) && + renderAction("changeVerticalAlign")} +
+
+ )} +
+
+ ); +}; + +const CombinedExtraActions = ({ + appState, + renderAction, + targetElements, + setAppState, + container, + app, +}: { + appState: UIAppState; + targetElements: ExcalidrawElement[]; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["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"; + + if (isEditingTextOrNewElement || targetElements.length === 0) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactOtherProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {appState.openPopup === "compactOtherProperties" && ( + {}} + > +
+
+ {t("labels.layers")} +
+ {renderAction("sendToBack")} + {renderAction("sendBackward")} + {renderAction("bringForward")} + {renderAction("bringToFront")} +
+
+ + {showAlignActions && !isSingleElementBoundContainer && ( +
+ {t("labels.align")} +
+ {isRTL ? ( + <> + {renderAction("alignRight")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignLeft")} + + ) : ( + <> + {renderAction("alignLeft")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignRight")} + + )} + {targetElements.length > 2 && + renderAction("distributeHorizontally")} + {/* breaks the row ˇˇ */} +
+
+ {renderAction("alignTop")} + {renderAction("alignVerticallyCentered")} + {renderAction("alignBottom")} + {targetElements.length > 2 && + renderAction("distributeVertically")} +
+
+
+ )} +
+ {t("labels.actions")} +
+ {renderAction("group")} + {renderAction("ungroup")} + {showLinkIcon && renderAction("hyperlink")} + {showCropEditorAction && renderAction("cropEditor")} +
+
+
+
+ )} +
+
+ ); +}; + +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 ( +
+ {renderAction("toggleLinearEditor")} +
+ ); +}; + export const CompactShapeActions = ({ appState, elementsMap, @@ -319,52 +781,28 @@ export const CompactShapeActions = ({ setAppState: React.Component["setState"]; }) => { const targetElements = getTargetElements(elementsMap, appState); - const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(); const { container } = useExcalidrawContainer(); const isEditingTextOrNewElement = Boolean( appState.editingTextElement || appState.newElement, ); - const showFillIcons = - (hasBackground(appState.activeTool.type) && - !isTransparent(appState.currentItemBackgroundColor)) || - targetElements.some( - (element) => - hasBackground(element.type) && !isTransparent(element.backgroundColor), - ); - - const showLinkIcon = targetElements.length === 1; - 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 (
{/* Stroke Color */} {canChangeStrokeColor(appState, targetElements) && ( -
+
{renderAction("changeStrokeColor")}
)} @@ -376,156 +814,22 @@ export const CompactShapeActions = ({
)} - {/* Combined Properties (Fill, Stroke, Opacity) */} - {(showFillIcons || - hasStrokeWidth(appState.activeTool.type) || - targetElements.some((element) => hasStrokeWidth(element.type)) || - hasStrokeStyle(appState.activeTool.type) || - targetElements.some((element) => hasStrokeStyle(element.type)) || - canChangeRoundness(appState.activeTool.type) || - targetElements.some((element) => canChangeRoundness(element.type))) && ( -
- { - if (open) { - setAppState({ openPopup: "compactStrokeStyles" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactStrokeStyles" && ( - {}} - > -
- {showFillIcons && renderAction("changeFillStyle")} - {(hasStrokeWidth(appState.activeTool.type) || - targetElements.some((element) => - hasStrokeWidth(element.type), - )) && - renderAction("changeStrokeWidth")} - {(hasStrokeStyle(appState.activeTool.type) || - targetElements.some((element) => - hasStrokeStyle(element.type), - )) && ( - <> - {renderAction("changeStrokeStyle")} - {renderAction("changeSloppiness")} - - )} - {(canChangeRoundness(appState.activeTool.type) || - targetElements.some((element) => - canChangeRoundness(element.type), - )) && - renderAction("changeRoundness")} - {renderAction("changeOpacity")} -
-
- )} -
-
- )} - - {/* Combined Arrow Properties */} - {(toolIsArrow(appState.activeTool.type) || - targetElements.some((element) => toolIsArrow(element.type))) && ( -
- { - if (open) { - setAppState({ openPopup: "compactArrowProperties" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactArrowProperties" && ( - {}} - > - {renderAction("changeArrowProperties")} - - )} - -
- )} + + {/* Linear Editor */} {showLineEditorAction && (
@@ -540,73 +844,14 @@ export const CompactShapeActions = ({
{renderAction("changeFontFamily")}
-
- { - if (open) { - if (appState.editingTextElement) { - saveCaretPosition(); - } - setAppState({ openPopup: "compactTextProperties" }); - } else { - setAppState({ openPopup: null }); - if (appState.editingTextElement) { - restoreCaretPosition(); - } - } - }} - > - - - - {appState.openPopup === "compactTextProperties" && ( - { - // Refocus text editor when popover closes with caret restoration - if (appState.editingTextElement) { - restoreCaretPosition(); - } - }} - > -
- {(appState.activeTool.type === "text" || - targetElements.some(isTextElement)) && - renderAction("changeFontSize")} - {(appState.activeTool.type === "text" || - suppportsHorizontalAlign(targetElements, elementsMap)) && - renderAction("changeTextAlign")} - {shouldAllowVerticalAlign(targetElements, elementsMap) && - renderAction("changeVerticalAlign")} -
-
- )} -
-
+ )} @@ -624,118 +869,150 @@ export const CompactShapeActions = ({
)} - {/* Combined Other Actions */} - {!isEditingTextOrNewElement && targetElements.length > 0 && ( -
- { - if (open) { - setAppState({ openPopup: "compactOtherProperties" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactOtherProperties" && ( - {}} - > -
-
- {t("labels.layers")} -
- {renderAction("sendToBack")} - {renderAction("sendBackward")} - {renderAction("bringForward")} - {renderAction("bringToFront")} -
-
- - {showAlignActions && !isSingleElementBoundContainer && ( -
- {t("labels.align")} -
- {isRTL ? ( - <> - {renderAction("alignRight")} - {renderAction("alignHorizontallyCentered")} - {renderAction("alignLeft")} - - ) : ( - <> - {renderAction("alignLeft")} - {renderAction("alignHorizontallyCentered")} - {renderAction("alignRight")} - - )} - {targetElements.length > 2 && - renderAction("distributeHorizontally")} - {/* breaks the row ˇˇ */} -
-
- {renderAction("alignTop")} - {renderAction("alignVerticallyCentered")} - {renderAction("alignBottom")} - {targetElements.length > 2 && - renderAction("distributeVertically")} -
-
-
- )} -
- {t("labels.actions")} -
- {renderAction("group")} - {renderAction("ungroup")} - {showLinkIcon && renderAction("hyperlink")} - {showCropEditorAction && renderAction("cropEditor")} -
-
-
-
- )} -
-
- )} +
); }; +export const MobileShapeActions = ({ + appState, + elementsMap, + renderAction, + app, + setAppState, +}: { + appState: UIAppState; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; + renderAction: ActionManager["renderAction"]; + app: AppClassProperties; + setAppState: React.Component["setState"]; +}) => { + const targetElements = getTargetElements(elementsMap, appState); + const { container } = useExcalidrawContainer(); + const mobileActionsRef = useRef(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 ( + +
+ {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor")} +
+ )} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor")} +
+ )} + + {/* Combined Arrow Properties */} + + {/* Linear Editor */} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {renderAction("changeFontFamily")} +
+ + + )} + + {showDuplicate && renderAction("duplicateSelection")} + {showDelete && renderAction("deleteSelectedElements")} + + {/* Combined Other Actions */} + +
+
+ {renderAction("undo")} + {showRedo && renderAction("redo")} +
+
+ ); +}; + export const ShapesSwitcher = ({ activeTool, appState, diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index 891ae49ef..c52286a17 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -106,6 +106,7 @@ export const FontPicker = React.memo( {isOpened && ( - + {app.state.stylesPanelMode === "full" && ( + + )} { const setAppState = useExcalidrawSetAppState(); @@ -37,6 +39,8 @@ export const FontPickerTrigger = ({ }} style={{ border: "none", + width: compactMode ? "1.625rem" : undefined, + height: compactMode ? "1.625rem" : undefined, }} />