You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
import { generateNKeysBetween } from "fractional-indexing";
|
|
import { mutateElement } from "./element/mutateElement";
|
|
import type {
|
|
ExcalidrawElement,
|
|
FractionalIndex,
|
|
OrderedExcalidrawElement,
|
|
} from "./element/types";
|
|
import { InvalidFractionalIndexError } from "./errors";
|
|
import { hasBoundTextElement } from "./element/typeChecks";
|
|
import { getBoundTextElement } from "./element/textElement";
|
|
import { arrayToMap } from "./utils";
|
|
|
|
/**
|
|
* 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;
|
|
};
|