actionCanvas.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import { ColorPicker } from "../components/ColorPicker";
  2. import {
  3. eraser,
  4. MoonIcon,
  5. SunIcon,
  6. ZoomInIcon,
  7. ZoomOutIcon,
  8. } from "../components/icons";
  9. import { ToolButton } from "../components/ToolButton";
  10. import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
  11. import { getCommonBounds, getNonDeletedElements } from "../element";
  12. import { ExcalidrawElement } from "../element/types";
  13. import { t } from "../i18n";
  14. import { CODES, KEYS } from "../keys";
  15. import { getNormalizedZoom, getSelectedElements } from "../scene";
  16. import { centerScrollOn } from "../scene/scroll";
  17. import { getStateForZoom } from "../scene/zoom";
  18. import { AppState, NormalizedZoomValue } from "../types";
  19. import { getShortcutKey, updateActiveTool } from "../utils";
  20. import { register } from "./register";
  21. import { Tooltip } from "../components/Tooltip";
  22. import { newElementWith } from "../element/mutateElement";
  23. import { getDefaultAppState, isEraserActive } from "../appState";
  24. import ClearCanvas from "../components/ClearCanvas";
  25. import clsx from "clsx";
  26. import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
  27. import { getShortcutFromShortcutName } from "./shortcuts";
  28. export const actionChangeViewBackgroundColor = register({
  29. name: "changeViewBackgroundColor",
  30. trackEvent: false,
  31. perform: (_, appState, value) => {
  32. return {
  33. appState: { ...appState, ...value },
  34. commitToHistory: !!value.viewBackgroundColor,
  35. };
  36. },
  37. PanelComponent: ({ elements, appState, updateData }) => {
  38. return (
  39. <div style={{ position: "relative" }}>
  40. <ColorPicker
  41. label={t("labels.canvasBackground")}
  42. type="canvasBackground"
  43. color={appState.viewBackgroundColor}
  44. onChange={(color) => updateData({ viewBackgroundColor: color })}
  45. isActive={appState.openPopup === "canvasColorPicker"}
  46. setActive={(active) =>
  47. updateData({ openPopup: active ? "canvasColorPicker" : null })
  48. }
  49. data-testid="canvas-background-picker"
  50. elements={elements}
  51. appState={appState}
  52. />
  53. </div>
  54. );
  55. },
  56. });
  57. export const actionClearCanvas = register({
  58. name: "clearCanvas",
  59. trackEvent: { category: "canvas" },
  60. perform: (elements, appState, _, app) => {
  61. app.imageCache.clear();
  62. return {
  63. elements: elements.map((element) =>
  64. newElementWith(element, { isDeleted: true }),
  65. ),
  66. appState: {
  67. ...getDefaultAppState(),
  68. files: {},
  69. theme: appState.theme,
  70. penMode: appState.penMode,
  71. penDetected: appState.penDetected,
  72. exportBackground: appState.exportBackground,
  73. exportEmbedScene: appState.exportEmbedScene,
  74. gridSize: appState.gridSize,
  75. showStats: appState.showStats,
  76. pasteDialog: appState.pasteDialog,
  77. activeTool:
  78. appState.activeTool.type === "image"
  79. ? { ...appState.activeTool, type: "selection" }
  80. : appState.activeTool,
  81. },
  82. commitToHistory: true,
  83. };
  84. },
  85. PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
  86. });
  87. export const actionZoomIn = register({
  88. name: "zoomIn",
  89. viewMode: true,
  90. trackEvent: { category: "canvas" },
  91. perform: (_elements, appState, _, app) => {
  92. return {
  93. appState: {
  94. ...appState,
  95. ...getStateForZoom(
  96. {
  97. viewportX: appState.width / 2 + appState.offsetLeft,
  98. viewportY: appState.height / 2 + appState.offsetTop,
  99. nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
  100. },
  101. appState,
  102. ),
  103. },
  104. commitToHistory: false,
  105. };
  106. },
  107. PanelComponent: ({ updateData }) => (
  108. <ToolButton
  109. type="button"
  110. className="zoom-in-button zoom-button"
  111. icon={ZoomInIcon}
  112. title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
  113. aria-label={t("buttons.zoomIn")}
  114. onClick={() => {
  115. updateData(null);
  116. }}
  117. />
  118. ),
  119. keyTest: (event) =>
  120. (event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
  121. (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
  122. });
  123. export const actionZoomOut = register({
  124. name: "zoomOut",
  125. viewMode: true,
  126. trackEvent: { category: "canvas" },
  127. perform: (_elements, appState, _, app) => {
  128. return {
  129. appState: {
  130. ...appState,
  131. ...getStateForZoom(
  132. {
  133. viewportX: appState.width / 2 + appState.offsetLeft,
  134. viewportY: appState.height / 2 + appState.offsetTop,
  135. nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
  136. },
  137. appState,
  138. ),
  139. },
  140. commitToHistory: false,
  141. };
  142. },
  143. PanelComponent: ({ updateData }) => (
  144. <ToolButton
  145. type="button"
  146. className="zoom-out-button zoom-button"
  147. icon={ZoomOutIcon}
  148. title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
  149. aria-label={t("buttons.zoomOut")}
  150. onClick={() => {
  151. updateData(null);
  152. }}
  153. />
  154. ),
  155. keyTest: (event) =>
  156. (event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
  157. (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
  158. });
  159. export const actionResetZoom = register({
  160. name: "resetZoom",
  161. viewMode: true,
  162. trackEvent: { category: "canvas" },
  163. perform: (_elements, appState, _, app) => {
  164. return {
  165. appState: {
  166. ...appState,
  167. ...getStateForZoom(
  168. {
  169. viewportX: appState.width / 2 + appState.offsetLeft,
  170. viewportY: appState.height / 2 + appState.offsetTop,
  171. nextZoom: getNormalizedZoom(1),
  172. },
  173. appState,
  174. ),
  175. },
  176. commitToHistory: false,
  177. };
  178. },
  179. PanelComponent: ({ updateData, appState }) => (
  180. <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
  181. <ToolButton
  182. type="button"
  183. className="reset-zoom-button zoom-button"
  184. title={t("buttons.resetZoom")}
  185. aria-label={t("buttons.resetZoom")}
  186. onClick={() => {
  187. updateData(null);
  188. }}
  189. >
  190. {(appState.zoom.value * 100).toFixed(0)}%
  191. </ToolButton>
  192. </Tooltip>
  193. ),
  194. keyTest: (event) =>
  195. (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
  196. (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
  197. });
  198. const zoomValueToFitBoundsOnViewport = (
  199. bounds: [number, number, number, number],
  200. viewportDimensions: { width: number; height: number },
  201. ) => {
  202. const [x1, y1, x2, y2] = bounds;
  203. const commonBoundsWidth = x2 - x1;
  204. const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
  205. const commonBoundsHeight = y2 - y1;
  206. const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
  207. const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
  208. const zoomAdjustedToSteps =
  209. Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
  210. const clampedZoomValueToFitElements = Math.min(
  211. Math.max(zoomAdjustedToSteps, MIN_ZOOM),
  212. 1,
  213. );
  214. return clampedZoomValueToFitElements as NormalizedZoomValue;
  215. };
  216. const zoomToFitElements = (
  217. elements: readonly ExcalidrawElement[],
  218. appState: Readonly<AppState>,
  219. zoomToSelection: boolean,
  220. ) => {
  221. const nonDeletedElements = getNonDeletedElements(elements);
  222. const selectedElements = getSelectedElements(nonDeletedElements, appState);
  223. const commonBounds =
  224. zoomToSelection && selectedElements.length > 0
  225. ? getCommonBounds(selectedElements)
  226. : getCommonBounds(nonDeletedElements);
  227. const newZoom = {
  228. value: zoomValueToFitBoundsOnViewport(commonBounds, {
  229. width: appState.width,
  230. height: appState.height,
  231. }),
  232. };
  233. const [x1, y1, x2, y2] = commonBounds;
  234. const centerX = (x1 + x2) / 2;
  235. const centerY = (y1 + y2) / 2;
  236. return {
  237. appState: {
  238. ...appState,
  239. ...centerScrollOn({
  240. scenePoint: { x: centerX, y: centerY },
  241. viewportDimensions: {
  242. width: appState.width,
  243. height: appState.height,
  244. },
  245. zoom: newZoom,
  246. }),
  247. zoom: newZoom,
  248. },
  249. commitToHistory: false,
  250. };
  251. };
  252. export const actionZoomToSelected = register({
  253. name: "zoomToSelection",
  254. trackEvent: { category: "canvas" },
  255. perform: (elements, appState) => zoomToFitElements(elements, appState, true),
  256. keyTest: (event) =>
  257. event.code === CODES.TWO &&
  258. event.shiftKey &&
  259. !event.altKey &&
  260. !event[KEYS.CTRL_OR_CMD],
  261. });
  262. export const actionZoomToFit = register({
  263. name: "zoomToFit",
  264. viewMode: true,
  265. trackEvent: { category: "canvas" },
  266. perform: (elements, appState) => zoomToFitElements(elements, appState, false),
  267. keyTest: (event) =>
  268. event.code === CODES.ONE &&
  269. event.shiftKey &&
  270. !event.altKey &&
  271. !event[KEYS.CTRL_OR_CMD],
  272. });
  273. export const actionToggleTheme = register({
  274. name: "toggleTheme",
  275. viewMode: true,
  276. trackEvent: { category: "canvas" },
  277. perform: (_, appState, value) => {
  278. return {
  279. appState: {
  280. ...appState,
  281. theme:
  282. value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
  283. },
  284. commitToHistory: false,
  285. };
  286. },
  287. PanelComponent: ({ appState, updateData }) => (
  288. <DropdownMenuItem
  289. onSelect={() => {
  290. updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
  291. }}
  292. icon={appState.theme === "dark" ? SunIcon : MoonIcon}
  293. dataTestId="toggle-dark-mode"
  294. shortcut={getShortcutFromShortcutName("toggleTheme")}
  295. ariaLabel={
  296. appState.theme === "dark"
  297. ? t("buttons.lightMode")
  298. : t("buttons.darkMode")
  299. }
  300. >
  301. {appState.theme === "dark"
  302. ? t("buttons.lightMode")
  303. : t("buttons.darkMode")}
  304. </DropdownMenuItem>
  305. ),
  306. keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
  307. });
  308. export const actionErase = register({
  309. name: "eraser",
  310. trackEvent: { category: "toolbar" },
  311. perform: (elements, appState) => {
  312. let activeTool: AppState["activeTool"];
  313. if (isEraserActive(appState)) {
  314. activeTool = updateActiveTool(appState, {
  315. ...(appState.activeTool.lastActiveToolBeforeEraser || {
  316. type: "selection",
  317. }),
  318. lastActiveToolBeforeEraser: null,
  319. });
  320. } else {
  321. activeTool = updateActiveTool(appState, {
  322. type: "eraser",
  323. lastActiveToolBeforeEraser: appState.activeTool,
  324. });
  325. }
  326. return {
  327. appState: {
  328. ...appState,
  329. selectedElementIds: {},
  330. selectedGroupIds: {},
  331. activeTool,
  332. },
  333. commitToHistory: true,
  334. };
  335. },
  336. keyTest: (event) => event.key === KEYS.E,
  337. PanelComponent: ({ elements, appState, updateData, data }) => (
  338. <ToolButton
  339. type="button"
  340. icon={eraser}
  341. className={clsx("eraser", { active: isEraserActive(appState) })}
  342. title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
  343. aria-label={t("toolBar.eraser")}
  344. onClick={() => {
  345. updateData(null);
  346. }}
  347. size={data?.size || "medium"}
  348. ></ToolButton>
  349. ),
  350. });