actionCanvas.tsx 10 KB

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