fractionalIndex.ts 13 KB

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