actionFlip.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import { register } from "./register";
  2. import { getSelectedElements } from "../scene";
  3. import { getElementMap, getNonDeletedElements } from "../element";
  4. import { mutateElement } from "../element/mutateElement";
  5. import { ExcalidrawElement, NonDeleted } from "../element/types";
  6. import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
  7. import { AppState } from "../types";
  8. import { getTransformHandles } from "../element/transformHandles";
  9. import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
  10. import { updateBoundElements } from "../element/binding";
  11. import { LinearElementEditor } from "../element/linearElementEditor";
  12. const enableActionFlipHorizontal = (
  13. elements: readonly ExcalidrawElement[],
  14. appState: AppState,
  15. ) => {
  16. const eligibleElements = getSelectedElements(
  17. getNonDeletedElements(elements),
  18. appState,
  19. );
  20. return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
  21. };
  22. const enableActionFlipVertical = (
  23. elements: readonly ExcalidrawElement[],
  24. appState: AppState,
  25. ) => {
  26. const eligibleElements = getSelectedElements(
  27. getNonDeletedElements(elements),
  28. appState,
  29. );
  30. return eligibleElements.length === 1;
  31. };
  32. export const actionFlipHorizontal = register({
  33. name: "flipHorizontal",
  34. perform: (elements, appState) => {
  35. return {
  36. elements: flipSelectedElements(elements, appState, "horizontal"),
  37. appState,
  38. commitToHistory: true,
  39. };
  40. },
  41. keyTest: (event) => event.shiftKey && event.code === "KeyH",
  42. contextItemLabel: "labels.flipHorizontal",
  43. contextItemPredicate: (elements, appState) =>
  44. enableActionFlipHorizontal(elements, appState),
  45. });
  46. export const actionFlipVertical = register({
  47. name: "flipVertical",
  48. perform: (elements, appState) => {
  49. return {
  50. elements: flipSelectedElements(elements, appState, "vertical"),
  51. appState,
  52. commitToHistory: true,
  53. };
  54. },
  55. keyTest: (event) => event.shiftKey && event.code === "KeyV",
  56. contextItemLabel: "labels.flipVertical",
  57. contextItemPredicate: (elements, appState) =>
  58. enableActionFlipVertical(elements, appState),
  59. });
  60. const flipSelectedElements = (
  61. elements: readonly ExcalidrawElement[],
  62. appState: Readonly<AppState>,
  63. flipDirection: "horizontal" | "vertical",
  64. ) => {
  65. const selectedElements = getSelectedElements(
  66. getNonDeletedElements(elements),
  67. appState,
  68. );
  69. // remove once we allow for groups of elements to be flipped
  70. if (selectedElements.length > 1) {
  71. return elements;
  72. }
  73. const updatedElements = flipElements(
  74. selectedElements,
  75. appState,
  76. flipDirection,
  77. );
  78. const updatedElementsMap = getElementMap(updatedElements);
  79. return elements.map((element) => updatedElementsMap[element.id] || element);
  80. };
  81. const flipElements = (
  82. elements: NonDeleted<ExcalidrawElement>[],
  83. appState: AppState,
  84. flipDirection: "horizontal" | "vertical",
  85. ): ExcalidrawElement[] => {
  86. elements.forEach((element) => {
  87. flipElement(element, appState);
  88. // If vertical flip, rotate an extra 180
  89. if (flipDirection === "vertical") {
  90. rotateElement(element, Math.PI);
  91. }
  92. });
  93. return elements;
  94. };
  95. const flipElement = (
  96. element: NonDeleted<ExcalidrawElement>,
  97. appState: AppState,
  98. ) => {
  99. const originalX = element.x;
  100. const originalY = element.y;
  101. const width = element.width;
  102. const height = element.height;
  103. const originalAngle = normalizeAngle(element.angle);
  104. let finalOffsetX = 0;
  105. if (isLinearElement(element) || isFreeDrawElement(element)) {
  106. finalOffsetX =
  107. element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
  108. element.width;
  109. }
  110. // Rotate back to zero, if necessary
  111. mutateElement(element, {
  112. angle: normalizeAngle(0),
  113. });
  114. // Flip unrotated by pulling TransformHandle to opposite side
  115. const transformHandles = getTransformHandles(element, appState.zoom);
  116. let usingNWHandle = true;
  117. let newNCoordsX = 0;
  118. let nHandle = transformHandles.nw;
  119. if (!nHandle) {
  120. // Use ne handle instead
  121. usingNWHandle = false;
  122. nHandle = transformHandles.ne;
  123. if (!nHandle) {
  124. mutateElement(element, {
  125. angle: originalAngle,
  126. });
  127. return;
  128. }
  129. }
  130. if (isLinearElement(element)) {
  131. for (let i = 1; i < element.points.length; i++) {
  132. LinearElementEditor.movePoint(element, i, [
  133. -element.points[i][0],
  134. element.points[i][1],
  135. ]);
  136. }
  137. LinearElementEditor.normalizePoints(element);
  138. } else {
  139. // calculate new x-coord for transformation
  140. newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
  141. resizeSingleElement(
  142. element,
  143. true,
  144. element,
  145. usingNWHandle ? "nw" : "ne",
  146. false,
  147. newNCoordsX,
  148. nHandle[1],
  149. );
  150. // fix the size to account for handle sizes
  151. mutateElement(element, {
  152. width,
  153. height,
  154. });
  155. }
  156. // Rotate by (360 degrees - original angle)
  157. let angle = normalizeAngle(2 * Math.PI - originalAngle);
  158. if (angle < 0) {
  159. // check, probably unnecessary
  160. angle = normalizeAngle(angle + 2 * Math.PI);
  161. }
  162. mutateElement(element, {
  163. angle,
  164. });
  165. // Move back to original spot to appear "flipped in place"
  166. mutateElement(element, {
  167. x: originalX + finalOffsetX,
  168. y: originalY,
  169. });
  170. updateBoundElements(element);
  171. };
  172. const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
  173. const originalX = element.x;
  174. const originalY = element.y;
  175. let angle = normalizeAngle(element.angle + rotationAngle);
  176. if (angle < 0) {
  177. // check, probably unnecessary
  178. angle = normalizeAngle(2 * Math.PI + angle);
  179. }
  180. mutateElement(element, {
  181. angle,
  182. });
  183. // Move back to original spot
  184. mutateElement(element, {
  185. x: originalX,
  186. y: originalY,
  187. });
  188. };