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 <marcel@excalidraw.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di 2024-04-04 20:51:11 +08:00 committed by GitHub
parent bbdcd30a73
commit 32df5502ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 3640 additions and 2047 deletions

View File

@ -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,
) => {

View File

@ -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();
};

View File

@ -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,

View File

@ -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;
};

View File

@ -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),
);
return { reconciledElements: savedData.reconciledElements };
FirebaseSceneVersionCache.set(socket, storedElements);
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 (

View File

@ -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[];

View File

@ -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([

View File

@ -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"]);
});
});

View File

@ -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;
}
}

View File

@ -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(

View File

@ -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,
};
},

View File

@ -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,

View File

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

View File

@ -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),
);
},
},
});

View File

@ -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,

View File

@ -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,

View File

@ -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[];
};

View File

@ -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;

View File

@ -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) => {

View File

@ -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,

View File

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

View File

@ -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">;

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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"]>,
) => {

View File

@ -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",

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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);
});

View File

@ -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,

View File

@ -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"]);
});
});

View File

@ -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,
}),
]);
});

View File

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

View File

@ -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,

View File

@ -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);
}
});
});
}

View File

@ -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:

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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 {

View File

@ -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 = {

View File

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

View File

@ -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;

View File

@ -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);
const nextElements =
direction === "left"
? [
...leadingElements,
...targetElements,
...displacedElements,
...trailingElements,
]
: [
...leadingElements,
...displacedElements,
...targetElements,
...trailingElements,
];
return direction === "left"
? [
...leadingElements,
...targetElements,
...displacedElements,
...trailingElements,
]
: [
...leadingElements,
...displacedElements,
...targetElements,
...trailingElements,
];
syncMovedIndices(nextElements, targetElementsMap);
return nextElements;
};
function shiftElementsAccountingForFrames(

View File

@ -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==
fractional-indexing@3.2.0:
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 @@ sass@1.51.0:
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"