Actions.tsx 8.2 KB

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