zindex.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import { bumpVersion } from "./element/mutateElement";
  2. import { ExcalidrawElement } from "./element/types";
  3. import { getElementsInGroup } from "./groups";
  4. import { getSelectedElements } from "./scene";
  5. import Scene from "./scene/Scene";
  6. import { AppState } from "./types";
  7. import { arrayToMap, findIndex, findLastIndex } from "./utils";
  8. /**
  9. * Returns indices of elements to move based on selected elements.
  10. * Includes contiguous deleted elements that are between two selected elements,
  11. * e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)]
  12. */
  13. const getIndicesToMove = (
  14. elements: readonly ExcalidrawElement[],
  15. appState: AppState,
  16. ) => {
  17. let selectedIndices: number[] = [];
  18. let deletedIndices: number[] = [];
  19. let includeDeletedIndex = null;
  20. let index = -1;
  21. const selectedElementIds = arrayToMap(
  22. getSelectedElements(elements, appState, true),
  23. );
  24. while (++index < elements.length) {
  25. if (selectedElementIds.get(elements[index].id)) {
  26. if (deletedIndices.length) {
  27. selectedIndices = selectedIndices.concat(deletedIndices);
  28. deletedIndices = [];
  29. }
  30. selectedIndices.push(index);
  31. includeDeletedIndex = index + 1;
  32. } else if (elements[index].isDeleted && includeDeletedIndex === index) {
  33. includeDeletedIndex = index + 1;
  34. deletedIndices.push(index);
  35. } else {
  36. deletedIndices = [];
  37. }
  38. }
  39. return selectedIndices;
  40. };
  41. const toContiguousGroups = (array: number[]) => {
  42. let cursor = 0;
  43. return array.reduce((acc, value, index) => {
  44. if (index > 0 && array[index - 1] !== value - 1) {
  45. cursor = ++cursor;
  46. }
  47. (acc[cursor] || (acc[cursor] = [])).push(value);
  48. return acc;
  49. }, [] as number[][]);
  50. };
  51. /**
  52. * @returns index of target element, consindering tightly-bound elements
  53. * (currently non-linear elements bound to a container) as a one unit.
  54. * If no binding present, returns `undefined`.
  55. */
  56. const getTargetIndexAccountingForBinding = (
  57. nextElement: ExcalidrawElement,
  58. elements: readonly ExcalidrawElement[],
  59. direction: "left" | "right",
  60. ) => {
  61. if ("containerId" in nextElement && nextElement.containerId) {
  62. if (direction === "left") {
  63. const containerElement = Scene.getScene(nextElement)!.getElement(
  64. nextElement.containerId,
  65. );
  66. if (containerElement) {
  67. return elements.indexOf(containerElement);
  68. }
  69. } else {
  70. return elements.indexOf(nextElement);
  71. }
  72. } else {
  73. const boundElementId = nextElement.boundElements?.find(
  74. (binding) => binding.type !== "arrow",
  75. )?.id;
  76. if (boundElementId) {
  77. if (direction === "left") {
  78. return elements.indexOf(nextElement);
  79. }
  80. const boundTextElement =
  81. Scene.getScene(nextElement)!.getElement(boundElementId);
  82. if (boundTextElement) {
  83. return elements.indexOf(boundTextElement);
  84. }
  85. }
  86. }
  87. };
  88. /**
  89. * Returns next candidate index that's available to be moved to. Currently that
  90. * is a non-deleted element, and not inside a group (unless we're editing it).
  91. */
  92. const getTargetIndex = (
  93. appState: AppState,
  94. elements: readonly ExcalidrawElement[],
  95. boundaryIndex: number,
  96. direction: "left" | "right",
  97. ) => {
  98. const sourceElement = elements[boundaryIndex];
  99. const indexFilter = (element: ExcalidrawElement) => {
  100. if (element.isDeleted) {
  101. return false;
  102. }
  103. // if we're editing group, find closest sibling irrespective of whether
  104. // there's a different-group element between them (for legacy reasons)
  105. if (appState.editingGroupId) {
  106. return element.groupIds.includes(appState.editingGroupId);
  107. }
  108. return true;
  109. };
  110. const candidateIndex =
  111. direction === "left"
  112. ? findLastIndex(elements, indexFilter, Math.max(0, boundaryIndex - 1))
  113. : findIndex(elements, indexFilter, boundaryIndex + 1);
  114. const nextElement = elements[candidateIndex];
  115. if (!nextElement) {
  116. return -1;
  117. }
  118. if (appState.editingGroupId) {
  119. if (
  120. // candidate element is a sibling in current editing group → return
  121. sourceElement?.groupIds.join("") === nextElement?.groupIds.join("")
  122. ) {
  123. return (
  124. getTargetIndexAccountingForBinding(nextElement, elements, direction) ??
  125. candidateIndex
  126. );
  127. } else if (!nextElement?.groupIds.includes(appState.editingGroupId)) {
  128. // candidate element is outside current editing group → prevent
  129. return -1;
  130. }
  131. }
  132. if (!nextElement.groupIds.length) {
  133. return (
  134. getTargetIndexAccountingForBinding(nextElement, elements, direction) ??
  135. candidateIndex
  136. );
  137. }
  138. const siblingGroupId = appState.editingGroupId
  139. ? nextElement.groupIds[
  140. nextElement.groupIds.indexOf(appState.editingGroupId) - 1
  141. ]
  142. : nextElement.groupIds[nextElement.groupIds.length - 1];
  143. const elementsInSiblingGroup = getElementsInGroup(elements, siblingGroupId);
  144. if (elementsInSiblingGroup.length) {
  145. // assumes getElementsInGroup() returned elements are sorted
  146. // by zIndex (ascending)
  147. return direction === "left"
  148. ? elements.indexOf(elementsInSiblingGroup[0])
  149. : elements.indexOf(
  150. elementsInSiblingGroup[elementsInSiblingGroup.length - 1],
  151. );
  152. }
  153. return candidateIndex;
  154. };
  155. const getTargetElementsMap = (
  156. elements: readonly ExcalidrawElement[],
  157. indices: number[],
  158. ) => {
  159. return indices.reduce((acc, index) => {
  160. const element = elements[index];
  161. acc[element.id] = element;
  162. return acc;
  163. }, {} as Record<string, ExcalidrawElement>);
  164. };
  165. const shiftElements = (
  166. appState: AppState,
  167. elements: readonly ExcalidrawElement[],
  168. direction: "left" | "right",
  169. ) => {
  170. const indicesToMove = getIndicesToMove(elements, appState);
  171. const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
  172. let groupedIndices = toContiguousGroups(indicesToMove);
  173. if (direction === "right") {
  174. groupedIndices = groupedIndices.reverse();
  175. }
  176. groupedIndices.forEach((indices, i) => {
  177. const leadingIndex = indices[0];
  178. const trailingIndex = indices[indices.length - 1];
  179. const boundaryIndex = direction === "left" ? leadingIndex : trailingIndex;
  180. const targetIndex = getTargetIndex(
  181. appState,
  182. elements,
  183. boundaryIndex,
  184. direction,
  185. );
  186. if (targetIndex === -1 || boundaryIndex === targetIndex) {
  187. return;
  188. }
  189. const leadingElements =
  190. direction === "left"
  191. ? elements.slice(0, targetIndex)
  192. : elements.slice(0, leadingIndex);
  193. const targetElements = elements.slice(leadingIndex, trailingIndex + 1);
  194. const displacedElements =
  195. direction === "left"
  196. ? elements.slice(targetIndex, leadingIndex)
  197. : elements.slice(trailingIndex + 1, targetIndex + 1);
  198. const trailingElements =
  199. direction === "left"
  200. ? elements.slice(trailingIndex + 1)
  201. : elements.slice(targetIndex + 1);
  202. elements =
  203. direction === "left"
  204. ? [
  205. ...leadingElements,
  206. ...targetElements,
  207. ...displacedElements,
  208. ...trailingElements,
  209. ]
  210. : [
  211. ...leadingElements,
  212. ...displacedElements,
  213. ...targetElements,
  214. ...trailingElements,
  215. ];
  216. });
  217. return elements.map((element) => {
  218. if (targetElementsMap[element.id]) {
  219. return bumpVersion(element);
  220. }
  221. return element;
  222. });
  223. };
  224. const shiftElementsToEnd = (
  225. elements: readonly ExcalidrawElement[],
  226. appState: AppState,
  227. direction: "left" | "right",
  228. ) => {
  229. const indicesToMove = getIndicesToMove(elements, appState);
  230. const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
  231. const displacedElements: ExcalidrawElement[] = [];
  232. let leadingIndex: number;
  233. let trailingIndex: number;
  234. if (direction === "left") {
  235. if (appState.editingGroupId) {
  236. const groupElements = getElementsInGroup(
  237. elements,
  238. appState.editingGroupId,
  239. );
  240. if (!groupElements.length) {
  241. return elements;
  242. }
  243. leadingIndex = elements.indexOf(groupElements[0]);
  244. } else {
  245. leadingIndex = 0;
  246. }
  247. trailingIndex = indicesToMove[indicesToMove.length - 1];
  248. } else {
  249. if (appState.editingGroupId) {
  250. const groupElements = getElementsInGroup(
  251. elements,
  252. appState.editingGroupId,
  253. );
  254. if (!groupElements.length) {
  255. return elements;
  256. }
  257. trailingIndex = elements.indexOf(groupElements[groupElements.length - 1]);
  258. } else {
  259. trailingIndex = elements.length - 1;
  260. }
  261. leadingIndex = indicesToMove[0];
  262. }
  263. for (let index = leadingIndex; index < trailingIndex + 1; index++) {
  264. if (!indicesToMove.includes(index)) {
  265. displacedElements.push(elements[index]);
  266. }
  267. }
  268. const targetElements = Object.values(targetElementsMap).map((element) => {
  269. return bumpVersion(element);
  270. });
  271. const leadingElements = elements.slice(0, leadingIndex);
  272. const trailingElements = elements.slice(trailingIndex + 1);
  273. return direction === "left"
  274. ? [
  275. ...leadingElements,
  276. ...targetElements,
  277. ...displacedElements,
  278. ...trailingElements,
  279. ]
  280. : [
  281. ...leadingElements,
  282. ...displacedElements,
  283. ...targetElements,
  284. ...trailingElements,
  285. ];
  286. };
  287. // public API
  288. // -----------------------------------------------------------------------------
  289. export const moveOneLeft = (
  290. elements: readonly ExcalidrawElement[],
  291. appState: AppState,
  292. ) => {
  293. return shiftElements(appState, elements, "left");
  294. };
  295. export const moveOneRight = (
  296. elements: readonly ExcalidrawElement[],
  297. appState: AppState,
  298. ) => {
  299. return shiftElements(appState, elements, "right");
  300. };
  301. export const moveAllLeft = (
  302. elements: readonly ExcalidrawElement[],
  303. appState: AppState,
  304. ) => {
  305. return shiftElementsToEnd(elements, appState, "left");
  306. };
  307. export const moveAllRight = (
  308. elements: readonly ExcalidrawElement[],
  309. appState: AppState,
  310. ) => {
  311. return shiftElementsToEnd(elements, appState, "right");
  312. };