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

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