actionGroup.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { KEYS } from "../keys";
  2. import { t } from "../i18n";
  3. import { arrayToMap, getShortcutKey } from "../utils";
  4. import { register } from "./register";
  5. import { UngroupIcon, GroupIcon } from "../components/icons";
  6. import { newElementWith } from "../element/mutateElement";
  7. import { getSelectedElements, isSomeElementSelected } from "../scene";
  8. import {
  9. getSelectedGroupIds,
  10. selectGroup,
  11. selectGroupsForSelectedElements,
  12. getElementsInGroup,
  13. addToGroup,
  14. removeFromSelectedGroups,
  15. isElementInGroup,
  16. } from "../groups";
  17. import { getNonDeletedElements } from "../element";
  18. import { randomId } from "../random";
  19. import { ToolButton } from "../components/ToolButton";
  20. import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
  21. import { AppState } from "../types";
  22. import { isBoundToContainer } from "../element/typeChecks";
  23. const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
  24. if (elements.length >= 2) {
  25. const groupIds = elements[0].groupIds;
  26. for (const groupId of groupIds) {
  27. if (
  28. elements.reduce(
  29. (acc, element) => acc && isElementInGroup(element, groupId),
  30. true,
  31. )
  32. ) {
  33. return true;
  34. }
  35. }
  36. }
  37. return false;
  38. };
  39. const enableActionGroup = (
  40. elements: readonly ExcalidrawElement[],
  41. appState: AppState,
  42. ) => {
  43. const selectedElements = getSelectedElements(
  44. getNonDeletedElements(elements),
  45. appState,
  46. true,
  47. );
  48. return (
  49. selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
  50. );
  51. };
  52. export const actionGroup = register({
  53. name: "group",
  54. trackEvent: { category: "element" },
  55. perform: (elements, appState) => {
  56. const selectedElements = getSelectedElements(
  57. getNonDeletedElements(elements),
  58. appState,
  59. true,
  60. );
  61. if (selectedElements.length < 2) {
  62. // nothing to group
  63. return { appState, elements, commitToHistory: false };
  64. }
  65. // if everything is already grouped into 1 group, there is nothing to do
  66. const selectedGroupIds = getSelectedGroupIds(appState);
  67. if (selectedGroupIds.length === 1) {
  68. const selectedGroupId = selectedGroupIds[0];
  69. const elementIdsInGroup = new Set(
  70. getElementsInGroup(elements, selectedGroupId).map(
  71. (element) => element.id,
  72. ),
  73. );
  74. const selectedElementIds = new Set(
  75. selectedElements.map((element) => element.id),
  76. );
  77. const combinedSet = new Set([
  78. ...Array.from(elementIdsInGroup),
  79. ...Array.from(selectedElementIds),
  80. ]);
  81. if (combinedSet.size === elementIdsInGroup.size) {
  82. // no incremental ids in the selected ids
  83. return { appState, elements, commitToHistory: false };
  84. }
  85. }
  86. const newGroupId = randomId();
  87. const selectElementIds = arrayToMap(selectedElements);
  88. const updatedElements = elements.map((element) => {
  89. if (!selectElementIds.get(element.id)) {
  90. return element;
  91. }
  92. return newElementWith(element, {
  93. groupIds: addToGroup(
  94. element.groupIds,
  95. newGroupId,
  96. appState.editingGroupId,
  97. ),
  98. });
  99. });
  100. // keep the z order within the group the same, but move them
  101. // to the z order of the highest element in the layer stack
  102. const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
  103. const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
  104. const lastGroupElementIndex =
  105. updatedElements.lastIndexOf(lastElementInGroup);
  106. const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
  107. const elementsBeforeGroup = updatedElements
  108. .slice(0, lastGroupElementIndex)
  109. .filter(
  110. (updatedElement) => !isElementInGroup(updatedElement, newGroupId),
  111. );
  112. const updatedElementsInOrder = [
  113. ...elementsBeforeGroup,
  114. ...elementsInGroup,
  115. ...elementsAfterGroup,
  116. ];
  117. return {
  118. appState: selectGroup(
  119. newGroupId,
  120. { ...appState, selectedGroupIds: {} },
  121. getNonDeletedElements(updatedElementsInOrder),
  122. ),
  123. elements: updatedElementsInOrder,
  124. commitToHistory: true,
  125. };
  126. },
  127. contextItemLabel: "labels.group",
  128. contextItemPredicate: (elements, appState) =>
  129. enableActionGroup(elements, appState),
  130. keyTest: (event) =>
  131. !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
  132. PanelComponent: ({ elements, appState, updateData }) => (
  133. <ToolButton
  134. hidden={!enableActionGroup(elements, appState)}
  135. type="button"
  136. icon={<GroupIcon theme={appState.theme} />}
  137. onClick={() => updateData(null)}
  138. title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
  139. aria-label={t("labels.group")}
  140. visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
  141. ></ToolButton>
  142. ),
  143. });
  144. export const actionUngroup = register({
  145. name: "ungroup",
  146. trackEvent: { category: "element" },
  147. perform: (elements, appState) => {
  148. const groupIds = getSelectedGroupIds(appState);
  149. if (groupIds.length === 0) {
  150. return { appState, elements, commitToHistory: false };
  151. }
  152. const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
  153. const nextElements = elements.map((element) => {
  154. if (isBoundToContainer(element)) {
  155. boundTextElementIds.push(element.id);
  156. }
  157. const nextGroupIds = removeFromSelectedGroups(
  158. element.groupIds,
  159. appState.selectedGroupIds,
  160. );
  161. if (nextGroupIds.length === element.groupIds.length) {
  162. return element;
  163. }
  164. return newElementWith(element, {
  165. groupIds: nextGroupIds,
  166. });
  167. });
  168. const updateAppState = selectGroupsForSelectedElements(
  169. { ...appState, selectedGroupIds: {} },
  170. getNonDeletedElements(nextElements),
  171. );
  172. // remove binded text elements from selection
  173. boundTextElementIds.forEach(
  174. (id) => (updateAppState.selectedElementIds[id] = false),
  175. );
  176. return {
  177. appState: updateAppState,
  178. elements: nextElements,
  179. commitToHistory: true,
  180. };
  181. },
  182. keyTest: (event) =>
  183. event.shiftKey &&
  184. event[KEYS.CTRL_OR_CMD] &&
  185. event.key === KEYS.G.toUpperCase(),
  186. contextItemLabel: "labels.ungroup",
  187. contextItemPredicate: (elements, appState) =>
  188. getSelectedGroupIds(appState).length > 0,
  189. PanelComponent: ({ elements, appState, updateData }) => (
  190. <ToolButton
  191. type="button"
  192. hidden={getSelectedGroupIds(appState).length === 0}
  193. icon={<UngroupIcon theme={appState.theme} />}
  194. onClick={() => updateData(null)}
  195. title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
  196. aria-label={t("labels.ungroup")}
  197. visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
  198. ></ToolButton>
  199. ),
  200. });