123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- 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;
- };
|