import clsx from "clsx"; import { useRef } from "react"; import { Popover } from "radix-ui"; import { CLASSES } from "@excalidraw/common"; import { isArrowElement } from "@excalidraw/element"; import type { ExcalidrawElement, NonDeletedElementsMap, NonDeletedSceneElementsMap, } from "@excalidraw/element/types"; import { actionToggleZenMode } from "../actions"; import { t } from "../i18n"; import { getTargetElements } from "../scene"; import { getFormValue } from "../actions/actionProperties"; import { useTextEditorFocus } from "../hooks/useTextEditorFocus"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import "./Actions.scss"; import { useExcalidrawContainer } from "./App"; import Stack from "./Stack"; import { Tooltip } from "./Tooltip"; import { PropertiesPopover } from "./PropertiesPopover"; import { sharpArrowIcon, roundArrowIcon, elbowArrowIcon, TextSizeIcon, adjustmentsIcon, DotsHorizontalIcon, pencilIcon, } from "./icons"; import { Island } from "./Island"; import { getShapeActionPredicates } from "./shapeActionPredicates"; import type { ShapeActionPredicates } from "./shapeActionPredicates"; import type { AppClassProperties, UIAppState, AppState } from "../types"; import type { ActionManager } from "../actions/manager"; // re-exported for consumers outside the styles panel (e.g. CommandPalette) export { canChangeStrokeColor, canChangeBackgroundColor, } from "./shapeActionPredicates"; // Common CSS class combinations const PROPERTIES_CLASSES = clsx([ CLASSES.SHAPE_ACTIONS_THEME_SCOPE, "properties-content", ]); /** * The "arrange" (z-order) fieldset, identical across every styles-panel layout. */ const LayersFieldset = ({ renderAction, }: { renderAction: ActionManager["renderAction"]; }) => (
{t("labels.layers")}
{renderAction("sendToBack")} {renderAction("sendBackward")} {renderAction("bringForward")} {renderAction("bringToFront")}
); /** * The align + distribute fieldset, identical across every styles-panel layout. * Button order is mirrored for RTL so the leftmost button always aligns left. */ const AlignFieldset = ({ renderAction, showDistribute, }: { renderAction: ActionManager["renderAction"]; showDistribute: boolean; }) => { const isRTL = document.documentElement.getAttribute("dir") === "rtl"; return (
{t("labels.align")}
{isRTL ? ( <> {renderAction("alignRight")} {renderAction("alignHorizontallyCentered")} {renderAction("alignLeft")} ) : ( <> {renderAction("alignLeft")} {renderAction("alignHorizontallyCentered")} {renderAction("alignRight")} )} {showDistribute && renderAction("distributeHorizontally")} {/* breaks the row ˇˇ */}
{renderAction("alignTop")} {renderAction("alignVerticallyCentered")} {renderAction("alignBottom")} {showDistribute && renderAction("distributeVertically")}
); }; /** * Full styles panel: the wide, always-expanded layout used on desktop when the * UI is in "full" mode. */ export const SelectedShapeActions = ({ appState, elementsMap, renderAction, app, }: { appState: UIAppState; elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; renderAction: ActionManager["renderAction"]; app: AppClassProperties; }) => { const targetElements = getTargetElements(elementsMap, appState); const predicates = getShapeActionPredicates( appState, targetElements, elementsMap, app, ); // the bucket fill tool configures only the fill it creates: color, fill // style, and opacity (shared `currentItem*` values; no stroke properties) if (appState.activeTool.type === "bucketfill") { return (
{renderAction("changeBucketFillBackgroundColor")}
{renderAction("changeFillStyle")} {renderAction("changeOpacity")}
); } return (
{predicates.strokeColor && renderAction("changeStrokeColor")}
{predicates.backgroundColor && (
{renderAction("changeBackgroundColor")}
)} {predicates.fill && renderAction("changeFillStyle")} {predicates.strokeWidth && renderAction("changeStrokeWidth")} {predicates.strokeStyle && <>{renderAction("changeStrokeStyle")}} {predicates.freedrawMode && renderAction("changeFreedrawMode")} {predicates.sloppiness && <>{renderAction("changeSloppiness")}} {predicates.roundness && <>{renderAction("changeRoundness")}} {predicates.arrowType && <>{renderAction("changeArrowType")}} {predicates.text && ( <>
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")} {predicates.textAlign && renderAction("changeTextAlign")} )} {predicates.verticalAlign && renderAction("changeVerticalAlign")} {predicates.arrowheads && <>{renderAction("changeArrowhead")}} {predicates.opacity && renderAction("changeOpacity")} {predicates.layers && } {predicates.align && ( )} {predicates.showExtraActions && (
{t("labels.actions")}
{renderAction("duplicateSelection")} {renderAction("deleteSelectedElements")} {renderAction("group")} {renderAction("ungroup")} {predicates.link && renderAction("hyperlink")} {predicates.cropEditor && renderAction("cropEditor")} {predicates.lineEditor && renderAction("toggleLinearEditor")}
)}
); }; const CombinedShapeProperties = ({ appState, renderAction, setAppState, predicates, container, }: { appState: UIAppState; renderAction: ActionManager["renderAction"]; setAppState: React.Component["setState"]; predicates: ShapeActionPredicates; container: HTMLDivElement | null; }) => { const shouldShowCombinedProperties = predicates.hasSelection || (appState.activeTool.type !== "selection" && appState.activeTool.type !== "eraser" && appState.activeTool.type !== "hand" && appState.activeTool.type !== "laser" && appState.activeTool.type !== "lasso"); const isOpen = appState.openPopup === "compactStrokeStyles"; if (!shouldShowCombinedProperties) { return null; } return (
{ if (open) { setAppState({ openPopup: "compactStrokeStyles" }); } else { setAppState({ openPopup: null }); } }} > {isOpen && ( {}} >
{predicates.fill && renderAction("changeFillStyle")} {predicates.strokeWidth && renderAction("changeStrokeWidth")} { /* in compact UI the freedraw pressure setting is rendered as a standalone cycle button in the compact actions list; we render it in the combined properties popup as well for clarity */ predicates.freedrawMode && renderAction("changeFreedrawMode") } {predicates.strokeStyle && ( <>{renderAction("changeStrokeStyle")} )} {predicates.sloppiness && <>{renderAction("changeSloppiness")}} {predicates.roundness && renderAction("changeRoundness")} {predicates.opacity && renderAction("changeOpacity")}
)}
); }; const CombinedArrowProperties = ({ appState, renderAction, setAppState, targetElements, predicates, container, app, }: { appState: UIAppState; renderAction: ActionManager["renderAction"]; setAppState: React.Component["setState"]; targetElements: ExcalidrawElement[]; predicates: ShapeActionPredicates; container: HTMLDivElement | null; app: AppClassProperties; }) => { if (!predicates.arrowType) { return null; } const isOpen = appState.openPopup === "compactArrowProperties"; return (
{ if (open) { setAppState({ openPopup: "compactArrowProperties" }); } else { setAppState({ openPopup: null }); } }} > {isOpen && ( {}} > {renderAction("changeArrowProperties")} )}
); }; const CombinedTextProperties = ({ appState, renderAction, setAppState, predicates, container, }: { appState: UIAppState; renderAction: ActionManager["renderAction"]; setAppState: React.Component["setState"]; predicates: ShapeActionPredicates; container: HTMLDivElement | null; }) => { const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus( container?.ownerDocument, ); const isOpen = appState.openPopup === "compactTextProperties"; 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(); } }} >
{predicates.text && renderAction("changeFontSize")} {predicates.textAlign && renderAction("changeTextAlign")} {predicates.verticalAlign && renderAction("changeVerticalAlign")}
)}
); }; const CombinedExtraActions = ({ appState, renderAction, predicates, setAppState, container, showDuplicate, showDelete, }: { appState: UIAppState; renderAction: ActionManager["renderAction"]; predicates: ShapeActionPredicates; setAppState: React.Component["setState"]; container: HTMLDivElement | null; showDuplicate?: boolean; showDelete?: boolean; }) => { const isOpen = appState.openPopup === "compactOtherProperties"; if (!predicates.showExtraActions) { return null; } return (
{ if (open) { setAppState({ openPopup: "compactOtherProperties" }); } else { setAppState({ openPopup: null }); } }} > {isOpen && ( {}} >
{predicates.layers && ( )} {predicates.align && ( )}
{t("labels.actions")}
{renderAction("group")} {renderAction("ungroup")} {predicates.linkSingleOnly && renderAction("hyperlink")} {predicates.cropEditor && renderAction("cropEditor")} {showDuplicate && renderAction("duplicateSelection")} {showDelete && renderAction("deleteSelectedElements")}
)}
); }; const LinearEditorAction = ({ renderAction, predicates, }: { renderAction: ActionManager["renderAction"]; predicates: ShapeActionPredicates; }) => { if (!predicates.lineEditor) { return null; } return (
{renderAction("toggleLinearEditor")}
); }; /** * Compact styles panel — the collapsed, popover-driven layout used on tablets * and on desktop when the UI is in "compact" mode. */ export const CompactShapeActions = ({ 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 predicates = getShapeActionPredicates( appState, targetElements, elementsMap, app, ); const { container } = useExcalidrawContainer(); return (
{/* Stroke Color */} {predicates.strokeColor && (
{renderAction("changeStrokeColor")}
)} {/* Background Color (the bucket fill variant excludes `transparent`) */} {predicates.backgroundColor && (
{renderAction( appState.activeTool.type === "bucketfill" ? "changeBucketFillBackgroundColor" : "changeBackgroundColor", )}
)} {/* Freedraw pressure: standalone button cycling the variability mode */} {predicates.freedrawMode && (
{renderAction("changeFreedrawMode", { cycle: true })}
)} {/* Linear Editor */} {predicates.lineEditor && (
{renderAction("toggleLinearEditor")}
)} {/* Text Properties */} {predicates.text && ( <>
{renderAction("changeFontFamily")}
)} {/* Dedicated Copy Button */} {predicates.showExtraActions && (
{renderAction("duplicateSelection")}
)} {/* Dedicated Delete Button */} {predicates.showExtraActions && (
{renderAction("deleteSelectedElements")}
)}
); }; /** * Mobile styles panel — the horizontal action bar used on phones, with an * overflow measurement that promotes duplicate/delete out of the popover when * there is room. */ 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 predicates = getShapeActionPredicates( appState, targetElements, elementsMap, app, ); const { container } = useExcalidrawContainer(); const mobileActionsRef = useRef(null); const ACTIONS_WIDTH = mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0; // 7 actions + 2 for undo/redo const MIN_ACTIONS = 9; const GAP = 6; const WIDTH = 32; const MIN_WIDTH = MIN_ACTIONS * WIDTH + (MIN_ACTIONS - 1) * GAP; const ADDITIONAL_WIDTH = WIDTH + GAP; const showDeleteOutside = ACTIONS_WIDTH >= MIN_WIDTH + ADDITIONAL_WIDTH; const showDuplicateOutside = ACTIONS_WIDTH >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH; return (
{predicates.strokeColor && (
{renderAction("changeStrokeColor")}
)} {/* Background Color (the bucket fill variant excludes `transparent`) */} {predicates.backgroundColor && (
{renderAction( appState.activeTool.type === "bucketfill" ? "changeBucketFillBackgroundColor" : "changeBackgroundColor", )}
)} {/* Combined Arrow Properties */} {/* Linear Editor */} {/* Text Properties */} {predicates.text && ( <>
{renderAction("changeFontFamily")}
)} {/* Combined Other Actions */}
{renderAction("undo")}
{renderAction("redo")}
{showDuplicateOutside && (
{renderAction("duplicateSelection")}
)} {showDeleteOutside && (
{renderAction("deleteSelectedElements")}
)}
); }; export const ZoomActions = ({ renderAction, }: { renderAction: ActionManager["renderAction"]; }) => ( {renderAction("zoomOut")} {renderAction("resetZoom")} {renderAction("zoomIn")} ); export const UndoRedoActions = ({ renderAction, className, }: { renderAction: ActionManager["renderAction"]; className?: string; }) => (
{renderAction("undo")}
{renderAction("redo")}
); export const ExitZenModeButton = ({ actionManager, showExitZenModeBtn, }: { actionManager: ActionManager; showExitZenModeBtn: boolean; }) => ( ); export const ExitViewModeButton = ({ actionManager, }: { actionManager: ActionManager; }) => ( );