actionAlign.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import { getNonDeletedElements } from "@excalidraw/element";
  2. import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
  3. import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
  4. import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
  5. import { alignElements } from "@excalidraw/element/align";
  6. import { CaptureUpdateAction } from "@excalidraw/element/store";
  7. import type { ExcalidrawElement } from "@excalidraw/element/types";
  8. import type { Alignment } from "@excalidraw/element/align";
  9. import { ToolButton } from "../components/ToolButton";
  10. import {
  11. AlignBottomIcon,
  12. AlignLeftIcon,
  13. AlignRightIcon,
  14. AlignTopIcon,
  15. CenterHorizontallyIcon,
  16. CenterVerticallyIcon,
  17. } from "../components/icons";
  18. import { t } from "../i18n";
  19. import { isSomeElementSelected } from "../scene";
  20. import { register } from "./register";
  21. import type { AppClassProperties, AppState, UIAppState } from "../types";
  22. export const alignActionsPredicate = (
  23. appState: UIAppState,
  24. app: AppClassProperties,
  25. ) => {
  26. const selectedElements = app.scene.getSelectedElements(appState);
  27. return (
  28. selectedElements.length > 1 &&
  29. // TODO enable aligning frames when implemented properly
  30. !selectedElements.some((el) => isFrameLikeElement(el))
  31. );
  32. };
  33. const alignSelectedElements = (
  34. elements: readonly ExcalidrawElement[],
  35. appState: Readonly<AppState>,
  36. app: AppClassProperties,
  37. alignment: Alignment,
  38. ) => {
  39. const selectedElements = app.scene.getSelectedElements(appState);
  40. const updatedElements = alignElements(selectedElements, alignment, app.scene);
  41. const updatedElementsMap = arrayToMap(updatedElements);
  42. return updateFrameMembershipOfSelectedElements(
  43. elements.map((element) => updatedElementsMap.get(element.id) || element),
  44. appState,
  45. app,
  46. );
  47. };
  48. export const actionAlignTop = register({
  49. name: "alignTop",
  50. label: "labels.alignTop",
  51. icon: AlignTopIcon,
  52. trackEvent: { category: "element" },
  53. predicate: (elements, appState, appProps, app) =>
  54. alignActionsPredicate(appState, app),
  55. perform: (elements, appState, _, app) => {
  56. return {
  57. appState,
  58. elements: alignSelectedElements(elements, appState, app, {
  59. position: "start",
  60. axis: "y",
  61. }),
  62. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  63. };
  64. },
  65. keyTest: (event) =>
  66. event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
  67. PanelComponent: ({ elements, appState, updateData, app }) => (
  68. <ToolButton
  69. hidden={!alignActionsPredicate(appState, app)}
  70. type="button"
  71. icon={AlignTopIcon}
  72. onClick={() => updateData(null)}
  73. title={`${t("labels.alignTop")} — ${getShortcutKey(
  74. "CtrlOrCmd+Shift+Up",
  75. )}`}
  76. aria-label={t("labels.alignTop")}
  77. visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
  78. />
  79. ),
  80. });
  81. export const actionAlignBottom = register({
  82. name: "alignBottom",
  83. label: "labels.alignBottom",
  84. icon: AlignBottomIcon,
  85. trackEvent: { category: "element" },
  86. predicate: (elements, appState, appProps, app) =>
  87. alignActionsPredicate(appState, app),
  88. perform: (elements, appState, _, app) => {
  89. return {
  90. appState,
  91. elements: alignSelectedElements(elements, appState, app, {
  92. position: "end",
  93. axis: "y",
  94. }),
  95. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  96. };
  97. },
  98. keyTest: (event) =>
  99. event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
  100. PanelComponent: ({ elements, appState, updateData, app }) => (
  101. <ToolButton
  102. hidden={!alignActionsPredicate(appState, app)}
  103. type="button"
  104. icon={AlignBottomIcon}
  105. onClick={() => updateData(null)}
  106. title={`${t("labels.alignBottom")} — ${getShortcutKey(
  107. "CtrlOrCmd+Shift+Down",
  108. )}`}
  109. aria-label={t("labels.alignBottom")}
  110. visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
  111. />
  112. ),
  113. });
  114. export const actionAlignLeft = register({
  115. name: "alignLeft",
  116. label: "labels.alignLeft",
  117. icon: AlignLeftIcon,
  118. trackEvent: { category: "element" },
  119. predicate: (elements, appState, appProps, app) =>
  120. alignActionsPredicate(appState, app),
  121. perform: (elements, appState, _, app) => {
  122. return {
  123. appState,
  124. elements: alignSelectedElements(elements, appState, app, {
  125. position: "start",
  126. axis: "x",
  127. }),
  128. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  129. };
  130. },
  131. keyTest: (event) =>
  132. event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
  133. PanelComponent: ({ elements, appState, updateData, app }) => (
  134. <ToolButton
  135. hidden={!alignActionsPredicate(appState, app)}
  136. type="button"
  137. icon={AlignLeftIcon}
  138. onClick={() => updateData(null)}
  139. title={`${t("labels.alignLeft")} — ${getShortcutKey(
  140. "CtrlOrCmd+Shift+Left",
  141. )}`}
  142. aria-label={t("labels.alignLeft")}
  143. visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
  144. />
  145. ),
  146. });
  147. export const actionAlignRight = register({
  148. name: "alignRight",
  149. label: "labels.alignRight",
  150. icon: AlignRightIcon,
  151. trackEvent: { category: "element" },
  152. predicate: (elements, appState, appProps, app) =>
  153. alignActionsPredicate(appState, app),
  154. perform: (elements, appState, _, app) => {
  155. return {
  156. appState,
  157. elements: alignSelectedElements(elements, appState, app, {
  158. position: "end",
  159. axis: "x",
  160. }),
  161. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  162. };
  163. },
  164. keyTest: (event) =>
  165. event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
  166. PanelComponent: ({ elements, appState, updateData, app }) => (
  167. <ToolButton
  168. hidden={!alignActionsPredicate(appState, app)}
  169. type="button"
  170. icon={AlignRightIcon}
  171. onClick={() => updateData(null)}
  172. title={`${t("labels.alignRight")} — ${getShortcutKey(
  173. "CtrlOrCmd+Shift+Right",
  174. )}`}
  175. aria-label={t("labels.alignRight")}
  176. visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
  177. />
  178. ),
  179. });
  180. export const actionAlignVerticallyCentered = register({
  181. name: "alignVerticallyCentered",
  182. label: "labels.centerVertically",
  183. icon: CenterVerticallyIcon,
  184. trackEvent: { category: "element" },
  185. predicate: (elements, appState, appProps, app) =>
  186. alignActionsPredicate(appState, app),
  187. perform: (elements, appState, _, app) => {
  188. return {
  189. appState,
  190. elements: alignSelectedElements(elements, appState, app, {
  191. position: "center",
  192. axis: "y",
  193. }),
  194. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  195. };
  196. },
  197. PanelComponent: ({ elements, appState, updateData, app }) => (
  198. <ToolButton
  199. hidden={!alignActionsPredicate(appState, app)}
  200. type="button"
  201. icon={CenterVerticallyIcon}
  202. onClick={() => updateData(null)}
  203. title={t("labels.centerVertically")}
  204. aria-label={t("labels.centerVertically")}
  205. visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
  206. />
  207. ),
  208. });
  209. export const actionAlignHorizontallyCentered = register({
  210. name: "alignHorizontallyCentered",
  211. label: "labels.centerHorizontally",
  212. icon: CenterHorizontallyIcon,
  213. trackEvent: { category: "element" },
  214. predicate: (elements, appState, appProps, app) =>
  215. alignActionsPredicate(appState, app),
  216. perform: (elements, appState, _, app) => {
  217. return {
  218. appState,
  219. elements: alignSelectedElements(elements, appState, app, {
  220. position: "center",
  221. axis: "x",
  222. }),
  223. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  224. };
  225. },
  226. PanelComponent: ({ elements, appState, updateData, app }) => (
  227. <ToolButton
  228. hidden={!alignActionsPredicate(appState, app)}
  229. type="button"
  230. icon={CenterHorizontallyIcon}
  231. onClick={() => updateData(null)}
  232. title={t("labels.centerHorizontally")}
  233. aria-label={t("labels.centerHorizontally")}
  234. visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
  235. />
  236. ),
  237. });