mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
bbdcd30a73
commit
32df5502ae
50 changed files with 3640 additions and 2047 deletions
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
|
|||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-arrow01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
|
@ -40,8 +41,8 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
|
|||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
|
@ -63,6 +64,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
|||
],
|
||||
"height": 200,
|
||||
"id": "1",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -77,8 +79,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
|||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
|
@ -100,6 +102,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
|||
],
|
||||
"height": 200,
|
||||
"id": "2",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -114,8 +117,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
|||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
|
@ -137,6 +140,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
|||
],
|
||||
"height": 200,
|
||||
"id": "3",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -151,8 +155,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
|||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
|
@ -170,6 +174,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
|
|||
"groupIds": [],
|
||||
"height": 0,
|
||||
"id": "id-freedraw01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
|
@ -188,8 +193,8 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
|
|||
"strokeWidth": 2,
|
||||
"type": "freedraw",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 0,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
|
@ -209,6 +214,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
|
|||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-line01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
|
@ -236,8 +242,8 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
|
|||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
|
@ -257,6 +263,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
|||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-draw01",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
|
@ -284,8 +291,8 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
|||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
|
@ -306,6 +313,7 @@ exports[`restoreElements > should restore text element correctly passing value f
|
|||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
|
@ -324,8 +332,8 @@ exports[`restoreElements > should restore text element correctly passing value f
|
|||
"textAlign": "center",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": -20,
|
||||
|
@ -347,6 +355,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
|
|||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"index": "a0",
|
||||
"isDeleted": true,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
|
@ -365,7 +374,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
|
|||
"textAlign": "left",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "top",
|
||||
"width": 100,
|
||||
|
|
374
packages/excalidraw/tests/data/reconcile.test.ts
Normal file
374
packages/excalidraw/tests/data/reconcile.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -17,6 +17,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
|
|||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: null,
|
||||
index: null,
|
||||
seed: 1041657908,
|
||||
version: 120,
|
||||
versionNonce: 1188004276,
|
||||
|
|
|
@ -412,7 +412,7 @@ describe("ellipse", () => {
|
|||
describe("arrow", () => {
|
||||
it("flips an unrotated arrow horizontally with line inside min/max points bounds", async () => {
|
||||
const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([arrow]);
|
||||
h.elements = [arrow];
|
||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||
await checkHorizontalFlip(
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
|
@ -421,7 +421,7 @@ describe("arrow", () => {
|
|||
|
||||
it("flips an unrotated arrow vertically with line inside min/max points bounds", async () => {
|
||||
const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([arrow]);
|
||||
h.elements = [arrow];
|
||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||
|
||||
await checkVerticalFlip(50);
|
||||
|
@ -431,7 +431,7 @@ describe("arrow", () => {
|
|||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
|
@ -450,7 +450,7 @@ describe("arrow", () => {
|
|||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
|
@ -468,7 +468,7 @@ describe("arrow", () => {
|
|||
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
||||
it.skip("flips an unrotated arrow horizontally with line outside min/max points bounds", async () => {
|
||||
const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([arrow]);
|
||||
h.elements = [arrow];
|
||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||
|
||||
await checkHorizontalFlip(
|
||||
|
@ -482,7 +482,7 @@ describe("arrow", () => {
|
|||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
mutateElement(line, { angle: originalAngle });
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkRotatedVerticalFlip(
|
||||
|
@ -494,7 +494,7 @@ describe("arrow", () => {
|
|||
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
||||
it.skip("flips an unrotated arrow vertically with line outside min/max points bounds", async () => {
|
||||
const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([arrow]);
|
||||
h.elements = [arrow];
|
||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||
|
||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||
|
@ -506,7 +506,7 @@ describe("arrow", () => {
|
|||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
mutateElement(line, { angle: originalAngle });
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkRotatedVerticalFlip(
|
||||
|
@ -542,7 +542,7 @@ describe("arrow", () => {
|
|||
describe("line", () => {
|
||||
it("flips an unrotated line horizontally with line inside min/max points bounds", async () => {
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkHorizontalFlip(
|
||||
|
@ -552,7 +552,7 @@ describe("line", () => {
|
|||
|
||||
it("flips an unrotated line vertically with line inside min/max points bounds", async () => {
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||
|
@ -567,7 +567,7 @@ describe("line", () => {
|
|||
//TODO: elements with curve outside minMax points have a wrong bounding box
|
||||
it.skip("flips an unrotated line horizontally with line outside min/max points bounds", async () => {
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkHorizontalFlip(
|
||||
|
@ -578,7 +578,7 @@ describe("line", () => {
|
|||
//TODO: elements with curve outside minMax points have a wrong bounding box
|
||||
it.skip("flips an unrotated line vertically with line outside min/max points bounds", async () => {
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||
|
@ -590,7 +590,7 @@ describe("line", () => {
|
|||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
mutateElement(line, { angle: originalAngle });
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkRotatedHorizontalFlip(
|
||||
|
@ -605,7 +605,7 @@ describe("line", () => {
|
|||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
mutateElement(line, { angle: originalAngle });
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkRotatedVerticalFlip(
|
||||
|
@ -623,7 +623,7 @@ describe("line", () => {
|
|||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
|
@ -642,7 +642,7 @@ describe("line", () => {
|
|||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.elements = [line];
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
|
|
774
packages/excalidraw/tests/fractionalIndex.test.ts
Normal file
774
packages/excalidraw/tests/fractionalIndex.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -46,6 +46,7 @@ const populateElements = (
|
|||
height?: number;
|
||||
containerId?: string;
|
||||
frameId?: ExcalidrawFrameElement["id"];
|
||||
index?: ExcalidrawElement["index"];
|
||||
}[],
|
||||
appState?: Partial<AppState>,
|
||||
) => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue