Actions.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. import React from "react";
  2. import { ActionManager } from "../actions/manager";
  3. import { getNonDeletedElements } from "../element";
  4. import { ExcalidrawElement, PointerType } from "../element/types";
  5. import { t } from "../i18n";
  6. import { useDevice } from "../components/App";
  7. import {
  8. canChangeSharpness,
  9. canHaveArrowheads,
  10. getTargetElements,
  11. hasBackground,
  12. hasStrokeStyle,
  13. hasStrokeWidth,
  14. hasText,
  15. } from "../scene";
  16. import { SHAPES } from "../shapes";
  17. import { AppState, Zoom } from "../types";
  18. import {
  19. capitalizeString,
  20. isTransparent,
  21. updateActiveTool,
  22. setCursorForShape,
  23. } from "../utils";
  24. import Stack from "./Stack";
  25. import { ToolButton } from "./ToolButton";
  26. import { hasStrokeColor } from "../scene/comparisons";
  27. import { trackEvent } from "../analytics";
  28. import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
  29. import clsx from "clsx";
  30. import { actionToggleZenMode } from "../actions";
  31. export const SelectedShapeActions = ({
  32. appState,
  33. elements,
  34. renderAction,
  35. }: {
  36. appState: AppState;
  37. elements: readonly ExcalidrawElement[];
  38. renderAction: ActionManager["renderAction"];
  39. }) => {
  40. const targetElements = getTargetElements(
  41. getNonDeletedElements(elements),
  42. appState,
  43. );
  44. let isSingleElementBoundContainer = false;
  45. if (
  46. targetElements.length === 2 &&
  47. (hasBoundTextElement(targetElements[0]) ||
  48. hasBoundTextElement(targetElements[1]))
  49. ) {
  50. isSingleElementBoundContainer = true;
  51. }
  52. const isEditing = Boolean(appState.editingElement);
  53. const device = useDevice();
  54. const isRTL = document.documentElement.getAttribute("dir") === "rtl";
  55. const showFillIcons =
  56. hasBackground(appState.activeTool.type) ||
  57. targetElements.some(
  58. (element) =>
  59. hasBackground(element.type) && !isTransparent(element.backgroundColor),
  60. );
  61. const showChangeBackgroundIcons =
  62. hasBackground(appState.activeTool.type) ||
  63. targetElements.some((element) => hasBackground(element.type));
  64. const showLinkIcon =
  65. targetElements.length === 1 || isSingleElementBoundContainer;
  66. let commonSelectedType: string | null = targetElements[0]?.type || null;
  67. for (const element of targetElements) {
  68. if (element.type !== commonSelectedType) {
  69. commonSelectedType = null;
  70. break;
  71. }
  72. }
  73. return (
  74. <div className="panelColumn">
  75. {((hasStrokeColor(appState.activeTool.type) &&
  76. appState.activeTool.type !== "image" &&
  77. commonSelectedType !== "image") ||
  78. targetElements.some((element) => hasStrokeColor(element.type))) &&
  79. renderAction("changeStrokeColor")}
  80. {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
  81. {showFillIcons && renderAction("changeFillStyle")}
  82. {(hasStrokeWidth(appState.activeTool.type) ||
  83. targetElements.some((element) => hasStrokeWidth(element.type))) &&
  84. renderAction("changeStrokeWidth")}
  85. {(appState.activeTool.type === "freedraw" ||
  86. targetElements.some((element) => element.type === "freedraw")) &&
  87. renderAction("changeStrokeShape")}
  88. {(hasStrokeStyle(appState.activeTool.type) ||
  89. targetElements.some((element) => hasStrokeStyle(element.type))) && (
  90. <>
  91. {renderAction("changeStrokeStyle")}
  92. {renderAction("changeSloppiness")}
  93. </>
  94. )}
  95. {(canChangeSharpness(appState.activeTool.type) ||
  96. targetElements.some((element) => canChangeSharpness(element.type))) && (
  97. <>{renderAction("changeSharpness")}</>
  98. )}
  99. {(hasText(appState.activeTool.type) ||
  100. targetElements.some((element) => hasText(element.type))) && (
  101. <>
  102. {renderAction("changeFontSize")}
  103. {renderAction("changeFontFamily")}
  104. {renderAction("changeTextAlign")}
  105. </>
  106. )}
  107. {targetElements.some(
  108. (element) =>
  109. hasBoundTextElement(element) || isBoundToContainer(element),
  110. ) && renderAction("changeVerticalAlign")}
  111. {(canHaveArrowheads(appState.activeTool.type) ||
  112. targetElements.some((element) => canHaveArrowheads(element.type))) && (
  113. <>{renderAction("changeArrowhead")}</>
  114. )}
  115. {renderAction("changeOpacity")}
  116. <fieldset>
  117. <legend>{t("labels.layers")}</legend>
  118. <div className="buttonList">
  119. {renderAction("sendToBack")}
  120. {renderAction("sendBackward")}
  121. {renderAction("bringToFront")}
  122. {renderAction("bringForward")}
  123. </div>
  124. </fieldset>
  125. {targetElements.length > 1 && !isSingleElementBoundContainer && (
  126. <fieldset>
  127. <legend>{t("labels.align")}</legend>
  128. <div className="buttonList">
  129. {
  130. // swap this order for RTL so the button positions always match their action
  131. // (i.e. the leftmost button aligns left)
  132. }
  133. {isRTL ? (
  134. <>
  135. {renderAction("alignRight")}
  136. {renderAction("alignHorizontallyCentered")}
  137. {renderAction("alignLeft")}
  138. </>
  139. ) : (
  140. <>
  141. {renderAction("alignLeft")}
  142. {renderAction("alignHorizontallyCentered")}
  143. {renderAction("alignRight")}
  144. </>
  145. )}
  146. {targetElements.length > 2 &&
  147. renderAction("distributeHorizontally")}
  148. <div className="iconRow">
  149. {renderAction("alignTop")}
  150. {renderAction("alignVerticallyCentered")}
  151. {renderAction("alignBottom")}
  152. {targetElements.length > 2 &&
  153. renderAction("distributeVertically")}
  154. </div>
  155. </div>
  156. </fieldset>
  157. )}
  158. {!isEditing && targetElements.length > 0 && (
  159. <fieldset>
  160. <legend>{t("labels.actions")}</legend>
  161. <div className="buttonList">
  162. {!device.isMobile && renderAction("duplicateSelection")}
  163. {!device.isMobile && renderAction("deleteSelectedElements")}
  164. {renderAction("group")}
  165. {renderAction("ungroup")}
  166. {showLinkIcon && renderAction("hyperlink")}
  167. </div>
  168. </fieldset>
  169. )}
  170. </div>
  171. );
  172. };
  173. export const ShapesSwitcher = ({
  174. canvas,
  175. activeTool,
  176. setAppState,
  177. onImageAction,
  178. appState,
  179. }: {
  180. canvas: HTMLCanvasElement | null;
  181. activeTool: AppState["activeTool"];
  182. setAppState: React.Component<any, AppState>["setState"];
  183. onImageAction: (data: { pointerType: PointerType | null }) => void;
  184. appState: AppState;
  185. }) => (
  186. <>
  187. {SHAPES.map(({ value, icon, key }, index) => {
  188. const label = t(`toolBar.${value}`);
  189. const letter = key && (typeof key === "string" ? key : key[0]);
  190. const shortcut = letter
  191. ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
  192. : `${index + 1}`;
  193. return (
  194. <ToolButton
  195. className="Shape"
  196. key={value}
  197. type="radio"
  198. icon={icon}
  199. checked={activeTool.type === value}
  200. name="editor-current-shape"
  201. title={`${capitalizeString(label)} — ${shortcut}`}
  202. keyBindingLabel={`${index + 1}`}
  203. aria-label={capitalizeString(label)}
  204. aria-keyshortcuts={shortcut}
  205. data-testid={value}
  206. onPointerDown={({ pointerType }) => {
  207. if (!appState.penDetected && pointerType === "pen") {
  208. setAppState({
  209. penDetected: true,
  210. penMode: true,
  211. });
  212. }
  213. }}
  214. onChange={({ pointerType }) => {
  215. if (appState.activeTool.type !== value) {
  216. trackEvent("toolbar", value, "ui");
  217. }
  218. const nextActiveTool = updateActiveTool(appState, {
  219. type: value,
  220. });
  221. setAppState({
  222. activeTool: nextActiveTool,
  223. multiElement: null,
  224. selectedElementIds: {},
  225. });
  226. setCursorForShape(canvas, {
  227. ...appState,
  228. activeTool: nextActiveTool,
  229. });
  230. if (value === "image") {
  231. onImageAction({ pointerType });
  232. }
  233. }}
  234. />
  235. );
  236. })}
  237. </>
  238. );
  239. export const ZoomActions = ({
  240. renderAction,
  241. zoom,
  242. }: {
  243. renderAction: ActionManager["renderAction"];
  244. zoom: Zoom;
  245. }) => (
  246. <Stack.Col gap={1}>
  247. <Stack.Row gap={1} align="center">
  248. {renderAction("zoomOut")}
  249. {renderAction("zoomIn")}
  250. {renderAction("resetZoom")}
  251. </Stack.Row>
  252. </Stack.Col>
  253. );
  254. export const UndoRedoActions = ({
  255. renderAction,
  256. className,
  257. }: {
  258. renderAction: ActionManager["renderAction"];
  259. className?: string;
  260. }) => (
  261. <div className={`undo-redo-buttons ${className}`}>
  262. {renderAction("undo", { size: "small" })}
  263. {renderAction("redo", { size: "small" })}
  264. </div>
  265. );
  266. export const ExitZenModeAction = ({
  267. executeAction,
  268. showExitZenModeBtn,
  269. }: {
  270. executeAction: ActionManager["executeAction"];
  271. showExitZenModeBtn: boolean;
  272. }) => (
  273. <button
  274. className={clsx("disable-zen-mode", {
  275. "disable-zen-mode--visible": showExitZenModeBtn,
  276. })}
  277. onClick={() => executeAction(actionToggleZenMode)}
  278. >
  279. {t("buttons.exitZenMode")}
  280. </button>
  281. );
  282. export const FinalizeAction = ({
  283. renderAction,
  284. className,
  285. }: {
  286. renderAction: ActionManager["renderAction"];
  287. className?: string;
  288. }) => (
  289. <div className={`finalize-button ${className}`}>
  290. {renderAction("finalize", { size: "small" })}
  291. </div>
  292. );