mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: fractional indexing (#7359)
* Introducing fractional indices as part of `element.index` * Ensuring invalid fractional indices are always synchronized with the array order * Simplifying reconciliation based on the fractional indices * Moving reconciliation inside the `@excalidraw/excalidraw` package --------- Co-authored-by: Marcel Mraz <marcel@excalidraw.com> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
bbdcd30a73
commit
32df5502ae
50 changed files with 3640 additions and 2047 deletions
|
@ -14,9 +14,9 @@ import {
|
||||||
} from "../packages/excalidraw/constants";
|
} from "../packages/excalidraw/constants";
|
||||||
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
|
||||||
FileId,
|
FileId,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
Theme,
|
Theme,
|
||||||
} from "../packages/excalidraw/element/types";
|
} from "../packages/excalidraw/element/types";
|
||||||
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
||||||
|
@ -88,7 +88,6 @@ import {
|
||||||
} from "./data/LocalData";
|
} from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { reconcileElements } from "./collab/reconciliation";
|
|
||||||
import {
|
import {
|
||||||
parseLibraryTokensFromUrl,
|
parseLibraryTokensFromUrl,
|
||||||
useHandleLibrary,
|
useHandleLibrary,
|
||||||
|
@ -108,6 +107,10 @@ import { OverwriteConfirmDialog } from "../packages/excalidraw/components/Overwr
|
||||||
import Trans from "../packages/excalidraw/components/Trans";
|
import Trans from "../packages/excalidraw/components/Trans";
|
||||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||||
|
import {
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../packages/excalidraw/data/reconcile";
|
||||||
import {
|
import {
|
||||||
CommandPalette,
|
CommandPalette,
|
||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
|
@ -269,7 +272,7 @@ const initializeScene = async (opts: {
|
||||||
},
|
},
|
||||||
elements: reconcileElements(
|
elements: reconcileElements(
|
||||||
scene?.elements || [],
|
scene?.elements || [],
|
||||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
|
||||||
excalidrawAPI.getAppState(),
|
excalidrawAPI.getAppState(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -581,7 +584,7 @@ const ExcalidrawWrapper = () => {
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import {
|
import {
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
|
@ -69,10 +70,6 @@ import {
|
||||||
isInitializedImageElement,
|
isInitializedImageElement,
|
||||||
} from "../../packages/excalidraw/element/typeChecks";
|
} from "../../packages/excalidraw/element/typeChecks";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import {
|
|
||||||
ReconciledElements,
|
|
||||||
reconcileElements as _reconcileElements,
|
|
||||||
} from "./reconciliation";
|
|
||||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
|
@ -82,6 +79,11 @@ import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||||
|
import {
|
||||||
|
ReconciledExcalidrawElement,
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../../packages/excalidraw/data/reconcile";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const isCollaboratingAtom = atom(false);
|
export const isCollaboratingAtom = atom(false);
|
||||||
|
@ -274,7 +276,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
syncableElements: readonly SyncableExcalidrawElement[],
|
syncableElements: readonly SyncableExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const savedData = await saveToFirebase(
|
const storedElements = await saveToFirebase(
|
||||||
this.portal,
|
this.portal,
|
||||||
syncableElements,
|
syncableElements,
|
||||||
this.excalidrawAPI.getAppState(),
|
this.excalidrawAPI.getAppState(),
|
||||||
|
@ -282,10 +284,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
|
|
||||||
this.resetErrorIndicator();
|
this.resetErrorIndicator();
|
||||||
|
|
||||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
if (this.isCollaborating() && storedElements) {
|
||||||
this.handleRemoteSceneUpdate(
|
this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
|
||||||
this.reconcileElements(savedData.reconciledElements),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||||
|
@ -429,7 +429,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
|
|
||||||
startCollaboration = async (
|
startCollaboration = async (
|
||||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||||
): Promise<ImportedDataState | null> => {
|
) => {
|
||||||
if (!this.state.username) {
|
if (!this.state.username) {
|
||||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||||
const username = getRandomUsername();
|
const username = getRandomUsername();
|
||||||
|
@ -455,7 +455,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
// TODO: `ImportedDataState` type here seems abused
|
||||||
|
const scenePromise = resolvablePromise<
|
||||||
|
| (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
|
||||||
|
| null
|
||||||
|
>();
|
||||||
|
|
||||||
this.setIsCollaborating(true);
|
this.setIsCollaborating(true);
|
||||||
LocalData.pauseSave("collaboration");
|
LocalData.pauseSave("collaboration");
|
||||||
|
@ -538,7 +542,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
if (!this.portal.socketInitialized) {
|
if (!this.portal.socketInitialized) {
|
||||||
this.initializeRoom({ fetchScene: false });
|
this.initializeRoom({ fetchScene: false });
|
||||||
const remoteElements = decryptedData.payload.elements;
|
const remoteElements = decryptedData.payload.elements;
|
||||||
const reconciledElements = this.reconcileElements(remoteElements);
|
const reconciledElements =
|
||||||
|
this._reconcileElements(remoteElements);
|
||||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||||
init: true,
|
init: true,
|
||||||
});
|
});
|
||||||
|
@ -552,7 +557,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
}
|
}
|
||||||
case WS_SUBTYPES.UPDATE:
|
case WS_SUBTYPES.UPDATE:
|
||||||
this.handleRemoteSceneUpdate(
|
this.handleRemoteSceneUpdate(
|
||||||
this.reconcileElements(decryptedData.payload.elements),
|
this._reconcileElements(decryptedData.payload.elements),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||||
|
@ -700,17 +705,15 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
private reconcileElements = (
|
private _reconcileElements = (
|
||||||
remoteElements: readonly ExcalidrawElement[],
|
remoteElements: readonly ExcalidrawElement[],
|
||||||
): ReconciledElements => {
|
): ReconciledExcalidrawElement[] => {
|
||||||
const localElements = this.getSceneElementsIncludingDeleted();
|
const localElements = this.getSceneElementsIncludingDeleted();
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
|
const restoredRemoteElements = restoreElements(remoteElements, null);
|
||||||
remoteElements = restoreElements(remoteElements, null);
|
const reconciledElements = reconcileElements(
|
||||||
|
|
||||||
const reconciledElements = _reconcileElements(
|
|
||||||
localElements,
|
localElements,
|
||||||
remoteElements,
|
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -741,7 +744,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
}, LOAD_IMAGES_TIMEOUT);
|
}, LOAD_IMAGES_TIMEOUT);
|
||||||
|
|
||||||
private handleRemoteSceneUpdate = (
|
private handleRemoteSceneUpdate = (
|
||||||
elements: ReconciledElements,
|
elements: ReconciledExcalidrawElement[],
|
||||||
{ init = false }: { init?: boolean } = {},
|
{ init = false }: { init?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
|
@ -887,7 +890,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
this.portal.broadcastIdleChange(userState);
|
this.portal.broadcastIdleChange(userState);
|
||||||
};
|
};
|
||||||
|
|
||||||
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||||
if (
|
if (
|
||||||
getSceneVersion(elements) >
|
getSceneVersion(elements) >
|
||||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||||
|
@ -898,7 +901,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||||
this.broadcastElements(elements);
|
this.broadcastElements(elements);
|
||||||
this.queueSaveToFirebase();
|
this.queueSaveToFirebase();
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,11 +2,12 @@ import {
|
||||||
isSyncableElement,
|
isSyncableElement,
|
||||||
SocketUpdateData,
|
SocketUpdateData,
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
|
SyncableExcalidrawElement,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
|
|
||||||
import { TCollabClass } from "./Collab";
|
import { TCollabClass } from "./Collab";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
import { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||||
import {
|
import {
|
||||||
OnUserFollowedPayload,
|
OnUserFollowedPayload,
|
||||||
|
@ -16,9 +17,7 @@ import {
|
||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
|
||||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
|
@ -133,7 +132,7 @@ class Portal {
|
||||||
|
|
||||||
broadcastScene = async (
|
broadcastScene = async (
|
||||||
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||||
allElements: readonly ExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
syncAll: boolean,
|
syncAll: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
||||||
|
@ -143,25 +142,17 @@ class Portal {
|
||||||
// sync out only the elements we think we need to to save bandwidth.
|
// sync out only the elements we think we need to to save bandwidth.
|
||||||
// periodically we'll resync the whole thing to make sure no one diverges
|
// periodically we'll resync the whole thing to make sure no one diverges
|
||||||
// due to a dropped message (server goes down etc).
|
// due to a dropped message (server goes down etc).
|
||||||
const syncableElements = allElements.reduce(
|
const syncableElements = elements.reduce((acc, element) => {
|
||||||
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
|
|
||||||
if (
|
if (
|
||||||
(syncAll ||
|
(syncAll ||
|
||||||
!this.broadcastedElementVersions.has(element.id) ||
|
!this.broadcastedElementVersions.has(element.id) ||
|
||||||
element.version >
|
element.version > this.broadcastedElementVersions.get(element.id)!) &&
|
||||||
this.broadcastedElementVersions.get(element.id)!) &&
|
|
||||||
isSyncableElement(element)
|
isSyncableElement(element)
|
||||||
) {
|
) {
|
||||||
acc.push({
|
acc.push(element);
|
||||||
...element,
|
|
||||||
// z-index info for the reconciler
|
|
||||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
},
|
}, [] as SyncableExcalidrawElement[]);
|
||||||
[] as BroadcastedExcalidrawElement[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||||
type: updateType,
|
type: updateType,
|
||||||
|
|
|
@ -1,154 +0,0 @@
|
||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
|
||||||
import { AppState } from "../../packages/excalidraw/types";
|
|
||||||
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
|
|
||||||
|
|
||||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
|
||||||
_brand: "reconciledElements";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
|
||||||
[PRECEDING_ELEMENT_KEY]?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldDiscardRemoteElement = (
|
|
||||||
localAppState: AppState,
|
|
||||||
local: ExcalidrawElement | undefined,
|
|
||||||
remote: BroadcastedExcalidrawElement,
|
|
||||||
): boolean => {
|
|
||||||
if (
|
|
||||||
local &&
|
|
||||||
// local element is being edited
|
|
||||||
(local.id === localAppState.editingElement?.id ||
|
|
||||||
local.id === localAppState.resizingElement?.id ||
|
|
||||||
local.id === localAppState.draggingElement?.id ||
|
|
||||||
// local element is newer
|
|
||||||
local.version > remote.version ||
|
|
||||||
// resolve conflicting edits deterministically by taking the one with
|
|
||||||
// the lowest versionNonce
|
|
||||||
(local.version === remote.version &&
|
|
||||||
local.versionNonce < remote.versionNonce))
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reconcileElements = (
|
|
||||||
localElements: readonly ExcalidrawElement[],
|
|
||||||
remoteElements: readonly BroadcastedExcalidrawElement[],
|
|
||||||
localAppState: AppState,
|
|
||||||
): ReconciledElements => {
|
|
||||||
const localElementsData =
|
|
||||||
arrayToMapWithIndex<ExcalidrawElement>(localElements);
|
|
||||||
|
|
||||||
const reconciledElements: ExcalidrawElement[] = localElements.slice();
|
|
||||||
|
|
||||||
const duplicates = new WeakMap<ExcalidrawElement, true>();
|
|
||||||
|
|
||||||
let cursor = 0;
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
let remoteElementIdx = -1;
|
|
||||||
for (const remoteElement of remoteElements) {
|
|
||||||
remoteElementIdx++;
|
|
||||||
|
|
||||||
const local = localElementsData.get(remoteElement.id);
|
|
||||||
|
|
||||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
|
||||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
|
||||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
|
||||||
if (local) {
|
|
||||||
// Unless the remote and local elements are the same element in which case
|
|
||||||
// we need to keep it as we'd otherwise discard it from the resulting
|
|
||||||
// array.
|
|
||||||
if (local[0] === remoteElement) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
duplicates.set(local[0], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// parent may not be defined in case the remote client is running an older
|
|
||||||
// excalidraw version
|
|
||||||
const parent =
|
|
||||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
|
||||||
remoteElements[remoteElementIdx - 1]?.id ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (parent != null) {
|
|
||||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
|
||||||
|
|
||||||
// ^ indicates the element is the first in elements array
|
|
||||||
if (parent === "^") {
|
|
||||||
offset++;
|
|
||||||
if (cursor === 0) {
|
|
||||||
reconciledElements.unshift(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor - offset,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let idx = localElementsData.has(parent)
|
|
||||||
? localElementsData.get(parent)![1]
|
|
||||||
: null;
|
|
||||||
if (idx != null) {
|
|
||||||
idx += offset;
|
|
||||||
}
|
|
||||||
if (idx != null && idx >= cursor) {
|
|
||||||
reconciledElements.splice(idx + 1, 0, remoteElement);
|
|
||||||
offset++;
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
idx + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor = idx + 1;
|
|
||||||
} else if (idx != null) {
|
|
||||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
|
||||||
offset++;
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor++;
|
|
||||||
} else {
|
|
||||||
reconciledElements.push(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
reconciledElements.length - 1 - offset,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// no parent z-index information, local element exists → replace in place
|
|
||||||
} else if (local) {
|
|
||||||
reconciledElements[local[1]] = remoteElement;
|
|
||||||
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
|
|
||||||
// otherwise push to the end
|
|
||||||
} else {
|
|
||||||
reconciledElements.push(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
reconciledElements.length - 1 - offset,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
|
|
||||||
(element) => !duplicates.has(element),
|
|
||||||
);
|
|
||||||
|
|
||||||
return ret as ReconciledElements;
|
|
||||||
};
|
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import { getSceneVersion } from "../../packages/excalidraw/element";
|
import { getSceneVersion } from "../../packages/excalidraw/element";
|
||||||
import Portal from "../collab/Portal";
|
import Portal from "../collab/Portal";
|
||||||
|
@ -18,10 +19,13 @@ import {
|
||||||
decryptData,
|
decryptData,
|
||||||
} from "../../packages/excalidraw/data/encryption";
|
} from "../../packages/excalidraw/data/encryption";
|
||||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||||
import { reconcileElements } from "../collab/reconciliation";
|
|
||||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
import {
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../../packages/excalidraw/data/reconcile";
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
@ -230,7 +234,7 @@ export const saveToFirebase = async (
|
||||||
!socket ||
|
!socket ||
|
||||||
isSavedToFirebase(portal, elements)
|
isSavedToFirebase(portal, elements)
|
||||||
) {
|
) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firebase = await loadFirestore();
|
const firebase = await loadFirestore();
|
||||||
|
@ -238,56 +242,59 @@ export const saveToFirebase = async (
|
||||||
|
|
||||||
const docRef = firestore.collection("scenes").doc(roomId);
|
const docRef = firestore.collection("scenes").doc(roomId);
|
||||||
|
|
||||||
const savedData = await firestore.runTransaction(async (transaction) => {
|
const storedScene = await firestore.runTransaction(async (transaction) => {
|
||||||
const snapshot = await transaction.get(docRef);
|
const snapshot = await transaction.get(docRef);
|
||||||
|
|
||||||
if (!snapshot.exists) {
|
if (!snapshot.exists) {
|
||||||
const sceneDocument = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
firebase,
|
firebase,
|
||||||
elements,
|
elements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
transaction.set(docRef, sceneDocument);
|
transaction.set(docRef, storedScene);
|
||||||
|
|
||||||
return {
|
return storedScene;
|
||||||
elements,
|
|
||||||
reconciledElements: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
|
||||||
const prevElements = getSyncableElements(
|
const prevStoredElements = getSyncableElements(
|
||||||
await decryptElements(prevDocData, roomKey),
|
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reconciledElements = getSyncableElements(
|
const reconciledElements = getSyncableElements(
|
||||||
reconcileElements(elements, prevElements, appState),
|
reconcileElements(
|
||||||
|
elements,
|
||||||
|
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
|
||||||
|
appState,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sceneDocument = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
firebase,
|
firebase,
|
||||||
reconciledElements,
|
reconciledElements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
transaction.update(docRef, sceneDocument);
|
transaction.update(docRef, storedScene);
|
||||||
return {
|
|
||||||
elements,
|
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
|
||||||
reconciledElements,
|
return storedScene;
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
FirebaseSceneVersionCache.set(socket, savedData.elements);
|
const storedElements = getSyncableElements(
|
||||||
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
|
);
|
||||||
|
|
||||||
return { reconciledElements: savedData.reconciledElements };
|
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||||
|
|
||||||
|
return storedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromFirebase = async (
|
export const loadFromFirebase = async (
|
||||||
roomId: string,
|
roomId: string,
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: Socket | null,
|
socket: Socket | null,
|
||||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||||
const firebase = await loadFirestore();
|
const firebase = await loadFirestore();
|
||||||
const db = firebase.firestore();
|
const db = firebase.firestore();
|
||||||
|
|
||||||
|
@ -298,14 +305,14 @@ export const loadFromFirebase = async (
|
||||||
}
|
}
|
||||||
const storedScene = doc.data() as FirebaseStoredScene;
|
const storedScene = doc.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
await decryptElements(storedScene, roomKey),
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
FirebaseSceneVersionCache.set(socket, elements);
|
FirebaseSceneVersionCache.set(socket, elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElements(elements, null);
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFilesFromFirebase = async (
|
export const loadFilesFromFirebase = async (
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { isInitializedImageElement } from "../../packages/excalidraw/element/typ
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import { t } from "../../packages/excalidraw/i18n";
|
import { t } from "../../packages/excalidraw/i18n";
|
||||||
import {
|
import {
|
||||||
|
@ -25,6 +26,7 @@ import {
|
||||||
SocketId,
|
SocketId,
|
||||||
UserIdleState,
|
UserIdleState,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
import { MakeBrand } from "../../packages/excalidraw/utility-types";
|
||||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
DELETED_ELEMENT_TIMEOUT,
|
DELETED_ELEMENT_TIMEOUT,
|
||||||
|
@ -35,12 +37,11 @@ import {
|
||||||
import { encodeFilesForUpload } from "./FileManager";
|
import { encodeFilesForUpload } from "./FileManager";
|
||||||
import { saveFilesToFirebase } from "./firebase";
|
import { saveFilesToFirebase } from "./firebase";
|
||||||
|
|
||||||
export type SyncableExcalidrawElement = ExcalidrawElement & {
|
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
||||||
_brand: "SyncableExcalidrawElement";
|
MakeBrand<"SyncableExcalidrawElement">;
|
||||||
};
|
|
||||||
|
|
||||||
export const isSyncableElement = (
|
export const isSyncableElement = (
|
||||||
element: ExcalidrawElement,
|
element: OrderedExcalidrawElement,
|
||||||
): element is SyncableExcalidrawElement => {
|
): element is SyncableExcalidrawElement => {
|
||||||
if (element.isDeleted) {
|
if (element.isDeleted) {
|
||||||
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
||||||
|
@ -51,7 +52,9 @@ export const isSyncableElement = (
|
||||||
return !isInvisiblySmallElement(element);
|
return !isInvisiblySmallElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
export const getSyncableElements = (
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
|
) =>
|
||||||
elements.filter((element) =>
|
elements.filter((element) =>
|
||||||
isSyncableElement(element),
|
isSyncableElement(element),
|
||||||
) as SyncableExcalidrawElement[];
|
) as SyncableExcalidrawElement[];
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
||||||
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
|
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
|
||||||
|
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
Object.defineProperty(window, "crypto", {
|
Object.defineProperty(window, "crypto", {
|
||||||
|
@ -61,14 +63,14 @@ describe("collaboration", () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
// To update the scene with deleted elements before starting collab
|
// To update the scene with deleted elements before starting collab
|
||||||
updateSceneData({
|
updateSceneData({
|
||||||
elements: [
|
elements: syncInvalidIndices([
|
||||||
API.createElement({ type: "rectangle", id: "A" }),
|
API.createElement({ type: "rectangle", id: "A" }),
|
||||||
API.createElement({
|
API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
id: "B",
|
id: "B",
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
]),
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
|
|
|
@ -1,421 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
|
||||||
import {
|
|
||||||
BroadcastedExcalidrawElement,
|
|
||||||
ReconciledElements,
|
|
||||||
reconcileElements,
|
|
||||||
} from "../../excalidraw-app/collab/reconciliation";
|
|
||||||
import { randomInteger } from "../../packages/excalidraw/random";
|
|
||||||
import { AppState } from "../../packages/excalidraw/types";
|
|
||||||
import { cloneJSON } from "../../packages/excalidraw/utils";
|
|
||||||
|
|
||||||
type Id = string;
|
|
||||||
type ElementLike = {
|
|
||||||
id: string;
|
|
||||||
version: number;
|
|
||||||
versionNonce: number;
|
|
||||||
[PRECEDING_ELEMENT_KEY]?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Cache = Record<string, ExcalidrawElement | undefined>;
|
|
||||||
|
|
||||||
const createElement = (opts: { uid: string } | ElementLike) => {
|
|
||||||
let uid: string;
|
|
||||||
let id: string;
|
|
||||||
let version: number | null;
|
|
||||||
let parent: string | null = null;
|
|
||||||
let versionNonce: number | null = null;
|
|
||||||
if ("uid" in opts) {
|
|
||||||
const match = opts.uid.match(
|
|
||||||
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
|
|
||||||
)!;
|
|
||||||
parent = match[1];
|
|
||||||
id = match[2];
|
|
||||||
version = match[3] ? parseInt(match[3]) : null;
|
|
||||||
uid = version ? `${id}:${version}` : id;
|
|
||||||
} else {
|
|
||||||
({ id, version, versionNonce } = opts);
|
|
||||||
parent = parent || null;
|
|
||||||
uid = id;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
uid,
|
|
||||||
id,
|
|
||||||
version,
|
|
||||||
versionNonce: versionNonce || randomInteger(),
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent || null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const idsToElements = (
|
|
||||||
ids: (Id | ElementLike)[],
|
|
||||||
cache: Cache = {},
|
|
||||||
): readonly ExcalidrawElement[] => {
|
|
||||||
return ids.reduce((acc, _uid, idx) => {
|
|
||||||
const {
|
|
||||||
uid,
|
|
||||||
id,
|
|
||||||
version,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent,
|
|
||||||
versionNonce,
|
|
||||||
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
|
|
||||||
const cached = cache[uid];
|
|
||||||
const elem = {
|
|
||||||
id,
|
|
||||||
version: version ?? 0,
|
|
||||||
versionNonce,
|
|
||||||
...cached,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent,
|
|
||||||
} as BroadcastedExcalidrawElement;
|
|
||||||
// @ts-ignore
|
|
||||||
cache[uid] = elem;
|
|
||||||
acc.push(elem);
|
|
||||||
return acc;
|
|
||||||
}, [] as ExcalidrawElement[]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
|
|
||||||
return elements.map((el, idx, els) => {
|
|
||||||
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanElements = (elements: ReconciledElements) => {
|
|
||||||
return elements.map((el) => {
|
|
||||||
// @ts-ignore
|
|
||||||
delete el[PRECEDING_ELEMENT_KEY];
|
|
||||||
// @ts-ignore
|
|
||||||
delete el.next;
|
|
||||||
// @ts-ignore
|
|
||||||
delete el.prev;
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const test = <U extends `${string}:${"L" | "R"}`>(
|
|
||||||
local: (Id | ElementLike)[],
|
|
||||||
remote: (Id | ElementLike)[],
|
|
||||||
target: U[],
|
|
||||||
bidirectional = true,
|
|
||||||
) => {
|
|
||||||
const cache: Cache = {};
|
|
||||||
const _local = idsToElements(local, cache);
|
|
||||||
const _remote = idsToElements(remote, cache);
|
|
||||||
const _target = target.map((uid) => {
|
|
||||||
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
|
|
||||||
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
|
|
||||||
}) as any as ReconciledElements;
|
|
||||||
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
|
|
||||||
expect(target.length).equal(remoteReconciled.length);
|
|
||||||
expect(cleanElements(remoteReconciled)).deep.equal(
|
|
||||||
cleanElements(_target),
|
|
||||||
"remote reconciliation",
|
|
||||||
);
|
|
||||||
|
|
||||||
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
|
|
||||||
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
|
|
||||||
if (bidirectional) {
|
|
||||||
try {
|
|
||||||
expect(
|
|
||||||
cleanElements(
|
|
||||||
reconcileElements(
|
|
||||||
cloneJSON(__local),
|
|
||||||
cloneJSON(__remote),
|
|
||||||
{} as AppState,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("local original", __local);
|
|
||||||
console.error("remote reconciled", __remote);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findIndex = <T>(
|
|
||||||
array: readonly T[],
|
|
||||||
cb: (element: T, index: number, array: readonly T[]) => boolean,
|
|
||||||
fromIndex: number = 0,
|
|
||||||
) => {
|
|
||||||
if (fromIndex < 0) {
|
|
||||||
fromIndex = array.length + fromIndex;
|
|
||||||
}
|
|
||||||
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
|
|
||||||
let index = fromIndex - 1;
|
|
||||||
while (++index < array.length) {
|
|
||||||
if (cb(array[index], index, array)) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("elements reconciliation", () => {
|
|
||||||
it("reconcileElements()", () => {
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// in following tests, we pass:
|
|
||||||
// (1) an array of local elements and their version (:1, :2...)
|
|
||||||
// (2) an array of remote elements and their version (:1, :2...)
|
|
||||||
// (3) expected reconciled elements
|
|
||||||
//
|
|
||||||
// in the reconciled array:
|
|
||||||
// :L means local element was resolved
|
|
||||||
// :R means remote element was resolved
|
|
||||||
//
|
|
||||||
// if a remote element is prefixed with parentheses, the enclosed string:
|
|
||||||
// (^) means the element is the first element in the array
|
|
||||||
// (<id>) means the element is preceded by <id> element
|
|
||||||
//
|
|
||||||
// if versions are missing, it defaults to version 0
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// non-annotated elements
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// usually when we sync elements they should always be annotated with
|
|
||||||
// their (preceding elements) parents, but let's test a couple of cases when
|
|
||||||
// they're not for whatever reason (remote clients are on older version...),
|
|
||||||
// in which case the first synced element either replaces existing element
|
|
||||||
// or is pushed at the end of the array
|
|
||||||
|
|
||||||
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
|
|
||||||
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
|
|
||||||
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
|
|
||||||
test(["A"], ["A", "B"], ["A:L", "B:R"]);
|
|
||||||
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
|
|
||||||
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
|
|
||||||
test(["A"], ["A:1"], ["A:R"]);
|
|
||||||
|
|
||||||
// C isn't added to the end because it follows B (even if B was resolved
|
|
||||||
// to local version)
|
|
||||||
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
|
|
||||||
|
|
||||||
// some of the following tests are kinda arbitrary and they're less
|
|
||||||
// likely to happen in real-world cases
|
|
||||||
|
|
||||||
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(
|
|
||||||
["A:2", "B:2", "C"],
|
|
||||||
["D", "B:1", "A:3"],
|
|
||||||
["B:L", "A:R", "C:L", "D:R"],
|
|
||||||
);
|
|
||||||
test(
|
|
||||||
["A:2", "B:2", "C"],
|
|
||||||
["D", "B:2", "A:3", "C"],
|
|
||||||
["D:R", "B:L", "A:R", "C:L"],
|
|
||||||
);
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E", "F"],
|
|
||||||
["A", "B:2", "X", "E:2", "F", "Y"],
|
|
||||||
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// annotated elements
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C"],
|
|
||||||
["(B)X", "(A)Y", "(Y)Z"],
|
|
||||||
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
|
|
||||||
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B"],
|
|
||||||
["(A)C", "(^)D", "F"],
|
|
||||||
["A:L", "C:R", "D:R", "F:R", "B:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(B)C:1", "B", "D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C"],
|
|
||||||
["(^)X", "(A)Y", "(B)Z"],
|
|
||||||
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["B", "A", "C"],
|
|
||||||
["(^)X", "(A)Y", "(B)Z"],
|
|
||||||
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E"],
|
|
||||||
["(A)X", "(C)Y", "(D)Z"],
|
|
||||||
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["X", "Y", "Z"],
|
|
||||||
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
|
|
||||||
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E"],
|
|
||||||
["(C)X", "(A)Y", "(D)E:1"],
|
|
||||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["C:1", "B", "D:1"],
|
|
||||||
["A", "B", "C:1", "D:1"],
|
|
||||||
["A:R", "B:L", "C:L", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "(C)B", "(B)D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "(C)B", "(B)D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["C:1", "B", "D:1"],
|
|
||||||
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
|
|
||||||
["A:R", "B:L", "C:R", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(C)X", "(B)Y", "(A)Z"],
|
|
||||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
|
|
||||||
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "B", "D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
|
|
||||||
|
|
||||||
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
|
|
||||||
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
|
|
||||||
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
|
|
||||||
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
|
|
||||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
|
||||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("test identical elements reconciliation", () => {
|
|
||||||
const testIdentical = (
|
|
||||||
local: ElementLike[],
|
|
||||||
remote: ElementLike[],
|
|
||||||
expected: Id[],
|
|
||||||
) => {
|
|
||||||
const ret = reconcileElements(
|
|
||||||
local as any as ExcalidrawElement[],
|
|
||||||
remote as any as ExcalidrawElement[],
|
|
||||||
{} as AppState,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
|
|
||||||
throw new Error("reconcileElements: duplicate elements found");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(ret.map((x) => x.id)).to.deep.equal(expected);
|
|
||||||
};
|
|
||||||
|
|
||||||
// identical id/version/versionNonce
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
testIdentical(
|
|
||||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
|
||||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
|
||||||
["A"],
|
|
||||||
);
|
|
||||||
testIdentical(
|
|
||||||
[
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
["B", "A"],
|
|
||||||
);
|
|
||||||
testIdentical(
|
|
||||||
[
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
["B", "A"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// actually identical (arrays and element objects)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const elements1 = [
|
|
||||||
{
|
|
||||||
id: "A",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "B",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testIdentical(elements1, elements1, ["A", "B"]);
|
|
||||||
testIdentical(elements1, elements1.slice(), ["A", "B"]);
|
|
||||||
testIdentical(elements1.slice(), elements1, ["A", "B"]);
|
|
||||||
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
|
|
||||||
|
|
||||||
const el1 = {
|
|
||||||
id: "A",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
};
|
|
||||||
const el2 = {
|
|
||||||
id: "B",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
};
|
|
||||||
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -31,8 +31,9 @@ import {
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { Mutable } from "../utility-types";
|
import { Mutable } from "../utility-types";
|
||||||
import { getFontString } from "../utils";
|
import { arrayToMap, getFontString } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
|
@ -180,6 +181,8 @@ const pushTextAboveContainer = (
|
||||||
(ele) => ele.id === container.id,
|
(ele) => ele.id === container.id,
|
||||||
);
|
);
|
||||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||||
|
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||||
|
|
||||||
return updatedElements;
|
return updatedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -198,6 +201,8 @@ const pushContainerBelowText = (
|
||||||
(ele) => ele.id === textElement.id,
|
(ele) => ele.id === textElement.id,
|
||||||
);
|
);
|
||||||
updatedElements.splice(textElementIndex, 0, container);
|
updatedElements.splice(textElementIndex, 0, container);
|
||||||
|
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||||
|
|
||||||
return updatedElements;
|
return updatedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -304,6 +309,7 @@ export const actionWrapTextInContainer = register({
|
||||||
container,
|
container,
|
||||||
textElement,
|
textElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
containerIds[container.id] = true;
|
containerIds[container.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ import {
|
||||||
excludeElementsInFramesFromSelection,
|
excludeElementsInFramesFromSelection,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
|
@ -90,6 +91,7 @@ const duplicateElements = (
|
||||||
const newElements: ExcalidrawElement[] = [];
|
const newElements: ExcalidrawElement[] = [];
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
const oldIdToDuplicatedId = new Map();
|
const oldIdToDuplicatedId = new Map();
|
||||||
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||||
const newElement = duplicateElement(
|
const newElement = duplicateElement(
|
||||||
|
@ -101,6 +103,7 @@ const duplicateElements = (
|
||||||
y: element.y + GRID_SIZE / 2,
|
y: element.y + GRID_SIZE / 2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
oldElements.push(element);
|
oldElements.push(element);
|
||||||
newElements.push(newElement);
|
newElements.push(newElement);
|
||||||
|
@ -238,9 +241,10 @@ const duplicateElements = (
|
||||||
}
|
}
|
||||||
|
|
||||||
// step (3)
|
// step (3)
|
||||||
|
|
||||||
const finalElements = finalElementsReversed.reverse();
|
const finalElements = finalElementsReversed.reverse();
|
||||||
|
|
||||||
|
syncMovedIndices(finalElements, arrayToMap([...oldElements, ...newElements]));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
bindTextToShapeAfterDuplication(
|
bindTextToShapeAfterDuplication(
|
||||||
|
|
|
@ -27,6 +27,7 @@ import {
|
||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "../frame";
|
} from "../frame";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (elements.length >= 2) {
|
if (elements.length >= 2) {
|
||||||
|
@ -140,11 +141,12 @@ export const actionGroup = register({
|
||||||
.filter(
|
.filter(
|
||||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||||
);
|
);
|
||||||
nextElements = [
|
const reorderedElements = [
|
||||||
...elementsBeforeGroup,
|
...elementsBeforeGroup,
|
||||||
...elementsInGroup,
|
...elementsInGroup,
|
||||||
...elementsAfterGroup,
|
...elementsAfterGroup,
|
||||||
];
|
];
|
||||||
|
syncMovedIndices(reorderedElements, arrayToMap(elementsInGroup));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
@ -155,7 +157,7 @@ export const actionGroup = register({
|
||||||
getNonDeletedElements(nextElements),
|
getNonDeletedElements(nextElements),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
elements: nextElements,
|
elements: reorderedElements,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
|
@ -48,6 +49,8 @@ const writeData = (
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
fixBindingsAfterDeletion(elements, deletedElements);
|
fixBindingsAfterDeletion(elements, deletedElements);
|
||||||
|
// TODO: will be replaced in #7348
|
||||||
|
syncInvalidIndices(elements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
moveOneLeft,
|
moveOneLeft,
|
||||||
moveOneRight,
|
moveOneRight,
|
||||||
|
|
|
@ -182,6 +182,7 @@ import {
|
||||||
IframeData,
|
IframeData,
|
||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
ExcalidrawEmbeddableElement,
|
ExcalidrawEmbeddableElement,
|
||||||
|
Ordered,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getCenter, getDistance } from "../gesture";
|
import { getCenter, getDistance } from "../gesture";
|
||||||
import {
|
import {
|
||||||
|
@ -276,6 +277,7 @@ import {
|
||||||
muteFSAbortError,
|
muteFSAbortError,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
easeOut,
|
easeOut,
|
||||||
|
arrayToMap,
|
||||||
updateStable,
|
updateStable,
|
||||||
addEventListener,
|
addEventListener,
|
||||||
normalizeEOL,
|
normalizeEOL,
|
||||||
|
@ -407,7 +409,6 @@ import { ElementCanvasButton } from "./MagicButton";
|
||||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||||
import FollowMode from "./FollowMode/FollowMode";
|
import FollowMode from "./FollowMode/FollowMode";
|
||||||
|
|
||||||
import { AnimationFrameHandler } from "../animation-frame-handler";
|
import { AnimationFrameHandler } from "../animation-frame-handler";
|
||||||
import { AnimatedTrail } from "../animated-trail";
|
import { AnimatedTrail } from "../animated-trail";
|
||||||
import { LaserTrails } from "../laser-trails";
|
import { LaserTrails } from "../laser-trails";
|
||||||
|
@ -422,6 +423,7 @@ import {
|
||||||
} from "../element/collision";
|
} from "../element/collision";
|
||||||
import { textWysiwyg } from "../element/textWysiwyg";
|
import { textWysiwyg } from "../element/textWysiwyg";
|
||||||
import { isOverScrollBars } from "../scene/scrollbars";
|
import { isOverScrollBars } from "../scene/scrollbars";
|
||||||
|
import { syncInvalidIndices, syncMovedIndices } from "../fractionalIndex";
|
||||||
import {
|
import {
|
||||||
isPointHittingLink,
|
isPointHittingLink,
|
||||||
isPointHittingLinkIcon,
|
isPointHittingLinkIcon,
|
||||||
|
@ -948,7 +950,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
const embeddableElements = this.scene
|
const embeddableElements = this.scene
|
||||||
.getNonDeletedElements()
|
.getNonDeletedElements()
|
||||||
.filter(
|
.filter(
|
||||||
(el): el is NonDeleted<ExcalidrawIframeLikeElement> =>
|
(el): el is Ordered<NonDeleted<ExcalidrawIframeLikeElement>> =>
|
||||||
(isEmbeddableElement(el) &&
|
(isEmbeddableElement(el) &&
|
||||||
this.embedsValidationStatus.get(el.id) === true) ||
|
this.embedsValidationStatus.get(el.id) === true) ||
|
||||||
isIframeElement(el),
|
isIframeElement(el),
|
||||||
|
@ -2056,7 +2058,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
locked: false,
|
locked: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scene.addNewElement(frame);
|
this.scene.insertElement(frame);
|
||||||
|
|
||||||
for (const child of selectedElements) {
|
for (const child of selectedElements) {
|
||||||
mutateElement(child, { frameId: frame.id });
|
mutateElement(child, { frameId: frame.id });
|
||||||
|
@ -3115,10 +3117,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const allElements = [
|
const prevElements = this.scene.getElementsIncludingDeleted();
|
||||||
...this.scene.getElementsIncludingDeleted(),
|
const nextElements = [...prevElements, ...newElements];
|
||||||
...newElements,
|
|
||||||
];
|
syncMovedIndices(nextElements, arrayToMap(newElements));
|
||||||
|
|
||||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y });
|
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y });
|
||||||
|
|
||||||
|
@ -3127,10 +3129,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
newElements,
|
newElements,
|
||||||
topLayerFrame,
|
topLayerFrame,
|
||||||
);
|
);
|
||||||
addElementsToFrame(allElements, eligibleElements, topLayerFrame);
|
addElementsToFrame(nextElements, eligibleElements, topLayerFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scene.replaceAllElements(allElements);
|
this.scene.replaceAllElements(nextElements);
|
||||||
|
|
||||||
newElements.forEach((newElement) => {
|
newElements.forEach((newElement) => {
|
||||||
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
|
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
|
||||||
|
@ -3361,19 +3363,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameId = textElements[0].frameId;
|
this.scene.insertElements(textElements);
|
||||||
|
|
||||||
if (frameId) {
|
|
||||||
this.scene.insertElementsAtIndex(
|
|
||||||
textElements,
|
|
||||||
this.scene.getElementIndex(frameId),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.scene.replaceAllElements([
|
|
||||||
...this.scene.getElementsIncludingDeleted(),
|
|
||||||
...textElements,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedElementIds: makeNextSelectedElementIds(
|
selectedElementIds: makeNextSelectedElementIds(
|
||||||
|
@ -4489,7 +4479,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
includeBoundTextElement: boolean = false,
|
includeBoundTextElement: boolean = false,
|
||||||
includeLockedElements: boolean = false,
|
includeLockedElements: boolean = false,
|
||||||
): NonDeleted<ExcalidrawElement>[] {
|
): NonDeleted<ExcalidrawElement>[] {
|
||||||
const iframeLikes: ExcalidrawIframeElement[] = [];
|
const iframeLikes: Ordered<ExcalidrawIframeElement>[] = [];
|
||||||
|
|
||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
@ -4758,7 +4748,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
const containerIndex = this.scene.getElementIndex(container.id);
|
const containerIndex = this.scene.getElementIndex(container.id);
|
||||||
this.scene.insertElementAtIndex(element, containerIndex + 1);
|
this.scene.insertElementAtIndex(element, containerIndex + 1);
|
||||||
} else {
|
} else {
|
||||||
this.scene.addNewElement(element);
|
this.scene.insertElement(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6639,7 +6629,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
pointerDownState.origin,
|
pointerDownState.origin,
|
||||||
this,
|
this,
|
||||||
);
|
);
|
||||||
this.scene.addNewElement(element);
|
this.scene.insertElement(element);
|
||||||
this.setState({
|
this.setState({
|
||||||
draggingElement: element,
|
draggingElement: element,
|
||||||
editingElement: element,
|
editingElement: element,
|
||||||
|
@ -6684,10 +6674,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
height,
|
height,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scene.replaceAllElements([
|
this.scene.insertElement(element);
|
||||||
...this.scene.getElementsIncludingDeleted(),
|
|
||||||
element,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
|
@ -6741,10 +6728,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
link,
|
link,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scene.replaceAllElements([
|
this.scene.insertElement(element);
|
||||||
...this.scene.getElementsIncludingDeleted(),
|
|
||||||
element,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
|
@ -6908,7 +6892,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this,
|
this,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scene.addNewElement(element);
|
this.scene.insertElement(element);
|
||||||
this.setState({
|
this.setState({
|
||||||
draggingElement: element,
|
draggingElement: element,
|
||||||
editingElement: element,
|
editingElement: element,
|
||||||
|
@ -6987,7 +6971,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
draggingElement: element,
|
draggingElement: element,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.scene.addNewElement(element);
|
this.scene.insertElement(element);
|
||||||
this.setState({
|
this.setState({
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
draggingElement: element,
|
draggingElement: element,
|
||||||
|
@ -7021,10 +7005,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
? newMagicFrameElement(constructorOpts)
|
? newMagicFrameElement(constructorOpts)
|
||||||
: newFrameElement(constructorOpts);
|
: newFrameElement(constructorOpts);
|
||||||
|
|
||||||
this.scene.replaceAllElements([
|
this.scene.insertElement(frame);
|
||||||
...this.scene.getElementsIncludingDeleted(),
|
|
||||||
frame,
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
|
@ -7437,7 +7418,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
nextElements.push(element);
|
nextElements.push(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSceneElements = [...nextElements, ...elementsToAppend];
|
const nextSceneElements = [...nextElements, ...elementsToAppend];
|
||||||
|
|
||||||
|
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
|
||||||
|
|
||||||
bindTextToShapeAfterDuplication(
|
bindTextToShapeAfterDuplication(
|
||||||
nextElements,
|
nextElements,
|
||||||
elementsToAppend,
|
elementsToAppend,
|
||||||
|
@ -7454,6 +7439,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
elementsToAppend,
|
elementsToAppend,
|
||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scene.replaceAllElements(nextSceneElements);
|
this.scene.replaceAllElements(nextSceneElements);
|
||||||
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
||||||
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
||||||
|
@ -8628,7 +8614,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scene.addNewElement(imageElement);
|
this.scene.insertElement(imageElement);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.initializeImage({
|
return await this.initializeImage({
|
||||||
|
@ -9792,7 +9778,9 @@ export const createTestHook = () => {
|
||||||
return this.app?.scene.getElementsIncludingDeleted();
|
return this.app?.scene.getElementsIncludingDeleted();
|
||||||
},
|
},
|
||||||
set(elements: ExcalidrawElement[]) {
|
set(elements: ExcalidrawElement[]) {
|
||||||
return this.app?.scene.replaceAllElements(elements);
|
return this.app?.scene.replaceAllElements(
|
||||||
|
syncInvalidIndices(elements),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -316,10 +316,6 @@ export const ROUNDNESS = {
|
||||||
ADAPTIVE_RADIUS: 3,
|
ADAPTIVE_RADIUS: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
|
||||||
* collaboration */
|
|
||||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
|
||||||
|
|
||||||
export const ROUGHNESS = {
|
export const ROUGHNESS = {
|
||||||
architect: 0,
|
architect: 0,
|
||||||
artist: 1,
|
artist: 1,
|
||||||
|
|
|
@ -20,6 +20,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 300,
|
"height": 300,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -32,7 +33,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 300,
|
"width": 300,
|
||||||
"x": 630,
|
"x": 630,
|
||||||
|
@ -56,6 +57,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -68,7 +70,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 140,
|
"width": 140,
|
||||||
"x": 96,
|
"x": 96,
|
||||||
|
@ -93,6 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 35,
|
"height": 35,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -122,7 +125,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 395,
|
"width": 395,
|
||||||
"x": 247,
|
"x": 247,
|
||||||
|
@ -147,6 +150,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -176,7 +180,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 400,
|
"width": 400,
|
||||||
"x": 227,
|
"x": 227,
|
||||||
|
@ -200,6 +204,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 300,
|
"height": 300,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a4",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -212,7 +217,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 300,
|
"width": 300,
|
||||||
"x": -53,
|
"x": -53,
|
||||||
|
@ -239,6 +244,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -255,7 +261,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 70,
|
"width": 70,
|
||||||
|
@ -283,6 +289,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -299,7 +306,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 100,
|
"width": 100,
|
||||||
|
@ -330,6 +337,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -359,7 +367,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 255,
|
"x": 255,
|
||||||
|
@ -381,6 +389,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -397,7 +406,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 130,
|
"width": 130,
|
||||||
|
@ -428,6 +437,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -457,7 +467,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 255,
|
"x": 255,
|
||||||
|
@ -479,6 +489,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -495,7 +506,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 130,
|
"width": 130,
|
||||||
|
@ -520,6 +531,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -532,7 +544,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 155,
|
"x": 155,
|
||||||
|
@ -556,6 +568,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -568,7 +581,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 355,
|
"x": 355,
|
||||||
|
@ -598,6 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -627,7 +641,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 255,
|
"x": 255,
|
||||||
|
@ -649,6 +663,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -665,7 +680,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 130,
|
"width": 130,
|
||||||
|
@ -693,6 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -709,7 +725,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 70,
|
"width": 70,
|
||||||
|
@ -737,6 +753,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -753,7 +770,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 100,
|
"width": 100,
|
||||||
|
@ -773,6 +790,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 200,
|
"height": 200,
|
||||||
"id": "rect-1",
|
"id": "rect-1",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -785,7 +803,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 300,
|
"x": 300,
|
||||||
|
@ -806,6 +824,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -831,7 +850,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -852,6 +871,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -877,7 +897,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 450,
|
"x": 450,
|
||||||
|
@ -898,6 +918,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -923,7 +944,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -944,6 +965,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -969,7 +991,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 450,
|
"x": 450,
|
||||||
|
@ -988,6 +1010,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1000,7 +1023,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1019,6 +1042,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1031,7 +1055,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1050,6 +1074,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1062,7 +1087,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1081,6 +1106,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1093,7 +1119,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 300,
|
"x": 300,
|
||||||
|
@ -1112,6 +1138,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a4",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1124,7 +1151,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 300,
|
"x": 300,
|
||||||
|
@ -1143,6 +1170,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a5",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1155,7 +1183,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 300,
|
"x": 300,
|
||||||
|
@ -1177,6 +1205,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1193,7 +1222,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 120,
|
"width": 120,
|
||||||
|
@ -1216,6 +1245,7 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1232,7 +1262,7 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 190,
|
"width": 190,
|
||||||
|
@ -1259,6 +1289,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1284,7 +1315,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1310,6 +1341,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1335,7 +1367,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1361,6 +1393,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1386,7 +1419,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1412,6 +1445,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1437,7 +1471,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1459,6 +1493,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a4",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1475,7 +1510,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 130,
|
"width": 130,
|
||||||
|
@ -1498,6 +1533,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a5",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1514,7 +1550,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 200,
|
"width": 200,
|
||||||
|
@ -1537,6 +1573,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a6",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1554,7 +1591,7 @@ LABELLED ARROW",
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 150,
|
"width": 150,
|
||||||
|
@ -1577,6 +1614,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a7",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1594,7 +1632,7 @@ LABELLED ARROW",
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 150,
|
"width": 150,
|
||||||
|
@ -1619,6 +1657,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 35,
|
"height": 35,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1631,7 +1670,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 250,
|
"width": 250,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1655,6 +1694,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 85,
|
"height": 85,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1667,7 +1707,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 500,
|
"x": 500,
|
||||||
|
@ -1691,6 +1731,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 170,
|
"height": 170,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1703,7 +1744,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 280,
|
"width": 280,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1727,6 +1768,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 120,
|
"height": 120,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a3",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1739,7 +1781,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 300,
|
"width": 300,
|
||||||
"x": 100,
|
"x": 100,
|
||||||
|
@ -1763,6 +1805,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 85,
|
"height": 85,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a4",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1775,7 +1818,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 500,
|
"x": 500,
|
||||||
|
@ -1799,6 +1842,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 120,
|
"height": 120,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a5",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -1811,7 +1855,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 500,
|
"x": 500,
|
||||||
|
@ -1833,6 +1877,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 25,
|
"height": 25,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a6",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1849,7 +1894,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 240,
|
"width": 240,
|
||||||
|
@ -1872,6 +1917,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a7",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1889,7 +1935,7 @@ CONTAINER",
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 130,
|
"width": 130,
|
||||||
|
@ -1912,6 +1958,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 75,
|
"height": 75,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a8",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1931,7 +1978,7 @@ CONTAINER",
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 90,
|
"width": 90,
|
||||||
|
@ -1954,6 +2001,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "a9",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -1971,7 +2019,7 @@ TEXT CONTAINER",
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 140,
|
"width": 140,
|
||||||
|
@ -1994,6 +2042,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 75,
|
"height": 75,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "aA",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -2012,7 +2061,7 @@ CONTAINER",
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 170,
|
"width": 170,
|
||||||
|
@ -2035,6 +2084,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 75,
|
"height": 75,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
|
"index": "aB",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -2053,7 +2103,7 @@ CONTAINER",
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 130,
|
"width": 130,
|
||||||
|
|
79
packages/excalidraw/data/reconcile.ts
Normal file
79
packages/excalidraw/data/reconcile.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { OrderedExcalidrawElement } from "../element/types";
|
||||||
|
import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { MakeBrand } from "../utility-types";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
||||||
|
MakeBrand<"ReconciledElement">;
|
||||||
|
|
||||||
|
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||||
|
MakeBrand<"RemoteExcalidrawElement">;
|
||||||
|
|
||||||
|
const shouldDiscardRemoteElement = (
|
||||||
|
localAppState: AppState,
|
||||||
|
local: OrderedExcalidrawElement | undefined,
|
||||||
|
remote: RemoteExcalidrawElement,
|
||||||
|
): boolean => {
|
||||||
|
if (
|
||||||
|
local &&
|
||||||
|
// local element is being edited
|
||||||
|
(local.id === localAppState.editingElement?.id ||
|
||||||
|
local.id === localAppState.resizingElement?.id ||
|
||||||
|
local.id === localAppState.draggingElement?.id ||
|
||||||
|
// local element is newer
|
||||||
|
local.version > remote.version ||
|
||||||
|
// resolve conflicting edits deterministically by taking the one with
|
||||||
|
// the lowest versionNonce
|
||||||
|
(local.version === remote.version &&
|
||||||
|
local.versionNonce < remote.versionNonce))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reconcileElements = (
|
||||||
|
localElements: readonly OrderedExcalidrawElement[],
|
||||||
|
remoteElements: readonly RemoteExcalidrawElement[],
|
||||||
|
localAppState: AppState,
|
||||||
|
): ReconciledExcalidrawElement[] => {
|
||||||
|
const localElementsMap = arrayToMap(localElements);
|
||||||
|
const reconciledElements: OrderedExcalidrawElement[] = [];
|
||||||
|
const added = new Set<string>();
|
||||||
|
|
||||||
|
// process remote elements
|
||||||
|
for (const remoteElement of remoteElements) {
|
||||||
|
if (!added.has(remoteElement.id)) {
|
||||||
|
const localElement = localElementsMap.get(remoteElement.id);
|
||||||
|
const discardRemoteElement = shouldDiscardRemoteElement(
|
||||||
|
localAppState,
|
||||||
|
localElement,
|
||||||
|
remoteElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (localElement && discardRemoteElement) {
|
||||||
|
reconciledElements.push(localElement);
|
||||||
|
added.add(localElement.id);
|
||||||
|
} else {
|
||||||
|
reconciledElements.push(remoteElement);
|
||||||
|
added.add(remoteElement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// process remaining local elements
|
||||||
|
for (const localElement of localElements) {
|
||||||
|
if (!added.has(localElement.id)) {
|
||||||
|
reconciledElements.push(localElement);
|
||||||
|
added.add(localElement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedElements = orderByFractionalIndex(reconciledElements);
|
||||||
|
|
||||||
|
// de-duplicate indices
|
||||||
|
syncInvalidIndices(orderedElements);
|
||||||
|
|
||||||
|
return orderedElements as ReconciledExcalidrawElement[];
|
||||||
|
};
|
|
@ -4,6 +4,7 @@ import {
|
||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
PointBinding,
|
PointBinding,
|
||||||
StrokeRoundness,
|
StrokeRoundness,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
|
@ -26,7 +27,6 @@ import {
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
PRECEDING_ELEMENT_KEY,
|
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
|
@ -44,6 +44,7 @@ import {
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { normalizeLink } from "./url";
|
import { normalizeLink } from "./url";
|
||||||
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
|
@ -73,7 +74,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RestoredDataState = {
|
export type RestoredDataState = {
|
||||||
elements: ExcalidrawElement[];
|
elements: OrderedExcalidrawElement[];
|
||||||
appState: RestoredAppState;
|
appState: RestoredAppState;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
};
|
};
|
||||||
|
@ -101,8 +102,6 @@ const restoreElementWithProperties = <
|
||||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
strokeSharpness?: StrokeRoundness;
|
strokeSharpness?: StrokeRoundness;
|
||||||
/** metadata that may be present in elements during collaboration */
|
|
||||||
[PRECEDING_ELEMENT_KEY]?: string;
|
|
||||||
},
|
},
|
||||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||||
>(
|
>(
|
||||||
|
@ -115,14 +114,13 @@ const restoreElementWithProperties = <
|
||||||
> &
|
> &
|
||||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
||||||
): T => {
|
): T => {
|
||||||
const base: Pick<T, keyof ExcalidrawElement> & {
|
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||||
[PRECEDING_ELEMENT_KEY]?: string;
|
|
||||||
} = {
|
|
||||||
type: extra.type || element.type,
|
type: extra.type || element.type,
|
||||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||||
// newly added elements
|
// newly added elements
|
||||||
version: element.version || 1,
|
version: element.version || 1,
|
||||||
versionNonce: element.versionNonce ?? 0,
|
versionNonce: element.versionNonce ?? 0,
|
||||||
|
index: element.index ?? null,
|
||||||
isDeleted: element.isDeleted ?? false,
|
isDeleted: element.isDeleted ?? false,
|
||||||
id: element.id || randomId(),
|
id: element.id || randomId(),
|
||||||
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||||
|
@ -166,10 +164,6 @@ const restoreElementWithProperties = <
|
||||||
"customData" in extra ? extra.customData : element.customData;
|
"customData" in extra ? extra.customData : element.customData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PRECEDING_ELEMENT_KEY in element) {
|
|
||||||
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
...getNormalizedDimensions(base),
|
...getNormalizedDimensions(base),
|
||||||
|
@ -407,11 +401,12 @@ export const restoreElements = (
|
||||||
/** NOTE doesn't serve for reconciliation */
|
/** NOTE doesn't serve for reconciliation */
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||||
): ExcalidrawElement[] => {
|
): OrderedExcalidrawElement[] => {
|
||||||
// used to detect duplicate top-level element ids
|
// used to detect duplicate top-level element ids
|
||||||
const existingIds = new Set<string>();
|
const existingIds = new Set<string>();
|
||||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||||
const restoredElements = (elements || []).reduce((elements, element) => {
|
const restoredElements = syncInvalidIndices(
|
||||||
|
(elements || []).reduce((elements, element) => {
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
// and causing issues if retained
|
// and causing issues if retained
|
||||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
|
@ -419,7 +414,10 @@ export const restoreElements = (
|
||||||
if (migratedElement) {
|
if (migratedElement) {
|
||||||
const localElement = localElementsMap?.get(element.id);
|
const localElement = localElementsMap?.get(element.id);
|
||||||
if (localElement && localElement.version > migratedElement.version) {
|
if (localElement && localElement.version > migratedElement.version) {
|
||||||
migratedElement = bumpVersion(migratedElement, localElement.version);
|
migratedElement = bumpVersion(
|
||||||
|
migratedElement,
|
||||||
|
localElement.version,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (existingIds.has(migratedElement.id)) {
|
if (existingIds.has(migratedElement.id)) {
|
||||||
migratedElement = { ...migratedElement, id: randomId() };
|
migratedElement = { ...migratedElement, id: randomId() };
|
||||||
|
@ -430,7 +428,8 @@ export const restoreElements = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return elements;
|
return elements;
|
||||||
}, [] as ExcalidrawElement[]);
|
}, [] as ExcalidrawElement[]),
|
||||||
|
);
|
||||||
|
|
||||||
if (!opts?.repairBindings) {
|
if (!opts?.repairBindings) {
|
||||||
return restoredElements;
|
return restoredElements;
|
||||||
|
|
|
@ -44,9 +44,16 @@ import {
|
||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { MarkOptional } from "../utility-types";
|
import { MarkOptional } from "../utility-types";
|
||||||
import { assertNever, cloneJSON, getFontString, toBrandedType } from "../utils";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
assertNever,
|
||||||
|
cloneJSON,
|
||||||
|
getFontString,
|
||||||
|
toBrandedType,
|
||||||
|
} from "../utils";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
|
|
||||||
export type ValidLinearElement = {
|
export type ValidLinearElement = {
|
||||||
type: "arrow" | "line";
|
type: "arrow" | "line";
|
||||||
|
@ -457,12 +464,15 @@ class ElementStore {
|
||||||
|
|
||||||
this.excalidrawElements.set(ele.id, ele);
|
this.excalidrawElements.set(ele.id, ele);
|
||||||
};
|
};
|
||||||
|
|
||||||
getElements = () => {
|
getElements = () => {
|
||||||
return Array.from(this.excalidrawElements.values());
|
return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
|
||||||
};
|
};
|
||||||
|
|
||||||
getElementsMap = () => {
|
getElementsMap = () => {
|
||||||
return toBrandedType<NonDeletedSceneElementsMap>(this.excalidrawElements);
|
return toBrandedType<NonDeletedSceneElementsMap>(
|
||||||
|
arrayToMap(this.getElements()),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
getElement = (id: string) => {
|
getElement = (id: string) => {
|
||||||
|
|
|
@ -55,6 +55,7 @@ export type ElementConstructorOpts = MarkOptional<
|
||||||
| "angle"
|
| "angle"
|
||||||
| "groupIds"
|
| "groupIds"
|
||||||
| "frameId"
|
| "frameId"
|
||||||
|
| "index"
|
||||||
| "boundElements"
|
| "boundElements"
|
||||||
| "seed"
|
| "seed"
|
||||||
| "version"
|
| "version"
|
||||||
|
@ -89,6 +90,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||||
angle = 0,
|
angle = 0,
|
||||||
groupIds = [],
|
groupIds = [],
|
||||||
frameId = null,
|
frameId = null,
|
||||||
|
index = null,
|
||||||
roundness = null,
|
roundness = null,
|
||||||
boundElements = null,
|
boundElements = null,
|
||||||
link = null,
|
link = null,
|
||||||
|
@ -114,6 +116,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||||
opacity,
|
opacity,
|
||||||
groupIds,
|
groupIds,
|
||||||
frameId,
|
frameId,
|
||||||
|
index,
|
||||||
roundness,
|
roundness,
|
||||||
seed: rest.seed ?? randomInteger(),
|
seed: rest.seed ?? randomInteger(),
|
||||||
version: rest.version || 1,
|
version: rest.version || 1,
|
||||||
|
|
|
@ -1454,7 +1454,7 @@ describe("textWysiwyg", () => {
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
updated: 1,
|
updated: 1,
|
||||||
version: 1,
|
version: 2,
|
||||||
width: 610,
|
width: 610,
|
||||||
x: 15,
|
x: 15,
|
||||||
y: 25,
|
y: 25,
|
||||||
|
|
|
@ -24,6 +24,7 @@ export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
||||||
|
|
||||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||||
|
export type FractionalIndex = string & { _brand: "franctionalIndex" };
|
||||||
|
|
||||||
type _ExcalidrawElementBase = Readonly<{
|
type _ExcalidrawElementBase = Readonly<{
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -50,6 +51,11 @@ type _ExcalidrawElementBase = Readonly<{
|
||||||
Used for deterministic reconciliation of updates during collaboration,
|
Used for deterministic reconciliation of updates during collaboration,
|
||||||
in case the versions (see above) are identical. */
|
in case the versions (see above) are identical. */
|
||||||
versionNonce: number;
|
versionNonce: number;
|
||||||
|
/** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing.
|
||||||
|
Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo.
|
||||||
|
Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`.
|
||||||
|
Could be null, i.e. for new elements which were not yet assigned to the scene. */
|
||||||
|
index: FractionalIndex | null;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
/** List of groups the element belongs to.
|
/** List of groups the element belongs to.
|
||||||
Ordered from deepest to shallowest. */
|
Ordered from deepest to shallowest. */
|
||||||
|
@ -164,6 +170,12 @@ export type ExcalidrawElement =
|
||||||
| ExcalidrawIframeElement
|
| ExcalidrawIframeElement
|
||||||
| ExcalidrawEmbeddableElement;
|
| ExcalidrawEmbeddableElement;
|
||||||
|
|
||||||
|
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
|
||||||
|
index: FractionalIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
|
||||||
|
|
||||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
};
|
};
|
||||||
|
@ -275,7 +287,10 @@ export type NonDeletedElementsMap = Map<
|
||||||
* Map of all excalidraw Scene elements, including deleted.
|
* Map of all excalidraw Scene elements, including deleted.
|
||||||
* Not a subset. Use this type when you need access to current Scene elements.
|
* Not a subset. Use this type when you need access to current Scene elements.
|
||||||
*/
|
*/
|
||||||
export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
|
export type SceneElementsMap = Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
Ordered<ExcalidrawElement>
|
||||||
|
> &
|
||||||
MakeBrand<"SceneElementsMap">;
|
MakeBrand<"SceneElementsMap">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -284,7 +299,7 @@ export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
|
||||||
*/
|
*/
|
||||||
export type NonDeletedSceneElementsMap = Map<
|
export type NonDeletedSceneElementsMap = Map<
|
||||||
ExcalidrawElement["id"],
|
ExcalidrawElement["id"],
|
||||||
NonDeletedExcalidrawElement
|
Ordered<NonDeletedExcalidrawElement>
|
||||||
> &
|
> &
|
||||||
MakeBrand<"NonDeletedSceneElementsMap">;
|
MakeBrand<"NonDeletedSceneElementsMap">;
|
||||||
|
|
||||||
|
|
|
@ -32,3 +32,7 @@ export class ImageSceneDataError extends Error {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InvalidFractionalIndexError extends Error {
|
||||||
|
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
|
||||||
|
}
|
||||||
|
|
348
packages/excalidraw/fractionalIndex.ts
Normal file
348
packages/excalidraw/fractionalIndex.ts
Normal file
|
@ -0,0 +1,348 @@
|
||||||
|
import { generateNKeysBetween } from "fractional-indexing";
|
||||||
|
import { mutateElement } from "./element/mutateElement";
|
||||||
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
FractionalIndex,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "./element/types";
|
||||||
|
import { InvalidFractionalIndexError } from "./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 to perform reorder for each related operation, thefeore 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 possibilties for incremental-like APIs
|
||||||
|
* - re-order based on fractional indices should be part of (multiplayer) operations such as reconcillitation & 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 = (
|
||||||
|
indices: (ExcalidrawElement["index"] | undefined)[],
|
||||||
|
) => {
|
||||||
|
for (const [i, index] of indices.entries()) {
|
||||||
|
const predecessorIndex = indices[i - 1];
|
||||||
|
const successorIndex = indices[i + 1];
|
||||||
|
|
||||||
|
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
|
||||||
|
throw new InvalidFractionalIndexError(
|
||||||
|
`Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
// ensure next indices are valid before mutation, throws on invalid ones
|
||||||
|
validateFractionalIndices(
|
||||||
|
elements.map((x) => elementsUpdates.get(x)?.index || x.index),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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) &&
|
||||||
|
!isValidFractionalIndex(
|
||||||
|
elements[i]?.index,
|
||||||
|
elements[i - 1]?.index,
|
||||||
|
elements[i + 1]?.index,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
|
||||||
|
|
||||||
|
while (++i < elements.length) {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
movedElements.has(elements[i].id) &&
|
||||||
|
!isValidFractionalIndex(
|
||||||
|
elements[i]?.index,
|
||||||
|
elements[i - 1]?.index,
|
||||||
|
elements[i + 1]?.index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
};
|
|
@ -29,7 +29,7 @@ import { ReadonlySetLike } from "./utility-types";
|
||||||
|
|
||||||
// --------------------------- Frame State ------------------------------------
|
// --------------------------- Frame State ------------------------------------
|
||||||
export const bindElementsToFramesAfterDuplication = (
|
export const bindElementsToFramesAfterDuplication = (
|
||||||
nextElements: ExcalidrawElement[],
|
nextElements: readonly ExcalidrawElement[],
|
||||||
oldElements: readonly ExcalidrawElement[],
|
oldElements: readonly ExcalidrawElement[],
|
||||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||||
) => {
|
) => {
|
||||||
|
|
|
@ -67,6 +67,7 @@
|
||||||
"canvas-roundrect-polyfill": "0.0.1",
|
"canvas-roundrect-polyfill": "0.0.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
|
"fractional-indexing": "3.2.0",
|
||||||
"fuzzy": "0.1.3",
|
"fuzzy": "0.1.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "1.13.1",
|
"jotai": "1.13.1",
|
||||||
|
|
|
@ -6,6 +6,8 @@ import {
|
||||||
ElementsMapOrArray,
|
ElementsMapOrArray,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
Ordered,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { isNonDeletedElement } from "../element";
|
import { isNonDeletedElement } from "../element";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
@ -14,7 +16,14 @@ import { getSelectedElements } from "./selection";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { Assert, SameType } from "../utility-types";
|
import { Assert, SameType } from "../utility-types";
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
|
import {
|
||||||
|
syncInvalidIndices,
|
||||||
|
syncMovedIndices,
|
||||||
|
validateFractionalIndices,
|
||||||
|
} from "../fractionalIndex";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
import { toBrandedType } from "../utils";
|
import { toBrandedType } from "../utils";
|
||||||
|
import { ENV } from "../constants";
|
||||||
|
|
||||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
||||||
type ElementKey = ExcalidrawElement | ElementIdKey;
|
type ElementKey = ExcalidrawElement | ElementIdKey;
|
||||||
|
@ -32,7 +41,10 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||||
for (const element of allElements) {
|
for (const element of allElements) {
|
||||||
if (!element.isDeleted) {
|
if (!element.isDeleted) {
|
||||||
elements.push(element as NonDeleted<T>);
|
elements.push(element as NonDeleted<T>);
|
||||||
elementsMap.set(element.id, element as NonDeletedExcalidrawElement);
|
elementsMap.set(
|
||||||
|
element.id,
|
||||||
|
element as Ordered<NonDeletedExcalidrawElement>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { elementsMap, elements };
|
return { elementsMap, elements };
|
||||||
|
@ -106,11 +118,13 @@ class Scene {
|
||||||
|
|
||||||
private callbacks: Set<SceneStateCallback> = new Set();
|
private callbacks: Set<SceneStateCallback> = new Set();
|
||||||
|
|
||||||
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
|
private nonDeletedElements: readonly Ordered<NonDeletedExcalidrawElement>[] =
|
||||||
|
[];
|
||||||
private nonDeletedElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
private nonDeletedElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
private elements: readonly ExcalidrawElement[] = [];
|
// ideally all elements within the scene should be wrapped around with `Ordered` type, but right now there is no real benefit doing so
|
||||||
|
private elements: readonly OrderedExcalidrawElement[] = [];
|
||||||
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
|
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
|
||||||
[];
|
[];
|
||||||
private frames: readonly ExcalidrawFrameLikeElement[] = [];
|
private frames: readonly ExcalidrawFrameLikeElement[] = [];
|
||||||
|
@ -138,7 +152,7 @@ class Scene {
|
||||||
return this.elements;
|
return this.elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] {
|
getNonDeletedElements() {
|
||||||
return this.nonDeletedElements;
|
return this.nonDeletedElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -244,12 +258,19 @@ class Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||||
this.elements =
|
const _nextElements =
|
||||||
// ts doesn't like `Array.isArray` of `instanceof Map`
|
// ts doesn't like `Array.isArray` of `instanceof Map`
|
||||||
nextElements instanceof Array
|
nextElements instanceof Array
|
||||||
? nextElements
|
? nextElements
|
||||||
: Array.from(nextElements.values());
|
: Array.from(nextElements.values());
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
|
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||||
|
// throw on invalid indices in test / dev to potentially detect cases were we forgot to sync moved elements
|
||||||
|
validateFractionalIndices(_nextElements.map((x) => x.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elements = syncInvalidIndices(_nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
this.elements.forEach((element) => {
|
this.elements.forEach((element) => {
|
||||||
if (isFrameLikeElement(element)) {
|
if (isFrameLikeElement(element)) {
|
||||||
|
@ -292,8 +313,8 @@ class Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.nonDeletedElements = [];
|
|
||||||
this.elements = [];
|
this.elements = [];
|
||||||
|
this.nonDeletedElements = [];
|
||||||
this.nonDeletedFramesLikes = [];
|
this.nonDeletedFramesLikes = [];
|
||||||
this.frames = [];
|
this.frames = [];
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
|
@ -318,11 +339,15 @@ class Scene {
|
||||||
"insertElementAtIndex can only be called with index >= 0",
|
"insertElementAtIndex can only be called with index >= 0",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextElements = [
|
const nextElements = [
|
||||||
...this.elements.slice(0, index),
|
...this.elements.slice(0, index),
|
||||||
element,
|
element,
|
||||||
...this.elements.slice(index),
|
...this.elements.slice(index),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
syncMovedIndices(nextElements, arrayToMap([element]));
|
||||||
|
|
||||||
this.replaceAllElements(nextElements);
|
this.replaceAllElements(nextElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -332,21 +357,32 @@ class Scene {
|
||||||
"insertElementAtIndex can only be called with index >= 0",
|
"insertElementAtIndex can only be called with index >= 0",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextElements = [
|
const nextElements = [
|
||||||
...this.elements.slice(0, index),
|
...this.elements.slice(0, index),
|
||||||
...elements,
|
...elements,
|
||||||
...this.elements.slice(index),
|
...this.elements.slice(index),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
syncMovedIndices(nextElements, arrayToMap(elements));
|
||||||
|
|
||||||
this.replaceAllElements(nextElements);
|
this.replaceAllElements(nextElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
addNewElement = (element: ExcalidrawElement) => {
|
insertElement = (element: ExcalidrawElement) => {
|
||||||
if (element.frameId) {
|
const index = element.frameId
|
||||||
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
|
? this.getElementIndex(element.frameId)
|
||||||
} else {
|
: this.elements.length;
|
||||||
this.replaceAllElements([...this.elements, element]);
|
|
||||||
}
|
this.insertElementAtIndex(element, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
insertElements = (elements: ExcalidrawElement[]) => {
|
||||||
|
const index = elements[0].frameId
|
||||||
|
? this.getElementIndex(elements[0].frameId)
|
||||||
|
: this.elements.length;
|
||||||
|
|
||||||
|
this.insertElementsAtIndex(elements, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
getElementIndex(elementId: string) {
|
getElementIndex(elementId: string) {
|
||||||
|
|
|
@ -38,6 +38,7 @@ import { Mutable } from "../utility-types";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { RenderableElementsMap } from "./types";
|
import { RenderableElementsMap } from "./types";
|
||||||
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { renderStaticScene } from "../renderer/staticScene";
|
import { renderStaticScene } from "../renderer/staticScene";
|
||||||
|
|
||||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||||
|
@ -224,7 +225,7 @@ export const exportToCanvas = async (
|
||||||
arrayToMap(elementsForRender),
|
arrayToMap(elementsForRender),
|
||||||
),
|
),
|
||||||
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
|
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
|
||||||
arrayToMap(elements),
|
arrayToMap(syncInvalidIndices(elements)),
|
||||||
),
|
),
|
||||||
visibleElements: elementsForRender,
|
visibleElements: elementsForRender,
|
||||||
scale,
|
scale,
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -15,6 +15,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -42,8 +43,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 2019559783,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -63,6 +64,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -77,8 +79,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -98,6 +100,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -112,8 +115,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -133,6 +136,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -160,8 +164,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 2019559783,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -181,6 +185,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -195,8 +200,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
|
|
@ -11,6 +11,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0_copy",
|
"id": "id0_copy",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -19,14 +20,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1014066025,
|
"seed": 238820263,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 5,
|
||||||
"versionNonce": 238820263,
|
"versionNonce": 400692809,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -44,6 +45,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -58,8 +60,8 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 6,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 23633383,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 60,
|
"y": 60,
|
||||||
|
@ -77,6 +79,7 @@ exports[`move element > rectangle 5`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -91,8 +94,8 @@ exports[`move element > rectangle 5`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": 1150084233,
|
"versionNonce": 1116226695,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 40,
|
"y": 40,
|
||||||
|
@ -115,6 +118,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -129,8 +133,8 @@ exports[`move element > rectangles with binding arrow 5`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": 81784553,
|
"versionNonce": 760410951,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -153,6 +157,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 300,
|
"height": 300,
|
||||||
"id": "id1",
|
"id": "id1",
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -161,14 +166,14 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 2019559783,
|
"seed": 1150084233,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 7,
|
||||||
"versionNonce": 927333447,
|
"versionNonce": 745419401,
|
||||||
"width": 300,
|
"width": 300,
|
||||||
"x": 201,
|
"x": 201,
|
||||||
"y": 2,
|
"y": 2,
|
||||||
|
@ -192,6 +197,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 81.48231043525051,
|
"height": 81.48231043525051,
|
||||||
"id": "id2",
|
"id": "id2",
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -211,7 +217,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 238820263,
|
"seed": 1604849351,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "id0",
|
"elementId": "id0",
|
||||||
|
@ -223,8 +229,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 11,
|
"version": 12,
|
||||||
"versionNonce": 1051383431,
|
"versionNonce": 1984422985,
|
||||||
"width": 81,
|
"width": 81,
|
||||||
"x": 110,
|
"x": 110,
|
||||||
"y": 49.981789081137734,
|
"y": 49.981789081137734,
|
||||||
|
|
|
@ -13,6 +13,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 110,
|
"height": 110,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": [
|
"lastCommittedPoint": [
|
||||||
70,
|
70,
|
||||||
|
@ -47,8 +48,8 @@ exports[`multi point mode in linear elements > arrow 3`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 7,
|
"version": 8,
|
||||||
"versionNonce": 1505387817,
|
"versionNonce": 23633383,
|
||||||
"width": 70,
|
"width": 70,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -68,6 +69,7 @@ exports[`multi point mode in linear elements > line 3`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 110,
|
"height": 110,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": [
|
"lastCommittedPoint": [
|
||||||
70,
|
70,
|
||||||
|
@ -102,8 +104,8 @@ exports[`multi point mode in linear elements > line 3`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 7,
|
"version": 8,
|
||||||
"versionNonce": 1505387817,
|
"versionNonce": 23633383,
|
||||||
"width": 70,
|
"width": 70,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -13,6 +13,7 @@ exports[`select single element on the scene > arrow 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -40,8 +41,8 @@ exports[`select single element on the scene > arrow 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 2019559783,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
@ -61,6 +62,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -88,8 +90,8 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 2019559783,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
@ -107,6 +109,7 @@ exports[`select single element on the scene > diamond 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -121,8 +124,8 @@ exports[`select single element on the scene > diamond 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
@ -140,6 +143,7 @@ exports[`select single element on the scene > ellipse 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -154,8 +158,8 @@ exports[`select single element on the scene > ellipse 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
@ -173,6 +177,7 @@ exports[`select single element on the scene > rectangle 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -187,8 +192,8 @@ exports[`select single element on the scene > rectangle 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
|
|
@ -423,8 +423,26 @@ describe("contextMenu element", () => {
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
|
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
|
||||||
expect(h.elements).toHaveLength(2);
|
expect(h.elements).toHaveLength(2);
|
||||||
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
|
const {
|
||||||
const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
|
id: _id0,
|
||||||
|
seed: _seed0,
|
||||||
|
x: _x0,
|
||||||
|
y: _y0,
|
||||||
|
index: _fractionalIndex0,
|
||||||
|
version: _version0,
|
||||||
|
versionNonce: _versionNonce0,
|
||||||
|
...rect1
|
||||||
|
} = h.elements[0];
|
||||||
|
const {
|
||||||
|
id: _id1,
|
||||||
|
seed: _seed1,
|
||||||
|
x: _x1,
|
||||||
|
y: _y1,
|
||||||
|
index: _fractionalIndex1,
|
||||||
|
version: _version1,
|
||||||
|
versionNonce: _versionNonce1,
|
||||||
|
...rect2
|
||||||
|
} = h.elements[1];
|
||||||
expect(rect1).toEqual(rect2);
|
expect(rect1).toEqual(rect2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-arrow01",
|
"id": "id-arrow01",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -40,8 +41,8 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": 0,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -63,6 +64,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||||
],
|
],
|
||||||
"height": 200,
|
"height": 200,
|
||||||
"id": "1",
|
"id": "1",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -77,8 +79,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": 0,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -100,6 +102,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||||
],
|
],
|
||||||
"height": 200,
|
"height": 200,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -114,8 +117,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": 0,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -137,6 +140,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||||
],
|
],
|
||||||
"height": 200,
|
"height": 200,
|
||||||
"id": "3",
|
"id": "3",
|
||||||
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -151,8 +155,8 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": 0,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -170,6 +174,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": "id-freedraw01",
|
"id": "id-freedraw01",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -188,8 +193,8 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "freedraw",
|
"type": "freedraw",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": 0,
|
"versionNonce": Any<Number>,
|
||||||
"width": 0,
|
"width": 0,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -209,6 +214,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-line01",
|
"id": "id-line01",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -236,8 +242,8 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": 0,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -257,6 +263,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-draw01",
|
"id": "id-draw01",
|
||||||
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -284,8 +291,8 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": 0,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -306,6 +313,7 @@ exports[`restoreElements > should restore text element correctly passing value f
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-text01",
|
"id": "id-text01",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -324,8 +332,8 @@ exports[`restoreElements > should restore text element correctly passing value f
|
||||||
"textAlign": "center",
|
"textAlign": "center",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"versionNonce": 0,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": -20,
|
"x": -20,
|
||||||
|
@ -347,6 +355,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-text01",
|
"id": "id-text01",
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": true,
|
"isDeleted": true,
|
||||||
"lineHeight": 1.25,
|
"lineHeight": 1.25,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -365,7 +374,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 100,
|
"width": 100,
|
||||||
|
|
374
packages/excalidraw/tests/data/reconcile.test.ts
Normal file
374
packages/excalidraw/tests/data/reconcile.test.ts
Normal file
|
@ -0,0 +1,374 @@
|
||||||
|
import {
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../../data/reconcile";
|
||||||
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "../../element/types";
|
||||||
|
import { syncInvalidIndices } from "../../fractionalIndex";
|
||||||
|
import { randomInteger } from "../../random";
|
||||||
|
import { AppState } from "../../types";
|
||||||
|
import { cloneJSON } from "../../utils";
|
||||||
|
|
||||||
|
type Id = string;
|
||||||
|
type ElementLike = {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
versionNonce: number;
|
||||||
|
index: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Cache = Record<string, ExcalidrawElement | undefined>;
|
||||||
|
|
||||||
|
const createElement = (opts: { uid: string } | ElementLike) => {
|
||||||
|
let uid: string;
|
||||||
|
let id: string;
|
||||||
|
let version: number | null;
|
||||||
|
let versionNonce: number | null = null;
|
||||||
|
if ("uid" in opts) {
|
||||||
|
const match = opts.uid.match(/^(\w+)(?::(\d+))?$/)!;
|
||||||
|
id = match[1];
|
||||||
|
version = match[2] ? parseInt(match[2]) : null;
|
||||||
|
uid = version ? `${id}:${version}` : id;
|
||||||
|
} else {
|
||||||
|
({ id, version, versionNonce } = opts);
|
||||||
|
uid = id;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uid,
|
||||||
|
id,
|
||||||
|
version,
|
||||||
|
versionNonce: versionNonce || randomInteger(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const idsToElements = (ids: (Id | ElementLike)[], cache: Cache = {}) => {
|
||||||
|
return syncInvalidIndices(
|
||||||
|
ids.reduce((acc, _uid) => {
|
||||||
|
const { uid, id, version, versionNonce } = createElement(
|
||||||
|
typeof _uid === "string" ? { uid: _uid } : _uid,
|
||||||
|
);
|
||||||
|
const cached = cache[uid];
|
||||||
|
const elem = {
|
||||||
|
id,
|
||||||
|
version: version ?? 0,
|
||||||
|
versionNonce,
|
||||||
|
...cached,
|
||||||
|
} as ExcalidrawElement;
|
||||||
|
// @ts-ignore
|
||||||
|
cache[uid] = elem;
|
||||||
|
acc.push(elem);
|
||||||
|
return acc;
|
||||||
|
}, [] as ExcalidrawElement[]),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = <U extends `${string}:${"L" | "R"}`>(
|
||||||
|
local: (Id | ElementLike)[],
|
||||||
|
remote: (Id | ElementLike)[],
|
||||||
|
target: U[],
|
||||||
|
) => {
|
||||||
|
const cache: Cache = {};
|
||||||
|
const _local = idsToElements(local, cache);
|
||||||
|
const _remote = idsToElements(remote, cache);
|
||||||
|
|
||||||
|
const reconciled = reconcileElements(
|
||||||
|
cloneJSON(_local),
|
||||||
|
cloneJSON(_remote) as RemoteExcalidrawElement[],
|
||||||
|
{} as AppState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reconciledIds = reconciled.map((x) => x.id);
|
||||||
|
const reconciledIndices = reconciled.map((x) => x.index);
|
||||||
|
|
||||||
|
expect(target.length).equal(reconciled.length);
|
||||||
|
expect(reconciledIndices.length).equal(new Set([...reconciledIndices]).size); // expect no duplicated indices
|
||||||
|
expect(reconciledIds).deep.equal(
|
||||||
|
target.map((uid) => {
|
||||||
|
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
|
||||||
|
const element = (source === "L" ? _local : _remote).find(
|
||||||
|
(e) => e.id === id,
|
||||||
|
)!;
|
||||||
|
|
||||||
|
return element.id;
|
||||||
|
}),
|
||||||
|
"remote reconciliation",
|
||||||
|
);
|
||||||
|
|
||||||
|
// convergent reconciliation on the remote client
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
reconcileElements(
|
||||||
|
cloneJSON(_remote),
|
||||||
|
cloneJSON(_local as RemoteExcalidrawElement[]),
|
||||||
|
{} as AppState,
|
||||||
|
).map((x) => x.id),
|
||||||
|
).deep.equal(reconciledIds, "convergent reconciliation");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("local original", _remote);
|
||||||
|
console.error("remote original", _local);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bidirectional re-reconciliation on remote client
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
reconcileElements(
|
||||||
|
cloneJSON(_remote),
|
||||||
|
cloneJSON(reconciled as unknown as RemoteExcalidrawElement[]),
|
||||||
|
{} as AppState,
|
||||||
|
).map((x) => x.id),
|
||||||
|
).deep.equal(reconciledIds, "local re-reconciliation");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("local original", _remote);
|
||||||
|
console.error("remote reconciled", reconciled);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("elements reconciliation", () => {
|
||||||
|
it("reconcileElements()", () => {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// in following tests, we pass:
|
||||||
|
// (1) an array of local elements and their version (:1, :2...)
|
||||||
|
// (2) an array of remote elements and their version (:1, :2...)
|
||||||
|
// (3) expected reconciled elements
|
||||||
|
//
|
||||||
|
// in the reconciled array:
|
||||||
|
// :L means local element was resolved
|
||||||
|
// :R means remote element was resolved
|
||||||
|
//
|
||||||
|
// if versions are missing, it defaults to version 0
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
||||||
|
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
|
||||||
|
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
|
||||||
|
test(["A:1", "C:1"], ["B:1"], ["A:L", "B:R", "C:L"]);
|
||||||
|
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
|
||||||
|
test(["A"], ["A", "B"], ["A:L", "B:R"]);
|
||||||
|
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
|
||||||
|
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
|
||||||
|
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
|
||||||
|
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
|
||||||
|
test(["A"], ["A:1"], ["A:R"]);
|
||||||
|
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["C:R", "A:R", "B:L", "D:L"]);
|
||||||
|
|
||||||
|
// some of the following tests are kinda arbitrary and they're less
|
||||||
|
// likely to happen in real-world cases
|
||||||
|
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
|
||||||
|
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
|
||||||
|
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
|
||||||
|
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:R", "B:R", "C:L", "G:R"]);
|
||||||
|
test(
|
||||||
|
["A:2", "B:2", "C"],
|
||||||
|
["D", "B:1", "A:3"],
|
||||||
|
["D:R", "B:L", "A:R", "C:L"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["A:2", "B:2", "C"],
|
||||||
|
["D", "B:2", "A:3", "C"],
|
||||||
|
["D:R", "B:L", "A:R", "C:L"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D", "E", "F"],
|
||||||
|
["A", "B:2", "X", "E:2", "F", "Y"],
|
||||||
|
["A:L", "B:R", "X:R", "C:L", "E:R", "D:L", "F:L", "Y:R"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// fractional elements (previously annotated)
|
||||||
|
test(
|
||||||
|
["A", "B", "C"],
|
||||||
|
["A", "B", "X", "Y", "Z"],
|
||||||
|
["A:R", "B:R", "C:L", "X:R", "Y:R", "Z:R"],
|
||||||
|
);
|
||||||
|
|
||||||
|
test(["A"], ["X", "Y"], ["A:L", "X:R", "Y:R"]);
|
||||||
|
test(["A"], ["X", "Y", "Z"], ["A:L", "X:R", "Y:R", "Z:R"]);
|
||||||
|
test(["A", "B"], ["C", "D", "F"], ["A:L", "C:R", "B:L", "D:R", "F:R"]);
|
||||||
|
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D"],
|
||||||
|
["C:1", "B", "D:1"],
|
||||||
|
["A:L", "C:R", "B:L", "D:R"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["A", "B", "C"],
|
||||||
|
["X", "A", "Y", "B", "Z"],
|
||||||
|
["X:R", "A:R", "Y:R", "B:L", "C:L", "Z:R"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["B", "A", "C"],
|
||||||
|
["X", "A", "Y", "B", "Z"],
|
||||||
|
["X:R", "A:R", "C:L", "Y:R", "B:R", "Z:R"],
|
||||||
|
);
|
||||||
|
test(["A", "B"], ["A", "X", "Y"], ["A:R", "B:L", "X:R", "Y:R"]);
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D", "E"],
|
||||||
|
["A", "X", "C", "Y", "D", "Z"],
|
||||||
|
["A:R", "B:L", "X:R", "C:R", "Y:R", "D:R", "E:L", "Z:R"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["X", "Y", "Z"],
|
||||||
|
["A", "B", "C"],
|
||||||
|
["A:R", "X:L", "B:R", "Y:L", "C:R", "Z:L"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["X", "Y", "Z"],
|
||||||
|
["A", "B", "C", "X", "D", "Y", "Z"],
|
||||||
|
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D", "E"],
|
||||||
|
["C", "X", "A", "Y", "D", "E:1"],
|
||||||
|
["B:L", "C:L", "X:R", "A:R", "Y:R", "D:R", "E:R"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["C:1", "B", "D:1"],
|
||||||
|
["A", "B", "C:1", "D:1"],
|
||||||
|
["A:R", "B:R", "C:R", "D:R"],
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
["C:1", "B", "D:1"],
|
||||||
|
["A", "B", "C:2", "D:1"],
|
||||||
|
["A:R", "B:L", "C:R", "D:L"],
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D"],
|
||||||
|
["A", "C:1", "B", "D:1"],
|
||||||
|
["A:L", "C:R", "B:L", "D:R"],
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D"],
|
||||||
|
["C", "X", "B", "Y", "A", "Z"],
|
||||||
|
["C:R", "D:L", "X:R", "B:R", "Y:R", "A:R", "Z:R"],
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D"],
|
||||||
|
["A", "B:1", "C:1"],
|
||||||
|
["A:R", "B:R", "C:R", "D:L"],
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D"],
|
||||||
|
["A", "C:1", "B:1"],
|
||||||
|
["A:R", "C:R", "B:R", "D:L"],
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
["A", "B", "C", "D"],
|
||||||
|
["A", "C:1", "B", "D:1"],
|
||||||
|
["A:R", "C:R", "B:R", "D:R"],
|
||||||
|
);
|
||||||
|
|
||||||
|
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
||||||
|
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
|
||||||
|
test(["A", "B"], ["A", "C", "B", "D"], ["A:R", "C:R", "B:R", "D:R"]);
|
||||||
|
test(["A", "B"], ["B", "C", "D"], ["A:L", "B:R", "C:R", "D:R"]);
|
||||||
|
test(["A", "B"], ["C", "D"], ["A:L", "C:R", "B:L", "D:R"]);
|
||||||
|
test(["A", "B"], ["A", "B:1"], ["A:L", "B:R"]);
|
||||||
|
test(["A:2", "B"], ["A", "B:1"], ["A:L", "B:R"]);
|
||||||
|
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
|
||||||
|
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
|
||||||
|
test(["A:2", "B:2"], ["A", "C", "B:1"], ["A:L", "B:L", "C:R"]);
|
||||||
|
|
||||||
|
// concurrent convergency
|
||||||
|
test(["A", "B", "C"], ["A", "B", "D"], ["A:R", "B:R", "C:L", "D:R"]);
|
||||||
|
test(["A", "B", "E"], ["A", "B", "D"], ["A:R", "B:R", "D:R", "E:L"]);
|
||||||
|
test(
|
||||||
|
["A", "B", "C"],
|
||||||
|
["A", "B", "D", "E"],
|
||||||
|
["A:R", "B:R", "C:L", "D:R", "E:R"],
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
["A", "B", "E"],
|
||||||
|
["A", "B", "D", "C"],
|
||||||
|
["A:R", "B:R", "D:R", "E:L", "C:R"],
|
||||||
|
);
|
||||||
|
test(["A", "B"], ["B", "D"], ["A:L", "B:R", "D:R"]);
|
||||||
|
test(["C", "A", "B"], ["C", "B", "D"], ["C:R", "A:L", "B:R", "D:R"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test identical elements reconciliation", () => {
|
||||||
|
const testIdentical = (
|
||||||
|
local: ElementLike[],
|
||||||
|
remote: ElementLike[],
|
||||||
|
expected: Id[],
|
||||||
|
) => {
|
||||||
|
const ret = reconcileElements(
|
||||||
|
local as unknown as OrderedExcalidrawElement[],
|
||||||
|
remote as unknown as RemoteExcalidrawElement[],
|
||||||
|
{} as AppState,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
|
||||||
|
throw new Error("reconcileElements: duplicate elements found");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ret.map((x) => x.id)).to.deep.equal(expected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// identical id/version/versionNonce/index
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
testIdentical(
|
||||||
|
[{ id: "A", version: 1, versionNonce: 1, index: "a0" }],
|
||||||
|
[{ id: "A", version: 1, versionNonce: 1, index: "a0" }],
|
||||||
|
["A"],
|
||||||
|
);
|
||||||
|
testIdentical(
|
||||||
|
[
|
||||||
|
{ id: "A", version: 1, versionNonce: 1, index: "a0" },
|
||||||
|
{ id: "B", version: 1, versionNonce: 1, index: "a0" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: "B", version: 1, versionNonce: 1, index: "a0" },
|
||||||
|
{ id: "A", version: 1, versionNonce: 1, index: "a0" },
|
||||||
|
],
|
||||||
|
["A", "B"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// actually identical (arrays and element objects)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const elements1 = [
|
||||||
|
{
|
||||||
|
id: "A",
|
||||||
|
version: 1,
|
||||||
|
versionNonce: 1,
|
||||||
|
index: "a0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "B",
|
||||||
|
version: 1,
|
||||||
|
versionNonce: 1,
|
||||||
|
index: "a0",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testIdentical(elements1, elements1, ["A", "B"]);
|
||||||
|
testIdentical(elements1, elements1.slice(), ["A", "B"]);
|
||||||
|
testIdentical(elements1.slice(), elements1, ["A", "B"]);
|
||||||
|
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
|
||||||
|
|
||||||
|
const el1 = {
|
||||||
|
id: "A",
|
||||||
|
version: 1,
|
||||||
|
versionNonce: 1,
|
||||||
|
index: "a0",
|
||||||
|
};
|
||||||
|
const el2 = {
|
||||||
|
id: "B",
|
||||||
|
version: 1,
|
||||||
|
versionNonce: 1,
|
||||||
|
index: "a0",
|
||||||
|
};
|
||||||
|
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -72,6 +72,7 @@ describe("restoreElements", () => {
|
||||||
|
|
||||||
expect(restoredText).toMatchSnapshot({
|
expect(restoredText).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
|
versionNonce: expect.any(Number),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -109,7 +110,10 @@ describe("restoreElements", () => {
|
||||||
null,
|
null,
|
||||||
)[0] as ExcalidrawFreeDrawElement;
|
)[0] as ExcalidrawFreeDrawElement;
|
||||||
|
|
||||||
expect(restoredFreedraw).toMatchSnapshot({ seed: expect.any(Number) });
|
expect(restoredFreedraw).toMatchSnapshot({
|
||||||
|
seed: expect.any(Number),
|
||||||
|
versionNonce: expect.any(Number),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should restore line and draw elements correctly", () => {
|
it("should restore line and draw elements correctly", () => {
|
||||||
|
@ -129,8 +133,14 @@ describe("restoreElements", () => {
|
||||||
const restoredLine = restoredElements[0] as ExcalidrawLinearElement;
|
const restoredLine = restoredElements[0] as ExcalidrawLinearElement;
|
||||||
const restoredDraw = restoredElements[1] as ExcalidrawLinearElement;
|
const restoredDraw = restoredElements[1] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
expect(restoredLine).toMatchSnapshot({ seed: expect.any(Number) });
|
expect(restoredLine).toMatchSnapshot({
|
||||||
expect(restoredDraw).toMatchSnapshot({ seed: expect.any(Number) });
|
seed: expect.any(Number),
|
||||||
|
versionNonce: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(restoredDraw).toMatchSnapshot({
|
||||||
|
seed: expect.any(Number),
|
||||||
|
versionNonce: expect.any(Number),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should restore arrow element correctly", () => {
|
it("should restore arrow element correctly", () => {
|
||||||
|
@ -140,7 +150,10 @@ describe("restoreElements", () => {
|
||||||
|
|
||||||
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
|
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) });
|
expect(restoredArrow).toMatchSnapshot({
|
||||||
|
seed: expect.any(Number),
|
||||||
|
versionNonce: expect.any(Number),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
||||||
|
@ -270,9 +283,18 @@ describe("restoreElements", () => {
|
||||||
|
|
||||||
const restoredElements = restore.restoreElements(elements, null);
|
const restoredElements = restore.restoreElements(elements, null);
|
||||||
|
|
||||||
expect(restoredElements[0]).toMatchSnapshot({ seed: expect.any(Number) });
|
expect(restoredElements[0]).toMatchSnapshot({
|
||||||
expect(restoredElements[1]).toMatchSnapshot({ seed: expect.any(Number) });
|
seed: expect.any(Number),
|
||||||
expect(restoredElements[2]).toMatchSnapshot({ seed: expect.any(Number) });
|
versionNonce: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(restoredElements[1]).toMatchSnapshot({
|
||||||
|
seed: expect.any(Number),
|
||||||
|
versionNonce: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(restoredElements[2]).toMatchSnapshot({
|
||||||
|
seed: expect.any(Number),
|
||||||
|
versionNonce: expect.any(Number),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bump versions of local duplicate elements when supplied", () => {
|
it("bump versions of local duplicate elements when supplied", () => {
|
||||||
|
@ -290,12 +312,11 @@ describe("restoreElements", () => {
|
||||||
expect(restoredElements).toEqual([
|
expect(restoredElements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: rectangle.id,
|
id: rectangle.id,
|
||||||
version: rectangle_modified.version + 1,
|
version: rectangle_modified.version + 2,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: ellipse.id,
|
id: ellipse.id,
|
||||||
version: ellipse.version,
|
version: ellipse.version + 1,
|
||||||
versionNonce: ellipse.versionNonce,
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -549,11 +570,10 @@ describe("restore", () => {
|
||||||
rectangle.versionNonce,
|
rectangle.versionNonce,
|
||||||
);
|
);
|
||||||
expect(restoredData.elements).toEqual([
|
expect(restoredData.elements).toEqual([
|
||||||
expect.objectContaining({ version: rectangle_modified.version + 1 }),
|
expect.objectContaining({ version: rectangle_modified.version + 2 }),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: ellipse.id,
|
id: ellipse.id,
|
||||||
version: ellipse.version,
|
version: ellipse.version + 1,
|
||||||
versionNonce: ellipse.versionNonce,
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,6 +17,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
frameId: null,
|
frameId: null,
|
||||||
roundness: null,
|
roundness: null,
|
||||||
|
index: null,
|
||||||
seed: 1041657908,
|
seed: 1041657908,
|
||||||
version: 120,
|
version: 120,
|
||||||
versionNonce: 1188004276,
|
versionNonce: 1188004276,
|
||||||
|
|
|
@ -412,7 +412,7 @@ describe("ellipse", () => {
|
||||||
describe("arrow", () => {
|
describe("arrow", () => {
|
||||||
it("flips an unrotated arrow horizontally with line inside min/max points bounds", async () => {
|
it("flips an unrotated arrow horizontally with line inside min/max points bounds", async () => {
|
||||||
const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||||
h.app.scene.replaceAllElements([arrow]);
|
h.elements = [arrow];
|
||||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||||
await checkHorizontalFlip(
|
await checkHorizontalFlip(
|
||||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||||
|
@ -421,7 +421,7 @@ describe("arrow", () => {
|
||||||
|
|
||||||
it("flips an unrotated arrow vertically with line inside min/max points bounds", async () => {
|
it("flips an unrotated arrow vertically with line inside min/max points bounds", async () => {
|
||||||
const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||||
h.app.scene.replaceAllElements([arrow]);
|
h.elements = [arrow];
|
||||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||||
|
|
||||||
await checkVerticalFlip(50);
|
await checkVerticalFlip(50);
|
||||||
|
@ -431,7 +431,7 @@ describe("arrow", () => {
|
||||||
const originalAngle = Math.PI / 4;
|
const originalAngle = Math.PI / 4;
|
||||||
const expectedAngle = (7 * Math.PI) / 4;
|
const expectedAngle = (7 * Math.PI) / 4;
|
||||||
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.state.selectedElementIds = {
|
h.state.selectedElementIds = {
|
||||||
...h.state.selectedElementIds,
|
...h.state.selectedElementIds,
|
||||||
[line.id]: true,
|
[line.id]: true,
|
||||||
|
@ -450,7 +450,7 @@ describe("arrow", () => {
|
||||||
const originalAngle = Math.PI / 4;
|
const originalAngle = Math.PI / 4;
|
||||||
const expectedAngle = (7 * Math.PI) / 4;
|
const expectedAngle = (7 * Math.PI) / 4;
|
||||||
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.state.selectedElementIds = {
|
h.state.selectedElementIds = {
|
||||||
...h.state.selectedElementIds,
|
...h.state.selectedElementIds,
|
||||||
[line.id]: true,
|
[line.id]: true,
|
||||||
|
@ -468,7 +468,7 @@ describe("arrow", () => {
|
||||||
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
||||||
it.skip("flips an unrotated arrow horizontally with line outside min/max points bounds", async () => {
|
it.skip("flips an unrotated arrow horizontally with line outside min/max points bounds", async () => {
|
||||||
const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||||
h.app.scene.replaceAllElements([arrow]);
|
h.elements = [arrow];
|
||||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||||
|
|
||||||
await checkHorizontalFlip(
|
await checkHorizontalFlip(
|
||||||
|
@ -482,7 +482,7 @@ describe("arrow", () => {
|
||||||
const expectedAngle = (7 * Math.PI) / 4;
|
const expectedAngle = (7 * Math.PI) / 4;
|
||||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||||
mutateElement(line, { angle: originalAngle });
|
mutateElement(line, { angle: originalAngle });
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||||
|
|
||||||
await checkRotatedVerticalFlip(
|
await checkRotatedVerticalFlip(
|
||||||
|
@ -494,7 +494,7 @@ describe("arrow", () => {
|
||||||
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
||||||
it.skip("flips an unrotated arrow vertically with line outside min/max points bounds", async () => {
|
it.skip("flips an unrotated arrow vertically with line outside min/max points bounds", async () => {
|
||||||
const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||||
h.app.scene.replaceAllElements([arrow]);
|
h.elements = [arrow];
|
||||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||||
|
|
||||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||||
|
@ -506,7 +506,7 @@ describe("arrow", () => {
|
||||||
const expectedAngle = (7 * Math.PI) / 4;
|
const expectedAngle = (7 * Math.PI) / 4;
|
||||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||||
mutateElement(line, { angle: originalAngle });
|
mutateElement(line, { angle: originalAngle });
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||||
|
|
||||||
await checkRotatedVerticalFlip(
|
await checkRotatedVerticalFlip(
|
||||||
|
@ -542,7 +542,7 @@ describe("arrow", () => {
|
||||||
describe("line", () => {
|
describe("line", () => {
|
||||||
it("flips an unrotated line horizontally with line inside min/max points bounds", async () => {
|
it("flips an unrotated line horizontally with line inside min/max points bounds", async () => {
|
||||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||||
|
|
||||||
await checkHorizontalFlip(
|
await checkHorizontalFlip(
|
||||||
|
@ -552,7 +552,7 @@ describe("line", () => {
|
||||||
|
|
||||||
it("flips an unrotated line vertically with line inside min/max points bounds", async () => {
|
it("flips an unrotated line vertically with line inside min/max points bounds", async () => {
|
||||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||||
|
|
||||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||||
|
@ -567,7 +567,7 @@ describe("line", () => {
|
||||||
//TODO: elements with curve outside minMax points have a wrong bounding box
|
//TODO: elements with curve outside minMax points have a wrong bounding box
|
||||||
it.skip("flips an unrotated line horizontally with line outside min/max points bounds", async () => {
|
it.skip("flips an unrotated line horizontally with line outside min/max points bounds", async () => {
|
||||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||||
|
|
||||||
await checkHorizontalFlip(
|
await checkHorizontalFlip(
|
||||||
|
@ -578,7 +578,7 @@ describe("line", () => {
|
||||||
//TODO: elements with curve outside minMax points have a wrong bounding box
|
//TODO: elements with curve outside minMax points have a wrong bounding box
|
||||||
it.skip("flips an unrotated line vertically with line outside min/max points bounds", async () => {
|
it.skip("flips an unrotated line vertically with line outside min/max points bounds", async () => {
|
||||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||||
|
|
||||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||||
|
@ -590,7 +590,7 @@ describe("line", () => {
|
||||||
const expectedAngle = (7 * Math.PI) / 4;
|
const expectedAngle = (7 * Math.PI) / 4;
|
||||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||||
mutateElement(line, { angle: originalAngle });
|
mutateElement(line, { angle: originalAngle });
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||||
|
|
||||||
await checkRotatedHorizontalFlip(
|
await checkRotatedHorizontalFlip(
|
||||||
|
@ -605,7 +605,7 @@ describe("line", () => {
|
||||||
const expectedAngle = (7 * Math.PI) / 4;
|
const expectedAngle = (7 * Math.PI) / 4;
|
||||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||||
mutateElement(line, { angle: originalAngle });
|
mutateElement(line, { angle: originalAngle });
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||||
|
|
||||||
await checkRotatedVerticalFlip(
|
await checkRotatedVerticalFlip(
|
||||||
|
@ -623,7 +623,7 @@ describe("line", () => {
|
||||||
const originalAngle = Math.PI / 4;
|
const originalAngle = Math.PI / 4;
|
||||||
const expectedAngle = (7 * Math.PI) / 4;
|
const expectedAngle = (7 * Math.PI) / 4;
|
||||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.state.selectedElementIds = {
|
h.state.selectedElementIds = {
|
||||||
...h.state.selectedElementIds,
|
...h.state.selectedElementIds,
|
||||||
[line.id]: true,
|
[line.id]: true,
|
||||||
|
@ -642,7 +642,7 @@ describe("line", () => {
|
||||||
const originalAngle = Math.PI / 4;
|
const originalAngle = Math.PI / 4;
|
||||||
const expectedAngle = (7 * Math.PI) / 4;
|
const expectedAngle = (7 * Math.PI) / 4;
|
||||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||||
h.app.scene.replaceAllElements([line]);
|
h.elements = [line];
|
||||||
h.state.selectedElementIds = {
|
h.state.selectedElementIds = {
|
||||||
...h.state.selectedElementIds,
|
...h.state.selectedElementIds,
|
||||||
[line.id]: true,
|
[line.id]: true,
|
||||||
|
|
774
packages/excalidraw/tests/fractionalIndex.test.ts
Normal file
774
packages/excalidraw/tests/fractionalIndex.test.ts
Normal file
|
@ -0,0 +1,774 @@
|
||||||
|
/* eslint-disable no-lone-blocks */
|
||||||
|
import {
|
||||||
|
syncInvalidIndices,
|
||||||
|
syncMovedIndices,
|
||||||
|
validateFractionalIndices,
|
||||||
|
} from "../fractionalIndex";
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
import { InvalidFractionalIndexError } from "../errors";
|
||||||
|
import { ExcalidrawElement, FractionalIndex } from "../element/types";
|
||||||
|
import { deepCopyElement } from "../element/newElement";
|
||||||
|
import { generateKeyBetween } from "fractional-indexing";
|
||||||
|
|
||||||
|
describe("sync invalid indices with array order", () => {
|
||||||
|
describe("should NOT sync empty array", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [],
|
||||||
|
movedElements: [],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [],
|
||||||
|
validInput: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [],
|
||||||
|
validInput: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should NOT sync when index is well defined", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [{ id: "A", index: "a1" }],
|
||||||
|
movedElements: [],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
validInput: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [{ id: "A", index: "a1" }],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
validInput: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should NOT sync when indices are well defined", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a3" },
|
||||||
|
],
|
||||||
|
movedElements: [],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "B", "C"],
|
||||||
|
validInput: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a3" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "B", "C"],
|
||||||
|
validInput: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should NOT sync index when it is already valid", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a2" },
|
||||||
|
{ id: "B", index: "a4" },
|
||||||
|
],
|
||||||
|
movedElements: ["A"],
|
||||||
|
expect: {
|
||||||
|
validInput: true,
|
||||||
|
unchangedElements: ["A", "B"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a2" },
|
||||||
|
{ id: "B", index: "a4" },
|
||||||
|
],
|
||||||
|
movedElements: ["B"],
|
||||||
|
expect: {
|
||||||
|
validInput: true,
|
||||||
|
unchangedElements: ["A", "B"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should NOT sync indices when they are already valid", () => {
|
||||||
|
{
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a0" },
|
||||||
|
{ id: "C", index: "a2" },
|
||||||
|
],
|
||||||
|
movedElements: ["B", "C"],
|
||||||
|
expect: {
|
||||||
|
// this should not sync 'C'
|
||||||
|
unchangedElements: ["A", "C"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a0" },
|
||||||
|
{ id: "C", index: "a2" },
|
||||||
|
],
|
||||||
|
movedElements: ["A", "B"],
|
||||||
|
expect: {
|
||||||
|
// but this should sync 'A' as it's invalid!
|
||||||
|
unchangedElements: ["C"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a0" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
{ id: "D", index: "a1" },
|
||||||
|
{ id: "E", index: "a2" },
|
||||||
|
],
|
||||||
|
movedElements: ["B", "D", "E"],
|
||||||
|
expect: {
|
||||||
|
// should not sync 'E'
|
||||||
|
unchangedElements: ["A", "C", "E"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A" },
|
||||||
|
{ id: "B" },
|
||||||
|
{ id: "C", index: "a0" },
|
||||||
|
{ id: "D", index: "a2" },
|
||||||
|
{ id: "E" },
|
||||||
|
{ id: "F", index: "a3" },
|
||||||
|
{ id: "G" },
|
||||||
|
{ id: "H", index: "a1" },
|
||||||
|
{ id: "I", index: "a2" },
|
||||||
|
{ id: "J" },
|
||||||
|
],
|
||||||
|
movedElements: ["A", "B", "D", "E", "F", "G", "J"],
|
||||||
|
expect: {
|
||||||
|
// should not sync 'D' and 'F'
|
||||||
|
unchangedElements: ["C", "D", "F"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync when fractional index is not defined", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [{ id: "A" }],
|
||||||
|
movedElements: ["A"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [{ id: "A" }],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync when fractional indices are duplicated", () => {
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a1" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a1" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync when a fractional index is out of order", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a2" },
|
||||||
|
{ id: "B", index: "a1" },
|
||||||
|
],
|
||||||
|
movedElements: ["B"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a2" },
|
||||||
|
{ id: "B", index: "a1" },
|
||||||
|
],
|
||||||
|
movedElements: ["A"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["B"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a2" },
|
||||||
|
{ id: "B", index: "a1" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync when fractional indices are out of order", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a3" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
],
|
||||||
|
movedElements: ["B", "C"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a3" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync when incorrect fractional index is in between correct ones ", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a0" },
|
||||||
|
{ id: "C", index: "a2" },
|
||||||
|
],
|
||||||
|
movedElements: ["B"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "C"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a0" },
|
||||||
|
{ id: "C", index: "a2" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "C"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync when incorrect fractional index is on top and duplicated below", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
],
|
||||||
|
movedElements: ["C"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "B"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "B"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync when given a mix of duplicate / invalid indices", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a0" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
{ id: "D", index: "a1" },
|
||||||
|
{ id: "E", index: "a2" },
|
||||||
|
],
|
||||||
|
movedElements: ["C", "D", "E"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "B"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a0" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
{ id: "D", index: "a1" },
|
||||||
|
{ id: "E", index: "a2" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "B"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync when given a mix of undefined / invalid indices", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A" },
|
||||||
|
{ id: "B" },
|
||||||
|
{ id: "C", index: "a0" },
|
||||||
|
{ id: "D", index: "a2" },
|
||||||
|
{ id: "E" },
|
||||||
|
{ id: "F", index: "a3" },
|
||||||
|
{ id: "G" },
|
||||||
|
{ id: "H", index: "a1" },
|
||||||
|
{ id: "I", index: "a2" },
|
||||||
|
{ id: "J" },
|
||||||
|
],
|
||||||
|
movedElements: ["A", "B", "E", "G", "H", "I", "J"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["C", "D", "F"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A" },
|
||||||
|
{ id: "B" },
|
||||||
|
{ id: "C", index: "a0" },
|
||||||
|
{ id: "D", index: "a2" },
|
||||||
|
{ id: "E" },
|
||||||
|
{ id: "F", index: "a3" },
|
||||||
|
{ id: "G" },
|
||||||
|
{ id: "H", index: "a1" },
|
||||||
|
{ id: "I", index: "a2" },
|
||||||
|
{ id: "J" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["C", "D", "F"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should generate fractions for explicitly moved elements", () => {
|
||||||
|
describe("should generate a fraction between 'A' and 'C'", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
// doing actual fractions, without jitter 'a1' becomes 'a1V'
|
||||||
|
// as V is taken as the charset's middle-right value
|
||||||
|
{ id: "B", index: "a1" },
|
||||||
|
{ id: "C", index: "a2" },
|
||||||
|
],
|
||||||
|
movedElements: ["B"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "C"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a1" },
|
||||||
|
{ id: "C", index: "a2" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
// as above, B will become fractional
|
||||||
|
unchangedElements: ["A", "C"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should generate fractions given duplicated indices", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a01" },
|
||||||
|
{ id: "B", index: "a01" },
|
||||||
|
{ id: "C", index: "a01" },
|
||||||
|
{ id: "D", index: "a01" },
|
||||||
|
{ id: "E", index: "a02" },
|
||||||
|
{ id: "F", index: "a02" },
|
||||||
|
{ id: "G", index: "a02" },
|
||||||
|
],
|
||||||
|
movedElements: ["B", "C", "D", "E", "F"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "G"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a01" },
|
||||||
|
{ id: "B", index: "a01" },
|
||||||
|
{ id: "C", index: "a01" },
|
||||||
|
{ id: "D", index: "a01" },
|
||||||
|
{ id: "E", index: "a02" },
|
||||||
|
{ id: "F", index: "a02" },
|
||||||
|
{ id: "G", index: "a02" },
|
||||||
|
],
|
||||||
|
movedElements: ["A", "C", "D", "E", "G"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["B", "F"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a01" },
|
||||||
|
{ id: "B", index: "a01" },
|
||||||
|
{ id: "C", index: "a01" },
|
||||||
|
{ id: "D", index: "a01" },
|
||||||
|
{ id: "E", index: "a02" },
|
||||||
|
{ id: "F", index: "a02" },
|
||||||
|
{ id: "G", index: "a02" },
|
||||||
|
],
|
||||||
|
movedElements: ["B", "C", "D", "F", "G"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "E"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a01" },
|
||||||
|
{ id: "B", index: "a01" },
|
||||||
|
{ id: "C", index: "a01" },
|
||||||
|
{ id: "D", index: "a01" },
|
||||||
|
{ id: "E", index: "a02" },
|
||||||
|
{ id: "F", index: "a02" },
|
||||||
|
{ id: "G", index: "a02" },
|
||||||
|
],
|
||||||
|
expect: {
|
||||||
|
// notice fallback considers first item (E) as a valid one
|
||||||
|
unchangedElements: ["A", "E"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should be able to sync 20K invalid indices", () => {
|
||||||
|
const length = 20_000;
|
||||||
|
|
||||||
|
describe("should sync all empty indices", () => {
|
||||||
|
const elements = Array.from({ length }).map((_, index) => ({
|
||||||
|
id: `A_${index}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
// elements without fractional index
|
||||||
|
elements,
|
||||||
|
movedElements: Array.from({ length }).map((_, index) => `A_${index}`),
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
// elements without fractional index
|
||||||
|
elements,
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync all but last index given a growing array of indices", () => {
|
||||||
|
let lastIndex: string | null = null;
|
||||||
|
|
||||||
|
const elements = Array.from({ length }).map((_, index) => {
|
||||||
|
// going up from 'a0'
|
||||||
|
lastIndex = generateKeyBetween(lastIndex, null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `A_${index}`,
|
||||||
|
// assigning the last generated index, so sync can go down from there
|
||||||
|
// without jitter lastIndex is 'c4BZ' for 20000th element
|
||||||
|
index: index === length - 1 ? lastIndex : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const movedElements = Array.from({ length }).map(
|
||||||
|
(_, index) => `A_${index}`,
|
||||||
|
);
|
||||||
|
// remove last element
|
||||||
|
movedElements.pop();
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements,
|
||||||
|
movedElements,
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [`A_${length - 1}`],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements,
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [`A_${length - 1}`],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should sync all but first index given a declining array of indices", () => {
|
||||||
|
let lastIndex: string | null = null;
|
||||||
|
|
||||||
|
const elements = Array.from({ length }).map((_, index) => {
|
||||||
|
// going down from 'a0'
|
||||||
|
lastIndex = generateKeyBetween(null, lastIndex);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `A_${index}`,
|
||||||
|
// without jitter lastIndex is 'XvoR' for 20000th element
|
||||||
|
index: lastIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const movedElements = Array.from({ length }).map(
|
||||||
|
(_, index) => `A_${index}`,
|
||||||
|
);
|
||||||
|
// remove first element
|
||||||
|
movedElements.shift();
|
||||||
|
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements,
|
||||||
|
movedElements,
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [`A_0`],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testInvalidIndicesSync({
|
||||||
|
elements,
|
||||||
|
expect: {
|
||||||
|
unchangedElements: [`A_0`],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should automatically fallback to fixing all invalid indices", () => {
|
||||||
|
describe("should fallback to syncing duplicated indices when moved elements are empty", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a1" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
],
|
||||||
|
// the validation will throw as nothing was synced
|
||||||
|
// therefore it will lead to triggering the fallback and fixing all invalid indices
|
||||||
|
movedElements: [],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should fallback to syncing undefined / invalid indices when moved elements are empty", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B" },
|
||||||
|
{ id: "C", index: "a0" },
|
||||||
|
],
|
||||||
|
// since elements are invalid, this will fail the validation
|
||||||
|
// leading to fallback fixing "B" and "C"
|
||||||
|
movedElements: [],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should fallback to syncing unordered indices when moved element is invalid", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a1" },
|
||||||
|
{ id: "B", index: "a2" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
],
|
||||||
|
movedElements: ["A"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "B"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should fallback when trying to generate an index in between unordered elements", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a2" },
|
||||||
|
{ id: "B" },
|
||||||
|
{ id: "C", index: "a1" },
|
||||||
|
],
|
||||||
|
// 'B' is invalid, but so is 'C', which was not marked as moved
|
||||||
|
// therefore it will try to generate a key between 'a2' and 'a1'
|
||||||
|
// which it cannot do, thus will throw during generation and automatically fallback
|
||||||
|
movedElements: ["B"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should fallback when trying to generate an index in between duplicate indices", () => {
|
||||||
|
testMovedIndicesSync({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", index: "a01" },
|
||||||
|
{ id: "B" },
|
||||||
|
{ id: "C" },
|
||||||
|
{ id: "D", index: "a01" },
|
||||||
|
{ id: "E", index: "a01" },
|
||||||
|
{ id: "F", index: "a01" },
|
||||||
|
{ id: "G" },
|
||||||
|
{ id: "I", index: "a03" },
|
||||||
|
{ id: "H" },
|
||||||
|
],
|
||||||
|
// missed "E" therefore upper bound for 'B' is a01, while lower bound is 'a02'
|
||||||
|
// therefore, similarly to above, it will fail during key generation and lead to fallback
|
||||||
|
movedElements: ["B", "C", "D", "F", "G", "H"],
|
||||||
|
expect: {
|
||||||
|
unchangedElements: ["A", "I"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function testMovedIndicesSync(args: {
|
||||||
|
elements: { id: string; index?: string }[];
|
||||||
|
movedElements: string[];
|
||||||
|
expect: {
|
||||||
|
unchangedElements: string[];
|
||||||
|
validInput?: true;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const [elements, movedElements] = prepareArguments(
|
||||||
|
args.elements,
|
||||||
|
args.movedElements,
|
||||||
|
);
|
||||||
|
const expectUnchangedElements = arrayToMap(
|
||||||
|
args.expect.unchangedElements.map((x) => ({ id: x })),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should sync invalid indices of moved elements or fallback",
|
||||||
|
elements,
|
||||||
|
movedElements,
|
||||||
|
expectUnchangedElements,
|
||||||
|
args.expect.validInput,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testInvalidIndicesSync(args: {
|
||||||
|
elements: { id: string; index?: string }[];
|
||||||
|
expect: {
|
||||||
|
unchangedElements: string[];
|
||||||
|
validInput?: true;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const [elements] = prepareArguments(args.elements);
|
||||||
|
const expectUnchangedElements = arrayToMap(
|
||||||
|
args.expect.unchangedElements.map((x) => ({ id: x })),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should sync invalid indices of all elements",
|
||||||
|
elements,
|
||||||
|
undefined,
|
||||||
|
expectUnchangedElements,
|
||||||
|
args.expect.validInput,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareArguments(
|
||||||
|
elementsLike: { id: string; index?: string }[],
|
||||||
|
movedElementsIds?: string[],
|
||||||
|
): [ExcalidrawElement[], Map<string, ExcalidrawElement> | undefined] {
|
||||||
|
const elements = elementsLike.map((x) =>
|
||||||
|
API.createElement({ id: x.id, index: x.index as FractionalIndex }),
|
||||||
|
);
|
||||||
|
const movedMap = arrayToMap(movedElementsIds || []);
|
||||||
|
const movedElements = movedElementsIds
|
||||||
|
? arrayToMap(elements.filter((x) => movedMap.has(x.id)))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return [elements, movedElements];
|
||||||
|
}
|
||||||
|
|
||||||
|
function test(
|
||||||
|
name: string,
|
||||||
|
elements: ExcalidrawElement[],
|
||||||
|
movedElements: Map<string, ExcalidrawElement> | undefined,
|
||||||
|
expectUnchangedElements: Map<string, { id: string }>,
|
||||||
|
expectValidInput?: boolean,
|
||||||
|
) {
|
||||||
|
it(name, () => {
|
||||||
|
// ensure the input is invalid (unless the flag is on)
|
||||||
|
if (!expectValidInput) {
|
||||||
|
expect(() =>
|
||||||
|
validateFractionalIndices(elements.map((x) => x.index)),
|
||||||
|
).toThrowError(InvalidFractionalIndexError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone due to mutation
|
||||||
|
const clonedElements = elements.map((x) => deepCopyElement(x));
|
||||||
|
|
||||||
|
// act
|
||||||
|
const syncedElements = movedElements
|
||||||
|
? syncMovedIndices(clonedElements, movedElements)
|
||||||
|
: syncInvalidIndices(clonedElements);
|
||||||
|
|
||||||
|
expect(syncedElements.length).toBe(elements.length);
|
||||||
|
expect(() =>
|
||||||
|
validateFractionalIndices(syncedElements.map((x) => x.index)),
|
||||||
|
).not.toThrowError(InvalidFractionalIndexError);
|
||||||
|
|
||||||
|
syncedElements.forEach((synced, index) => {
|
||||||
|
const element = elements[index];
|
||||||
|
// ensure the order hasn't changed
|
||||||
|
expect(synced.id).toBe(element.id);
|
||||||
|
|
||||||
|
if (expectUnchangedElements.has(synced.id)) {
|
||||||
|
// ensure we didn't mutate where we didn't want to mutate
|
||||||
|
expect(synced.index).toBe(elements[index].index);
|
||||||
|
expect(synced.version).toBe(elements[index].version);
|
||||||
|
} else {
|
||||||
|
expect(synced.index).not.toBe(elements[index].index);
|
||||||
|
// ensure we mutated just once, even with fallback triggered
|
||||||
|
expect(synced.version).toBe(elements[index].version + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -103,6 +103,7 @@ export class API {
|
||||||
id?: string;
|
id?: string;
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
frameId?: ExcalidrawElement["id"] | null;
|
frameId?: ExcalidrawElement["id"] | null;
|
||||||
|
index?: ExcalidrawElement["index"];
|
||||||
groupIds?: string[];
|
groupIds?: string[];
|
||||||
// generic element props
|
// generic element props
|
||||||
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
||||||
|
@ -170,6 +171,7 @@ export class API {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
frameId: rest.frameId ?? null,
|
frameId: rest.frameId ?? null,
|
||||||
|
index: rest.index ?? null,
|
||||||
angle: rest.angle ?? 0,
|
angle: rest.angle ?? 0,
|
||||||
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
|
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|
|
@ -211,10 +211,11 @@ describe("library menu", () => {
|
||||||
const latestLibrary = await h.app.library.getLatestLibrary();
|
const latestLibrary = await h.app.library.getLatestLibrary();
|
||||||
expect(latestLibrary.length).toBeGreaterThan(0);
|
expect(latestLibrary.length).toBeGreaterThan(0);
|
||||||
expect(latestLibrary.length).toBe(libraryItems.length);
|
expect(latestLibrary.length).toBe(libraryItems.length);
|
||||||
expect(latestLibrary[0].elements).toEqual(libraryItems[0].elements);
|
const { versionNonce, ...strippedElement } = libraryItems[0]?.elements[0]; // stripped due to mutations
|
||||||
|
expect(latestLibrary[0].elements).toEqual([
|
||||||
|
expect.objectContaining(strippedElement),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -562,7 +562,7 @@ describe("regression tests", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adjusts z order when grouping", () => {
|
it("adjusts z order when grouping", () => {
|
||||||
const positions = [];
|
const positions: number[][] = [];
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down(10, 10);
|
mouse.down(10, 10);
|
||||||
|
|
|
@ -107,7 +107,7 @@ exports[`exportToSvg > with elements that have a link 1`] = `
|
||||||
exports[`exportToSvg > with exportEmbedScene 1`] = `
|
exports[`exportToSvg > with exportEmbedScene 1`] = `
|
||||||
"
|
"
|
||||||
<!-- svg-source:excalidraw -->
|
<!-- svg-source:excalidraw -->
|
||||||
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1SPW/CMFx1MDAxMN35XHUwMDE1UbpcIuFAIJSNlqpCqtqBXHUwMDAxqVVcdTAwMDdcdTAwMTNfiFx1MDAxNcdcdTAwMGW2w4dcdTAwMTD/vbaBuETMnerBkt+9d3e+e8dOXHUwMDEwhPpQQThcdELYp5hRXCLxLuxafFx1MDAwYlJRwU2o795K1DJ1zFxc62rS6zFhXHUwMDA0uVB6MkBcYp1FwKBcdTAwMDSulaF9mXdcdTAwMTBcdTAwMWPdbVwilFjpdik3XHUwMDFm06ygnPQ3aZm8zaavn07qSHvDiaO4eVx1MDAxZmz1QdK8d5To3GBcdTAwMTFCXHKWXHUwMDAzXee6XHUwMDA1Yr5mtlePKC1FXHUwMDAxz4JcdGlcdTAwMWJ5QO740iucXHUwMDE2aylqTjwnXHUwMDFhYrzKPCejjC30gZ2ngNO8llx1MDAxMLYqLK8ttvBGp4SZsleZkuucg1I3XHUwMDFhUeGU6kPrV7a/ak7cdL99V1x1MDAxMpcwt+PlNWO/XHUwMDEzc3JJfFx1MDAxM1BcdTAwMDDEJY6j0TB5ROMm4ldcdTAwMWX1UVx1MDAxYn1cdTAwMTfcrT+KxmOE4n4yalx1MDAxOFTNzOK1S5thpsBP1Tbx4k1x00hdXHUwMDExfFx1MDAxNvmPM8qLNs9cdTAwMTituJP7alxcQnEpOFx0XHUwMDFkfur+2+7fdn9hO2CMVlxuLrYzt1x1MDAxYk2Iq2qhTX5DOZsw3FLYPd1Zc+aO1TvT2jWDbfZ46px+XHUwMDAwcU5t0CJ9<!-- payload-end -->
|
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1Sy27CMFx1MDAxMLzzXHUwMDE1kXtFwuFdbrRUXHUwMDE1UtVcdTAwMWU4ILXqwcRcdTAwMWJixdjBdnhcYvHvtVxyxFx1MDAxMPFcdTAwMDFVVVx1MDAxZizt7M7uejyHRlx1MDAxNCGzL1x1MDAwMI1cIlx1MDAwNLuEcEZcdTAwMTXZoqbDN6A0k8Km2j7WslSJr8yMKUatXHUwMDE2l5aQSW1GXHUwMDFkjPGJXHUwMDA0XHUwMDFjViCMtmVfNo6ig79thlFH3czV+mOc5kzQ9jpZXHLeJuPXT0/1RTtb0427Vbx30zuDKt4yajKLxVx1MDAxOFdYXHUwMDA2bJmZXHUwMDFhSMSSu11cdTAwMDOijZI5PEsulVvkXHUwMDAx+1x1MDAxM0YvSJIvlSxcdTAwMDVccjVxj5BFXHUwMDFhalLG+czs+UlcdTAwMDWSZKVcdTAwMDJUmzC/rFjDK56WVuXAsiOXmVx1MDAwMK1vOLIgXHQz+9qr3H7FlHp1v8NWiqxg6uRcdTAwMTUl59eNXHUwMDA1PTe+SVjtwVx0jcjV8zVcdTAwMDD107pxvzd4xMMqXHUwMDEzfFx1MDAxMLdxXHUwMDFkfZfCe1wijodDjLvtQT+M0Vx1MDAxM+tcdTAwMDbj26aEa1xiUrvNXoJTbrYrXHUwMDBiSk6koFx1MDAwNmdcIq/XWffld3pf3ExcdTAwMTlZSUGRx4/Nfy/+di/Gf9eLwDkrNJy9aG+vXHUwMDE3XCJFMTO2vy05OVx1MDAxM21cdTAwMThsn+78feqP43snu79cdTAwMDe37OHYOP5cdTAwMDBcdTAwMDLtdtMifQ==<!-- payload-end -->
|
||||||
<defs>
|
<defs>
|
||||||
<style class="style-fonts">
|
<style class="style-fonts">
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|
|
@ -15,8 +15,18 @@ describe("exportToSvg", () => {
|
||||||
const ELEMENT_HEIGHT = 100;
|
const ELEMENT_HEIGHT = 100;
|
||||||
const ELEMENT_WIDTH = 100;
|
const ELEMENT_WIDTH = 100;
|
||||||
const ELEMENTS = [
|
const ELEMENTS = [
|
||||||
{ ...diamondFixture, height: ELEMENT_HEIGHT, width: ELEMENT_WIDTH },
|
{
|
||||||
{ ...ellipseFixture, height: ELEMENT_HEIGHT, width: ELEMENT_WIDTH },
|
...diamondFixture,
|
||||||
|
height: ELEMENT_HEIGHT,
|
||||||
|
width: ELEMENT_WIDTH,
|
||||||
|
index: "a0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...ellipseFixture,
|
||||||
|
height: ELEMENT_HEIGHT,
|
||||||
|
width: ELEMENT_WIDTH,
|
||||||
|
index: "a1",
|
||||||
|
},
|
||||||
] as NonDeletedExcalidrawElement[];
|
] as NonDeletedExcalidrawElement[];
|
||||||
|
|
||||||
const DEFAULT_OPTIONS = {
|
const DEFAULT_OPTIONS = {
|
||||||
|
|
|
@ -46,6 +46,7 @@ const populateElements = (
|
||||||
height?: number;
|
height?: number;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
frameId?: ExcalidrawFrameElement["id"];
|
frameId?: ExcalidrawFrameElement["id"];
|
||||||
|
index?: ExcalidrawElement["index"];
|
||||||
}[],
|
}[],
|
||||||
appState?: Partial<AppState>,
|
appState?: Partial<AppState>,
|
||||||
) => {
|
) => {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import {
|
||||||
ExcalidrawFrameLikeElement,
|
ExcalidrawFrameLikeElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
ExcalidrawIframeLikeElement,
|
ExcalidrawIframeLikeElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { Action } from "./actions/types";
|
import { Action } from "./actions/types";
|
||||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
|
@ -415,7 +416,7 @@ export type OnUserFollowedPayload = {
|
||||||
|
|
||||||
export interface ExcalidrawProps {
|
export interface ExcalidrawProps {
|
||||||
onChange?: (
|
onChange?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { bumpVersion } from "./element/mutateElement";
|
|
||||||
import { isFrameLikeElement } from "./element/typeChecks";
|
import { isFrameLikeElement } from "./element/typeChecks";
|
||||||
import { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./element/types";
|
import { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./element/types";
|
||||||
|
import { syncMovedIndices } from "./fractionalIndex";
|
||||||
import { getElementsInGroup } from "./groups";
|
import { getElementsInGroup } from "./groups";
|
||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import Scene from "./scene/Scene";
|
import Scene from "./scene/Scene";
|
||||||
|
@ -234,9 +234,9 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
|
||||||
) => {
|
) => {
|
||||||
return indices.reduce((acc, index) => {
|
return indices.reduce((acc, index) => {
|
||||||
const element = elements[index];
|
const element = elements[index];
|
||||||
acc[element.id] = element;
|
acc.set(element.id, element);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, ExcalidrawElement>);
|
}, new Map<string, ExcalidrawElement>());
|
||||||
};
|
};
|
||||||
|
|
||||||
const shiftElementsByOne = (
|
const shiftElementsByOne = (
|
||||||
|
@ -246,6 +246,7 @@ const shiftElementsByOne = (
|
||||||
) => {
|
) => {
|
||||||
const indicesToMove = getIndicesToMove(elements, appState);
|
const indicesToMove = getIndicesToMove(elements, appState);
|
||||||
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
||||||
|
|
||||||
let groupedIndices = toContiguousGroups(indicesToMove);
|
let groupedIndices = toContiguousGroups(indicesToMove);
|
||||||
|
|
||||||
if (direction === "right") {
|
if (direction === "right") {
|
||||||
|
@ -312,12 +313,9 @@ const shiftElementsByOne = (
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return elements.map((element) => {
|
syncMovedIndices(elements, targetElementsMap);
|
||||||
if (targetElementsMap[element.id]) {
|
|
||||||
return bumpVersion(element);
|
return elements;
|
||||||
}
|
|
||||||
return element;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const shiftElementsToEnd = (
|
const shiftElementsToEnd = (
|
||||||
|
@ -383,14 +381,11 @@ const shiftElementsToEnd = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetElements = Object.values(targetElementsMap).map((element) => {
|
const targetElements = Array.from(targetElementsMap.values());
|
||||||
return bumpVersion(element);
|
|
||||||
});
|
|
||||||
|
|
||||||
const leadingElements = elements.slice(0, leadingIndex);
|
const leadingElements = elements.slice(0, leadingIndex);
|
||||||
const trailingElements = elements.slice(trailingIndex + 1);
|
const trailingElements = elements.slice(trailingIndex + 1);
|
||||||
|
const nextElements =
|
||||||
return direction === "left"
|
direction === "left"
|
||||||
? [
|
? [
|
||||||
...leadingElements,
|
...leadingElements,
|
||||||
...targetElements,
|
...targetElements,
|
||||||
|
@ -403,6 +398,10 @@ const shiftElementsToEnd = (
|
||||||
...targetElements,
|
...targetElements,
|
||||||
...trailingElements,
|
...trailingElements,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
syncMovedIndices(nextElements, targetElementsMap);
|
||||||
|
|
||||||
|
return nextElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
function shiftElementsAccountingForFrames(
|
function shiftElementsAccountingForFrames(
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -6513,6 +6513,12 @@ fraction.js@^4.2.0:
|
||||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||||
|
|
||||||
|
|
||||||
|
fractional-indexing@3.2.0:
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fractional-indexing/-/fractional-indexing-3.2.0.tgz#1193e63d54ff4e0cbe0c79a9ed6cfbab25d91628"
|
||||||
|
integrity sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==
|
||||||
|
|
||||||
fs-constants@^1.0.0:
|
fs-constants@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
|
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
|
||||||
|
@ -9375,9 +9381,9 @@ sass@1.51.0:
|
||||||
source-map-js ">=0.6.2 <2.0.0"
|
source-map-js ">=0.6.2 <2.0.0"
|
||||||
|
|
||||||
sass@^1.7.3:
|
sass@^1.7.3:
|
||||||
version "1.69.5"
|
version "1.69.6"
|
||||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.5.tgz#23e18d1c757a35f2e52cc81871060b9ad653dfde"
|
resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.6.tgz#88ae1f93facc46d2da9b0bdd652d65068bcfa397"
|
||||||
integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==
|
integrity sha512-qbRr3k9JGHWXCvZU77SD2OTwUlC+gNT+61JOLcmLm+XqH4h/5D+p4IIsxvpkB89S9AwJOyb5+rWNpIucaFxSFQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar ">=3.0.0 <4.0.0"
|
chokidar ">=3.0.0 <4.0.0"
|
||||||
immutable "^4.0.0"
|
immutable "^4.0.0"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue