fractionalIndex.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. import { generateNKeysBetween } from "fractional-indexing";
  2. import { mutateElement } from "./element/mutateElement";
  3. import type {
  4. ExcalidrawElement,
  5. FractionalIndex,
  6. OrderedExcalidrawElement,
  7. } from "./element/types";
  8. import { InvalidFractionalIndexError } from "./errors";
  9. import { hasBoundTextElement } from "./element/typeChecks";
  10. import { getBoundTextElement } from "./element/textElement";
  11. import { arrayToMap } from "./utils";
  12. /**
  13. * Envisioned relation between array order and fractional indices:
  14. *
  15. * 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation.
  16. * - it's undesirable to perform reorder for each related operation, therefore it's necessary to cache the order defined by fractional indices into an ordered data structure
  17. * - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps)
  18. * - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc.
  19. * - it's necessary to always keep the fractional indices in sync with the array order
  20. * - elements with invalid indices should be detected and synced, without altering the already valid indices
  21. *
  22. * 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated.
  23. * - as the fractional indices are encoded as part of the elements, it opens up possibilities for incremental-like APIs
  24. * - re-order based on fractional indices should be part of (multiplayer) operations such as reconciliation & undo/redo
  25. * - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits,
  26. * as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order
  27. */
  28. /**
  29. * Ensure that all elements have valid fractional indices.
  30. *
  31. * @throws `InvalidFractionalIndexError` if invalid index is detected.
  32. */
  33. export const validateFractionalIndices = (
  34. elements: readonly ExcalidrawElement[],
  35. {
  36. shouldThrow = false,
  37. includeBoundTextValidation = false,
  38. reconciliationContext,
  39. }: {
  40. shouldThrow: boolean;
  41. includeBoundTextValidation: boolean;
  42. reconciliationContext?: {
  43. localElements: ReadonlyArray<ExcalidrawElement>;
  44. remoteElements: ReadonlyArray<ExcalidrawElement>;
  45. };
  46. },
  47. ) => {
  48. const errorMessages = [];
  49. const stringifyElement = (element: ExcalidrawElement | void) =>
  50. `${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
  51. const indices = elements.map((x) => x.index);
  52. for (const [i, index] of indices.entries()) {
  53. const predecessorIndex = indices[i - 1];
  54. const successorIndex = indices[i + 1];
  55. if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
  56. errorMessages.push(
  57. `Fractional indices invariant has been compromised: "${stringifyElement(
  58. elements[i - 1],
  59. )}", "${stringifyElement(elements[i])}", "${stringifyElement(
  60. elements[i + 1],
  61. )}"`,
  62. );
  63. }
  64. // disabled by default, as we don't fix it
  65. if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
  66. const container = elements[i];
  67. const text = getBoundTextElement(container, arrayToMap(elements));
  68. if (text && text.index! <= container.index!) {
  69. errorMessages.push(
  70. `Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
  71. text,
  72. )}", "${stringifyElement(container)}"`,
  73. );
  74. }
  75. }
  76. }
  77. if (errorMessages.length) {
  78. const error = new InvalidFractionalIndexError();
  79. const additionalContext = [];
  80. if (reconciliationContext) {
  81. additionalContext.push("Additional reconciliation context:");
  82. additionalContext.push(
  83. reconciliationContext.localElements.map((x) => stringifyElement(x)),
  84. );
  85. additionalContext.push(
  86. reconciliationContext.remoteElements.map((x) => stringifyElement(x)),
  87. );
  88. }
  89. // report just once and with the stacktrace
  90. console.error(
  91. errorMessages.join("\n\n"),
  92. error.stack,
  93. elements.map((x) => stringifyElement(x)),
  94. ...additionalContext,
  95. );
  96. if (shouldThrow) {
  97. // if enabled, gather all the errors first, throw once
  98. throw error;
  99. }
  100. }
  101. };
  102. /**
  103. * Order the elements based on the fractional indices.
  104. * - when fractional indices are identical, break the tie based on the element id
  105. * - when there is no fractional index in one of the elements, respect the order of the array
  106. */
  107. export const orderByFractionalIndex = (
  108. elements: OrderedExcalidrawElement[],
  109. ) => {
  110. return elements.sort((a, b) => {
  111. // in case the indices are not the defined at runtime
  112. if (isOrderedElement(a) && isOrderedElement(b)) {
  113. if (a.index < b.index) {
  114. return -1;
  115. } else if (a.index > b.index) {
  116. return 1;
  117. }
  118. // break ties based on the element id
  119. return a.id < b.id ? -1 : 1;
  120. }
  121. // defensively keep the array order
  122. return 1;
  123. });
  124. };
  125. /**
  126. * Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements.
  127. * If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`.
  128. */
  129. export const syncMovedIndices = (
  130. elements: readonly ExcalidrawElement[],
  131. movedElements: Map<string, ExcalidrawElement>,
  132. ): OrderedExcalidrawElement[] => {
  133. try {
  134. const indicesGroups = getMovedIndicesGroups(elements, movedElements);
  135. // try generatating indices, throws on invalid movedElements
  136. const elementsUpdates = generateIndices(elements, indicesGroups);
  137. const elementsCandidates = elements.map((x) =>
  138. elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
  139. );
  140. // ensure next indices are valid before mutation, throws on invalid ones
  141. validateFractionalIndices(
  142. elementsCandidates,
  143. // we don't autofix invalid bound text indices, hence don't include it in the validation
  144. { includeBoundTextValidation: false, shouldThrow: true },
  145. );
  146. // split mutation so we don't end up in an incosistent state
  147. for (const [element, update] of elementsUpdates) {
  148. mutateElement(element, update, false);
  149. }
  150. } catch (e) {
  151. // fallback to default sync
  152. syncInvalidIndices(elements);
  153. }
  154. return elements as OrderedExcalidrawElement[];
  155. };
  156. /**
  157. * Synchronizes all invalid fractional indices with the array order by mutating passed elements.
  158. *
  159. * WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
  160. */
  161. export const syncInvalidIndices = (
  162. elements: readonly ExcalidrawElement[],
  163. ): OrderedExcalidrawElement[] => {
  164. const indicesGroups = getInvalidIndicesGroups(elements);
  165. const elementsUpdates = generateIndices(elements, indicesGroups);
  166. for (const [element, update] of elementsUpdates) {
  167. mutateElement(element, update, false);
  168. }
  169. return elements as OrderedExcalidrawElement[];
  170. };
  171. /**
  172. * Get contiguous groups of indices of passed moved elements.
  173. *
  174. * NOTE: First and last elements within the groups are indices of lower and upper bounds.
  175. */
  176. const getMovedIndicesGroups = (
  177. elements: readonly ExcalidrawElement[],
  178. movedElements: Map<string, ExcalidrawElement>,
  179. ) => {
  180. const indicesGroups: number[][] = [];
  181. let i = 0;
  182. while (i < elements.length) {
  183. if (movedElements.has(elements[i].id)) {
  184. const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
  185. while (++i < elements.length) {
  186. if (!movedElements.has(elements[i].id)) {
  187. break;
  188. }
  189. indicesGroup.push(i);
  190. }
  191. indicesGroup.push(i); // push the upper bound index as the last item
  192. indicesGroups.push(indicesGroup);
  193. } else {
  194. i++;
  195. }
  196. }
  197. return indicesGroups;
  198. };
  199. /**
  200. * Gets contiguous groups of all invalid indices automatically detected inside the elements array.
  201. *
  202. * WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds!
  203. */
  204. const getInvalidIndicesGroups = (elements: readonly ExcalidrawElement[]) => {
  205. const indicesGroups: number[][] = [];
  206. // once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf.
  207. let lowerBound: ExcalidrawElement["index"] | undefined = undefined;
  208. let upperBound: ExcalidrawElement["index"] | undefined = undefined;
  209. let lowerBoundIndex: number = -1;
  210. let upperBoundIndex: number = 0;
  211. /** @returns maybe valid lowerBound */
  212. const getLowerBound = (
  213. index: number,
  214. ): [ExcalidrawElement["index"] | undefined, number] => {
  215. const lowerBound = elements[lowerBoundIndex]
  216. ? elements[lowerBoundIndex].index
  217. : undefined;
  218. // we are already iterating left to right, therefore there is no need for additional looping
  219. const candidate = elements[index - 1]?.index;
  220. if (
  221. (!lowerBound && candidate) || // first lowerBound
  222. (lowerBound && candidate && candidate > lowerBound) // next lowerBound
  223. ) {
  224. // WARN: candidate's index could be higher or same as the current element's index
  225. return [candidate, index - 1];
  226. }
  227. // cache hit! take the last lower bound
  228. return [lowerBound, lowerBoundIndex];
  229. };
  230. /** @returns always valid upperBound */
  231. const getUpperBound = (
  232. index: number,
  233. ): [ExcalidrawElement["index"] | undefined, number] => {
  234. const upperBound = elements[upperBoundIndex]
  235. ? elements[upperBoundIndex].index
  236. : undefined;
  237. // cache hit! don't let it find the upper bound again
  238. if (upperBound && index < upperBoundIndex) {
  239. return [upperBound, upperBoundIndex];
  240. }
  241. // set the current upperBoundIndex as the starting point
  242. let i = upperBoundIndex;
  243. while (++i < elements.length) {
  244. const candidate = elements[i]?.index;
  245. if (
  246. (!upperBound && candidate) || // first upperBound
  247. (upperBound && candidate && candidate > upperBound) // next upperBound
  248. ) {
  249. return [candidate, i];
  250. }
  251. }
  252. // we reached the end, sky is the limit
  253. return [undefined, i];
  254. };
  255. let i = 0;
  256. while (i < elements.length) {
  257. const current = elements[i].index;
  258. [lowerBound, lowerBoundIndex] = getLowerBound(i);
  259. [upperBound, upperBoundIndex] = getUpperBound(i);
  260. if (!isValidFractionalIndex(current, lowerBound, upperBound)) {
  261. // push the lower bound index as the first item
  262. const indicesGroup = [lowerBoundIndex, i];
  263. while (++i < elements.length) {
  264. const current = elements[i].index;
  265. const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i);
  266. const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i);
  267. if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) {
  268. break;
  269. }
  270. // assign bounds only for the moved elements
  271. [lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex];
  272. [upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex];
  273. indicesGroup.push(i);
  274. }
  275. // push the upper bound index as the last item
  276. indicesGroup.push(upperBoundIndex);
  277. indicesGroups.push(indicesGroup);
  278. } else {
  279. i++;
  280. }
  281. }
  282. return indicesGroups;
  283. };
  284. const isValidFractionalIndex = (
  285. index: ExcalidrawElement["index"] | undefined,
  286. predecessor: ExcalidrawElement["index"] | undefined,
  287. successor: ExcalidrawElement["index"] | undefined,
  288. ) => {
  289. if (!index) {
  290. return false;
  291. }
  292. if (predecessor && successor) {
  293. return predecessor < index && index < successor;
  294. }
  295. if (!predecessor && successor) {
  296. // first element
  297. return index < successor;
  298. }
  299. if (predecessor && !successor) {
  300. // last element
  301. return predecessor < index;
  302. }
  303. // only element in the array
  304. return !!index;
  305. };
  306. const generateIndices = (
  307. elements: readonly ExcalidrawElement[],
  308. indicesGroups: number[][],
  309. ) => {
  310. const elementsUpdates = new Map<
  311. ExcalidrawElement,
  312. { index: FractionalIndex }
  313. >();
  314. for (const indices of indicesGroups) {
  315. const lowerBoundIndex = indices.shift()!;
  316. const upperBoundIndex = indices.pop()!;
  317. const fractionalIndices = generateNKeysBetween(
  318. elements[lowerBoundIndex]?.index,
  319. elements[upperBoundIndex]?.index,
  320. indices.length,
  321. ) as FractionalIndex[];
  322. for (let i = 0; i < indices.length; i++) {
  323. const element = elements[indices[i]];
  324. elementsUpdates.set(element, {
  325. index: fractionalIndices[i],
  326. });
  327. }
  328. }
  329. return elementsUpdates;
  330. };
  331. const isOrderedElement = (
  332. element: ExcalidrawElement,
  333. ): element is OrderedExcalidrawElement => {
  334. // for now it's sufficient whether the index is there
  335. // meaning, the element was already ordered in the past
  336. // meaning, it is not a newly inserted element, not an unrestored element, etc.
  337. // it does not have to mean that the index itself is valid
  338. if (element.index) {
  339. return true;
  340. }
  341. return false;
  342. };