mobile actions

This commit is contained in:
Ryan Di
2025-09-19 13:52:54 +10:00
parent e064bc236f
commit ddb5dc313c
5 changed files with 662 additions and 362 deletions

View File

@@ -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);

View File

@@ -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,27 +306,19 @@ export const SelectedShapeActions = ({
);
};
export const CompactShapeActions = ({
const CombinedShapeProperties = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
targetElements,
container,
}: {
targetElements: ExcalidrawElement[];
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
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 =
(hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
@@ -334,56 +327,20 @@ export const CompactShapeActions = ({
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 (
<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 ||
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))) && (
targetElements.some((element) => canChangeRoundness(element.type));
if (!showShowCombinedProperties) {
return null;
}
return (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactStrokeStyles"}
@@ -449,11 +406,33 @@ export const CompactShapeActions = ({
)}
</Popover.Root>
</div>
)}
);
};
{/* Combined Arrow Properties */}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
const CombinedArrowProperties = ({
appState,
renderAction,
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">
<Popover.Root
open={appState.openPopup === "compactArrowProperties"}
@@ -524,22 +503,27 @@ export const CompactShapeActions = ({
)}
</Popover.Root>
</div>
)}
);
};
{/* Linear Editor */}
{showLineEditorAction && (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
)}
const CombinedTextProperties = ({
appState,
renderAction,
setAppState,
targetElements,
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();
{/* Text Properties */}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
return (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactTextProperties"}
@@ -607,25 +591,49 @@ export const CompactShapeActions = ({
)}
</Popover.Root>
</div>
</>
)}
);
};
{/* Dedicated Copy Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("duplicateSelection")}
</div>
)}
const CombinedExtraActions = ({
appState,
renderAction,
targetElements,
setAppState,
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;
}
{/* Dedicated Delete Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
{/* Combined Other Actions */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
if (isEditingTextOrNewElement || targetElements.length === 0) {
return null;
}
return (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactOtherProperties"}
@@ -662,7 +670,6 @@ export const CompactShapeActions = ({
container={container}
style={{
maxWidth: "12rem",
// center the popover content
justifyContent: "center",
alignItems: "center",
}}
@@ -731,11 +738,281 @@ export const CompactShapeActions = ({
)}
</Popover.Root>
</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>
)}
<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>
);
};
export const ShapesSwitcher = ({
activeTool,
appState,

View File

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

View File

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

View File

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