mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Move fractional index in element
This commit is contained in:
parent
1c5b8372b9
commit
3782407c76
18 changed files with 88 additions and 45 deletions
|
@ -47,7 +47,9 @@
|
|||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"fractional-indexing": "3.2.0"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
|
|
414
packages/element/src/fractionalIndex.ts
Normal file
414
packages/element/src/fractionalIndex.ts
Normal file
|
@ -0,0 +1,414 @@
|
|||
import { generateNKeysBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import { getBoundTextElement } from "@excalidraw/element/textElement";
|
||||
import { hasBoundTextElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { InvalidFractionalIndexError } from "../../excalidraw/errors";
|
||||
|
||||
/**
|
||||
* Envisioned relation between array order and fractional indices:
|
||||
*
|
||||
* 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation.
|
||||
* - it's undesirable to perform reorder for each related operation, therefore it's necessary to cache the order defined by fractional indices into an ordered data structure
|
||||
* - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps)
|
||||
* - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc.
|
||||
* - it's necessary to always keep the fractional indices in sync with the array order
|
||||
* - elements with invalid indices should be detected and synced, without altering the already valid indices
|
||||
*
|
||||
* 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated.
|
||||
* - as the fractional indices are encoded as part of the elements, it opens up possibilities for incremental-like APIs
|
||||
* - re-order based on fractional indices should be part of (multiplayer) operations such as reconciliation & undo/redo
|
||||
* - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits,
|
||||
* as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ensure that all elements have valid fractional indices.
|
||||
*
|
||||
* @throws `InvalidFractionalIndexError` if invalid index is detected.
|
||||
*/
|
||||
export const validateFractionalIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{
|
||||
shouldThrow = false,
|
||||
includeBoundTextValidation = false,
|
||||
ignoreLogs,
|
||||
reconciliationContext,
|
||||
}: {
|
||||
shouldThrow: boolean;
|
||||
includeBoundTextValidation: boolean;
|
||||
ignoreLogs?: true;
|
||||
reconciliationContext?: {
|
||||
localElements: ReadonlyArray<ExcalidrawElement>;
|
||||
remoteElements: ReadonlyArray<ExcalidrawElement>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
const errorMessages = [];
|
||||
const stringifyElement = (element: ExcalidrawElement | void) =>
|
||||
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
|
||||
|
||||
const indices = elements.map((x) => x.index);
|
||||
for (const [i, index] of indices.entries()) {
|
||||
const predecessorIndex = indices[i - 1];
|
||||
const successorIndex = indices[i + 1];
|
||||
|
||||
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
|
||||
errorMessages.push(
|
||||
`Fractional indices invariant has been compromised: "${stringifyElement(
|
||||
elements[i - 1],
|
||||
)}", "${stringifyElement(elements[i])}", "${stringifyElement(
|
||||
elements[i + 1],
|
||||
)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// disabled by default, as we don't fix it
|
||||
if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
|
||||
const container = elements[i];
|
||||
const text = getBoundTextElement(container, arrayToMap(elements));
|
||||
|
||||
if (text && text.index! <= container.index!) {
|
||||
errorMessages.push(
|
||||
`Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
|
||||
text,
|
||||
)}", "${stringifyElement(container)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessages.length) {
|
||||
const error = new InvalidFractionalIndexError();
|
||||
const additionalContext = [];
|
||||
|
||||
if (reconciliationContext) {
|
||||
additionalContext.push("Additional reconciliation context:");
|
||||
additionalContext.push(
|
||||
reconciliationContext.localElements.map((x) => stringifyElement(x)),
|
||||
);
|
||||
additionalContext.push(
|
||||
reconciliationContext.remoteElements.map((x) => stringifyElement(x)),
|
||||
);
|
||||
}
|
||||
|
||||
if (!ignoreLogs) {
|
||||
// report just once and with the stacktrace
|
||||
console.error(
|
||||
errorMessages.join("\n\n"),
|
||||
error.stack,
|
||||
elements.map((x) => stringifyElement(x)),
|
||||
...additionalContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldThrow) {
|
||||
// if enabled, gather all the errors first, throw once
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Order the elements based on the fractional indices.
|
||||
* - when fractional indices are identical, break the tie based on the element id
|
||||
* - when there is no fractional index in one of the elements, respect the order of the array
|
||||
*/
|
||||
export const orderByFractionalIndex = (
|
||||
elements: OrderedExcalidrawElement[],
|
||||
) => {
|
||||
return elements.sort((a, b) => {
|
||||
// in case the indices are not the defined at runtime
|
||||
if (isOrderedElement(a) && isOrderedElement(b)) {
|
||||
if (a.index < b.index) {
|
||||
return -1;
|
||||
} else if (a.index > b.index) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// break ties based on the element id
|
||||
return a.id < b.id ? -1 : 1;
|
||||
}
|
||||
|
||||
// defensively keep the array order
|
||||
return 1;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements.
|
||||
* If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`.
|
||||
*/
|
||||
export const syncMovedIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
movedElements: Map<string, ExcalidrawElement>,
|
||||
): OrderedExcalidrawElement[] => {
|
||||
try {
|
||||
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
|
||||
|
||||
// try generatating indices, throws on invalid movedElements
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
const elementsCandidates = elements.map((x) =>
|
||||
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
||||
);
|
||||
|
||||
// ensure next indices are valid before mutation, throws on invalid ones
|
||||
validateFractionalIndices(
|
||||
elementsCandidates,
|
||||
// we don't autofix invalid bound text indices, hence don't include it in the validation
|
||||
{
|
||||
includeBoundTextValidation: false,
|
||||
shouldThrow: true,
|
||||
ignoreLogs: true,
|
||||
},
|
||||
);
|
||||
|
||||
// split mutation so we don't end up in an incosistent state
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, update, false);
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback to default sync
|
||||
syncInvalidIndices(elements);
|
||||
}
|
||||
|
||||
return elements as OrderedExcalidrawElement[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
||||
*
|
||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||
*/
|
||||
export const syncInvalidIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): OrderedExcalidrawElement[] => {
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, update, false);
|
||||
}
|
||||
|
||||
return elements as OrderedExcalidrawElement[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get contiguous groups of indices of passed moved elements.
|
||||
*
|
||||
* NOTE: First and last elements within the groups are indices of lower and upper bounds.
|
||||
*/
|
||||
const getMovedIndicesGroups = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
movedElements: Map<string, ExcalidrawElement>,
|
||||
) => {
|
||||
const indicesGroups: number[][] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < elements.length) {
|
||||
if (movedElements.has(elements[i].id)) {
|
||||
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
|
||||
|
||||
while (++i < elements.length) {
|
||||
if (!movedElements.has(elements[i].id)) {
|
||||
break;
|
||||
}
|
||||
|
||||
indicesGroup.push(i);
|
||||
}
|
||||
|
||||
indicesGroup.push(i); // push the upper bound index as the last item
|
||||
indicesGroups.push(indicesGroup);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return indicesGroups;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets contiguous groups of all invalid indices automatically detected inside the elements array.
|
||||
*
|
||||
* WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds!
|
||||
*/
|
||||
const getInvalidIndicesGroups = (elements: readonly ExcalidrawElement[]) => {
|
||||
const indicesGroups: number[][] = [];
|
||||
|
||||
// once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf.
|
||||
let lowerBound: ExcalidrawElement["index"] | undefined = undefined;
|
||||
let upperBound: ExcalidrawElement["index"] | undefined = undefined;
|
||||
let lowerBoundIndex: number = -1;
|
||||
let upperBoundIndex: number = 0;
|
||||
|
||||
/** @returns maybe valid lowerBound */
|
||||
const getLowerBound = (
|
||||
index: number,
|
||||
): [ExcalidrawElement["index"] | undefined, number] => {
|
||||
const lowerBound = elements[lowerBoundIndex]
|
||||
? elements[lowerBoundIndex].index
|
||||
: undefined;
|
||||
|
||||
// we are already iterating left to right, therefore there is no need for additional looping
|
||||
const candidate = elements[index - 1]?.index;
|
||||
|
||||
if (
|
||||
(!lowerBound && candidate) || // first lowerBound
|
||||
(lowerBound && candidate && candidate > lowerBound) // next lowerBound
|
||||
) {
|
||||
// WARN: candidate's index could be higher or same as the current element's index
|
||||
return [candidate, index - 1];
|
||||
}
|
||||
|
||||
// cache hit! take the last lower bound
|
||||
return [lowerBound, lowerBoundIndex];
|
||||
};
|
||||
|
||||
/** @returns always valid upperBound */
|
||||
const getUpperBound = (
|
||||
index: number,
|
||||
): [ExcalidrawElement["index"] | undefined, number] => {
|
||||
const upperBound = elements[upperBoundIndex]
|
||||
? elements[upperBoundIndex].index
|
||||
: undefined;
|
||||
|
||||
// cache hit! don't let it find the upper bound again
|
||||
if (upperBound && index < upperBoundIndex) {
|
||||
return [upperBound, upperBoundIndex];
|
||||
}
|
||||
|
||||
// set the current upperBoundIndex as the starting point
|
||||
let i = upperBoundIndex;
|
||||
while (++i < elements.length) {
|
||||
const candidate = elements[i]?.index;
|
||||
|
||||
if (
|
||||
(!upperBound && candidate) || // first upperBound
|
||||
(upperBound && candidate && candidate > upperBound) // next upperBound
|
||||
) {
|
||||
return [candidate, i];
|
||||
}
|
||||
}
|
||||
|
||||
// we reached the end, sky is the limit
|
||||
return [undefined, i];
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < elements.length) {
|
||||
const current = elements[i].index;
|
||||
[lowerBound, lowerBoundIndex] = getLowerBound(i);
|
||||
[upperBound, upperBoundIndex] = getUpperBound(i);
|
||||
|
||||
if (!isValidFractionalIndex(current, lowerBound, upperBound)) {
|
||||
// push the lower bound index as the first item
|
||||
const indicesGroup = [lowerBoundIndex, i];
|
||||
|
||||
while (++i < elements.length) {
|
||||
const current = elements[i].index;
|
||||
const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i);
|
||||
const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i);
|
||||
|
||||
if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// assign bounds only for the moved elements
|
||||
[lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex];
|
||||
[upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex];
|
||||
|
||||
indicesGroup.push(i);
|
||||
}
|
||||
|
||||
// push the upper bound index as the last item
|
||||
indicesGroup.push(upperBoundIndex);
|
||||
indicesGroups.push(indicesGroup);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return indicesGroups;
|
||||
};
|
||||
|
||||
const isValidFractionalIndex = (
|
||||
index: ExcalidrawElement["index"] | undefined,
|
||||
predecessor: ExcalidrawElement["index"] | undefined,
|
||||
successor: ExcalidrawElement["index"] | undefined,
|
||||
) => {
|
||||
if (!index) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (predecessor && successor) {
|
||||
return predecessor < index && index < successor;
|
||||
}
|
||||
|
||||
if (!predecessor && successor) {
|
||||
// first element
|
||||
return index < successor;
|
||||
}
|
||||
|
||||
if (predecessor && !successor) {
|
||||
// last element
|
||||
return predecessor < index;
|
||||
}
|
||||
|
||||
// only element in the array
|
||||
return !!index;
|
||||
};
|
||||
|
||||
const generateIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
indicesGroups: number[][],
|
||||
) => {
|
||||
const elementsUpdates = new Map<
|
||||
ExcalidrawElement,
|
||||
{ index: FractionalIndex }
|
||||
>();
|
||||
|
||||
for (const indices of indicesGroups) {
|
||||
const lowerBoundIndex = indices.shift()!;
|
||||
const upperBoundIndex = indices.pop()!;
|
||||
|
||||
const fractionalIndices = generateNKeysBetween(
|
||||
elements[lowerBoundIndex]?.index,
|
||||
elements[upperBoundIndex]?.index,
|
||||
indices.length,
|
||||
) as FractionalIndex[];
|
||||
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const element = elements[indices[i]];
|
||||
|
||||
elementsUpdates.set(element, {
|
||||
index: fractionalIndices[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return elementsUpdates;
|
||||
};
|
||||
|
||||
const isOrderedElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is OrderedExcalidrawElement => {
|
||||
// for now it's sufficient whether the index is there
|
||||
// meaning, the element was already ordered in the past
|
||||
// meaning, it is not a newly inserted element, not an unrestored element, etc.
|
||||
// it does not have to mean that the index itself is valid
|
||||
if (element.index) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
482
packages/element/tests/binding.test.tsx
Normal file
482
packages/element/tests/binding.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
816
packages/element/tests/fractionalIndex.test.ts
Normal file
816
packages/element/tests/fractionalIndex.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue