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

View File

@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useState } from "react"; import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { import {
@@ -86,6 +86,7 @@ import type {
AppState, AppState,
} from "../types"; } from "../types";
import type { ActionManager } from "../actions/manager"; import type { ActionManager } from "../actions/manager";
import { Island } from "./Island";
// Common CSS class combinations // Common CSS class combinations
const PROPERTIES_CLASSES = clsx([ const PROPERTIES_CLASSES = clsx([
@@ -305,27 +306,19 @@ export const SelectedShapeActions = ({
); );
}; };
export const CompactShapeActions = ({ const CombinedShapeProperties = ({
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)) ||
@@ -334,56 +327,20 @@ export const CompactShapeActions = ({
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
); );
const showLinkIcon = targetElements.length === 1; const showShowCombinedProperties =
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"}
@@ -449,11 +406,33 @@ export const CompactShapeActions = ({
)} )}
</Popover.Root> </Popover.Root>
</div> </div>
)} );
};
{/* Combined Arrow Properties */} const CombinedArrowProperties = ({
{(toolIsArrow(appState.activeTool.type) || appState,
targetElements.some((element) => toolIsArrow(element.type))) && ( 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"> <div className="compact-action-item">
<Popover.Root <Popover.Root
open={appState.openPopup === "compactArrowProperties"} open={appState.openPopup === "compactArrowProperties"}
@@ -524,22 +503,27 @@ export const CompactShapeActions = ({
)} )}
</Popover.Root> </Popover.Root>
</div> </div>
)} );
};
{/* Linear Editor */} const CombinedTextProperties = ({
{showLineEditorAction && ( appState,
<div className="compact-action-item"> renderAction,
{renderAction("toggleLinearEditor")} setAppState,
</div> 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 */} return (
{(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"}
@@ -607,25 +591,49 @@ export const CompactShapeActions = ({
)} )}
</Popover.Root> </Popover.Root>
</div> </div>
</> );
)} };
{/* Dedicated Copy Button */} const CombinedExtraActions = ({
{!isEditingTextOrNewElement && targetElements.length > 0 && ( appState,
<div className="compact-action-item"> renderAction,
{renderAction("duplicateSelection")} targetElements,
</div> 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 */} const isRTL = document.documentElement.getAttribute("dir") === "rtl";
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
{/* Combined Other Actions */} if (isEditingTextOrNewElement || targetElements.length === 0) {
{!isEditingTextOrNewElement && targetElements.length > 0 && ( return null;
}
return (
<div className="compact-action-item"> <div className="compact-action-item">
<Popover.Root <Popover.Root
open={appState.openPopup === "compactOtherProperties"} open={appState.openPopup === "compactOtherProperties"}
@@ -662,7 +670,6 @@ export const CompactShapeActions = ({
container={container} container={container}
style={{ style={{
maxWidth: "12rem", maxWidth: "12rem",
// center the popover content
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}} }}
@@ -731,11 +738,281 @@ export const CompactShapeActions = ({
)} )}
</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> </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 = ({ export const ShapesSwitcher = ({
activeTool, activeTool,
appState, appState,

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}
> >
{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,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>