import { useState } from "react"; import type { ActionManager } from "../actions/manager"; import type { ExcalidrawElement, ExcalidrawElementType, NonDeletedElementsMap, NonDeletedSceneElementsMap, } from "../element/types"; import { t } from "../i18n"; import { useDevice } from "./App"; import { canChangeRoundness, canHaveArrowheads, getTargetElements, hasBackground, hasStrokeStyle, hasStrokeWidth, } from "../scene"; import { SHAPES } from "../shapes"; import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; import { capitalizeString, isTransparent } from "../utils"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { hasStrokeColor } from "../scene/comparisons"; import { trackEvent } from "../analytics"; import { hasBoundTextElement, isLinearElement, isTextElement, } from "../element/typeChecks"; import clsx from "clsx"; import { actionToggleZenMode } from "../actions"; import { Tooltip } from "./Tooltip"; import { shouldAllowVerticalAlign, suppportsHorizontalAlign, } from "../element/textElement"; import "./Actions.scss"; import DropdownMenu from "./dropdownMenu/DropdownMenu"; import { EmbedIcon, extraToolsIcon, frameToolIcon, mermaidLogoIcon, laserPointerToolIcon, OpenAIIcon, MagicIcon, } from "./icons"; import { KEYS } from "../keys"; import { useTunnels } from "../context/tunnels"; export const canChangeStrokeColor = ( appState: UIAppState, targetElements: ExcalidrawElement[], ) => { let commonSelectedType: ExcalidrawElementType | null = targetElements[0]?.type || null; for (const element of targetElements) { if (element.type !== commonSelectedType) { commonSelectedType = null; break; } } return ( (hasStrokeColor(appState.activeTool.type) && appState.activeTool.type !== "image" && commonSelectedType !== "image" && commonSelectedType !== "frame" && commonSelectedType !== "magicframe") || targetElements.some((element) => hasStrokeColor(element.type)) ); }; export const canChangeBackgroundColor = ( appState: UIAppState, targetElements: ExcalidrawElement[], ) => { return ( hasBackground(appState.activeTool.type) || targetElements.some((element) => hasBackground(element.type)) ); }; export const SelectedShapeActions = ({ appState, elementsMap, renderAction, }: { appState: UIAppState; elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; renderAction: ActionManager["renderAction"]; }) => { const targetElements = getTargetElements(elementsMap, appState); let isSingleElementBoundContainer = false; if ( targetElements.length === 2 && (hasBoundTextElement(targetElements[0]) || hasBoundTextElement(targetElements[1])) ) { isSingleElementBoundContainer = true; } const isEditing = Boolean(appState.editingElement); const device = useDevice(); const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const showFillIcons = (hasBackground(appState.activeTool.type) && !isTransparent(appState.currentItemBackgroundColor)) || targetElements.some( (element) => hasBackground(element.type) && !isTransparent(element.backgroundColor), ); const showLinkIcon = targetElements.length === 1 || isSingleElementBoundContainer; const showLineEditorAction = !appState.editingLinearElement && targetElements.length === 1 && isLinearElement(targetElements[0]); return (
{canChangeStrokeColor(appState, targetElements) && renderAction("changeStrokeColor")}
{canChangeBackgroundColor(appState, targetElements) && (
{renderAction("changeBackgroundColor")}
)} {showFillIcons && renderAction("changeFillStyle")} {(hasStrokeWidth(appState.activeTool.type) || targetElements.some((element) => hasStrokeWidth(element.type))) && renderAction("changeStrokeWidth")} {(appState.activeTool.type === "freedraw" || targetElements.some((element) => element.type === "freedraw")) && renderAction("changeStrokeShape")} {(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")} )} {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> {renderAction("changeFontSize")} {renderAction("changeFontFamily")} {(appState.activeTool.type === "text" || suppportsHorizontalAlign(targetElements, elementsMap)) && renderAction("changeTextAlign")} )} {shouldAllowVerticalAlign(targetElements, elementsMap) && renderAction("changeVerticalAlign")} {(canHaveArrowheads(appState.activeTool.type) || targetElements.some((element) => canHaveArrowheads(element.type))) && ( <>{renderAction("changeArrowhead")} )} {renderAction("changeOpacity")}
{t("labels.layers")}
{renderAction("sendToBack")} {renderAction("sendBackward")} {renderAction("bringForward")} {renderAction("bringToFront")}
{targetElements.length > 1 && !isSingleElementBoundContainer && (
{t("labels.align")}
{ // swap this order for RTL so the button positions always match their action // (i.e. the leftmost button aligns left) } {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")}
)} {!isEditing && targetElements.length > 0 && (
{t("labels.actions")}
{!device.editor.isMobile && renderAction("duplicateSelection")} {!device.editor.isMobile && renderAction("deleteSelectedElements")} {renderAction("group")} {renderAction("ungroup")} {showLinkIcon && renderAction("hyperlink")} {showLineEditorAction && renderAction("toggleLinearEditor")}
)}
); }; export const ShapesSwitcher = ({ activeTool, appState, app, UIOptions, }: { activeTool: UIAppState["activeTool"]; appState: UIAppState; app: AppClassProperties; UIOptions: AppProps["UIOptions"]; }) => { const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); const frameToolSelected = activeTool.type === "frame"; const laserToolSelected = activeTool.type === "laser"; const embeddableToolSelected = activeTool.type === "embeddable"; const { TTDDialogTriggerTunnel } = useTunnels(); return ( <> {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { if ( UIOptions.tools?.[ value as Extract ] === false ) { return null; } const label = t(`toolBar.${value}`); const letter = key && capitalizeString(typeof key === "string" ? key : key[0]); const shortcut = letter ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; return ( { if (!appState.penDetected && pointerType === "pen") { app.togglePenMode(true); } }} onChange={({ pointerType }) => { if (appState.activeTool.type !== value) { trackEvent("toolbar", value, "ui"); } if (value === "image") { app.setActiveTool({ type: value, insertOnCanvasDirectly: pointerType !== "mouse", }); } else { app.setActiveTool({ type: value }); } }} /> ); })}
setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)} title={t("toolBar.extraTools")} > {extraToolsIcon} {app.props.aiEnabled !== false && (
AI
)}
setIsExtraToolsMenuOpen(false)} onSelect={() => setIsExtraToolsMenuOpen(false)} className="App-toolbar__extra-tools-dropdown" > app.setActiveTool({ type: "frame" })} icon={frameToolIcon} shortcut={KEYS.F.toLocaleUpperCase()} data-testid="toolbar-frame" selected={frameToolSelected} > {t("toolBar.frame")} app.setActiveTool({ type: "embeddable" })} icon={EmbedIcon} data-testid="toolbar-embeddable" selected={embeddableToolSelected} > {t("toolBar.embeddable")} app.setActiveTool({ type: "laser" })} icon={laserPointerToolIcon} data-testid="toolbar-laser" selected={laserToolSelected} shortcut={KEYS.K.toLocaleUpperCase()} > {t("toolBar.laser")}
Generate
{app.props.aiEnabled !== false && } app.setOpenDialog({ name: "ttd", tab: "mermaid" })} icon={mermaidLogoIcon} data-testid="toolbar-embeddable" > {t("toolBar.mermaidToExcalidraw")} {app.props.aiEnabled !== false && ( <> app.onMagicframeToolSelect()} icon={MagicIcon} data-testid="toolbar-magicframe" > {t("toolBar.magicframe")} AI { trackEvent("ai", "open-settings", "d2c"); app.setOpenDialog({ name: "settings", source: "settings", tab: "diagram-to-code", }); }} icon={OpenAIIcon} data-testid="toolbar-magicSettings" > {t("toolBar.magicSettings")} )}
); }; export const ZoomActions = ({ renderAction, zoom, }: { renderAction: ActionManager["renderAction"]; zoom: Zoom; }) => ( {renderAction("zoomOut")} {renderAction("resetZoom")} {renderAction("zoomIn")} ); export const UndoRedoActions = ({ renderAction, className, }: { renderAction: ActionManager["renderAction"]; className?: string; }) => (
{renderAction("undo")}
{renderAction("redo")}
); export const ExitZenModeAction = ({ actionManager, showExitZenModeBtn, }: { actionManager: ActionManager; showExitZenModeBtn: boolean; }) => ( ); export const FinalizeAction = ({ renderAction, className, }: { renderAction: ActionManager["renderAction"]; className?: string; }) => (
{renderAction("finalize", { size: "small" })}
);