Procházet zdrojové kódy

feat: fractional indexing (#7359)

* Introducing fractional indices as part of `element.index`

* Ensuring invalid fractional indices are always synchronized with the array order

* Simplifying reconciliation based on the fractional indices

* Moving reconciliation inside the `@excalidraw/excalidraw` package

---------

Co-authored-by: Marcel Mraz <[email protected]>
Co-authored-by: dwelle <[email protected]>
Ryan Di před 1 rokem
rodič
revize
32df5502ae
50 změnil soubory, kde provedl 2457 přidání a 1153 odebrání
  1. 7 4
      excalidraw-app/App.tsx
  2. 26 23
      excalidraw-app/collab/Collab.tsx
  3. 14 23
      excalidraw-app/collab/Portal.tsx
  4. 0 154
      excalidraw-app/collab/reconciliation.ts
  5. 32 25
      excalidraw-app/data/firebase.ts
  6. 8 5
      excalidraw-app/data/index.ts
  7. 4 2
      excalidraw-app/tests/collab.test.tsx
  8. 0 421
      excalidraw-app/tests/reconciliation.test.ts
  9. 7 1
      packages/excalidraw/actions/actionBoundText.tsx
  10. 5 1
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  11. 4 2
      packages/excalidraw/actions/actionGroup.tsx
  12. 3 0
      packages/excalidraw/actions/actionHistory.tsx
  13. 0 1
      packages/excalidraw/actions/actionZindex.tsx
  14. 29 41
      packages/excalidraw/components/App.tsx
  15. 0 4
      packages/excalidraw/constants.ts
  16. 100 50
      packages/excalidraw/data/__snapshots__/transform.test.ts.snap
  17. 79 0
      packages/excalidraw/data/reconcile.ts
  18. 29 30
      packages/excalidraw/data/restore.ts
  19. 13 3
      packages/excalidraw/data/transform.ts
  20. 3 0
      packages/excalidraw/element/newElement.ts
  21. 1 1
      packages/excalidraw/element/textWysiwyg.test.tsx
  22. 17 2
      packages/excalidraw/element/types.ts
  23. 4 0
      packages/excalidraw/errors.ts
  24. 348 0
      packages/excalidraw/fractionalIndex.ts
  25. 1 1
      packages/excalidraw/frame.ts
  26. 1 0
      packages/excalidraw/package.json
  27. 48 12
      packages/excalidraw/scene/Scene.ts
  28. 2 1
      packages/excalidraw/scene/export.ts
  29. 157 110
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  30. 15 10
      packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap
  31. 21 15
      packages/excalidraw/tests/__snapshots__/move.test.tsx.snap
  32. 6 4
      packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap
  33. 156 111
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  34. 15 10
      packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap
  35. 20 2
      packages/excalidraw/tests/contextmenu.test.tsx
  36. 26 17
      packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap
  37. 374 0
      packages/excalidraw/tests/data/reconcile.test.ts
  38. 33 13
      packages/excalidraw/tests/data/restore.test.ts
  39. 1 0
      packages/excalidraw/tests/fixtures/elementFixture.ts
  40. 16 16
      packages/excalidraw/tests/flip.test.tsx
  41. 774 0
      packages/excalidraw/tests/fractionalIndex.test.ts
  42. 2 0
      packages/excalidraw/tests/helpers/api.ts
  43. 4 3
      packages/excalidraw/tests/library.test.tsx
  44. 1 1
      packages/excalidraw/tests/regressionTests.test.tsx
  45. 1 1
      packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap
  46. 12 2
      packages/excalidraw/tests/scene/export.test.ts
  47. 1 0
      packages/excalidraw/tests/zindex.test.tsx
  48. 2 1
      packages/excalidraw/types.ts
  49. 26 27
      packages/excalidraw/zindex.ts
  50. 9 3
      yarn.lock

+ 7 - 4
excalidraw-app/App.tsx

@@ -14,9 +14,9 @@ import {
 } from "../packages/excalidraw/constants";
 import { loadFromBlob } from "../packages/excalidraw/data/blob";
 import {
-  ExcalidrawElement,
   FileId,
   NonDeletedExcalidrawElement,
+  OrderedExcalidrawElement,
   Theme,
 } from "../packages/excalidraw/element/types";
 import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
@@ -88,7 +88,6 @@ import {
 } from "./data/LocalData";
 import { isBrowserStorageStateNewer } from "./data/tabSync";
 import clsx from "clsx";
-import { reconcileElements } from "./collab/reconciliation";
 import {
   parseLibraryTokensFromUrl,
   useHandleLibrary,
@@ -108,6 +107,10 @@ import { OverwriteConfirmDialog } from "../packages/excalidraw/components/Overwr
 import Trans from "../packages/excalidraw/components/Trans";
 import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
 import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
+import {
+  RemoteExcalidrawElement,
+  reconcileElements,
+} from "../packages/excalidraw/data/reconcile";
 import {
   CommandPalette,
   DEFAULT_CATEGORIES,
@@ -269,7 +272,7 @@ const initializeScene = async (opts: {
         },
         elements: reconcileElements(
           scene?.elements || [],
-          excalidrawAPI.getSceneElementsIncludingDeleted(),
+          excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
           excalidrawAPI.getAppState(),
         ),
       },
@@ -581,7 +584,7 @@ const ExcalidrawWrapper = () => {
   }, [theme]);
 
   const onChange = (
-    elements: readonly ExcalidrawElement[],
+    elements: readonly OrderedExcalidrawElement[],
     appState: AppState,
     files: BinaryFiles,
   ) => {

+ 26 - 23
excalidraw-app/collab/Collab.tsx

@@ -10,6 +10,7 @@ import { ImportedDataState } from "../../packages/excalidraw/data/types";
 import {
   ExcalidrawElement,
   InitializedExcalidrawImageElement,
+  OrderedExcalidrawElement,
 } from "../../packages/excalidraw/element/types";
 import {
   getSceneVersion,
@@ -69,10 +70,6 @@ import {
   isInitializedImageElement,
 } from "../../packages/excalidraw/element/typeChecks";
 import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
-import {
-  ReconciledElements,
-  reconcileElements as _reconcileElements,
-} from "./reconciliation";
 import { decryptData } from "../../packages/excalidraw/data/encryption";
 import { resetBrowserStateVersions } from "../data/tabSync";
 import { LocalData } from "../data/LocalData";
@@ -82,6 +79,11 @@ import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
 import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
 import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
 import { collabErrorIndicatorAtom } from "./CollabError";
+import {
+  ReconciledExcalidrawElement,
+  RemoteExcalidrawElement,
+  reconcileElements,
+} from "../../packages/excalidraw/data/reconcile";
 
 export const collabAPIAtom = atom<CollabAPI | null>(null);
 export const isCollaboratingAtom = atom(false);
@@ -274,7 +276,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
     syncableElements: readonly SyncableExcalidrawElement[],
   ) => {
     try {
-      const savedData = await saveToFirebase(
+      const storedElements = await saveToFirebase(
         this.portal,
         syncableElements,
         this.excalidrawAPI.getAppState(),
@@ -282,10 +284,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 
       this.resetErrorIndicator();
 
-      if (this.isCollaborating() && savedData && savedData.reconciledElements) {
-        this.handleRemoteSceneUpdate(
-          this.reconcileElements(savedData.reconciledElements),
-        );
+      if (this.isCollaborating() && storedElements) {
+        this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
       }
     } catch (error: any) {
       const errorMessage = /is longer than.*?bytes/.test(error.message)
@@ -429,7 +429,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 
   startCollaboration = async (
     existingRoomLinkData: null | { roomId: string; roomKey: string },
-  ): Promise<ImportedDataState | null> => {
+  ) => {
     if (!this.state.username) {
       import("@excalidraw/random-username").then(({ getRandomUsername }) => {
         const username = getRandomUsername();
@@ -455,7 +455,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
       );
     }
 
-    const scenePromise = resolvablePromise<ImportedDataState | null>();
+    // TODO: `ImportedDataState` type here seems abused
+    const scenePromise = resolvablePromise<
+      | (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
+      | null
+    >();
 
     this.setIsCollaborating(true);
     LocalData.pauseSave("collaboration");
@@ -538,7 +542,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
             if (!this.portal.socketInitialized) {
               this.initializeRoom({ fetchScene: false });
               const remoteElements = decryptedData.payload.elements;
-              const reconciledElements = this.reconcileElements(remoteElements);
+              const reconciledElements =
+                this._reconcileElements(remoteElements);
               this.handleRemoteSceneUpdate(reconciledElements, {
                 init: true,
               });
@@ -552,7 +557,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
           }
           case WS_SUBTYPES.UPDATE:
             this.handleRemoteSceneUpdate(
-              this.reconcileElements(decryptedData.payload.elements),
+              this._reconcileElements(decryptedData.payload.elements),
             );
             break;
           case WS_SUBTYPES.MOUSE_LOCATION: {
@@ -700,17 +705,15 @@ class Collab extends PureComponent<CollabProps, CollabState> {
     return null;
   };
 
-  private reconcileElements = (
+  private _reconcileElements = (
     remoteElements: readonly ExcalidrawElement[],
-  ): ReconciledElements => {
+  ): ReconciledExcalidrawElement[] => {
     const localElements = this.getSceneElementsIncludingDeleted();
     const appState = this.excalidrawAPI.getAppState();
-
-    remoteElements = restoreElements(remoteElements, null);
-
-    const reconciledElements = _reconcileElements(
+    const restoredRemoteElements = restoreElements(remoteElements, null);
+    const reconciledElements = reconcileElements(
       localElements,
-      remoteElements,
+      restoredRemoteElements as RemoteExcalidrawElement[],
       appState,
     );
 
@@ -741,7 +744,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
   }, LOAD_IMAGES_TIMEOUT);
 
   private handleRemoteSceneUpdate = (
-    elements: ReconciledElements,
+    elements: ReconciledExcalidrawElement[],
     { init = false }: { init?: boolean } = {},
   ) => {
     this.excalidrawAPI.updateScene({
@@ -887,7 +890,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
     this.portal.broadcastIdleChange(userState);
   };
 
-  broadcastElements = (elements: readonly ExcalidrawElement[]) => {
+  broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
     if (
       getSceneVersion(elements) >
       this.getLastBroadcastedOrReceivedSceneVersion()
@@ -898,7 +901,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
     }
   };
 
-  syncElements = (elements: readonly ExcalidrawElement[]) => {
+  syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
     this.broadcastElements(elements);
     this.queueSaveToFirebase();
   };

+ 14 - 23
excalidraw-app/collab/Portal.tsx

@@ -2,11 +2,12 @@ import {
   isSyncableElement,
   SocketUpdateData,
   SocketUpdateDataSource,
+  SyncableExcalidrawElement,
 } from "../data";
 
 import { TCollabClass } from "./Collab";
 
-import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
+import { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
 import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
 import {
   OnUserFollowedPayload,
@@ -16,9 +17,7 @@ import {
 import { trackEvent } from "../../packages/excalidraw/analytics";
 import throttle from "lodash.throttle";
 import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
-import { BroadcastedExcalidrawElement } from "./reconciliation";
 import { encryptData } from "../../packages/excalidraw/data/encryption";
-import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
 import type { Socket } from "socket.io-client";
 
 class Portal {
@@ -133,7 +132,7 @@ class Portal {
 
   broadcastScene = async (
     updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
-    allElements: readonly ExcalidrawElement[],
+    elements: readonly OrderedExcalidrawElement[],
     syncAll: boolean,
   ) => {
     if (updateType === WS_SUBTYPES.INIT && !syncAll) {
@@ -143,25 +142,17 @@ class Portal {
     // sync out only the elements we think we need to to save bandwidth.
     // periodically we'll resync the whole thing to make sure no one diverges
     // due to a dropped message (server goes down etc).
-    const syncableElements = allElements.reduce(
-      (acc, element: BroadcastedExcalidrawElement, idx, elements) => {
-        if (
-          (syncAll ||
-            !this.broadcastedElementVersions.has(element.id) ||
-            element.version >
-              this.broadcastedElementVersions.get(element.id)!) &&
-          isSyncableElement(element)
-        ) {
-          acc.push({
-            ...element,
-            // z-index info for the reconciler
-            [PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
-          });
-        }
-        return acc;
-      },
-      [] as BroadcastedExcalidrawElement[],
-    );
+    const syncableElements = elements.reduce((acc, element) => {
+      if (
+        (syncAll ||
+          !this.broadcastedElementVersions.has(element.id) ||
+          element.version > this.broadcastedElementVersions.get(element.id)!) &&
+        isSyncableElement(element)
+      ) {
+        acc.push(element);
+      }
+      return acc;
+    }, [] as SyncableExcalidrawElement[]);
 
     const data: SocketUpdateDataSource[typeof updateType] = {
       type: updateType,

+ 0 - 154
excalidraw-app/collab/reconciliation.ts

@@ -1,154 +0,0 @@
-import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
-import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
-import { AppState } from "../../packages/excalidraw/types";
-import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
-
-export type ReconciledElements = readonly ExcalidrawElement[] & {
-  _brand: "reconciledElements";
-};
-
-export type BroadcastedExcalidrawElement = ExcalidrawElement & {
-  [PRECEDING_ELEMENT_KEY]?: string;
-};
-
-const shouldDiscardRemoteElement = (
-  localAppState: AppState,
-  local: ExcalidrawElement | undefined,
-  remote: BroadcastedExcalidrawElement,
-): boolean => {
-  if (
-    local &&
-    // local element is being edited
-    (local.id === localAppState.editingElement?.id ||
-      local.id === localAppState.resizingElement?.id ||
-      local.id === localAppState.draggingElement?.id ||
-      // local element is newer
-      local.version > remote.version ||
-      // resolve conflicting edits deterministically by taking the one with
-      // the lowest versionNonce
-      (local.version === remote.version &&
-        local.versionNonce < remote.versionNonce))
-  ) {
-    return true;
-  }
-  return false;
-};
-
-export const reconcileElements = (
-  localElements: readonly ExcalidrawElement[],
-  remoteElements: readonly BroadcastedExcalidrawElement[],
-  localAppState: AppState,
-): ReconciledElements => {
-  const localElementsData =
-    arrayToMapWithIndex<ExcalidrawElement>(localElements);
-
-  const reconciledElements: ExcalidrawElement[] = localElements.slice();
-
-  const duplicates = new WeakMap<ExcalidrawElement, true>();
-
-  let cursor = 0;
-  let offset = 0;
-
-  let remoteElementIdx = -1;
-  for (const remoteElement of remoteElements) {
-    remoteElementIdx++;
-
-    const local = localElementsData.get(remoteElement.id);
-
-    if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
-      if (remoteElement[PRECEDING_ELEMENT_KEY]) {
-        delete remoteElement[PRECEDING_ELEMENT_KEY];
-      }
-
-      continue;
-    }
-
-    // Mark duplicate for removal as it'll be replaced with the remote element
-    if (local) {
-      // Unless the remote and local elements are the same element in which case
-      // we need to keep it as we'd otherwise discard it from the resulting
-      // array.
-      if (local[0] === remoteElement) {
-        continue;
-      }
-      duplicates.set(local[0], true);
-    }
-
-    // parent may not be defined in case the remote client is running an older
-    // excalidraw version
-    const parent =
-      remoteElement[PRECEDING_ELEMENT_KEY] ||
-      remoteElements[remoteElementIdx - 1]?.id ||
-      null;
-
-    if (parent != null) {
-      delete remoteElement[PRECEDING_ELEMENT_KEY];
-
-      // ^ indicates the element is the first in elements array
-      if (parent === "^") {
-        offset++;
-        if (cursor === 0) {
-          reconciledElements.unshift(remoteElement);
-          localElementsData.set(remoteElement.id, [
-            remoteElement,
-            cursor - offset,
-          ]);
-        } else {
-          reconciledElements.splice(cursor + 1, 0, remoteElement);
-          localElementsData.set(remoteElement.id, [
-            remoteElement,
-            cursor + 1 - offset,
-          ]);
-          cursor++;
-        }
-      } else {
-        let idx = localElementsData.has(parent)
-          ? localElementsData.get(parent)![1]
-          : null;
-        if (idx != null) {
-          idx += offset;
-        }
-        if (idx != null && idx >= cursor) {
-          reconciledElements.splice(idx + 1, 0, remoteElement);
-          offset++;
-          localElementsData.set(remoteElement.id, [
-            remoteElement,
-            idx + 1 - offset,
-          ]);
-          cursor = idx + 1;
-        } else if (idx != null) {
-          reconciledElements.splice(cursor + 1, 0, remoteElement);
-          offset++;
-          localElementsData.set(remoteElement.id, [
-            remoteElement,
-            cursor + 1 - offset,
-          ]);
-          cursor++;
-        } else {
-          reconciledElements.push(remoteElement);
-          localElementsData.set(remoteElement.id, [
-            remoteElement,
-            reconciledElements.length - 1 - offset,
-          ]);
-        }
-      }
-      // no parent z-index information, local element exists → replace in place
-    } else if (local) {
-      reconciledElements[local[1]] = remoteElement;
-      localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
-      // otherwise push to the end
-    } else {
-      reconciledElements.push(remoteElement);
-      localElementsData.set(remoteElement.id, [
-        remoteElement,
-        reconciledElements.length - 1 - offset,
-      ]);
-    }
-  }
-
-  const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
-    (element) => !duplicates.has(element),
-  );
-
-  return ret as ReconciledElements;
-};

+ 32 - 25
excalidraw-app/data/firebase.ts

@@ -1,6 +1,7 @@
 import {
   ExcalidrawElement,
   FileId,
+  OrderedExcalidrawElement,
 } from "../../packages/excalidraw/element/types";
 import { getSceneVersion } from "../../packages/excalidraw/element";
 import Portal from "../collab/Portal";
@@ -18,10 +19,13 @@ import {
   decryptData,
 } from "../../packages/excalidraw/data/encryption";
 import { MIME_TYPES } from "../../packages/excalidraw/constants";
-import { reconcileElements } from "../collab/reconciliation";
 import { getSyncableElements, SyncableExcalidrawElement } from ".";
 import { ResolutionType } from "../../packages/excalidraw/utility-types";
 import type { Socket } from "socket.io-client";
+import {
+  RemoteExcalidrawElement,
+  reconcileElements,
+} from "../../packages/excalidraw/data/reconcile";
 
 // private
 // -----------------------------------------------------------------------------
@@ -230,7 +234,7 @@ export const saveToFirebase = async (
     !socket ||
     isSavedToFirebase(portal, elements)
   ) {
-    return false;
+    return null;
   }
 
   const firebase = await loadFirestore();
@@ -238,56 +242,59 @@ export const saveToFirebase = async (
 
   const docRef = firestore.collection("scenes").doc(roomId);
 
-  const savedData = await firestore.runTransaction(async (transaction) => {
+  const storedScene = await firestore.runTransaction(async (transaction) => {
     const snapshot = await transaction.get(docRef);
 
     if (!snapshot.exists) {
-      const sceneDocument = await createFirebaseSceneDocument(
+      const storedScene = await createFirebaseSceneDocument(
         firebase,
         elements,
         roomKey,
       );
 
-      transaction.set(docRef, sceneDocument);
+      transaction.set(docRef, storedScene);
 
-      return {
-        elements,
-        reconciledElements: null,
-      };
+      return storedScene;
     }
 
-    const prevDocData = snapshot.data() as FirebaseStoredScene;
-    const prevElements = getSyncableElements(
-      await decryptElements(prevDocData, roomKey),
+    const prevStoredScene = snapshot.data() as FirebaseStoredScene;
+    const prevStoredElements = getSyncableElements(
+      restoreElements(await decryptElements(prevStoredScene, roomKey), null),
     );
-
     const reconciledElements = getSyncableElements(
-      reconcileElements(elements, prevElements, appState),
+      reconcileElements(
+        elements,
+        prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
+        appState,
+      ),
     );
 
-    const sceneDocument = await createFirebaseSceneDocument(
+    const storedScene = await createFirebaseSceneDocument(
       firebase,
       reconciledElements,
       roomKey,
     );
 
-    transaction.update(docRef, sceneDocument);
-    return {
-      elements,
-      reconciledElements,
-    };
+    transaction.update(docRef, storedScene);
+
+    // Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
+    return storedScene;
   });
 
-  FirebaseSceneVersionCache.set(socket, savedData.elements);
+  const storedElements = getSyncableElements(
+    restoreElements(await decryptElements(storedScene, roomKey), null),
+  );
+
+  FirebaseSceneVersionCache.set(socket, storedElements);
 
-  return { reconciledElements: savedData.reconciledElements };
+  return storedElements;
 };
 
 export const loadFromFirebase = async (
   roomId: string,
   roomKey: string,
   socket: Socket | null,
-): Promise<readonly ExcalidrawElement[] | null> => {
+): Promise<readonly SyncableExcalidrawElement[] | null> => {
   const firebase = await loadFirestore();
   const db = firebase.firestore();
 
@@ -298,14 +305,14 @@ export const loadFromFirebase = async (
   }
   const storedScene = doc.data() as FirebaseStoredScene;
   const elements = getSyncableElements(
-    await decryptElements(storedScene, roomKey),
+    restoreElements(await decryptElements(storedScene, roomKey), null),
   );
 
   if (socket) {
     FirebaseSceneVersionCache.set(socket, elements);
   }
 
-  return restoreElements(elements, null);
+  return elements;
 };
 
 export const loadFilesFromFirebase = async (

+ 8 - 5
excalidraw-app/data/index.ts

@@ -16,6 +16,7 @@ import { isInitializedImageElement } from "../../packages/excalidraw/element/typ
 import {
   ExcalidrawElement,
   FileId,
+  OrderedExcalidrawElement,
 } from "../../packages/excalidraw/element/types";
 import { t } from "../../packages/excalidraw/i18n";
 import {
@@ -25,6 +26,7 @@ import {
   SocketId,
   UserIdleState,
 } from "../../packages/excalidraw/types";
+import { MakeBrand } from "../../packages/excalidraw/utility-types";
 import { bytesToHexString } from "../../packages/excalidraw/utils";
 import {
   DELETED_ELEMENT_TIMEOUT,
@@ -35,12 +37,11 @@ import {
 import { encodeFilesForUpload } from "./FileManager";
 import { saveFilesToFirebase } from "./firebase";
 
-export type SyncableExcalidrawElement = ExcalidrawElement & {
-  _brand: "SyncableExcalidrawElement";
-};
+export type SyncableExcalidrawElement = OrderedExcalidrawElement &
+  MakeBrand<"SyncableExcalidrawElement">;
 
 export const isSyncableElement = (
-  element: ExcalidrawElement,
+  element: OrderedExcalidrawElement,
 ): element is SyncableExcalidrawElement => {
   if (element.isDeleted) {
     if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
@@ -51,7 +52,9 @@ export const isSyncableElement = (
   return !isInvisiblySmallElement(element);
 };
 
-export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
+export const getSyncableElements = (
+  elements: readonly OrderedExcalidrawElement[],
+) =>
   elements.filter((element) =>
     isSyncableElement(element),
   ) as SyncableExcalidrawElement[];

+ 4 - 2
excalidraw-app/tests/collab.test.tsx

@@ -7,6 +7,8 @@ import {
 import ExcalidrawApp from "../App";
 import { API } from "../../packages/excalidraw/tests/helpers/api";
 import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
+import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
+
 const { h } = window;
 
 Object.defineProperty(window, "crypto", {
@@ -61,14 +63,14 @@ describe("collaboration", () => {
     await render(<ExcalidrawApp />);
     // To update the scene with deleted elements before starting collab
     updateSceneData({
-      elements: [
+      elements: syncInvalidIndices([
         API.createElement({ type: "rectangle", id: "A" }),
         API.createElement({
           type: "rectangle",
           id: "B",
           isDeleted: true,
         }),
-      ],
+      ]),
     });
     await waitFor(() => {
       expect(h.elements).toEqual([

+ 0 - 421
excalidraw-app/tests/reconciliation.test.ts

@@ -1,421 +0,0 @@
-import { expect } from "chai";
-import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
-import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
-import {
-  BroadcastedExcalidrawElement,
-  ReconciledElements,
-  reconcileElements,
-} from "../../excalidraw-app/collab/reconciliation";
-import { randomInteger } from "../../packages/excalidraw/random";
-import { AppState } from "../../packages/excalidraw/types";
-import { cloneJSON } from "../../packages/excalidraw/utils";
-
-type Id = string;
-type ElementLike = {
-  id: string;
-  version: number;
-  versionNonce: number;
-  [PRECEDING_ELEMENT_KEY]?: string | null;
-};
-
-type Cache = Record<string, ExcalidrawElement | undefined>;
-
-const createElement = (opts: { uid: string } | ElementLike) => {
-  let uid: string;
-  let id: string;
-  let version: number | null;
-  let parent: string | null = null;
-  let versionNonce: number | null = null;
-  if ("uid" in opts) {
-    const match = opts.uid.match(
-      /^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
-    )!;
-    parent = match[1];
-    id = match[2];
-    version = match[3] ? parseInt(match[3]) : null;
-    uid = version ? `${id}:${version}` : id;
-  } else {
-    ({ id, version, versionNonce } = opts);
-    parent = parent || null;
-    uid = id;
-  }
-  return {
-    uid,
-    id,
-    version,
-    versionNonce: versionNonce || randomInteger(),
-    [PRECEDING_ELEMENT_KEY]: parent || null,
-  };
-};
-
-const idsToElements = (
-  ids: (Id | ElementLike)[],
-  cache: Cache = {},
-): readonly ExcalidrawElement[] => {
-  return ids.reduce((acc, _uid, idx) => {
-    const {
-      uid,
-      id,
-      version,
-      [PRECEDING_ELEMENT_KEY]: parent,
-      versionNonce,
-    } = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
-    const cached = cache[uid];
-    const elem = {
-      id,
-      version: version ?? 0,
-      versionNonce,
-      ...cached,
-      [PRECEDING_ELEMENT_KEY]: parent,
-    } as BroadcastedExcalidrawElement;
-    // @ts-ignore
-    cache[uid] = elem;
-    acc.push(elem);
-    return acc;
-  }, [] as ExcalidrawElement[]);
-};
-
-const addParents = (elements: BroadcastedExcalidrawElement[]) => {
-  return elements.map((el, idx, els) => {
-    el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
-    return el;
-  });
-};
-
-const cleanElements = (elements: ReconciledElements) => {
-  return elements.map((el) => {
-    // @ts-ignore
-    delete el[PRECEDING_ELEMENT_KEY];
-    // @ts-ignore
-    delete el.next;
-    // @ts-ignore
-    delete el.prev;
-    return el;
-  });
-};
-
-const test = <U extends `${string}:${"L" | "R"}`>(
-  local: (Id | ElementLike)[],
-  remote: (Id | ElementLike)[],
-  target: U[],
-  bidirectional = true,
-) => {
-  const cache: Cache = {};
-  const _local = idsToElements(local, cache);
-  const _remote = idsToElements(remote, cache);
-  const _target = target.map((uid) => {
-    const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
-    return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
-  }) as any as ReconciledElements;
-  const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
-  expect(target.length).equal(remoteReconciled.length);
-  expect(cleanElements(remoteReconciled)).deep.equal(
-    cleanElements(_target),
-    "remote reconciliation",
-  );
-
-  const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
-  const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
-  if (bidirectional) {
-    try {
-      expect(
-        cleanElements(
-          reconcileElements(
-            cloneJSON(__local),
-            cloneJSON(__remote),
-            {} as AppState,
-          ),
-        ),
-      ).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
-    } catch (error: any) {
-      console.error("local original", __local);
-      console.error("remote reconciled", __remote);
-      throw error;
-    }
-  }
-};
-
-export const findIndex = <T>(
-  array: readonly T[],
-  cb: (element: T, index: number, array: readonly T[]) => boolean,
-  fromIndex: number = 0,
-) => {
-  if (fromIndex < 0) {
-    fromIndex = array.length + fromIndex;
-  }
-  fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
-  let index = fromIndex - 1;
-  while (++index < array.length) {
-    if (cb(array[index], index, array)) {
-      return index;
-    }
-  }
-  return -1;
-};
-
-// -----------------------------------------------------------------------------
-
-describe("elements reconciliation", () => {
-  it("reconcileElements()", () => {
-    // -------------------------------------------------------------------------
-    //
-    // in following tests, we pass:
-    //  (1) an array of local elements and their version (:1, :2...)
-    //  (2) an array of remote elements and their version (:1, :2...)
-    //  (3) expected reconciled elements
-    //
-    // in the reconciled array:
-    //  :L means local element was resolved
-    //  :R means remote element was resolved
-    //
-    // if a remote element is prefixed with parentheses, the enclosed string:
-    //  (^) means the element is the first element in the array
-    //  (<id>) means the element is preceded by <id> element
-    //
-    // if versions are missing, it defaults to version 0
-    // -------------------------------------------------------------------------
-
-    // non-annotated elements
-    // -------------------------------------------------------------------------
-    // usually when we sync elements they should always be annotated with
-    // their (preceding elements) parents, but let's test a couple of cases when
-    // they're not for whatever reason (remote clients are on older version...),
-    // in which case the first synced element either replaces existing element
-    // or is pushed at the end of the array
-
-    test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
-    test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
-    test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
-    test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
-    test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
-    test(["A"], ["A", "B"], ["A:L", "B:R"]);
-    test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
-    test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
-    test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
-    test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
-    test(["A"], ["A:1"], ["A:R"]);
-
-    // C isn't added to the end because it follows B (even if B was resolved
-    // to local version)
-    test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
-
-    // some of the following tests are kinda arbitrary and they're less
-    // likely to happen in real-world cases
-
-    test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
-    test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
-    test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
-    test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
-    test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
-    test(
-      ["A:2", "B:2", "C"],
-      ["D", "B:1", "A:3"],
-      ["B:L", "A:R", "C:L", "D:R"],
-    );
-    test(
-      ["A:2", "B:2", "C"],
-      ["D", "B:2", "A:3", "C"],
-      ["D:R", "B:L", "A:R", "C:L"],
-    );
-    test(
-      ["A", "B", "C", "D", "E", "F"],
-      ["A", "B:2", "X", "E:2", "F", "Y"],
-      ["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
-    );
-
-    // annotated elements
-    // -------------------------------------------------------------------------
-
-    test(
-      ["A", "B", "C"],
-      ["(B)X", "(A)Y", "(Y)Z"],
-      ["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
-    );
-
-    test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
-    test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
-
-    test(
-      ["A", "B"],
-      ["(A)C", "(^)D", "F"],
-      ["A:L", "C:R", "D:R", "F:R", "B:L"],
-    );
-
-    test(
-      ["A", "B", "C", "D"],
-      ["(B)C:1", "B", "D:1"],
-      ["A:L", "C:R", "B:L", "D:R"],
-    );
-
-    test(
-      ["A", "B", "C"],
-      ["(^)X", "(A)Y", "(B)Z"],
-      ["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
-    );
-
-    test(
-      ["B", "A", "C"],
-      ["(^)X", "(A)Y", "(B)Z"],
-      ["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
-    );
-
-    test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
-
-    test(
-      ["A", "B", "C", "D", "E"],
-      ["(A)X", "(C)Y", "(D)Z"],
-      ["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
-    );
-
-    test(
-      ["X", "Y", "Z"],
-      ["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
-      ["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
-    );
-
-    test(
-      ["A", "B", "C", "D", "E"],
-      ["(C)X", "(A)Y", "(D)E:1"],
-      ["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
-    );
-
-    test(
-      ["C:1", "B", "D:1"],
-      ["A", "B", "C:1", "D:1"],
-      ["A:R", "B:L", "C:L", "D:L"],
-    );
-
-    test(
-      ["A", "B", "C", "D"],
-      ["(A)C:1", "(C)B", "(B)D:1"],
-      ["A:L", "C:R", "B:L", "D:R"],
-    );
-
-    test(
-      ["A", "B", "C", "D"],
-      ["(A)C:1", "(C)B", "(B)D:1"],
-      ["A:L", "C:R", "B:L", "D:R"],
-    );
-
-    test(
-      ["C:1", "B", "D:1"],
-      ["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
-      ["A:R", "B:L", "C:R", "D:L"],
-    );
-
-    test(
-      ["A", "B", "C", "D"],
-      ["(C)X", "(B)Y", "(A)Z"],
-      ["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
-    );
-
-    test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
-    test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
-    test(
-      ["A", "B", "C", "D"],
-      ["(A)C:1", "B", "D:1"],
-      ["A:L", "C:R", "B:L", "D:R"],
-    );
-
-    test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
-    test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
-
-    test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
-    test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
-    test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
-    test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
-    test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
-    test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
-    test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
-    test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
-    test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
-  });
-
-  it("test identical elements reconciliation", () => {
-    const testIdentical = (
-      local: ElementLike[],
-      remote: ElementLike[],
-      expected: Id[],
-    ) => {
-      const ret = reconcileElements(
-        local as any as ExcalidrawElement[],
-        remote as any as ExcalidrawElement[],
-        {} as AppState,
-      );
-
-      if (new Set(ret.map((x) => x.id)).size !== ret.length) {
-        throw new Error("reconcileElements: duplicate elements found");
-      }
-
-      expect(ret.map((x) => x.id)).to.deep.equal(expected);
-    };
-
-    // identical id/version/versionNonce
-    // -------------------------------------------------------------------------
-
-    testIdentical(
-      [{ id: "A", version: 1, versionNonce: 1 }],
-      [{ id: "A", version: 1, versionNonce: 1 }],
-      ["A"],
-    );
-    testIdentical(
-      [
-        { id: "A", version: 1, versionNonce: 1 },
-        { id: "B", version: 1, versionNonce: 1 },
-      ],
-      [
-        { id: "B", version: 1, versionNonce: 1 },
-        { id: "A", version: 1, versionNonce: 1 },
-      ],
-      ["B", "A"],
-    );
-    testIdentical(
-      [
-        { id: "A", version: 1, versionNonce: 1 },
-        { id: "B", version: 1, versionNonce: 1 },
-      ],
-      [
-        { id: "B", version: 1, versionNonce: 1 },
-        { id: "A", version: 1, versionNonce: 1 },
-      ],
-      ["B", "A"],
-    );
-
-    // actually identical (arrays and element objects)
-    // -------------------------------------------------------------------------
-
-    const elements1 = [
-      {
-        id: "A",
-        version: 1,
-        versionNonce: 1,
-        [PRECEDING_ELEMENT_KEY]: null,
-      },
-      {
-        id: "B",
-        version: 1,
-        versionNonce: 1,
-        [PRECEDING_ELEMENT_KEY]: null,
-      },
-    ];
-
-    testIdentical(elements1, elements1, ["A", "B"]);
-    testIdentical(elements1, elements1.slice(), ["A", "B"]);
-    testIdentical(elements1.slice(), elements1, ["A", "B"]);
-    testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
-
-    const el1 = {
-      id: "A",
-      version: 1,
-      versionNonce: 1,
-      [PRECEDING_ELEMENT_KEY]: null,
-    };
-    const el2 = {
-      id: "B",
-      version: 1,
-      versionNonce: 1,
-      [PRECEDING_ELEMENT_KEY]: null,
-    };
-    testIdentical([el1, el2], [el2, el1], ["A", "B"]);
-  });
-});

+ 7 - 1
packages/excalidraw/actions/actionBoundText.tsx

@@ -31,8 +31,9 @@ import {
 } from "../element/types";
 import { AppState } from "../types";
 import { Mutable } from "../utility-types";
-import { getFontString } from "../utils";
+import { arrayToMap, getFontString } from "../utils";
 import { register } from "./register";
+import { syncMovedIndices } from "../fractionalIndex";
 
 export const actionUnbindText = register({
   name: "unbindText",
@@ -180,6 +181,8 @@ const pushTextAboveContainer = (
     (ele) => ele.id === container.id,
   );
   updatedElements.splice(containerIndex + 1, 0, textElement);
+  syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
+
   return updatedElements;
 };
 
@@ -198,6 +201,8 @@ const pushContainerBelowText = (
     (ele) => ele.id === textElement.id,
   );
   updatedElements.splice(textElementIndex, 0, container);
+  syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
+
   return updatedElements;
 };
 
@@ -304,6 +309,7 @@ export const actionWrapTextInContainer = register({
           container,
           textElement,
         );
+
         containerIds[container.id] = true;
       }
     }

+ 5 - 1
packages/excalidraw/actions/actionDuplicateSelection.tsx

@@ -31,6 +31,7 @@ import {
   excludeElementsInFramesFromSelection,
   getSelectedElements,
 } from "../scene/selection";
+import { syncMovedIndices } from "../fractionalIndex";
 
 export const actionDuplicateSelection = register({
   name: "duplicateSelection",
@@ -90,6 +91,7 @@ const duplicateElements = (
   const newElements: ExcalidrawElement[] = [];
   const oldElements: ExcalidrawElement[] = [];
   const oldIdToDuplicatedId = new Map();
+  const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
 
   const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
     const newElement = duplicateElement(
@@ -101,6 +103,7 @@ const duplicateElements = (
         y: element.y + GRID_SIZE / 2,
       },
     );
+    duplicatedElementsMap.set(newElement.id, newElement);
     oldIdToDuplicatedId.set(element.id, newElement.id);
     oldElements.push(element);
     newElements.push(newElement);
@@ -238,9 +241,10 @@ const duplicateElements = (
   }
 
   // step (3)
-
   const finalElements = finalElementsReversed.reverse();
 
+  syncMovedIndices(finalElements, arrayToMap([...oldElements, ...newElements]));
+
   // ---------------------------------------------------------------------------
 
   bindTextToShapeAfterDuplication(

+ 4 - 2
packages/excalidraw/actions/actionGroup.tsx

@@ -27,6 +27,7 @@ import {
   removeElementsFromFrame,
   replaceAllElementsInFrame,
 } from "../frame";
+import { syncMovedIndices } from "../fractionalIndex";
 
 const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
   if (elements.length >= 2) {
@@ -140,11 +141,12 @@ export const actionGroup = register({
       .filter(
         (updatedElement) => !isElementInGroup(updatedElement, newGroupId),
       );
-    nextElements = [
+    const reorderedElements = [
       ...elementsBeforeGroup,
       ...elementsInGroup,
       ...elementsAfterGroup,
     ];
+    syncMovedIndices(reorderedElements, arrayToMap(elementsInGroup));
 
     return {
       appState: {
@@ -155,7 +157,7 @@ export const actionGroup = register({
           getNonDeletedElements(nextElements),
         ),
       },
-      elements: nextElements,
+      elements: reorderedElements,
       commitToHistory: true,
     };
   },

+ 3 - 0
packages/excalidraw/actions/actionHistory.tsx

@@ -10,6 +10,7 @@ import { newElementWith } from "../element/mutateElement";
 import { fixBindingsAfterDeletion } from "../element/binding";
 import { arrayToMap } from "../utils";
 import { isWindows } from "../constants";
+import { syncInvalidIndices } from "../fractionalIndex";
 
 const writeData = (
   prevElements: readonly ExcalidrawElement[],
@@ -48,6 +49,8 @@ const writeData = (
         ),
       );
     fixBindingsAfterDeletion(elements, deletedElements);
+    // TODO: will be replaced in #7348
+    syncInvalidIndices(elements);
 
     return {
       elements,

+ 0 - 1
packages/excalidraw/actions/actionZindex.tsx

@@ -1,4 +1,3 @@
-import React from "react";
 import {
   moveOneLeft,
   moveOneRight,

+ 29 - 41
packages/excalidraw/components/App.tsx

@@ -182,6 +182,7 @@ import {
   IframeData,
   ExcalidrawIframeElement,
   ExcalidrawEmbeddableElement,
+  Ordered,
 } from "../element/types";
 import { getCenter, getDistance } from "../gesture";
 import {
@@ -276,6 +277,7 @@ import {
   muteFSAbortError,
   isTestEnv,
   easeOut,
+  arrayToMap,
   updateStable,
   addEventListener,
   normalizeEOL,
@@ -407,7 +409,6 @@ import { ElementCanvasButton } from "./MagicButton";
 import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
 import { EditorLocalStorage } from "../data/EditorLocalStorage";
 import FollowMode from "./FollowMode/FollowMode";
-
 import { AnimationFrameHandler } from "../animation-frame-handler";
 import { AnimatedTrail } from "../animated-trail";
 import { LaserTrails } from "../laser-trails";
@@ -422,6 +423,7 @@ import {
 } from "../element/collision";
 import { textWysiwyg } from "../element/textWysiwyg";
 import { isOverScrollBars } from "../scene/scrollbars";
+import { syncInvalidIndices, syncMovedIndices } from "../fractionalIndex";
 import {
   isPointHittingLink,
   isPointHittingLinkIcon,
@@ -948,7 +950,7 @@ class App extends React.Component<AppProps, AppState> {
     const embeddableElements = this.scene
       .getNonDeletedElements()
       .filter(
-        (el): el is NonDeleted<ExcalidrawIframeLikeElement> =>
+        (el): el is Ordered<NonDeleted<ExcalidrawIframeLikeElement>> =>
           (isEmbeddableElement(el) &&
             this.embedsValidationStatus.get(el.id) === true) ||
           isIframeElement(el),
@@ -2056,7 +2058,7 @@ class App extends React.Component<AppProps, AppState> {
           locked: false,
         });
 
-        this.scene.addNewElement(frame);
+        this.scene.insertElement(frame);
 
         for (const child of selectedElements) {
           mutateElement(child, { frameId: frame.id });
@@ -3115,10 +3117,10 @@ class App extends React.Component<AppProps, AppState> {
       },
     );
 
-    const allElements = [
-      ...this.scene.getElementsIncludingDeleted(),
-      ...newElements,
-    ];
+    const prevElements = this.scene.getElementsIncludingDeleted();
+    const nextElements = [...prevElements, ...newElements];
+
+    syncMovedIndices(nextElements, arrayToMap(newElements));
 
     const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y });
 
@@ -3127,10 +3129,10 @@ class App extends React.Component<AppProps, AppState> {
         newElements,
         topLayerFrame,
       );
-      addElementsToFrame(allElements, eligibleElements, topLayerFrame);
+      addElementsToFrame(nextElements, eligibleElements, topLayerFrame);
     }
 
-    this.scene.replaceAllElements(allElements);
+    this.scene.replaceAllElements(nextElements);
 
     newElements.forEach((newElement) => {
       if (isTextElement(newElement) && isBoundToContainer(newElement)) {
@@ -3361,19 +3363,7 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
-    const frameId = textElements[0].frameId;
-
-    if (frameId) {
-      this.scene.insertElementsAtIndex(
-        textElements,
-        this.scene.getElementIndex(frameId),
-      );
-    } else {
-      this.scene.replaceAllElements([
-        ...this.scene.getElementsIncludingDeleted(),
-        ...textElements,
-      ]);
-    }
+    this.scene.insertElements(textElements);
 
     this.setState({
       selectedElementIds: makeNextSelectedElementIds(
@@ -4489,7 +4479,7 @@ class App extends React.Component<AppProps, AppState> {
     includeBoundTextElement: boolean = false,
     includeLockedElements: boolean = false,
   ): NonDeleted<ExcalidrawElement>[] {
-    const iframeLikes: ExcalidrawIframeElement[] = [];
+    const iframeLikes: Ordered<ExcalidrawIframeElement>[] = [];
 
     const elementsMap = this.scene.getNonDeletedElementsMap();
 
@@ -4758,7 +4748,7 @@ class App extends React.Component<AppProps, AppState> {
         const containerIndex = this.scene.getElementIndex(container.id);
         this.scene.insertElementAtIndex(element, containerIndex + 1);
       } else {
-        this.scene.addNewElement(element);
+        this.scene.insertElement(element);
       }
     }
 
@@ -6639,7 +6629,7 @@ class App extends React.Component<AppProps, AppState> {
       pointerDownState.origin,
       this,
     );
-    this.scene.addNewElement(element);
+    this.scene.insertElement(element);
     this.setState({
       draggingElement: element,
       editingElement: element,
@@ -6684,10 +6674,7 @@ class App extends React.Component<AppProps, AppState> {
       height,
     });
 
-    this.scene.replaceAllElements([
-      ...this.scene.getElementsIncludingDeleted(),
-      element,
-    ]);
+    this.scene.insertElement(element);
 
     return element;
   };
@@ -6741,10 +6728,7 @@ class App extends React.Component<AppProps, AppState> {
       link,
     });
 
-    this.scene.replaceAllElements([
-      ...this.scene.getElementsIncludingDeleted(),
-      element,
-    ]);
+    this.scene.insertElement(element);
 
     return element;
   };
@@ -6908,7 +6892,7 @@ class App extends React.Component<AppProps, AppState> {
         this,
       );
 
-      this.scene.addNewElement(element);
+      this.scene.insertElement(element);
       this.setState({
         draggingElement: element,
         editingElement: element,
@@ -6987,7 +6971,7 @@ class App extends React.Component<AppProps, AppState> {
         draggingElement: element,
       });
     } else {
-      this.scene.addNewElement(element);
+      this.scene.insertElement(element);
       this.setState({
         multiElement: null,
         draggingElement: element,
@@ -7021,10 +7005,7 @@ class App extends React.Component<AppProps, AppState> {
         ? newMagicFrameElement(constructorOpts)
         : newFrameElement(constructorOpts);
 
-    this.scene.replaceAllElements([
-      ...this.scene.getElementsIncludingDeleted(),
-      frame,
-    ]);
+    this.scene.insertElement(frame);
 
     this.setState({
       multiElement: null,
@@ -7437,7 +7418,11 @@ class App extends React.Component<AppProps, AppState> {
                 nextElements.push(element);
               }
             }
+
             const nextSceneElements = [...nextElements, ...elementsToAppend];
+
+            syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
+
             bindTextToShapeAfterDuplication(
               nextElements,
               elementsToAppend,
@@ -7454,6 +7439,7 @@ class App extends React.Component<AppProps, AppState> {
               elementsToAppend,
               oldIdToDuplicatedId,
             );
+
             this.scene.replaceAllElements(nextSceneElements);
             this.maybeCacheVisibleGaps(event, selectedElements, true);
             this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
@@ -8628,7 +8614,7 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
-    this.scene.addNewElement(imageElement);
+    this.scene.insertElement(imageElement);
 
     try {
       return await this.initializeImage({
@@ -9792,7 +9778,9 @@ export const createTestHook = () => {
           return this.app?.scene.getElementsIncludingDeleted();
         },
         set(elements: ExcalidrawElement[]) {
-          return this.app?.scene.replaceAllElements(elements);
+          return this.app?.scene.replaceAllElements(
+            syncInvalidIndices(elements),
+          );
         },
       },
     });

+ 0 - 4
packages/excalidraw/constants.ts

@@ -316,10 +316,6 @@ export const ROUNDNESS = {
   ADAPTIVE_RADIUS: 3,
 } as const;
 
-/** key containt id of precedeing elemnt id we use in reconciliation during
- * collaboration */
-export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
-
 export const ROUGHNESS = {
   architect: 0,
   artist: 1,

+ 100 - 50
packages/excalidraw/data/__snapshots__/transform.test.ts.snap

@@ -20,6 +20,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "groupIds": [],
   "height": 300,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -32,7 +33,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 3,
+  "version": 4,
   "versionNonce": Any<Number>,
   "width": 300,
   "x": 630,
@@ -56,6 +57,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -68,7 +70,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "strokeWidth": 2,
   "type": "diamond",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 140,
   "x": 96,
@@ -93,6 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "groupIds": [],
   "height": 35,
   "id": Any<String>,
+  "index": "a2",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -122,7 +125,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 3,
+  "version": 4,
   "versionNonce": Any<Number>,
   "width": 395,
   "x": 247,
@@ -147,6 +150,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a3",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -176,7 +180,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 3,
+  "version": 4,
   "versionNonce": Any<Number>,
   "width": 400,
   "x": 227,
@@ -200,6 +204,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "groupIds": [],
   "height": 300,
   "id": Any<String>,
+  "index": "a4",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -212,7 +217,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 300,
   "x": -53,
@@ -239,6 +244,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -255,7 +261,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "top",
   "width": 70,
@@ -283,6 +289,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -299,7 +306,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "top",
   "width": 100,
@@ -330,6 +337,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a2",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -359,7 +367,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 3,
+  "version": 4,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 255,
@@ -381,6 +389,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a3",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -397,7 +406,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 130,
@@ -428,6 +437,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -457,7 +467,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 3,
+  "version": 4,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 255,
@@ -479,6 +489,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -495,7 +506,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 130,
@@ -520,6 +531,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a2",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -532,7 +544,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 155,
@@ -556,6 +568,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a3",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -568,7 +581,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 355,
@@ -598,6 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -627,7 +641,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 3,
+  "version": 4,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 255,
@@ -649,6 +663,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -665,7 +680,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 130,
@@ -693,6 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a2",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -709,7 +725,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "top",
   "width": 70,
@@ -737,6 +753,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a3",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -753,7 +770,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "top",
   "width": 100,
@@ -773,6 +790,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
   "groupIds": [],
   "height": 200,
   "id": "rect-1",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -785,7 +803,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 300,
@@ -806,6 +824,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -831,7 +850,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -852,6 +871,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -877,7 +897,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 450,
@@ -898,6 +918,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a2",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -923,7 +944,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
   "strokeWidth": 2,
   "type": "line",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -944,6 +965,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a3",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -969,7 +991,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
   "strokeWidth": 2,
   "type": "line",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 450,
@@ -988,6 +1010,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1000,7 +1023,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -1019,6 +1042,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1031,7 +1055,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -1050,6 +1074,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a2",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1062,7 +1087,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
   "strokeWidth": 2,
   "type": "diamond",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -1081,6 +1106,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a3",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1093,7 +1119,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 200,
   "x": 300,
@@ -1112,6 +1138,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a4",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1124,7 +1151,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 200,
   "x": 300,
@@ -1143,6 +1170,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
   "groupIds": [],
   "height": 100,
   "id": Any<String>,
+  "index": "a5",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1155,7 +1183,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
   "strokeWidth": 2,
   "type": "diamond",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 200,
   "x": 300,
@@ -1177,6 +1205,7 @@ exports[`Test Transform > should transform text element 1`] = `
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1193,7 +1222,7 @@ exports[`Test Transform > should transform text element 1`] = `
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "verticalAlign": "top",
   "width": 120,
@@ -1216,6 +1245,7 @@ exports[`Test Transform > should transform text element 2`] = `
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1232,7 +1262,7 @@ exports[`Test Transform > should transform text element 2`] = `
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "verticalAlign": "top",
   "width": 190,
@@ -1259,6 +1289,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -1284,7 +1315,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -1310,6 +1341,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -1335,7 +1367,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -1361,6 +1393,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a2",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -1386,7 +1419,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -1412,6 +1445,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "groupIds": [],
   "height": 0,
   "id": Any<String>,
+  "index": "a3",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -1437,7 +1471,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 1,
+  "version": 2,
   "versionNonce": Any<Number>,
   "width": 100,
   "x": 100,
@@ -1459,6 +1493,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a4",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1475,7 +1510,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 130,
@@ -1498,6 +1533,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a5",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1514,7 +1550,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 200,
@@ -1537,6 +1573,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "groupIds": [],
   "height": 50,
   "id": Any<String>,
+  "index": "a6",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1554,7 +1591,7 @@ LABELLED ARROW",
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 150,
@@ -1577,6 +1614,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "groupIds": [],
   "height": 50,
   "id": Any<String>,
+  "index": "a7",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1594,7 +1632,7 @@ LABELLED ARROW",
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 150,
@@ -1619,6 +1657,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 35,
   "id": Any<String>,
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1631,7 +1670,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 3,
+  "version": 4,
   "versionNonce": Any<Number>,
   "width": 250,
   "x": 100,
@@ -1655,6 +1694,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 85,
   "id": Any<String>,
+  "index": "a1",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1667,7 +1707,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 200,
   "x": 500,
@@ -1691,6 +1731,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 170,
   "id": Any<String>,
+  "index": "a2",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1703,7 +1744,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "strokeWidth": 2,
   "type": "diamond",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 280,
   "x": 100,
@@ -1727,6 +1768,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 120,
   "id": Any<String>,
+  "index": "a3",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1739,7 +1781,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "strokeWidth": 2,
   "type": "diamond",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 300,
   "x": 100,
@@ -1763,6 +1805,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 85,
   "id": Any<String>,
+  "index": "a4",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1775,7 +1818,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 200,
   "x": 500,
@@ -1799,6 +1842,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 120,
   "id": Any<String>,
+  "index": "a5",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -1811,7 +1855,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "width": 200,
   "x": 500,
@@ -1833,6 +1877,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 25,
   "id": Any<String>,
+  "index": "a6",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1849,7 +1894,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 240,
@@ -1872,6 +1917,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 50,
   "id": Any<String>,
+  "index": "a7",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1889,7 +1935,7 @@ CONTAINER",
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 130,
@@ -1912,6 +1958,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 75,
   "id": Any<String>,
+  "index": "a8",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1931,7 +1978,7 @@ CONTAINER",
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 90,
@@ -1954,6 +2001,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 50,
   "id": Any<String>,
+  "index": "a9",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -1971,7 +2019,7 @@ TEXT CONTAINER",
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 140,
@@ -1994,6 +2042,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 75,
   "id": Any<String>,
+  "index": "aA",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -2012,7 +2061,7 @@ CONTAINER",
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "top",
   "width": 170,
@@ -2035,6 +2084,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "groupIds": [],
   "height": 75,
   "id": Any<String>,
+  "index": "aB",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -2053,7 +2103,7 @@ CONTAINER",
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 130,

+ 79 - 0
packages/excalidraw/data/reconcile.ts

@@ -0,0 +1,79 @@
+import { OrderedExcalidrawElement } from "../element/types";
+import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex";
+import { AppState } from "../types";
+import { MakeBrand } from "../utility-types";
+import { arrayToMap } from "../utils";
+
+export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
+  MakeBrand<"ReconciledElement">;
+
+export type RemoteExcalidrawElement = OrderedExcalidrawElement &
+  MakeBrand<"RemoteExcalidrawElement">;
+
+const shouldDiscardRemoteElement = (
+  localAppState: AppState,
+  local: OrderedExcalidrawElement | undefined,
+  remote: RemoteExcalidrawElement,
+): boolean => {
+  if (
+    local &&
+    // local element is being edited
+    (local.id === localAppState.editingElement?.id ||
+      local.id === localAppState.resizingElement?.id ||
+      local.id === localAppState.draggingElement?.id ||
+      // local element is newer
+      local.version > remote.version ||
+      // resolve conflicting edits deterministically by taking the one with
+      // the lowest versionNonce
+      (local.version === remote.version &&
+        local.versionNonce < remote.versionNonce))
+  ) {
+    return true;
+  }
+  return false;
+};
+
+export const reconcileElements = (
+  localElements: readonly OrderedExcalidrawElement[],
+  remoteElements: readonly RemoteExcalidrawElement[],
+  localAppState: AppState,
+): ReconciledExcalidrawElement[] => {
+  const localElementsMap = arrayToMap(localElements);
+  const reconciledElements: OrderedExcalidrawElement[] = [];
+  const added = new Set<string>();
+
+  // process remote elements
+  for (const remoteElement of remoteElements) {
+    if (!added.has(remoteElement.id)) {
+      const localElement = localElementsMap.get(remoteElement.id);
+      const discardRemoteElement = shouldDiscardRemoteElement(
+        localAppState,
+        localElement,
+        remoteElement,
+      );
+
+      if (localElement && discardRemoteElement) {
+        reconciledElements.push(localElement);
+        added.add(localElement.id);
+      } else {
+        reconciledElements.push(remoteElement);
+        added.add(remoteElement.id);
+      }
+    }
+  }
+
+  // process remaining local elements
+  for (const localElement of localElements) {
+    if (!added.has(localElement.id)) {
+      reconciledElements.push(localElement);
+      added.add(localElement.id);
+    }
+  }
+
+  const orderedElements = orderByFractionalIndex(reconciledElements);
+
+  // de-duplicate indices
+  syncInvalidIndices(orderedElements);
+
+  return orderedElements as ReconciledExcalidrawElement[];
+};

+ 29 - 30
packages/excalidraw/data/restore.ts

@@ -4,6 +4,7 @@ import {
   ExcalidrawSelectionElement,
   ExcalidrawTextElement,
   FontFamilyValues,
+  OrderedExcalidrawElement,
   PointBinding,
   StrokeRoundness,
 } from "../element/types";
@@ -26,7 +27,6 @@ import {
   DEFAULT_FONT_FAMILY,
   DEFAULT_TEXT_ALIGN,
   DEFAULT_VERTICAL_ALIGN,
-  PRECEDING_ELEMENT_KEY,
   FONT_FAMILY,
   ROUNDNESS,
   DEFAULT_SIDEBAR,
@@ -44,6 +44,7 @@ import {
   getDefaultLineHeight,
 } from "../element/textElement";
 import { normalizeLink } from "./url";
+import { syncInvalidIndices } from "../fractionalIndex";
 
 type RestoredAppState = Omit<
   AppState,
@@ -73,7 +74,7 @@ export const AllowedExcalidrawActiveTools: Record<
 };
 
 export type RestoredDataState = {
-  elements: ExcalidrawElement[];
+  elements: OrderedExcalidrawElement[];
   appState: RestoredAppState;
   files: BinaryFiles;
 };
@@ -101,8 +102,6 @@ const restoreElementWithProperties = <
     boundElementIds?: readonly ExcalidrawElement["id"][];
     /** @deprecated */
     strokeSharpness?: StrokeRoundness;
-    /** metadata that may be present in elements during collaboration */
-    [PRECEDING_ELEMENT_KEY]?: string;
   },
   K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
 >(
@@ -115,14 +114,13 @@ const restoreElementWithProperties = <
   > &
     Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
 ): T => {
-  const base: Pick<T, keyof ExcalidrawElement> & {
-    [PRECEDING_ELEMENT_KEY]?: string;
-  } = {
+  const base: Pick<T, keyof ExcalidrawElement> = {
     type: extra.type || element.type,
     // all elements must have version > 0 so getSceneVersion() will pick up
     // newly added elements
     version: element.version || 1,
     versionNonce: element.versionNonce ?? 0,
+    index: element.index ?? null,
     isDeleted: element.isDeleted ?? false,
     id: element.id || randomId(),
     fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
@@ -166,10 +164,6 @@ const restoreElementWithProperties = <
       "customData" in extra ? extra.customData : element.customData;
   }
 
-  if (PRECEDING_ELEMENT_KEY in element) {
-    base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
-  }
-
   return {
     ...base,
     ...getNormalizedDimensions(base),
@@ -407,30 +401,35 @@ export const restoreElements = (
   /** NOTE doesn't serve for reconciliation */
   localElements: readonly ExcalidrawElement[] | null | undefined,
   opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
-): ExcalidrawElement[] => {
+): OrderedExcalidrawElement[] => {
   // used to detect duplicate top-level element ids
   const existingIds = new Set<string>();
   const localElementsMap = localElements ? arrayToMap(localElements) : null;
-  const restoredElements = (elements || []).reduce((elements, element) => {
-    // filtering out selection, which is legacy, no longer kept in elements,
-    // and causing issues if retained
-    if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
-      let migratedElement: ExcalidrawElement | null = restoreElement(element);
-      if (migratedElement) {
-        const localElement = localElementsMap?.get(element.id);
-        if (localElement && localElement.version > migratedElement.version) {
-          migratedElement = bumpVersion(migratedElement, localElement.version);
-        }
-        if (existingIds.has(migratedElement.id)) {
-          migratedElement = { ...migratedElement, id: randomId() };
-        }
-        existingIds.add(migratedElement.id);
+  const restoredElements = syncInvalidIndices(
+    (elements || []).reduce((elements, element) => {
+      // filtering out selection, which is legacy, no longer kept in elements,
+      // and causing issues if retained
+      if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
+        let migratedElement: ExcalidrawElement | null = restoreElement(element);
+        if (migratedElement) {
+          const localElement = localElementsMap?.get(element.id);
+          if (localElement && localElement.version > migratedElement.version) {
+            migratedElement = bumpVersion(
+              migratedElement,
+              localElement.version,
+            );
+          }
+          if (existingIds.has(migratedElement.id)) {
+            migratedElement = { ...migratedElement, id: randomId() };
+          }
+          existingIds.add(migratedElement.id);
 
-        elements.push(migratedElement);
+          elements.push(migratedElement);
+        }
       }
-    }
-    return elements;
-  }, [] as ExcalidrawElement[]);
+      return elements;
+    }, [] as ExcalidrawElement[]),
+  );
 
   if (!opts?.repairBindings) {
     return restoredElements;

+ 13 - 3
packages/excalidraw/data/transform.ts

@@ -44,9 +44,16 @@ import {
   VerticalAlign,
 } from "../element/types";
 import { MarkOptional } from "../utility-types";
-import { assertNever, cloneJSON, getFontString, toBrandedType } from "../utils";
+import {
+  arrayToMap,
+  assertNever,
+  cloneJSON,
+  getFontString,
+  toBrandedType,
+} from "../utils";
 import { getSizeFromPoints } from "../points";
 import { randomId } from "../random";
+import { syncInvalidIndices } from "../fractionalIndex";
 
 export type ValidLinearElement = {
   type: "arrow" | "line";
@@ -457,12 +464,15 @@ class ElementStore {
 
     this.excalidrawElements.set(ele.id, ele);
   };
+
   getElements = () => {
-    return Array.from(this.excalidrawElements.values());
+    return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
   };
 
   getElementsMap = () => {
-    return toBrandedType<NonDeletedSceneElementsMap>(this.excalidrawElements);
+    return toBrandedType<NonDeletedSceneElementsMap>(
+      arrayToMap(this.getElements()),
+    );
   };
 
   getElement = (id: string) => {

+ 3 - 0
packages/excalidraw/element/newElement.ts

@@ -55,6 +55,7 @@ export type ElementConstructorOpts = MarkOptional<
   | "angle"
   | "groupIds"
   | "frameId"
+  | "index"
   | "boundElements"
   | "seed"
   | "version"
@@ -89,6 +90,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     angle = 0,
     groupIds = [],
     frameId = null,
+    index = null,
     roundness = null,
     boundElements = null,
     link = null,
@@ -114,6 +116,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     opacity,
     groupIds,
     frameId,
+    index,
     roundness,
     seed: rest.seed ?? randomInteger(),
     version: rest.version || 1,

+ 1 - 1
packages/excalidraw/element/textWysiwyg.test.tsx

@@ -1454,7 +1454,7 @@ describe("textWysiwyg", () => {
           strokeWidth: 2,
           type: "rectangle",
           updated: 1,
-          version: 1,
+          version: 2,
           width: 610,
           x: 15,
           y: 25,

+ 17 - 2
packages/excalidraw/element/types.ts

@@ -24,6 +24,7 @@ export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
 
 type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
 export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
+export type FractionalIndex = string & { _brand: "franctionalIndex" };
 
 type _ExcalidrawElementBase = Readonly<{
   id: string;
@@ -50,6 +51,11 @@ type _ExcalidrawElementBase = Readonly<{
       Used for deterministic reconciliation of updates during collaboration,
       in case the versions (see above) are identical. */
   versionNonce: number;
+  /** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing.
+      Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo.
+      Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`.
+      Could be null, i.e. for new elements which were not yet assigned to the scene. */
+  index: FractionalIndex | null;
   isDeleted: boolean;
   /** List of groups the element belongs to.
       Ordered from deepest to shallowest. */
@@ -164,6 +170,12 @@ export type ExcalidrawElement =
   | ExcalidrawIframeElement
   | ExcalidrawEmbeddableElement;
 
+export type Ordered<TElement extends ExcalidrawElement> = TElement & {
+  index: FractionalIndex;
+};
+
+export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
+
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
   isDeleted: boolean;
 };
@@ -275,7 +287,10 @@ export type NonDeletedElementsMap = Map<
  * Map of all excalidraw Scene elements, including deleted.
  * Not a subset. Use this type when you need access to current Scene elements.
  */
-export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
+export type SceneElementsMap = Map<
+  ExcalidrawElement["id"],
+  Ordered<ExcalidrawElement>
+> &
   MakeBrand<"SceneElementsMap">;
 
 /**
@@ -284,7 +299,7 @@ export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
  */
 export type NonDeletedSceneElementsMap = Map<
   ExcalidrawElement["id"],
-  NonDeletedExcalidrawElement
+  Ordered<NonDeletedExcalidrawElement>
 > &
   MakeBrand<"NonDeletedSceneElementsMap">;
 

+ 4 - 0
packages/excalidraw/errors.ts

@@ -32,3 +32,7 @@ export class ImageSceneDataError extends Error {
     this.code = code;
   }
 }
+
+export class InvalidFractionalIndexError extends Error {
+  public code = "ELEMENT_HAS_INVALID_INDEX" as const;
+}

+ 348 - 0
packages/excalidraw/fractionalIndex.ts

@@ -0,0 +1,348 @@
+import { generateNKeysBetween } from "fractional-indexing";
+import { mutateElement } from "./element/mutateElement";
+import {
+  ExcalidrawElement,
+  FractionalIndex,
+  OrderedExcalidrawElement,
+} from "./element/types";
+import { InvalidFractionalIndexError } from "./errors";
+
+/**
+ * Envisioned relation between array order and fractional indices:
+ *
+ * 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation.
+ * - it's undesirable to to perform reorder for each related operation, thefeore it's necessary to cache the order defined by fractional indices into an ordered data structure
+ * - 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)
+ * - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc.
+ * - it's necessary to always keep the fractional indices in sync with the array order
+ * - elements with invalid indices should be detected and synced, without altering the already valid indices
+ *
+ * 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated.
+ * - as the fractional indices are encoded as part of the elements, it opens up possibilties for incremental-like APIs
+ * - re-order based on fractional indices should be part of (multiplayer) operations such as reconcillitation & undo/redo
+ * - 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,
+ *   as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order
+ */
+
+/**
+ * Ensure that all elements have valid fractional indices.
+ *
+ * @throws `InvalidFractionalIndexError` if invalid index is detected.
+ */
+export const validateFractionalIndices = (
+  indices: (ExcalidrawElement["index"] | undefined)[],
+) => {
+  for (const [i, index] of indices.entries()) {
+    const predecessorIndex = indices[i - 1];
+    const successorIndex = indices[i + 1];
+
+    if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
+      throw new InvalidFractionalIndexError(
+        `Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`,
+      );
+    }
+  }
+};
+
+/**
+ * Order the elements based on the fractional indices.
+ * - when fractional indices are identical, break the tie based on the element id
+ * - when there is no fractional index in one of the elements, respect the order of the array
+ */
+export const orderByFractionalIndex = (
+  elements: OrderedExcalidrawElement[],
+) => {
+  return elements.sort((a, b) => {
+    // in case the indices are not the defined at runtime
+    if (isOrderedElement(a) && isOrderedElement(b)) {
+      if (a.index < b.index) {
+        return -1;
+      } else if (a.index > b.index) {
+        return 1;
+      }
+
+      // break ties based on the element id
+      return a.id < b.id ? -1 : 1;
+    }
+
+    // defensively keep the array order
+    return 1;
+  });
+};
+
+/**
+ * Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements.
+ * If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`.
+ */
+export const syncMovedIndices = (
+  elements: readonly ExcalidrawElement[],
+  movedElements: Map<string, ExcalidrawElement>,
+): OrderedExcalidrawElement[] => {
+  try {
+    const indicesGroups = getMovedIndicesGroups(elements, movedElements);
+
+    // try generatating indices, throws on invalid movedElements
+    const elementsUpdates = generateIndices(elements, indicesGroups);
+
+    // ensure next indices are valid before mutation, throws on invalid ones
+    validateFractionalIndices(
+      elements.map((x) => elementsUpdates.get(x)?.index || x.index),
+    );
+
+    // split mutation so we don't end up in an incosistent state
+    for (const [element, update] of elementsUpdates) {
+      mutateElement(element, update, false);
+    }
+  } catch (e) {
+    // fallback to default sync
+    syncInvalidIndices(elements);
+  }
+
+  return elements as OrderedExcalidrawElement[];
+};
+
+/**
+ * Synchronizes all invalid fractional indices with the array order by mutating passed elements.
+ *
+ * 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.
+ */
+export const syncInvalidIndices = (
+  elements: readonly ExcalidrawElement[],
+): OrderedExcalidrawElement[] => {
+  const indicesGroups = getInvalidIndicesGroups(elements);
+  const elementsUpdates = generateIndices(elements, indicesGroups);
+
+  for (const [element, update] of elementsUpdates) {
+    mutateElement(element, update, false);
+  }
+
+  return elements as OrderedExcalidrawElement[];
+};
+
+/**
+ * Get contiguous groups of indices of passed moved elements.
+ *
+ * NOTE: First and last elements within the groups are indices of lower and upper bounds.
+ */
+const getMovedIndicesGroups = (
+  elements: readonly ExcalidrawElement[],
+  movedElements: Map<string, ExcalidrawElement>,
+) => {
+  const indicesGroups: number[][] = [];
+
+  let i = 0;
+
+  while (i < elements.length) {
+    if (
+      movedElements.has(elements[i].id) &&
+      !isValidFractionalIndex(
+        elements[i]?.index,
+        elements[i - 1]?.index,
+        elements[i + 1]?.index,
+      )
+    ) {
+      const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
+
+      while (++i < elements.length) {
+        if (
+          !(
+            movedElements.has(elements[i].id) &&
+            !isValidFractionalIndex(
+              elements[i]?.index,
+              elements[i - 1]?.index,
+              elements[i + 1]?.index,
+            )
+          )
+        ) {
+          break;
+        }
+
+        indicesGroup.push(i);
+      }
+
+      indicesGroup.push(i); // push the upper bound index as the last item
+      indicesGroups.push(indicesGroup);
+    } else {
+      i++;
+    }
+  }
+
+  return indicesGroups;
+};
+
+/**
+ * Gets contiguous groups of all invalid indices automatically detected inside the elements array.
+ *
+ * WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds!
+ */
+const getInvalidIndicesGroups = (elements: readonly ExcalidrawElement[]) => {
+  const indicesGroups: number[][] = [];
+
+  // once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf.
+  let lowerBound: ExcalidrawElement["index"] | undefined = undefined;
+  let upperBound: ExcalidrawElement["index"] | undefined = undefined;
+  let lowerBoundIndex: number = -1;
+  let upperBoundIndex: number = 0;
+
+  /** @returns maybe valid lowerBound */
+  const getLowerBound = (
+    index: number,
+  ): [ExcalidrawElement["index"] | undefined, number] => {
+    const lowerBound = elements[lowerBoundIndex]
+      ? elements[lowerBoundIndex].index
+      : undefined;
+
+    // we are already iterating left to right, therefore there is no need for additional looping
+    const candidate = elements[index - 1]?.index;
+
+    if (
+      (!lowerBound && candidate) || // first lowerBound
+      (lowerBound && candidate && candidate > lowerBound) // next lowerBound
+    ) {
+      // WARN: candidate's index could be higher or same as the current element's index
+      return [candidate, index - 1];
+    }
+
+    // cache hit! take the last lower bound
+    return [lowerBound, lowerBoundIndex];
+  };
+
+  /** @returns always valid upperBound */
+  const getUpperBound = (
+    index: number,
+  ): [ExcalidrawElement["index"] | undefined, number] => {
+    const upperBound = elements[upperBoundIndex]
+      ? elements[upperBoundIndex].index
+      : undefined;
+
+    // cache hit! don't let it find the upper bound again
+    if (upperBound && index < upperBoundIndex) {
+      return [upperBound, upperBoundIndex];
+    }
+
+    // set the current upperBoundIndex as the starting point
+    let i = upperBoundIndex;
+    while (++i < elements.length) {
+      const candidate = elements[i]?.index;
+
+      if (
+        (!upperBound && candidate) || // first upperBound
+        (upperBound && candidate && candidate > upperBound) // next upperBound
+      ) {
+        return [candidate, i];
+      }
+    }
+
+    // we reached the end, sky is the limit
+    return [undefined, i];
+  };
+
+  let i = 0;
+
+  while (i < elements.length) {
+    const current = elements[i].index;
+    [lowerBound, lowerBoundIndex] = getLowerBound(i);
+    [upperBound, upperBoundIndex] = getUpperBound(i);
+
+    if (!isValidFractionalIndex(current, lowerBound, upperBound)) {
+      // push the lower bound index as the first item
+      const indicesGroup = [lowerBoundIndex, i];
+
+      while (++i < elements.length) {
+        const current = elements[i].index;
+        const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i);
+        const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i);
+
+        if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) {
+          break;
+        }
+
+        // assign bounds only for the moved elements
+        [lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex];
+        [upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex];
+
+        indicesGroup.push(i);
+      }
+
+      // push the upper bound index as the last item
+      indicesGroup.push(upperBoundIndex);
+      indicesGroups.push(indicesGroup);
+    } else {
+      i++;
+    }
+  }
+
+  return indicesGroups;
+};
+
+const isValidFractionalIndex = (
+  index: ExcalidrawElement["index"] | undefined,
+  predecessor: ExcalidrawElement["index"] | undefined,
+  successor: ExcalidrawElement["index"] | undefined,
+) => {
+  if (!index) {
+    return false;
+  }
+
+  if (predecessor && successor) {
+    return predecessor < index && index < successor;
+  }
+
+  if (!predecessor && successor) {
+    // first element
+    return index < successor;
+  }
+
+  if (predecessor && !successor) {
+    // last element
+    return predecessor < index;
+  }
+
+  // only element in the array
+  return !!index;
+};
+
+const generateIndices = (
+  elements: readonly ExcalidrawElement[],
+  indicesGroups: number[][],
+) => {
+  const elementsUpdates = new Map<
+    ExcalidrawElement,
+    { index: FractionalIndex }
+  >();
+
+  for (const indices of indicesGroups) {
+    const lowerBoundIndex = indices.shift()!;
+    const upperBoundIndex = indices.pop()!;
+
+    const fractionalIndices = generateNKeysBetween(
+      elements[lowerBoundIndex]?.index,
+      elements[upperBoundIndex]?.index,
+      indices.length,
+    ) as FractionalIndex[];
+
+    for (let i = 0; i < indices.length; i++) {
+      const element = elements[indices[i]];
+
+      elementsUpdates.set(element, {
+        index: fractionalIndices[i],
+      });
+    }
+  }
+
+  return elementsUpdates;
+};
+
+const isOrderedElement = (
+  element: ExcalidrawElement,
+): element is OrderedExcalidrawElement => {
+  // for now it's sufficient whether the index is there
+  // meaning, the element was already ordered in the past
+  // meaning, it is not a newly inserted element, not an unrestored element, etc.
+  // it does not have to mean that the index itself is valid
+  if (element.index) {
+    return true;
+  }
+
+  return false;
+};

+ 1 - 1
packages/excalidraw/frame.ts

@@ -29,7 +29,7 @@ import { ReadonlySetLike } from "./utility-types";
 
 // --------------------------- Frame State ------------------------------------
 export const bindElementsToFramesAfterDuplication = (
-  nextElements: ExcalidrawElement[],
+  nextElements: readonly ExcalidrawElement[],
   oldElements: readonly ExcalidrawElement[],
   oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
 ) => {

+ 1 - 0
packages/excalidraw/package.json

@@ -67,6 +67,7 @@
     "canvas-roundrect-polyfill": "0.0.1",
     "clsx": "1.1.1",
     "cross-env": "7.0.3",
+    "fractional-indexing": "3.2.0",
     "fuzzy": "0.1.3",
     "image-blob-reduce": "3.0.1",
     "jotai": "1.13.1",

+ 48 - 12
packages/excalidraw/scene/Scene.ts

@@ -6,6 +6,8 @@ import {
   ElementsMapOrArray,
   SceneElementsMap,
   NonDeletedSceneElementsMap,
+  OrderedExcalidrawElement,
+  Ordered,
 } from "../element/types";
 import { isNonDeletedElement } from "../element";
 import { LinearElementEditor } from "../element/linearElementEditor";
@@ -14,7 +16,14 @@ import { getSelectedElements } from "./selection";
 import { AppState } from "../types";
 import { Assert, SameType } from "../utility-types";
 import { randomInteger } from "../random";
+import {
+  syncInvalidIndices,
+  syncMovedIndices,
+  validateFractionalIndices,
+} from "../fractionalIndex";
+import { arrayToMap } from "../utils";
 import { toBrandedType } from "../utils";
+import { ENV } from "../constants";
 
 type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
 type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -32,7 +41,10 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
   for (const element of allElements) {
     if (!element.isDeleted) {
       elements.push(element as NonDeleted<T>);
-      elementsMap.set(element.id, element as NonDeletedExcalidrawElement);
+      elementsMap.set(
+        element.id,
+        element as Ordered<NonDeletedExcalidrawElement>,
+      );
     }
   }
   return { elementsMap, elements };
@@ -106,11 +118,13 @@ class Scene {
 
   private callbacks: Set<SceneStateCallback> = new Set();
 
-  private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
+  private nonDeletedElements: readonly Ordered<NonDeletedExcalidrawElement>[] =
+    [];
   private nonDeletedElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
     new Map(),
   );
-  private elements: readonly ExcalidrawElement[] = [];
+  // ideally all elements within the scene should be wrapped around with `Ordered` type, but right now there is no real benefit doing so
+  private elements: readonly OrderedExcalidrawElement[] = [];
   private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
     [];
   private frames: readonly ExcalidrawFrameLikeElement[] = [];
@@ -138,7 +152,7 @@ class Scene {
     return this.elements;
   }
 
-  getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] {
+  getNonDeletedElements() {
     return this.nonDeletedElements;
   }
 
@@ -244,12 +258,19 @@ class Scene {
   }
 
   replaceAllElements(nextElements: ElementsMapOrArray) {
-    this.elements =
+    const _nextElements =
       // ts doesn't like `Array.isArray` of `instanceof Map`
       nextElements instanceof Array
         ? nextElements
         : Array.from(nextElements.values());
     const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
+
+    if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
+      // throw on invalid indices in test / dev to potentially detect cases were we forgot to sync moved elements
+      validateFractionalIndices(_nextElements.map((x) => x.index));
+    }
+
+    this.elements = syncInvalidIndices(_nextElements);
     this.elementsMap.clear();
     this.elements.forEach((element) => {
       if (isFrameLikeElement(element)) {
@@ -292,8 +313,8 @@ class Scene {
   }
 
   destroy() {
-    this.nonDeletedElements = [];
     this.elements = [];
+    this.nonDeletedElements = [];
     this.nonDeletedFramesLikes = [];
     this.frames = [];
     this.elementsMap.clear();
@@ -318,11 +339,15 @@ class Scene {
         "insertElementAtIndex can only be called with index >= 0",
       );
     }
+
     const nextElements = [
       ...this.elements.slice(0, index),
       element,
       ...this.elements.slice(index),
     ];
+
+    syncMovedIndices(nextElements, arrayToMap([element]));
+
     this.replaceAllElements(nextElements);
   }
 
@@ -332,21 +357,32 @@ class Scene {
         "insertElementAtIndex can only be called with index >= 0",
       );
     }
+
     const nextElements = [
       ...this.elements.slice(0, index),
       ...elements,
       ...this.elements.slice(index),
     ];
 
+    syncMovedIndices(nextElements, arrayToMap(elements));
+
     this.replaceAllElements(nextElements);
   }
 
-  addNewElement = (element: ExcalidrawElement) => {
-    if (element.frameId) {
-      this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
-    } else {
-      this.replaceAllElements([...this.elements, element]);
-    }
+  insertElement = (element: ExcalidrawElement) => {
+    const index = element.frameId
+      ? this.getElementIndex(element.frameId)
+      : this.elements.length;
+
+    this.insertElementAtIndex(element, index);
+  };
+
+  insertElements = (elements: ExcalidrawElement[]) => {
+    const index = elements[0].frameId
+      ? this.getElementIndex(elements[0].frameId)
+      : this.elements.length;
+
+    this.insertElementsAtIndex(elements, index);
   };
 
   getElementIndex(elementId: string) {

+ 2 - 1
packages/excalidraw/scene/export.ts

@@ -38,6 +38,7 @@ import { Mutable } from "../utility-types";
 import { newElementWith } from "../element/mutateElement";
 import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
 import { RenderableElementsMap } from "./types";
+import { syncInvalidIndices } from "../fractionalIndex";
 import { renderStaticScene } from "../renderer/staticScene";
 
 const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@@ -224,7 +225,7 @@ export const exportToCanvas = async (
       arrayToMap(elementsForRender),
     ),
     allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
-      arrayToMap(elements),
+      arrayToMap(syncInvalidIndices(elements)),
     ),
     visibleElements: elementsForRender,
     scale,

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 157 - 110
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap


+ 15 - 10
packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -15,6 +15,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -42,8 +43,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 3,
-  "versionNonce": 401146281,
+  "version": 4,
+  "versionNonce": 2019559783,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -63,6 +64,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -77,8 +79,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "strokeWidth": 2,
   "type": "diamond",
   "updated": 1,
-  "version": 2,
-  "versionNonce": 453191,
+  "version": 3,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -98,6 +100,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -112,8 +115,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 2,
-  "versionNonce": 453191,
+  "version": 3,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -133,6 +136,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -160,8 +164,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "strokeWidth": 2,
   "type": "line",
   "updated": 1,
-  "version": 3,
-  "versionNonce": 401146281,
+  "version": 4,
+  "versionNonce": 2019559783,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -181,6 +185,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -195,8 +200,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 2,
-  "versionNonce": 453191,
+  "version": 3,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 30,
   "y": 20,

+ 21 - 15
packages/excalidraw/tests/__snapshots__/move.test.tsx.snap

@@ -11,6 +11,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
   "groupIds": [],
   "height": 50,
   "id": "id0_copy",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -19,14 +20,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
   "roundness": {
     "type": 3,
   },
-  "seed": 1014066025,
+  "seed": 238820263,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 4,
-  "versionNonce": 238820263,
+  "version": 5,
+  "versionNonce": 400692809,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -44,6 +45,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a1",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -58,8 +60,8 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 4,
-  "versionNonce": 1604849351,
+  "version": 6,
+  "versionNonce": 23633383,
   "width": 30,
   "x": -10,
   "y": 60,
@@ -77,6 +79,7 @@ exports[`move element > rectangle 5`] = `
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -91,8 +94,8 @@ exports[`move element > rectangle 5`] = `
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 3,
-  "versionNonce": 1150084233,
+  "version": 4,
+  "versionNonce": 1116226695,
   "width": 30,
   "x": 0,
   "y": 40,
@@ -115,6 +118,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
   "groupIds": [],
   "height": 100,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -129,8 +133,8 @@ exports[`move element > rectangles with binding arrow 5`] = `
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 3,
-  "versionNonce": 81784553,
+  "version": 4,
+  "versionNonce": 760410951,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -153,6 +157,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
   "groupIds": [],
   "height": 300,
   "id": "id1",
+  "index": "a1",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -161,14 +166,14 @@ exports[`move element > rectangles with binding arrow 6`] = `
   "roundness": {
     "type": 3,
   },
-  "seed": 2019559783,
+  "seed": 1150084233,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 6,
-  "versionNonce": 927333447,
+  "version": 7,
+  "versionNonce": 745419401,
   "width": 300,
   "x": 201,
   "y": 2,
@@ -192,6 +197,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "groupIds": [],
   "height": 81.48231043525051,
   "id": "id2",
+  "index": "a2",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -211,7 +217,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 238820263,
+  "seed": 1604849351,
   "startArrowhead": null,
   "startBinding": {
     "elementId": "id0",
@@ -223,8 +229,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "strokeWidth": 2,
   "type": "line",
   "updated": 1,
-  "version": 11,
-  "versionNonce": 1051383431,
+  "version": 12,
+  "versionNonce": 1984422985,
   "width": 81,
   "x": 110,
   "y": 49.981789081137734,

+ 6 - 4
packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -13,6 +13,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
   "groupIds": [],
   "height": 110,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": [
     70,
@@ -47,8 +48,8 @@ exports[`multi point mode in linear elements > arrow 3`] = `
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 7,
-  "versionNonce": 1505387817,
+  "version": 8,
+  "versionNonce": 23633383,
   "width": 70,
   "x": 30,
   "y": 30,
@@ -68,6 +69,7 @@ exports[`multi point mode in linear elements > line 3`] = `
   "groupIds": [],
   "height": 110,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": [
     70,
@@ -102,8 +104,8 @@ exports[`multi point mode in linear elements > line 3`] = `
   "strokeWidth": 2,
   "type": "line",
   "updated": 1,
-  "version": 7,
-  "versionNonce": 1505387817,
+  "version": 8,
+  "versionNonce": 23633383,
   "width": 70,
   "x": 30,
   "y": 30,

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 156 - 111
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap


+ 15 - 10
packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap

@@ -13,6 +13,7 @@ exports[`select single element on the scene > arrow 1`] = `
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -40,8 +41,8 @@ exports[`select single element on the scene > arrow 1`] = `
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 3,
-  "versionNonce": 401146281,
+  "version": 4,
+  "versionNonce": 2019559783,
   "width": 30,
   "x": 10,
   "y": 10,
@@ -61,6 +62,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -88,8 +90,8 @@ exports[`select single element on the scene > arrow escape 1`] = `
   "strokeWidth": 2,
   "type": "line",
   "updated": 1,
-  "version": 3,
-  "versionNonce": 401146281,
+  "version": 4,
+  "versionNonce": 2019559783,
   "width": 30,
   "x": 10,
   "y": 10,
@@ -107,6 +109,7 @@ exports[`select single element on the scene > diamond 1`] = `
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -121,8 +124,8 @@ exports[`select single element on the scene > diamond 1`] = `
   "strokeWidth": 2,
   "type": "diamond",
   "updated": 1,
-  "version": 2,
-  "versionNonce": 453191,
+  "version": 3,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 10,
   "y": 10,
@@ -140,6 +143,7 @@ exports[`select single element on the scene > ellipse 1`] = `
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -154,8 +158,8 @@ exports[`select single element on the scene > ellipse 1`] = `
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 2,
-  "versionNonce": 453191,
+  "version": 3,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 10,
   "y": 10,
@@ -173,6 +177,7 @@ exports[`select single element on the scene > rectangle 1`] = `
   "groupIds": [],
   "height": 50,
   "id": "id0",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -187,8 +192,8 @@ exports[`select single element on the scene > rectangle 1`] = `
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 2,
-  "versionNonce": 453191,
+  "version": 3,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 10,
   "y": 10,

+ 20 - 2
packages/excalidraw/tests/contextmenu.test.tsx

@@ -423,8 +423,26 @@ describe("contextMenu element", () => {
     const contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
     expect(h.elements).toHaveLength(2);
-    const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
-    const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
+    const {
+      id: _id0,
+      seed: _seed0,
+      x: _x0,
+      y: _y0,
+      index: _fractionalIndex0,
+      version: _version0,
+      versionNonce: _versionNonce0,
+      ...rect1
+    } = h.elements[0];
+    const {
+      id: _id1,
+      seed: _seed1,
+      x: _x1,
+      y: _y1,
+      index: _fractionalIndex1,
+      version: _version1,
+      versionNonce: _versionNonce1,
+      ...rect2
+    } = h.elements[1];
     expect(rect1).toEqual(rect2);
   });
 

+ 26 - 17
packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap

@@ -13,6 +13,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
   "groupIds": [],
   "height": 100,
   "id": "id-arrow01",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -40,8 +41,8 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 1,
-  "versionNonce": 0,
+  "version": 2,
+  "versionNonce": Any<Number>,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -63,6 +64,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
   ],
   "height": 200,
   "id": "1",
+  "index": "a0",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -77,8 +79,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 1,
-  "versionNonce": 0,
+  "version": 2,
+  "versionNonce": Any<Number>,
   "width": 100,
   "x": 10,
   "y": 20,
@@ -100,6 +102,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
   ],
   "height": 200,
   "id": "2",
+  "index": "a1",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -114,8 +117,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
   "strokeWidth": 2,
   "type": "ellipse",
   "updated": 1,
-  "version": 1,
-  "versionNonce": 0,
+  "version": 2,
+  "versionNonce": Any<Number>,
   "width": 100,
   "x": 10,
   "y": 20,
@@ -137,6 +140,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
   ],
   "height": 200,
   "id": "3",
+  "index": "a2",
   "isDeleted": false,
   "link": null,
   "locked": false,
@@ -151,8 +155,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
   "strokeWidth": 2,
   "type": "diamond",
   "updated": 1,
-  "version": 1,
-  "versionNonce": 0,
+  "version": 2,
+  "versionNonce": Any<Number>,
   "width": 100,
   "x": 10,
   "y": 20,
@@ -170,6 +174,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
   "groupIds": [],
   "height": 0,
   "id": "id-freedraw01",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -188,8 +193,8 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
   "strokeWidth": 2,
   "type": "freedraw",
   "updated": 1,
-  "version": 1,
-  "versionNonce": 0,
+  "version": 2,
+  "versionNonce": Any<Number>,
   "width": 0,
   "x": 0,
   "y": 0,
@@ -209,6 +214,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
   "groupIds": [],
   "height": 100,
   "id": "id-line01",
+  "index": "a0",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -236,8 +242,8 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
   "strokeWidth": 2,
   "type": "line",
   "updated": 1,
-  "version": 1,
-  "versionNonce": 0,
+  "version": 2,
+  "versionNonce": Any<Number>,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -257,6 +263,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
   "groupIds": [],
   "height": 100,
   "id": "id-draw01",
+  "index": "a1",
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
@@ -284,8 +291,8 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
   "strokeWidth": 2,
   "type": "line",
   "updated": 1,
-  "version": 1,
-  "versionNonce": 0,
+  "version": 2,
+  "versionNonce": Any<Number>,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -306,6 +313,7 @@ exports[`restoreElements > should restore text element correctly passing value f
   "groupIds": [],
   "height": 100,
   "id": "id-text01",
+  "index": "a0",
   "isDeleted": false,
   "lineHeight": 1.25,
   "link": null,
@@ -324,8 +332,8 @@ exports[`restoreElements > should restore text element correctly passing value f
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 1,
-  "versionNonce": 0,
+  "version": 2,
+  "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 100,
   "x": -20,
@@ -347,6 +355,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
   "groupIds": [],
   "height": 100,
   "id": "id-text01",
+  "index": "a0",
   "isDeleted": true,
   "lineHeight": 1.25,
   "link": null,
@@ -365,7 +374,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 2,
+  "version": 3,
   "versionNonce": Any<Number>,
   "verticalAlign": "top",
   "width": 100,

+ 374 - 0
packages/excalidraw/tests/data/reconcile.test.ts

@@ -0,0 +1,374 @@
+import {
+  RemoteExcalidrawElement,
+  reconcileElements,
+} from "../../data/reconcile";
+import {
+  ExcalidrawElement,
+  OrderedExcalidrawElement,
+} from "../../element/types";
+import { syncInvalidIndices } from "../../fractionalIndex";
+import { randomInteger } from "../../random";
+import { AppState } from "../../types";
+import { cloneJSON } from "../../utils";
+
+type Id = string;
+type ElementLike = {
+  id: string;
+  version: number;
+  versionNonce: number;
+  index: string;
+};
+
+type Cache = Record<string, ExcalidrawElement | undefined>;
+
+const createElement = (opts: { uid: string } | ElementLike) => {
+  let uid: string;
+  let id: string;
+  let version: number | null;
+  let versionNonce: number | null = null;
+  if ("uid" in opts) {
+    const match = opts.uid.match(/^(\w+)(?::(\d+))?$/)!;
+    id = match[1];
+    version = match[2] ? parseInt(match[2]) : null;
+    uid = version ? `${id}:${version}` : id;
+  } else {
+    ({ id, version, versionNonce } = opts);
+    uid = id;
+  }
+  return {
+    uid,
+    id,
+    version,
+    versionNonce: versionNonce || randomInteger(),
+  };
+};
+
+const idsToElements = (ids: (Id | ElementLike)[], cache: Cache = {}) => {
+  return syncInvalidIndices(
+    ids.reduce((acc, _uid) => {
+      const { uid, id, version, versionNonce } = createElement(
+        typeof _uid === "string" ? { uid: _uid } : _uid,
+      );
+      const cached = cache[uid];
+      const elem = {
+        id,
+        version: version ?? 0,
+        versionNonce,
+        ...cached,
+      } as ExcalidrawElement;
+      // @ts-ignore
+      cache[uid] = elem;
+      acc.push(elem);
+      return acc;
+    }, [] as ExcalidrawElement[]),
+  );
+};
+
+const test = <U extends `${string}:${"L" | "R"}`>(
+  local: (Id | ElementLike)[],
+  remote: (Id | ElementLike)[],
+  target: U[],
+) => {
+  const cache: Cache = {};
+  const _local = idsToElements(local, cache);
+  const _remote = idsToElements(remote, cache);
+
+  const reconciled = reconcileElements(
+    cloneJSON(_local),
+    cloneJSON(_remote) as RemoteExcalidrawElement[],
+    {} as AppState,
+  );
+
+  const reconciledIds = reconciled.map((x) => x.id);
+  const reconciledIndices = reconciled.map((x) => x.index);
+
+  expect(target.length).equal(reconciled.length);
+  expect(reconciledIndices.length).equal(new Set([...reconciledIndices]).size); // expect no duplicated indices
+  expect(reconciledIds).deep.equal(
+    target.map((uid) => {
+      const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
+      const element = (source === "L" ? _local : _remote).find(
+        (e) => e.id === id,
+      )!;
+
+      return element.id;
+    }),
+    "remote reconciliation",
+  );
+
+  // convergent reconciliation on the remote client
+  try {
+    expect(
+      reconcileElements(
+        cloneJSON(_remote),
+        cloneJSON(_local as RemoteExcalidrawElement[]),
+        {} as AppState,
+      ).map((x) => x.id),
+    ).deep.equal(reconciledIds, "convergent reconciliation");
+  } catch (error: any) {
+    console.error("local original", _remote);
+    console.error("remote original", _local);
+    throw error;
+  }
+
+  // bidirectional re-reconciliation on remote client
+  try {
+    expect(
+      reconcileElements(
+        cloneJSON(_remote),
+        cloneJSON(reconciled as unknown as RemoteExcalidrawElement[]),
+        {} as AppState,
+      ).map((x) => x.id),
+    ).deep.equal(reconciledIds, "local re-reconciliation");
+  } catch (error: any) {
+    console.error("local original", _remote);
+    console.error("remote reconciled", reconciled);
+    throw error;
+  }
+};
+
+describe("elements reconciliation", () => {
+  it("reconcileElements()", () => {
+    // -------------------------------------------------------------------------
+    //
+    // in following tests, we pass:
+    //  (1) an array of local elements and their version (:1, :2...)
+    //  (2) an array of remote elements and their version (:1, :2...)
+    //  (3) expected reconciled elements
+    //
+    // in the reconciled array:
+    //  :L means local element was resolved
+    //  :R means remote element was resolved
+    //
+    // if versions are missing, it defaults to version 0
+    // -------------------------------------------------------------------------
+
+    test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
+    test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
+    test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
+    test(["A:1", "C:1"], ["B:1"], ["A:L", "B:R", "C:L"]);
+    test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
+    test(["A"], ["A", "B"], ["A:L", "B:R"]);
+    test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
+    test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
+    test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
+    test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
+    test(["A"], ["A:1"], ["A:R"]);
+    test(["A", "B:1", "D"], ["B", "C:2", "A"], ["C:R", "A:R", "B:L", "D:L"]);
+
+    // some of the following tests are kinda arbitrary and they're less
+    // likely to happen in real-world cases
+    test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
+    test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
+    test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
+    test(["A", "B", "C"], ["A", "B:2", "G"], ["A:R", "B:R", "C:L", "G:R"]);
+    test(
+      ["A:2", "B:2", "C"],
+      ["D", "B:1", "A:3"],
+      ["D:R", "B:L", "A:R", "C:L"],
+    );
+    test(
+      ["A:2", "B:2", "C"],
+      ["D", "B:2", "A:3", "C"],
+      ["D:R", "B:L", "A:R", "C:L"],
+    );
+    test(
+      ["A", "B", "C", "D", "E", "F"],
+      ["A", "B:2", "X", "E:2", "F", "Y"],
+      ["A:L", "B:R", "X:R", "C:L", "E:R", "D:L", "F:L", "Y:R"],
+    );
+
+    // fractional elements (previously annotated)
+    test(
+      ["A", "B", "C"],
+      ["A", "B", "X", "Y", "Z"],
+      ["A:R", "B:R", "C:L", "X:R", "Y:R", "Z:R"],
+    );
+
+    test(["A"], ["X", "Y"], ["A:L", "X:R", "Y:R"]);
+    test(["A"], ["X", "Y", "Z"], ["A:L", "X:R", "Y:R", "Z:R"]);
+    test(["A", "B"], ["C", "D", "F"], ["A:L", "C:R", "B:L", "D:R", "F:R"]);
+
+    test(
+      ["A", "B", "C", "D"],
+      ["C:1", "B", "D:1"],
+      ["A:L", "C:R", "B:L", "D:R"],
+    );
+    test(
+      ["A", "B", "C"],
+      ["X", "A", "Y", "B", "Z"],
+      ["X:R", "A:R", "Y:R", "B:L", "C:L", "Z:R"],
+    );
+    test(
+      ["B", "A", "C"],
+      ["X", "A", "Y", "B", "Z"],
+      ["X:R", "A:R", "C:L", "Y:R", "B:R", "Z:R"],
+    );
+    test(["A", "B"], ["A", "X", "Y"], ["A:R", "B:L", "X:R", "Y:R"]);
+    test(
+      ["A", "B", "C", "D", "E"],
+      ["A", "X", "C", "Y", "D", "Z"],
+      ["A:R", "B:L", "X:R", "C:R", "Y:R", "D:R", "E:L", "Z:R"],
+    );
+    test(
+      ["X", "Y", "Z"],
+      ["A", "B", "C"],
+      ["A:R", "X:L", "B:R", "Y:L", "C:R", "Z:L"],
+    );
+    test(
+      ["X", "Y", "Z"],
+      ["A", "B", "C", "X", "D", "Y", "Z"],
+      ["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
+    );
+    test(
+      ["A", "B", "C", "D", "E"],
+      ["C", "X", "A", "Y", "D", "E:1"],
+      ["B:L", "C:L", "X:R", "A:R", "Y:R", "D:R", "E:R"],
+    );
+    test(
+      ["C:1", "B", "D:1"],
+      ["A", "B", "C:1", "D:1"],
+      ["A:R", "B:R", "C:R", "D:R"],
+    );
+
+    test(
+      ["C:1", "B", "D:1"],
+      ["A", "B", "C:2", "D:1"],
+      ["A:R", "B:L", "C:R", "D:L"],
+    );
+
+    test(
+      ["A", "B", "C", "D"],
+      ["A", "C:1", "B", "D:1"],
+      ["A:L", "C:R", "B:L", "D:R"],
+    );
+
+    test(
+      ["A", "B", "C", "D"],
+      ["C", "X", "B", "Y", "A", "Z"],
+      ["C:R", "D:L", "X:R", "B:R", "Y:R", "A:R", "Z:R"],
+    );
+
+    test(
+      ["A", "B", "C", "D"],
+      ["A", "B:1", "C:1"],
+      ["A:R", "B:R", "C:R", "D:L"],
+    );
+
+    test(
+      ["A", "B", "C", "D"],
+      ["A", "C:1", "B:1"],
+      ["A:R", "C:R", "B:R", "D:L"],
+    );
+
+    test(
+      ["A", "B", "C", "D"],
+      ["A", "C:1", "B", "D:1"],
+      ["A:R", "C:R", "B:R", "D:R"],
+    );
+
+    test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
+    test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
+    test(["A", "B"], ["A", "C", "B", "D"], ["A:R", "C:R", "B:R", "D:R"]);
+    test(["A", "B"], ["B", "C", "D"], ["A:L", "B:R", "C:R", "D:R"]);
+    test(["A", "B"], ["C", "D"], ["A:L", "C:R", "B:L", "D:R"]);
+    test(["A", "B"], ["A", "B:1"], ["A:L", "B:R"]);
+    test(["A:2", "B"], ["A", "B:1"], ["A:L", "B:R"]);
+    test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
+    test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
+    test(["A:2", "B:2"], ["A", "C", "B:1"], ["A:L", "B:L", "C:R"]);
+
+    // concurrent convergency
+    test(["A", "B", "C"], ["A", "B", "D"], ["A:R", "B:R", "C:L", "D:R"]);
+    test(["A", "B", "E"], ["A", "B", "D"], ["A:R", "B:R", "D:R", "E:L"]);
+    test(
+      ["A", "B", "C"],
+      ["A", "B", "D", "E"],
+      ["A:R", "B:R", "C:L", "D:R", "E:R"],
+    );
+    test(
+      ["A", "B", "E"],
+      ["A", "B", "D", "C"],
+      ["A:R", "B:R", "D:R", "E:L", "C:R"],
+    );
+    test(["A", "B"], ["B", "D"], ["A:L", "B:R", "D:R"]);
+    test(["C", "A", "B"], ["C", "B", "D"], ["C:R", "A:L", "B:R", "D:R"]);
+  });
+
+  it("test identical elements reconciliation", () => {
+    const testIdentical = (
+      local: ElementLike[],
+      remote: ElementLike[],
+      expected: Id[],
+    ) => {
+      const ret = reconcileElements(
+        local as unknown as OrderedExcalidrawElement[],
+        remote as unknown as RemoteExcalidrawElement[],
+        {} as AppState,
+      );
+
+      if (new Set(ret.map((x) => x.id)).size !== ret.length) {
+        throw new Error("reconcileElements: duplicate elements found");
+      }
+
+      expect(ret.map((x) => x.id)).to.deep.equal(expected);
+    };
+
+    // identical id/version/versionNonce/index
+    // -------------------------------------------------------------------------
+
+    testIdentical(
+      [{ id: "A", version: 1, versionNonce: 1, index: "a0" }],
+      [{ id: "A", version: 1, versionNonce: 1, index: "a0" }],
+      ["A"],
+    );
+    testIdentical(
+      [
+        { id: "A", version: 1, versionNonce: 1, index: "a0" },
+        { id: "B", version: 1, versionNonce: 1, index: "a0" },
+      ],
+      [
+        { id: "B", version: 1, versionNonce: 1, index: "a0" },
+        { id: "A", version: 1, versionNonce: 1, index: "a0" },
+      ],
+      ["A", "B"],
+    );
+
+    // actually identical (arrays and element objects)
+    // -------------------------------------------------------------------------
+
+    const elements1 = [
+      {
+        id: "A",
+        version: 1,
+        versionNonce: 1,
+        index: "a0",
+      },
+      {
+        id: "B",
+        version: 1,
+        versionNonce: 1,
+        index: "a0",
+      },
+    ];
+
+    testIdentical(elements1, elements1, ["A", "B"]);
+    testIdentical(elements1, elements1.slice(), ["A", "B"]);
+    testIdentical(elements1.slice(), elements1, ["A", "B"]);
+    testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
+
+    const el1 = {
+      id: "A",
+      version: 1,
+      versionNonce: 1,
+      index: "a0",
+    };
+    const el2 = {
+      id: "B",
+      version: 1,
+      versionNonce: 1,
+      index: "a0",
+    };
+    testIdentical([el1, el2], [el2, el1], ["A", "B"]);
+  });
+});

+ 33 - 13
packages/excalidraw/tests/data/restore.test.ts

@@ -72,6 +72,7 @@ describe("restoreElements", () => {
 
     expect(restoredText).toMatchSnapshot({
       seed: expect.any(Number),
+      versionNonce: expect.any(Number),
     });
   });
 
@@ -109,7 +110,10 @@ describe("restoreElements", () => {
       null,
     )[0] as ExcalidrawFreeDrawElement;
 
-    expect(restoredFreedraw).toMatchSnapshot({ seed: expect.any(Number) });
+    expect(restoredFreedraw).toMatchSnapshot({
+      seed: expect.any(Number),
+      versionNonce: expect.any(Number),
+    });
   });
 
   it("should restore line and draw elements correctly", () => {
@@ -129,8 +133,14 @@ describe("restoreElements", () => {
     const restoredLine = restoredElements[0] as ExcalidrawLinearElement;
     const restoredDraw = restoredElements[1] as ExcalidrawLinearElement;
 
-    expect(restoredLine).toMatchSnapshot({ seed: expect.any(Number) });
-    expect(restoredDraw).toMatchSnapshot({ seed: expect.any(Number) });
+    expect(restoredLine).toMatchSnapshot({
+      seed: expect.any(Number),
+      versionNonce: expect.any(Number),
+    });
+    expect(restoredDraw).toMatchSnapshot({
+      seed: expect.any(Number),
+      versionNonce: expect.any(Number),
+    });
   });
 
   it("should restore arrow element correctly", () => {
@@ -140,7 +150,10 @@ describe("restoreElements", () => {
 
     const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
 
-    expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) });
+    expect(restoredArrow).toMatchSnapshot({
+      seed: expect.any(Number),
+      versionNonce: expect.any(Number),
+    });
   });
 
   it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
@@ -270,9 +283,18 @@ describe("restoreElements", () => {
 
     const restoredElements = restore.restoreElements(elements, null);
 
-    expect(restoredElements[0]).toMatchSnapshot({ seed: expect.any(Number) });
-    expect(restoredElements[1]).toMatchSnapshot({ seed: expect.any(Number) });
-    expect(restoredElements[2]).toMatchSnapshot({ seed: expect.any(Number) });
+    expect(restoredElements[0]).toMatchSnapshot({
+      seed: expect.any(Number),
+      versionNonce: expect.any(Number),
+    });
+    expect(restoredElements[1]).toMatchSnapshot({
+      seed: expect.any(Number),
+      versionNonce: expect.any(Number),
+    });
+    expect(restoredElements[2]).toMatchSnapshot({
+      seed: expect.any(Number),
+      versionNonce: expect.any(Number),
+    });
   });
 
   it("bump versions of local duplicate elements when supplied", () => {
@@ -290,12 +312,11 @@ describe("restoreElements", () => {
     expect(restoredElements).toEqual([
       expect.objectContaining({
         id: rectangle.id,
-        version: rectangle_modified.version + 1,
+        version: rectangle_modified.version + 2,
       }),
       expect.objectContaining({
         id: ellipse.id,
-        version: ellipse.version,
-        versionNonce: ellipse.versionNonce,
+        version: ellipse.version + 1,
       }),
     ]);
   });
@@ -549,11 +570,10 @@ describe("restore", () => {
       rectangle.versionNonce,
     );
     expect(restoredData.elements).toEqual([
-      expect.objectContaining({ version: rectangle_modified.version + 1 }),
+      expect.objectContaining({ version: rectangle_modified.version + 2 }),
       expect.objectContaining({
         id: ellipse.id,
-        version: ellipse.version,
-        versionNonce: ellipse.versionNonce,
+        version: ellipse.version + 1,
       }),
     ]);
   });

+ 1 - 0
packages/excalidraw/tests/fixtures/elementFixture.ts

@@ -17,6 +17,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
   groupIds: [],
   frameId: null,
   roundness: null,
+  index: null,
   seed: 1041657908,
   version: 120,
   versionNonce: 1188004276,

+ 16 - 16
packages/excalidraw/tests/flip.test.tsx

@@ -412,7 +412,7 @@ describe("ellipse", () => {
 describe("arrow", () => {
   it("flips an unrotated arrow horizontally with line inside min/max points bounds", async () => {
     const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
-    h.app.scene.replaceAllElements([arrow]);
+    h.elements = [arrow];
     h.app.setState({ selectedElementIds: { [arrow.id]: true } });
     await checkHorizontalFlip(
       MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
@@ -421,7 +421,7 @@ describe("arrow", () => {
 
   it("flips an unrotated arrow vertically with line inside min/max points bounds", async () => {
     const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
-    h.app.scene.replaceAllElements([arrow]);
+    h.elements = [arrow];
     h.app.setState({ selectedElementIds: { [arrow.id]: true } });
 
     await checkVerticalFlip(50);
@@ -431,7 +431,7 @@ describe("arrow", () => {
     const originalAngle = Math.PI / 4;
     const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.state.selectedElementIds = {
       ...h.state.selectedElementIds,
       [line.id]: true,
@@ -450,7 +450,7 @@ describe("arrow", () => {
     const originalAngle = Math.PI / 4;
     const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.state.selectedElementIds = {
       ...h.state.selectedElementIds,
       [line.id]: true,
@@ -468,7 +468,7 @@ describe("arrow", () => {
   //TODO: elements with curve outside minMax points have a wrong bounding box!!!
   it.skip("flips an unrotated arrow horizontally with line outside min/max points bounds", async () => {
     const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
-    h.app.scene.replaceAllElements([arrow]);
+    h.elements = [arrow];
     h.app.setState({ selectedElementIds: { [arrow.id]: true } });
 
     await checkHorizontalFlip(
@@ -482,7 +482,7 @@ describe("arrow", () => {
     const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
     mutateElement(line, { angle: originalAngle });
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.app.setState({ selectedElementIds: { [line.id]: true } });
 
     await checkRotatedVerticalFlip(
@@ -494,7 +494,7 @@ describe("arrow", () => {
   //TODO: elements with curve outside minMax points have a wrong bounding box!!!
   it.skip("flips an unrotated arrow vertically with line outside min/max points bounds", async () => {
     const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
-    h.app.scene.replaceAllElements([arrow]);
+    h.elements = [arrow];
     h.app.setState({ selectedElementIds: { [arrow.id]: true } });
 
     await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
@@ -506,7 +506,7 @@ describe("arrow", () => {
     const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
     mutateElement(line, { angle: originalAngle });
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.app.setState({ selectedElementIds: { [line.id]: true } });
 
     await checkRotatedVerticalFlip(
@@ -542,7 +542,7 @@ describe("arrow", () => {
 describe("line", () => {
   it("flips an unrotated line horizontally with line inside min/max points bounds", async () => {
     const line = createLinearElementWithCurveInsideMinMaxPoints("line");
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.app.setState({ selectedElementIds: { [line.id]: true } });
 
     await checkHorizontalFlip(
@@ -552,7 +552,7 @@ describe("line", () => {
 
   it("flips an unrotated line vertically with line inside min/max points bounds", async () => {
     const line = createLinearElementWithCurveInsideMinMaxPoints("line");
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.app.setState({ selectedElementIds: { [line.id]: true } });
 
     await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
@@ -567,7 +567,7 @@ describe("line", () => {
   //TODO: elements with curve outside minMax points have a wrong bounding box
   it.skip("flips an unrotated line horizontally with line outside min/max points bounds", async () => {
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.app.setState({ selectedElementIds: { [line.id]: true } });
 
     await checkHorizontalFlip(
@@ -578,7 +578,7 @@ describe("line", () => {
   //TODO: elements with curve outside minMax points have a wrong bounding box
   it.skip("flips an unrotated line vertically with line outside min/max points bounds", async () => {
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.app.setState({ selectedElementIds: { [line.id]: true } });
 
     await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
@@ -590,7 +590,7 @@ describe("line", () => {
     const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
     mutateElement(line, { angle: originalAngle });
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.app.setState({ selectedElementIds: { [line.id]: true } });
 
     await checkRotatedHorizontalFlip(
@@ -605,7 +605,7 @@ describe("line", () => {
     const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
     mutateElement(line, { angle: originalAngle });
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.app.setState({ selectedElementIds: { [line.id]: true } });
 
     await checkRotatedVerticalFlip(
@@ -623,7 +623,7 @@ describe("line", () => {
     const originalAngle = Math.PI / 4;
     const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementWithCurveInsideMinMaxPoints("line");
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.state.selectedElementIds = {
       ...h.state.selectedElementIds,
       [line.id]: true,
@@ -642,7 +642,7 @@ describe("line", () => {
     const originalAngle = Math.PI / 4;
     const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementWithCurveInsideMinMaxPoints("line");
-    h.app.scene.replaceAllElements([line]);
+    h.elements = [line];
     h.state.selectedElementIds = {
       ...h.state.selectedElementIds,
       [line.id]: true,

+ 774 - 0
packages/excalidraw/tests/fractionalIndex.test.ts

@@ -0,0 +1,774 @@
+/* eslint-disable no-lone-blocks */
+import {
+  syncInvalidIndices,
+  syncMovedIndices,
+  validateFractionalIndices,
+} from "../fractionalIndex";
+import { API } from "./helpers/api";
+import { arrayToMap } from "../utils";
+import { InvalidFractionalIndexError } from "../errors";
+import { ExcalidrawElement, FractionalIndex } from "../element/types";
+import { deepCopyElement } from "../element/newElement";
+import { generateKeyBetween } from "fractional-indexing";
+
+describe("sync invalid indices with array order", () => {
+  describe("should NOT sync empty array", () => {
+    testMovedIndicesSync({
+      elements: [],
+      movedElements: [],
+      expect: {
+        unchangedElements: [],
+        validInput: true,
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [],
+      expect: {
+        unchangedElements: [],
+        validInput: true,
+      },
+    });
+  });
+
+  describe("should NOT sync when index is well defined", () => {
+    testMovedIndicesSync({
+      elements: [{ id: "A", index: "a1" }],
+      movedElements: [],
+      expect: {
+        unchangedElements: ["A"],
+        validInput: true,
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [{ id: "A", index: "a1" }],
+      expect: {
+        unchangedElements: ["A"],
+        validInput: true,
+      },
+    });
+  });
+
+  describe("should NOT sync when indices are well defined", () => {
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a1" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a3" },
+      ],
+      movedElements: [],
+      expect: {
+        unchangedElements: ["A", "B", "C"],
+        validInput: true,
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A", index: "a1" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a3" },
+      ],
+      expect: {
+        unchangedElements: ["A", "B", "C"],
+        validInput: true,
+      },
+    });
+  });
+
+  describe("should NOT sync index when it is already valid", () => {
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a2" },
+        { id: "B", index: "a4" },
+      ],
+      movedElements: ["A"],
+      expect: {
+        validInput: true,
+        unchangedElements: ["A", "B"],
+      },
+    });
+
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a2" },
+        { id: "B", index: "a4" },
+      ],
+      movedElements: ["B"],
+      expect: {
+        validInput: true,
+        unchangedElements: ["A", "B"],
+      },
+    });
+  });
+
+  describe("should NOT sync indices when they are already valid", () => {
+    {
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a1" },
+          { id: "B", index: "a0" },
+          { id: "C", index: "a2" },
+        ],
+        movedElements: ["B", "C"],
+        expect: {
+          // this should not sync 'C'
+          unchangedElements: ["A", "C"],
+        },
+      });
+
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a1" },
+          { id: "B", index: "a0" },
+          { id: "C", index: "a2" },
+        ],
+        movedElements: ["A", "B"],
+        expect: {
+          // but this should sync 'A' as it's invalid!
+          unchangedElements: ["C"],
+        },
+      });
+    }
+
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a0" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a1" },
+        { id: "D", index: "a1" },
+        { id: "E", index: "a2" },
+      ],
+      movedElements: ["B", "D", "E"],
+      expect: {
+        // should not sync 'E'
+        unchangedElements: ["A", "C", "E"],
+      },
+    });
+
+    testMovedIndicesSync({
+      elements: [
+        { id: "A" },
+        { id: "B" },
+        { id: "C", index: "a0" },
+        { id: "D", index: "a2" },
+        { id: "E" },
+        { id: "F", index: "a3" },
+        { id: "G" },
+        { id: "H", index: "a1" },
+        { id: "I", index: "a2" },
+        { id: "J" },
+      ],
+      movedElements: ["A", "B", "D", "E", "F", "G", "J"],
+      expect: {
+        // should not sync 'D' and 'F'
+        unchangedElements: ["C", "D", "F"],
+      },
+    });
+  });
+
+  describe("should sync when fractional index is not defined", () => {
+    testMovedIndicesSync({
+      elements: [{ id: "A" }],
+      movedElements: ["A"],
+      expect: {
+        unchangedElements: [],
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [{ id: "A" }],
+      expect: {
+        unchangedElements: [],
+      },
+    });
+  });
+
+  describe("should sync when fractional indices are duplicated", () => {
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A", index: "a1" },
+        { id: "B", index: "a1" },
+      ],
+      expect: {
+        unchangedElements: ["A"],
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A", index: "a1" },
+        { id: "B", index: "a1" },
+      ],
+      expect: {
+        unchangedElements: ["A"],
+      },
+    });
+  });
+
+  describe("should sync when a fractional index is out of order", () => {
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a2" },
+        { id: "B", index: "a1" },
+      ],
+      movedElements: ["B"],
+      expect: {
+        unchangedElements: ["A"],
+      },
+    });
+
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a2" },
+        { id: "B", index: "a1" },
+      ],
+      movedElements: ["A"],
+      expect: {
+        unchangedElements: ["B"],
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A", index: "a2" },
+        { id: "B", index: "a1" },
+      ],
+      expect: {
+        unchangedElements: ["A"],
+      },
+    });
+  });
+
+  describe("should sync when fractional indices are out of order", () => {
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a3" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a1" },
+      ],
+      movedElements: ["B", "C"],
+      expect: {
+        unchangedElements: ["A"],
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A", index: "a3" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a1" },
+      ],
+      expect: {
+        unchangedElements: ["A"],
+      },
+    });
+  });
+
+  describe("should sync when incorrect fractional index is in between correct ones ", () => {
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a1" },
+        { id: "B", index: "a0" },
+        { id: "C", index: "a2" },
+      ],
+      movedElements: ["B"],
+      expect: {
+        unchangedElements: ["A", "C"],
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A", index: "a1" },
+        { id: "B", index: "a0" },
+        { id: "C", index: "a2" },
+      ],
+      expect: {
+        unchangedElements: ["A", "C"],
+      },
+    });
+  });
+
+  describe("should sync when incorrect fractional index is on top and duplicated below", () => {
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a1" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a1" },
+      ],
+      movedElements: ["C"],
+      expect: {
+        unchangedElements: ["A", "B"],
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A", index: "a1" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a1" },
+      ],
+      expect: {
+        unchangedElements: ["A", "B"],
+      },
+    });
+  });
+
+  describe("should sync when given a mix of duplicate / invalid indices", () => {
+    testMovedIndicesSync({
+      elements: [
+        { id: "A", index: "a0" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a1" },
+        { id: "D", index: "a1" },
+        { id: "E", index: "a2" },
+      ],
+      movedElements: ["C", "D", "E"],
+      expect: {
+        unchangedElements: ["A", "B"],
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A", index: "a0" },
+        { id: "B", index: "a2" },
+        { id: "C", index: "a1" },
+        { id: "D", index: "a1" },
+        { id: "E", index: "a2" },
+      ],
+      expect: {
+        unchangedElements: ["A", "B"],
+      },
+    });
+  });
+
+  describe("should sync when given a mix of undefined / invalid indices", () => {
+    testMovedIndicesSync({
+      elements: [
+        { id: "A" },
+        { id: "B" },
+        { id: "C", index: "a0" },
+        { id: "D", index: "a2" },
+        { id: "E" },
+        { id: "F", index: "a3" },
+        { id: "G" },
+        { id: "H", index: "a1" },
+        { id: "I", index: "a2" },
+        { id: "J" },
+      ],
+      movedElements: ["A", "B", "E", "G", "H", "I", "J"],
+      expect: {
+        unchangedElements: ["C", "D", "F"],
+      },
+    });
+
+    testInvalidIndicesSync({
+      elements: [
+        { id: "A" },
+        { id: "B" },
+        { id: "C", index: "a0" },
+        { id: "D", index: "a2" },
+        { id: "E" },
+        { id: "F", index: "a3" },
+        { id: "G" },
+        { id: "H", index: "a1" },
+        { id: "I", index: "a2" },
+        { id: "J" },
+      ],
+      expect: {
+        unchangedElements: ["C", "D", "F"],
+      },
+    });
+  });
+
+  describe("should generate fractions for explicitly moved elements", () => {
+    describe("should generate a fraction between 'A' and 'C'", () => {
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a1" },
+          // doing actual fractions, without jitter 'a1' becomes 'a1V'
+          // as V is taken as the charset's middle-right value
+          { id: "B", index: "a1" },
+          { id: "C", index: "a2" },
+        ],
+        movedElements: ["B"],
+        expect: {
+          unchangedElements: ["A", "C"],
+        },
+      });
+
+      testInvalidIndicesSync({
+        elements: [
+          { id: "A", index: "a1" },
+          { id: "B", index: "a1" },
+          { id: "C", index: "a2" },
+        ],
+        expect: {
+          // as above, B will become fractional
+          unchangedElements: ["A", "C"],
+        },
+      });
+    });
+
+    describe("should generate fractions given duplicated indices", () => {
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a01" },
+          { id: "B", index: "a01" },
+          { id: "C", index: "a01" },
+          { id: "D", index: "a01" },
+          { id: "E", index: "a02" },
+          { id: "F", index: "a02" },
+          { id: "G", index: "a02" },
+        ],
+        movedElements: ["B", "C", "D", "E", "F"],
+        expect: {
+          unchangedElements: ["A", "G"],
+        },
+      });
+
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a01" },
+          { id: "B", index: "a01" },
+          { id: "C", index: "a01" },
+          { id: "D", index: "a01" },
+          { id: "E", index: "a02" },
+          { id: "F", index: "a02" },
+          { id: "G", index: "a02" },
+        ],
+        movedElements: ["A", "C", "D", "E", "G"],
+        expect: {
+          unchangedElements: ["B", "F"],
+        },
+      });
+
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a01" },
+          { id: "B", index: "a01" },
+          { id: "C", index: "a01" },
+          { id: "D", index: "a01" },
+          { id: "E", index: "a02" },
+          { id: "F", index: "a02" },
+          { id: "G", index: "a02" },
+        ],
+        movedElements: ["B", "C", "D", "F", "G"],
+        expect: {
+          unchangedElements: ["A", "E"],
+        },
+      });
+
+      testInvalidIndicesSync({
+        elements: [
+          { id: "A", index: "a01" },
+          { id: "B", index: "a01" },
+          { id: "C", index: "a01" },
+          { id: "D", index: "a01" },
+          { id: "E", index: "a02" },
+          { id: "F", index: "a02" },
+          { id: "G", index: "a02" },
+        ],
+        expect: {
+          // notice fallback considers first item (E) as a valid one
+          unchangedElements: ["A", "E"],
+        },
+      });
+    });
+  });
+
+  describe("should be able to sync 20K invalid indices", () => {
+    const length = 20_000;
+
+    describe("should sync all empty indices", () => {
+      const elements = Array.from({ length }).map((_, index) => ({
+        id: `A_${index}`,
+      }));
+
+      testMovedIndicesSync({
+        // elements without fractional index
+        elements,
+        movedElements: Array.from({ length }).map((_, index) => `A_${index}`),
+        expect: {
+          unchangedElements: [],
+        },
+      });
+
+      testInvalidIndicesSync({
+        // elements without fractional index
+        elements,
+        expect: {
+          unchangedElements: [],
+        },
+      });
+    });
+
+    describe("should sync all but last index given a growing array of indices", () => {
+      let lastIndex: string | null = null;
+
+      const elements = Array.from({ length }).map((_, index) => {
+        // going up from 'a0'
+        lastIndex = generateKeyBetween(lastIndex, null);
+
+        return {
+          id: `A_${index}`,
+          // assigning the last generated index, so sync can go down from there
+          // without jitter lastIndex is 'c4BZ' for 20000th element
+          index: index === length - 1 ? lastIndex : undefined,
+        };
+      });
+      const movedElements = Array.from({ length }).map(
+        (_, index) => `A_${index}`,
+      );
+      // remove last element
+      movedElements.pop();
+
+      testMovedIndicesSync({
+        elements,
+        movedElements,
+        expect: {
+          unchangedElements: [`A_${length - 1}`],
+        },
+      });
+
+      testInvalidIndicesSync({
+        elements,
+        expect: {
+          unchangedElements: [`A_${length - 1}`],
+        },
+      });
+    });
+
+    describe("should sync all but first index given a declining array of indices", () => {
+      let lastIndex: string | null = null;
+
+      const elements = Array.from({ length }).map((_, index) => {
+        // going down from 'a0'
+        lastIndex = generateKeyBetween(null, lastIndex);
+
+        return {
+          id: `A_${index}`,
+          // without jitter lastIndex is 'XvoR' for 20000th element
+          index: lastIndex,
+        };
+      });
+      const movedElements = Array.from({ length }).map(
+        (_, index) => `A_${index}`,
+      );
+      // remove first element
+      movedElements.shift();
+
+      testMovedIndicesSync({
+        elements,
+        movedElements,
+        expect: {
+          unchangedElements: [`A_0`],
+        },
+      });
+
+      testInvalidIndicesSync({
+        elements,
+        expect: {
+          unchangedElements: [`A_0`],
+        },
+      });
+    });
+  });
+
+  describe("should automatically fallback to fixing all invalid indices", () => {
+    describe("should fallback to syncing duplicated indices when moved elements are empty", () => {
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a1" },
+          { id: "B", index: "a1" },
+          { id: "C", index: "a1" },
+        ],
+        // the validation will throw as nothing was synced
+        // therefore it will lead to triggering the fallback and fixing all invalid indices
+        movedElements: [],
+        expect: {
+          unchangedElements: ["A"],
+        },
+      });
+    });
+
+    describe("should fallback to syncing undefined / invalid indices when moved elements are empty", () => {
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a1" },
+          { id: "B" },
+          { id: "C", index: "a0" },
+        ],
+        // since elements are invalid, this will fail the validation
+        // leading to fallback fixing "B" and "C"
+        movedElements: [],
+        expect: {
+          unchangedElements: ["A"],
+        },
+      });
+    });
+
+    describe("should fallback to syncing unordered indices when moved element is invalid", () => {
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a1" },
+          { id: "B", index: "a2" },
+          { id: "C", index: "a1" },
+        ],
+        movedElements: ["A"],
+        expect: {
+          unchangedElements: ["A", "B"],
+        },
+      });
+    });
+
+    describe("should fallback when trying to generate an index in between unordered elements", () => {
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a2" },
+          { id: "B" },
+          { id: "C", index: "a1" },
+        ],
+        // 'B' is invalid, but so is 'C', which was not marked as moved
+        // therefore it will try to generate a key between 'a2' and 'a1'
+        // which it cannot do, thus will throw during generation and automatically fallback
+        movedElements: ["B"],
+        expect: {
+          unchangedElements: ["A"],
+        },
+      });
+    });
+
+    describe("should fallback when trying to generate an index in between duplicate indices", () => {
+      testMovedIndicesSync({
+        elements: [
+          { id: "A", index: "a01" },
+          { id: "B" },
+          { id: "C" },
+          { id: "D", index: "a01" },
+          { id: "E", index: "a01" },
+          { id: "F", index: "a01" },
+          { id: "G" },
+          { id: "I", index: "a03" },
+          { id: "H" },
+        ],
+        // missed "E" therefore upper bound for 'B' is a01, while lower bound is 'a02'
+        // therefore, similarly to above, it will fail during key generation and lead to fallback
+        movedElements: ["B", "C", "D", "F", "G", "H"],
+        expect: {
+          unchangedElements: ["A", "I"],
+        },
+      });
+    });
+  });
+});
+
+function testMovedIndicesSync(args: {
+  elements: { id: string; index?: string }[];
+  movedElements: string[];
+  expect: {
+    unchangedElements: string[];
+    validInput?: true;
+  };
+}) {
+  const [elements, movedElements] = prepareArguments(
+    args.elements,
+    args.movedElements,
+  );
+  const expectUnchangedElements = arrayToMap(
+    args.expect.unchangedElements.map((x) => ({ id: x })),
+  );
+
+  test(
+    "should sync invalid indices of moved elements or fallback",
+    elements,
+    movedElements,
+    expectUnchangedElements,
+    args.expect.validInput,
+  );
+}
+
+function testInvalidIndicesSync(args: {
+  elements: { id: string; index?: string }[];
+  expect: {
+    unchangedElements: string[];
+    validInput?: true;
+  };
+}) {
+  const [elements] = prepareArguments(args.elements);
+  const expectUnchangedElements = arrayToMap(
+    args.expect.unchangedElements.map((x) => ({ id: x })),
+  );
+
+  test(
+    "should sync invalid indices of all elements",
+    elements,
+    undefined,
+    expectUnchangedElements,
+    args.expect.validInput,
+  );
+}
+
+function prepareArguments(
+  elementsLike: { id: string; index?: string }[],
+  movedElementsIds?: string[],
+): [ExcalidrawElement[], Map<string, ExcalidrawElement> | undefined] {
+  const elements = elementsLike.map((x) =>
+    API.createElement({ id: x.id, index: x.index as FractionalIndex }),
+  );
+  const movedMap = arrayToMap(movedElementsIds || []);
+  const movedElements = movedElementsIds
+    ? arrayToMap(elements.filter((x) => movedMap.has(x.id)))
+    : undefined;
+
+  return [elements, movedElements];
+}
+
+function test(
+  name: string,
+  elements: ExcalidrawElement[],
+  movedElements: Map<string, ExcalidrawElement> | undefined,
+  expectUnchangedElements: Map<string, { id: string }>,
+  expectValidInput?: boolean,
+) {
+  it(name, () => {
+    // ensure the input is invalid (unless the flag is on)
+    if (!expectValidInput) {
+      expect(() =>
+        validateFractionalIndices(elements.map((x) => x.index)),
+      ).toThrowError(InvalidFractionalIndexError);
+    }
+
+    // clone due to mutation
+    const clonedElements = elements.map((x) => deepCopyElement(x));
+
+    // act
+    const syncedElements = movedElements
+      ? syncMovedIndices(clonedElements, movedElements)
+      : syncInvalidIndices(clonedElements);
+
+    expect(syncedElements.length).toBe(elements.length);
+    expect(() =>
+      validateFractionalIndices(syncedElements.map((x) => x.index)),
+    ).not.toThrowError(InvalidFractionalIndexError);
+
+    syncedElements.forEach((synced, index) => {
+      const element = elements[index];
+      // ensure the order hasn't changed
+      expect(synced.id).toBe(element.id);
+
+      if (expectUnchangedElements.has(synced.id)) {
+        // ensure we didn't mutate where we didn't want to mutate
+        expect(synced.index).toBe(elements[index].index);
+        expect(synced.version).toBe(elements[index].version);
+      } else {
+        expect(synced.index).not.toBe(elements[index].index);
+        // ensure we mutated just once, even with fallback triggered
+        expect(synced.version).toBe(elements[index].version + 1);
+      }
+    });
+  });
+}

+ 2 - 0
packages/excalidraw/tests/helpers/api.ts

@@ -103,6 +103,7 @@ export class API {
     id?: string;
     isDeleted?: boolean;
     frameId?: ExcalidrawElement["id"] | null;
+    index?: ExcalidrawElement["index"];
     groupIds?: string[];
     // generic element props
     strokeColor?: ExcalidrawGenericElement["strokeColor"];
@@ -170,6 +171,7 @@ export class API {
       x,
       y,
       frameId: rest.frameId ?? null,
+      index: rest.index ?? null,
       angle: rest.angle ?? 0,
       strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
       backgroundColor:

+ 4 - 3
packages/excalidraw/tests/library.test.tsx

@@ -211,10 +211,11 @@ describe("library menu", () => {
       const latestLibrary = await h.app.library.getLatestLibrary();
       expect(latestLibrary.length).toBeGreaterThan(0);
       expect(latestLibrary.length).toBe(libraryItems.length);
-      expect(latestLibrary[0].elements).toEqual(libraryItems[0].elements);
+      const { versionNonce, ...strippedElement } = libraryItems[0]?.elements[0]; // stripped due to mutations
+      expect(latestLibrary[0].elements).toEqual([
+        expect.objectContaining(strippedElement),
+      ]);
     });
-
-    expect(true).toBe(true);
   });
 });
 

+ 1 - 1
packages/excalidraw/tests/regressionTests.test.tsx

@@ -562,7 +562,7 @@ describe("regression tests", () => {
   });
 
   it("adjusts z order when grouping", () => {
-    const positions = [];
+    const positions: number[][] = [];
 
     UI.clickTool("rectangle");
     mouse.down(10, 10);

+ 1 - 1
packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap

@@ -107,7 +107,7 @@ exports[`exportToSvg > with elements that have a link 1`] = `
 exports[`exportToSvg > with exportEmbedScene 1`] = `
 "
   <!-- svg-source:excalidraw -->
-  <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1SPW/CMFx1MDAxMN35XHUwMDE1UbpcIuFAIJSNlqpCqtqBXHUwMDAxqVVcdTAwMDdcdTAwMTNfiFx1MDAxNcdcdTAwMGW2w4dcdTAwMTD/vbaBuETMnerBkt+9d3e+e8dOXHUwMDEwhPpQQThcdELYp5hRXCLxLuxafFx1MDAwYlJRwU2o795K1DJ1zFxc62rS6zFhXHUwMDA0uVB6MkBcYp1FwKBcdTAwMDSulaF9mXdcdTAwMTBcdTAwMWPdbVwilFjpdik3XHUwMDFm06ygnPQ3aZm8zaavn07qSHvDiaO4eVx1MDAxZmz1QdK8d5To3GBcdTAwMTFCXHKWXHUwMDAzXee6XHUwMDA1Yr5mtlePKC1FXHUwMDAxz4JcdGlcdTAwMWJ5QO740iucXHUwMDE2aylqTjwnXHUwMDFhYrzKPCejjC30gZ2ngNO8llx1MDAxMLYqLK8ttvBGp4SZsleZkuucg1I3XHUwMDFhUeGU6kPrV7a/ak7cdL99V1x1MDAxMpcwt+PlNWO/XHUwMDEzc3JJfFx1MDAxM1BcdTAwMDDEJY6j0TB5ROMm4ldcdTAwMWX1UVx1MDAxYn1cdTAwMTfcrT+KxmOE4n4yalx1MDAxOFTNzOK1S5thpsBP1Tbx4k1x00hdXHUwMDExfFx1MDAxNvmPM8qLNs9cdTAwMTituJP7alxcQnEpOFx0XHUwMDFkfur+2+7fdn9hO2CMVlxuLrYzt1x1MDAxYk2Iq2qhTX5DOZsw3FLYPd1Zc+aO1TvT2jWDbfZ46px+XHUwMDAwcU5t0CJ9<!-- payload-end -->
+  <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1Sy27CMFx1MDAxMLzzXHUwMDE1kXtFwuFdbrRUXHUwMDE1UtVcdTAwMWU4ILXqwcRcdTAwMWJixdjBdnhcYvHvtVxyxFx1MDAxMPFcdTAwMDFVVVx1MDAxZizt7M7uejyHRlx1MDAxNCGzL1x1MDAwMI1cIlx1MDAwNLuEcEZcdTAwMTXZoqbDN6A0k8Km2j7WslSJr8yMKUatXHUwMDE2l5aQSW1GXHUwMDFkjPGJXHUwMDA0XHUwMDFjViCMtmVfNo6ig79thlFH3czV+mOc5kzQ9jpZXHLeJuPXT0/1RTtb0427Vbx30zuDKt4yajKLxVx1MDAxOFdYXHUwMDA2bJmZXHUwMDFhSMSSu11cdTAwMDOijZI5PEsulVvkXHUwMDAx+1x1MDAxM0YvSJIvlSxcdTAwMDVccjVxj5BFXHUwMDFhalLG+czs+UlcdTAwMDWSZKVcdTAwMDJUmzC/rFjDK56WVuXAsiOXmVx1MDAwMK1vOLIgXHQz+9qr3H7FlHp1v8NWiqxg6uRcdTAwMTUl59eNXHUwMDA1PTe+SVjtwVx0jcjV8zVcdTAwMDD107pxvzd4xMMqXHUwMDEzfFx1MDAxMLdxXHUwMDFkfZfCe1wijodDjLvtQT+M0Vx1MDAxM+tcdTAwMDbj26aEa1xiUrvNXoJTbrYrXHUwMDBiSk6koFx1MDAwNmdcIq/XWffld3pf3ExcdTAwMTlZSUGRx4/Nfy/+di/Gf9eLwDkrNJy9aG+vXHUwMDE3XCJFMTO2vy05OVx1MDAxM21cdTAwMThsn+78feqP43snu79cdTAwMDe37OHYOP5cdTAwMDBcdTAwMDLtdtMifQ==<!-- payload-end -->
   <defs>
     <style class="style-fonts">
       @font-face {

+ 12 - 2
packages/excalidraw/tests/scene/export.test.ts

@@ -15,8 +15,18 @@ describe("exportToSvg", () => {
   const ELEMENT_HEIGHT = 100;
   const ELEMENT_WIDTH = 100;
   const ELEMENTS = [
-    { ...diamondFixture, height: ELEMENT_HEIGHT, width: ELEMENT_WIDTH },
-    { ...ellipseFixture, height: ELEMENT_HEIGHT, width: ELEMENT_WIDTH },
+    {
+      ...diamondFixture,
+      height: ELEMENT_HEIGHT,
+      width: ELEMENT_WIDTH,
+      index: "a0",
+    },
+    {
+      ...ellipseFixture,
+      height: ELEMENT_HEIGHT,
+      width: ELEMENT_WIDTH,
+      index: "a1",
+    },
   ] as NonDeletedExcalidrawElement[];
 
   const DEFAULT_OPTIONS = {

+ 1 - 0
packages/excalidraw/tests/zindex.test.tsx

@@ -46,6 +46,7 @@ const populateElements = (
     height?: number;
     containerId?: string;
     frameId?: ExcalidrawFrameElement["id"];
+    index?: ExcalidrawElement["index"];
   }[],
   appState?: Partial<AppState>,
 ) => {

+ 2 - 1
packages/excalidraw/types.ts

@@ -20,6 +20,7 @@ import {
   ExcalidrawFrameLikeElement,
   ExcalidrawElementType,
   ExcalidrawIframeLikeElement,
+  OrderedExcalidrawElement,
 } from "./element/types";
 import { Action } from "./actions/types";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -415,7 +416,7 @@ export type OnUserFollowedPayload = {
 
 export interface ExcalidrawProps {
   onChange?: (
-    elements: readonly ExcalidrawElement[],
+    elements: readonly OrderedExcalidrawElement[],
     appState: AppState,
     files: BinaryFiles,
   ) => void;

+ 26 - 27
packages/excalidraw/zindex.ts

@@ -1,6 +1,6 @@
-import { bumpVersion } from "./element/mutateElement";
 import { isFrameLikeElement } from "./element/typeChecks";
 import { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./element/types";
+import { syncMovedIndices } from "./fractionalIndex";
 import { getElementsInGroup } from "./groups";
 import { getSelectedElements } from "./scene";
 import Scene from "./scene/Scene";
@@ -234,9 +234,9 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
 ) => {
   return indices.reduce((acc, index) => {
     const element = elements[index];
-    acc[element.id] = element;
+    acc.set(element.id, element);
     return acc;
-  }, {} as Record<string, ExcalidrawElement>);
+  }, new Map<string, ExcalidrawElement>());
 };
 
 const shiftElementsByOne = (
@@ -246,6 +246,7 @@ const shiftElementsByOne = (
 ) => {
   const indicesToMove = getIndicesToMove(elements, appState);
   const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
+
   let groupedIndices = toContiguousGroups(indicesToMove);
 
   if (direction === "right") {
@@ -312,12 +313,9 @@ const shiftElementsByOne = (
           ];
   });
 
-  return elements.map((element) => {
-    if (targetElementsMap[element.id]) {
-      return bumpVersion(element);
-    }
-    return element;
-  });
+  syncMovedIndices(elements, targetElementsMap);
+
+  return elements;
 };
 
 const shiftElementsToEnd = (
@@ -383,26 +381,27 @@ const shiftElementsToEnd = (
     }
   }
 
-  const targetElements = Object.values(targetElementsMap).map((element) => {
-    return bumpVersion(element);
-  });
-
+  const targetElements = Array.from(targetElementsMap.values());
   const leadingElements = elements.slice(0, leadingIndex);
   const trailingElements = elements.slice(trailingIndex + 1);
-
-  return direction === "left"
-    ? [
-        ...leadingElements,
-        ...targetElements,
-        ...displacedElements,
-        ...trailingElements,
-      ]
-    : [
-        ...leadingElements,
-        ...displacedElements,
-        ...targetElements,
-        ...trailingElements,
-      ];
+  const nextElements =
+    direction === "left"
+      ? [
+          ...leadingElements,
+          ...targetElements,
+          ...displacedElements,
+          ...trailingElements,
+        ]
+      : [
+          ...leadingElements,
+          ...displacedElements,
+          ...targetElements,
+          ...trailingElements,
+        ];
+
+  syncMovedIndices(nextElements, targetElementsMap);
+
+  return nextElements;
 };
 
 function shiftElementsAccountingForFrames(

+ 9 - 3
yarn.lock

@@ -6513,6 +6513,12 @@ fraction.js@^4.2.0:
   resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
   integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
 
+
[email protected]:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/fractional-indexing/-/fractional-indexing-3.2.0.tgz#1193e63d54ff4e0cbe0c79a9ed6cfbab25d91628"
+  integrity sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==
+
 fs-constants@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
@@ -9375,9 +9381,9 @@ [email protected]:
     source-map-js ">=0.6.2 <2.0.0"
 
 sass@^1.7.3:
-  version "1.69.5"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.5.tgz#23e18d1c757a35f2e52cc81871060b9ad653dfde"
-  integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==
+  version "1.69.6"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.6.tgz#88ae1f93facc46d2da9b0bdd652d65068bcfa397"
+  integrity sha512-qbRr3k9JGHWXCvZU77SD2OTwUlC+gNT+61JOLcmLm+XqH4h/5D+p4IIsxvpkB89S9AwJOyb5+rWNpIucaFxSFQ==
   dependencies:
     chokidar ">=3.0.0 <4.0.0"
     immutable "^4.0.0"

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů