Move fractional index in element

This commit is contained in:
Marcel Mraz 2025-03-18 19:28:58 +01:00
parent 1c5b8372b9
commit 3782407c76
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
18 changed files with 88 additions and 45 deletions

View file

@ -0,0 +1,482 @@
import { KEYS, arrayToMap } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { actionWrapTextInContainer } from "@excalidraw/excalidraw/actions/actionBoundText";
import { getTransformHandles } from "@excalidraw/element/transformHandles";
import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
const { h } = window;
const mouse = new Pointer("mouse");
describe("element binding", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should create valid binding if duplicate start/end points", async () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
});
const arrow = API.createElement({
type: "arrow",
x: 100,
y: 0,
width: 100,
height: 1,
points: [
pointFrom(0, 0),
pointFrom(0, 0),
pointFrom(100, 0),
pointFrom(100, 0),
],
});
API.setElements([rect, arrow]);
expect(arrow.startBinding).toBe(null);
// select arrow
mouse.clickAt(150, 0);
// move arrow start to potential binding position
mouse.downAt(100, 0);
mouse.moveTo(55, 0);
mouse.up(0, 0);
// Point selection is evaluated like the points are rendered,
// from right to left. So clicking on the first point should move the joint,
// not the start point.
expect(arrow.startBinding).toBe(null);
// Now that the start point is free, move it into overlapping position
mouse.downAt(100, 0);
mouse.moveTo(55, 0);
mouse.up(0, 0);
expect(API.getSelectedElements()).toEqual([arrow]);
expect(arrow.startBinding).toEqual({
elementId: rect.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
// Move the end point to the overlapping binding position
mouse.downAt(200, 0);
mouse.moveTo(55, 0);
mouse.up(0, 0);
// Both the start and the end points should be bound
expect(arrow.startBinding).toEqual({
elementId: rect.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
expect(arrow.endBinding).toEqual({
elementId: rect.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
});
//@TODO fix the test with rotation
it.skip("rotation of arrow should rebind both ends", () => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
width: 200,
height: 500,
});
const arrow = UI.createElement("arrow", {
x: 210,
y: 250,
width: 180,
height: 1,
});
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
const rotation = getTransformHandles(
arrow,
h.state.zoom,
arrayToMap(h.elements),
"mouse",
).rotation!;
const rotationHandleX = rotation[0] + rotation[2] / 2;
const rotationHandleY = rotation[1] + rotation[3] / 2;
mouse.down(rotationHandleX, rotationHandleY);
mouse.move(300, 400);
mouse.up();
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
expect(arrow.startBinding?.elementId).toBe(rectRight.id);
expect(arrow.endBinding?.elementId).toBe(rectLeft.id);
});
// TODO fix & reenable once we rewrite tests to work with concurrency
it.skip(
"editing arrow and moving its head to bind it to element A, finalizing the" +
"editing by clicking on element A should end up selecting A",
async () => {
UI.createElement("rectangle", {
y: 0,
size: 100,
});
// Create arrow bound to rectangle
UI.clickTool("arrow");
mouse.down(50, -100);
mouse.up(0, 80);
// Edit arrow with multi-point
mouse.doubleClick();
// move arrow head
mouse.down();
mouse.up(0, 10);
expect(API.getSelectedElement().type).toBe("arrow");
// NOTE this mouse down/up + await needs to be done in order to repro
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
mouse.reset();
expect(h.state.editingLinearElement).not.toBe(null);
mouse.down(0, 0);
await new Promise((r) => setTimeout(r, 100));
expect(h.state.editingLinearElement).toBe(null);
expect(API.getSelectedElement().type).toBe("rectangle");
mouse.up();
expect(API.getSelectedElement().type).toBe("rectangle");
},
);
it("should unbind arrow when moving it with keyboard", () => {
const rectangle = UI.createElement("rectangle", {
x: 75,
y: 0,
size: 100,
});
// Creates arrow 1px away from bidding with rectangle
const arrow = UI.createElement("arrow", {
x: 0,
y: 0,
size: 50,
});
expect(arrow.endBinding).toBe(null);
mouse.downAt(50, 50);
mouse.moveTo(51, 0);
mouse.up(0, 0);
// Test sticky connection
expect(API.getSelectedElement().type).toBe("arrow");
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
// Sever connection
expect(API.getSelectedElement().type).toBe("arrow");
Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(arrow.endBinding).toBe(null);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(arrow.endBinding).toBe(null);
});
it("should unbind on bound element deletion", () => {
const rectangle = UI.createElement("rectangle", {
x: 60,
y: 0,
size: 100,
});
const arrow = UI.createElement("arrow", {
x: 0,
y: 0,
size: 50,
});
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
mouse.select(rectangle);
expect(API.getSelectedElement().type).toBe("rectangle");
Keyboard.keyDown(KEYS.DELETE);
expect(arrow.endBinding).toBe(null);
});
it("should unbind on text element deletion by submitting empty text", async () => {
const text = API.createElement({
type: "text",
text: "ola",
x: 60,
y: 0,
width: 100,
height: 100,
});
API.setElements([text]);
const arrow = UI.createElement("arrow", {
x: 0,
y: 0,
size: 50,
});
expect(arrow.endBinding?.elementId).toBe(text.id);
// edit text element and submit
// -------------------------------------------------------------------------
UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect(
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding).toBe(null);
});
it("should keep binding on text update", async () => {
const text = API.createElement({
type: "text",
text: "ola",
x: 60,
y: 0,
width: 100,
height: 100,
});
API.setElements([text]);
const arrow = UI.createElement("arrow", {
x: 0,
y: 0,
size: 50,
});
expect(arrow.endBinding?.elementId).toBe(text.id);
// delete text element by submitting empty text
// -------------------------------------------------------------------------
UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect(
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id);
});
it("should update binding when text containerized", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
width: 100,
height: 100,
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
],
});
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
endBinding: {
elementId: "text1",
focus: 0.2,
gap: 7,
fixedPoint: [1, 0.5],
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
startBinding: {
elementId: "text1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [1, 0.5],
},
});
const text1 = API.createElement({
type: "text",
id: "text1",
text: "ola",
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
],
});
API.setElements([rectangle1, arrow1, arrow2, text1]);
API.setSelectedElements([text1]);
expect(h.state.selectedElementIds[text1.id]).toBe(true);
API.executeAction(actionWrapTextInContainer);
// new text container will be placed before the text element
const container = h.elements.at(-2)!;
expect(container.type).toBe("rectangle");
expect(container.id).not.toBe(rectangle1.id);
expect(container).toEqual(
expect.objectContaining({
boundElements: expect.arrayContaining([
{
type: "text",
id: text1.id,
},
{
type: "arrow",
id: arrow1.id,
},
{
type: "arrow",
id: arrow2.id,
},
]),
}),
);
expect(arrow1.startBinding?.elementId).toBe(rectangle1.id);
expect(arrow1.endBinding?.elementId).toBe(container.id);
expect(arrow2.startBinding?.elementId).toBe(container.id);
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
});
// #6459
it("should unbind arrow only from the latest element", () => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
width: 200,
height: 500,
});
const arrow = UI.createElement("arrow", {
x: 210,
y: 250,
width: 180,
height: 1,
});
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
// Drag arrow off of bound rectangle range
const handles = getTransformHandles(
arrow,
h.state.zoom,
arrayToMap(h.elements),
"mouse",
).se!;
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
const elX = handles[0] + handles[2] / 2;
const elY = handles[1] + handles[3] / 2;
mouse.downAt(elX, elY);
mouse.moveTo(300, 400);
mouse.up();
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).toBe(null);
});
it("should not unbind when duplicating via selection group", () => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
y: 200,
width: 200,
height: 500,
});
const arrow = UI.createElement("arrow", {
x: 210,
y: 250,
width: 177,
height: 1,
});
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
mouse.downAt(-100, -100);
mouse.moveTo(650, 750);
mouse.up(0, 0);
expect(API.getSelectedElements().length).toBe(3);
mouse.moveTo(5, 5);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.downAt(5, 5);
mouse.moveTo(1000, 1000);
mouse.up(0, 0);
expect(window.h.elements.length).toBe(6);
window.h.elements.forEach((element) => {
if (isLinearElement(element)) {
expect(element.startBinding).not.toBe(null);
expect(element.endBinding).not.toBe(null);
} else {
expect(element.boundElements).not.toBe(null);
}
});
});
});
});

View file

@ -0,0 +1,816 @@
/* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
import { deepCopyElement } from "@excalidraw/element/newElement";
import { InvalidFractionalIndexError } from "@excalidraw/excalidraw/errors";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type {
ExcalidrawElement,
FractionalIndex,
} from "@excalidraw/element/types";
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 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 sync all moved elements regardless of their validity", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a4" },
],
movedElements: ["A"],
expect: {
validInput: true,
unchangedElements: ["B"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a4" },
],
movedElements: ["B"],
expect: {
validInput: true,
unchangedElements: ["A"],
},
});
testMovedIndicesSync({
elements: [
{ id: "C", index: "a2" },
{ id: "D", index: "a3" },
{ id: "A", index: "a0" },
{ id: "B", index: "a1" },
],
movedElements: ["C", "D"],
expect: {
unchangedElements: ["A", "B"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a2" },
{ id: "D", index: "a4" },
{ id: "C", index: "a3" },
{ id: "F", index: "a6" },
{ id: "E", index: "a5" },
{ id: "H", index: "a8" },
{ id: "G", index: "a7" },
{ id: "I", index: "a9" },
],
movedElements: ["D", "F", "H"],
expect: {
unchangedElements: ["A", "B", "C", "E", "G", "I"],
},
});
{
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a0" },
{ id: "C", index: "a2" },
],
movedElements: ["B", "C"],
expect: {
unchangedElements: ["A"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a0" },
{ id: "C", index: "a2" },
],
movedElements: ["A", "B"],
expect: {
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: {
unchangedElements: ["A", "C"],
},
});
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: {
unchangedElements: ["C", "H", "I"],
},
});
});
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, {
shouldThrow: true,
includeBoundTextValidation: true,
ignoreLogs: true,
}),
).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, {
shouldThrow: true,
includeBoundTextValidation: true,
ignoreLogs: true,
}),
).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);
}
});
});
}