fix: reconciliation of server updates & refactor restore (#10597)

This commit is contained in:
David Luzar
2026-01-04 15:13:38 +01:00
committed by GitHub
parent 0586fc138c
commit 6c908553a9
12 changed files with 342 additions and 259 deletions
+40 -8
View File
@@ -48,7 +48,11 @@ import {
youtubeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import {
bumpElementVersions,
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx";
@@ -105,8 +109,8 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
exportToBackend,
getCollaborationLinkData,
importFromBackend,
isCollaborationLink,
loadScene,
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
@@ -224,9 +228,20 @@ const initializeScene = async (opts: {
const localDataState = importFromLocalStorage();
let scene: RestoredDataState & {
let scene: Omit<
RestoredDataState,
// we're not storing files in the scene database/localStorage, and instead
// fetch them async from a different store
"files"
> & {
scrollToContent?: boolean;
} = await loadScene(null, null, localDataState);
} = {
elements: restoreElements(localDataState?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
};
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
@@ -240,11 +255,26 @@ const initializeScene = async (opts: {
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
scene = await loadScene(
const imported = await importFromBackend(
jsonBackendMatch[1],
jsonBackendMatch[2],
localDataState,
);
scene = {
elements: bumpElementVersions(
restoreElements(imported.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
localDataState?.elements,
),
appState: restoreAppState(
imported.appState,
// local appState when importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
localDataState?.appState,
),
};
}
scene.scrollToContent = true;
if (!roomLinkData) {
@@ -496,8 +526,10 @@ const ExcalidrawWrapper = () => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
elements: restoreElements(data.scene.elements, null, {
repairBindings: true,
}),
appState: restoreAppState(data.scene.appState, null),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}
+28 -11
View File
@@ -6,7 +6,7 @@ import {
reconcileElements,
} from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, EVENT } from "@excalidraw/common";
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
@@ -29,6 +29,8 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
@@ -311,6 +313,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[],
) => {
syncableElements = cloneJSON(syncableElements);
try {
const storedElements = await saveToFirebase(
this.portal,
@@ -579,7 +582,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
case WS_SUBTYPES.INIT: {
if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const remoteElements = toBrandedType<
readonly RemoteExcalidrawElement[]
>(decryptedData.payload.elements);
const reconciledElements =
this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements);
@@ -593,7 +598,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate(
this._reconcileElements(decryptedData.payload.elements),
this._reconcileElements(
toBrandedType<readonly RemoteExcalidrawElement[]>(
decryptedData.payload.elements,
),
),
);
break;
case WS_SUBTYPES.MOUSE_LOCATION: {
@@ -742,20 +751,28 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
private _reconcileElements = (
remoteElements: readonly ExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements(
const existingElements = this.getSceneElementsIncludingDeleted();
// NOTE ideally we restore _after_ reconciliation but we can't do that
// as we'd regenerate even elements such as appState.newElement which would
// break the state
remoteElements = restoreElements(remoteElements, existingElements);
let reconciledElements = reconcileElements(
existingElements,
remoteElements,
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
);
const reconciledElements = reconcileElements(
localElements,
restoredRemoteElements as RemoteExcalidrawElement[],
appState,
);
reconciledElements = bumpElementVersions(
reconciledElements,
existingElements,
);
// Avoid broadcasting to the rest of the collaborators the scene
// we just received!
// Note: this needs to be set before updating the scene as it
+2 -2
View File
@@ -1,5 +1,5 @@
import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES } from "@excalidraw/common";
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode";
import {
encryptData,
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements;
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
};
export const loadFromFirebase = async (
+3 -43
View File
@@ -8,7 +8,6 @@ import {
IV_LENGTH_BYTES,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
SCENE_INIT: {
type: WS_SUBTYPES.INIT;
payload: {
elements: readonly ExcalidrawElement[];
elements: readonly OrderedExcalidrawElement[];
};
};
SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE;
payload: {
elements: readonly ExcalidrawElement[];
elements: readonly OrderedExcalidrawElement[];
};
};
MOUSE_LOCATION: {
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
};
};
const importFromBackend = async (
export const importFromBackend = async (
id: string,
decryptionKey: string,
): Promise<ImportedDataState> => {
@@ -242,45 +241,6 @@ const importFromBackend = async (
}
};
export const loadScene = async (
id: string | null,
privateKey: string | null,
// Supply local state even if importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
// Non-optional so we don't forget to pass it even if `undefined`.
localDataState: ImportedDataState | undefined | null,
) => {
let data;
if (id != null && privateKey != null) {
// the private key is used to decrypt the content from the server, take
// extra care not to leak it
data = restore(
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
}
return {
elements: data.elements,
appState: data.appState,
// note: this will always be empty because we're not storing files
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
};
};
type ExportToBackendResult =
| { url: null; errorMessage: string }
| { url: string; errorMessage: null };
+47 -17
View File
@@ -1157,39 +1157,69 @@ export const normalizeEOL = (str: string) => {
};
// -----------------------------------------------------------------------------
type HasBrand<T> = {
export type HasBrand<T> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
}[keyof T];
type RemoveAllBrands<T> = HasBrand<T> extends true
? {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
[K in keyof T as K extends `~brand~${infer _}` | "_brand"
? never
: K]: T[K];
}
: never;
: T;
// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
// currently does not cover all types (e.g. tuples, promises...)
type Unbrand<T> = T extends Map<infer E, infer F>
? Map<E, F>
// For accepting values - uses loose matching for branded types
// Preserves readonly modifier: mutable array requires mutable input
type UnbrandForValue<T> = T extends Map<infer E, infer F>
? Map<UnbrandForValue<E>, UnbrandForValue<F>>
: T extends Set<infer E>
? Set<E>
: T extends Array<infer E>
? Array<E>
? Set<UnbrandForValue<E>>
: T extends readonly any[]
? T extends any[]
? unknown[] // mutable array - require mutable input
: readonly unknown[] // readonly array - accept readonly input
: RemoveAllBrands<T>;
// For return types - preserves array element unbranding
export type Unbrand<T> = T extends Map<infer E, infer F>
? Map<Unbrand<E>, Unbrand<F>>
: T extends Set<infer E>
? Set<Unbrand<E>>
: T extends readonly (infer E)[]
? Array<Unbrand<E>>
: RemoveAllBrands<T>;
export type CombineBrands<BrandedType, CurrentType> =
BrandedType extends readonly (infer BE)[]
? CurrentType extends readonly (infer CE)[]
? Array<CE & BE>
: CurrentType & BrandedType
: CurrentType & BrandedType;
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
? T[]
: HasBrand<T> extends true
? CombineBrands<T, Required>[]
: Required[];
/**
* Makes type into a branded type, ensuring that value is assignable to
* the base ubranded type. Optionally you can explicitly supply current value
* the base unbranded type. Optionally you can explicitly supply current value
* type to combine both (useful for composite branded types. Make sure you
* compose branded types which are not composite themselves.)
*/
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
value: Unbrand<BrandedType>,
) => {
return value as CurrentType & BrandedType;
};
export function toBrandedType<BrandedType>(
value: UnbrandForValue<BrandedType>,
): BrandedType;
export function toBrandedType<BrandedType, CurrentType>(
value: CurrentType,
): CombineBrands<BrandedType, CurrentType>;
export function toBrandedType(value: unknown) {
return value;
}
// -----------------------------------------------------------------------------
+19 -16
View File
@@ -346,7 +346,7 @@ import {
import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore";
import { restoreAppState, restoreElements } from "../data/restore";
import { getCenter, getDistance } from "../gesture";
import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
@@ -2701,46 +2701,47 @@ class App extends React.Component<AppProps, AppState> {
},
};
}
const scene = restore(initialData, null, null, {
const restoredElements = restoreElements(initialData?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
const activeTool = scene.appState.activeTool;
let restoredAppState = restoreAppState(initialData?.appState, null);
const activeTool = restoredAppState.activeTool;
if (!scene.appState.preferredSelectionTool.initialized) {
scene.appState.preferredSelectionTool = {
if (!restoredAppState.preferredSelectionTool.initialized) {
restoredAppState.preferredSelectionTool = {
type:
this.editorInterface.formFactor === "phone" ? "lasso" : "selection",
initialized: true,
};
}
scene.appState = {
...scene.appState,
theme: this.props.theme || scene.appState.theme,
restoredAppState = {
...restoredAppState,
theme: this.props.theme || restoredAppState.theme,
// we're falling back to current (pre-init) state when deciding
// whether to open the library, to handle a case where we
// update the state outside of initialData (e.g. when loading the app
// with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
openSidebar: restoredAppState?.openSidebar || this.state.openSidebar,
activeTool:
activeTool.type === "image" ||
activeTool.type === "lasso" ||
activeTool.type === "selection"
? {
...activeTool,
type: scene.appState.preferredSelectionTool.type,
type: restoredAppState.preferredSelectionTool.type,
}
: scene.appState.activeTool,
: restoredAppState.activeTool,
isLoading: false,
toast: this.state.toast,
};
if (initialData?.scrollToContent) {
scene.appState = {
...scene.appState,
...calculateScrollCenter(scene.elements, {
...scene.appState,
restoredAppState = {
...restoredAppState,
...calculateScrollCenter(restoredElements, {
...restoredAppState,
width: this.state.width,
height: this.state.height,
offsetTop: this.state.offsetTop,
@@ -2752,7 +2753,9 @@ class App extends React.Component<AppProps, AppState> {
this.resetStore();
this.resetHistory();
this.syncActionResult({
...scene,
elements: restoredElements,
appState: restoredAppState,
files: initialData?.files,
captureUpdate: CaptureUpdateAction.NEVER,
});
+16 -15
View File
@@ -19,7 +19,11 @@ import { decodeSvgBase64Payload } from "../scene/export";
import { base64ToString, stringToBase64, toByteString } from "./encode";
import { nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import {
restoreAppState,
restoreElements,
restoreLibraryItems,
} from "./restore";
import type { AppState, DataURL, LibraryItem } from "../types";
@@ -155,10 +159,13 @@ export const loadSceneOrLibraryFromBlob = async (
if (isValidExcalidrawData(data)) {
return {
type: MIME_TYPES.excalidraw,
data: restore(
{
elements: data.elements || [],
appState: {
data: {
elements: restoreElements(data.elements, localElements, {
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(
{
theme: localAppState?.theme,
fileHandle: fileHandle || blob.handle || null,
...cleanAppStateForExport(data.appState || {}),
@@ -166,16 +173,10 @@ export const loadSceneOrLibraryFromBlob = async (
? calculateScrollCenter(data.elements || [], localAppState)
: {}),
},
files: data.files,
},
localAppState,
localElements,
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
),
localAppState,
),
files: data.files || {},
},
};
} else if (isValidLibrary(data)) {
return {
+1 -1
View File
@@ -36,7 +36,7 @@ export const shouldDiscardRemoteElement = (
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce < remote.versionNonce))
local.versionNonce <= remote.versionNonce))
) {
return true;
}
+90 -50
View File
@@ -1,6 +1,7 @@
import { isFiniteNumber, pointFrom } from "@excalidraw/math";
import {
type CombineBrandsIfNeeded,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
@@ -131,13 +132,18 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
element: T,
binding: FixedPointBinding | null,
targetElementsMap: Readonly<ElementsMap>,
localElementsMap: Readonly<ElementsMap> | null | undefined,
/** used for context (arrow bindings) */
existingElementsMap: Readonly<ElementsMap> | null | undefined,
startOrEnd: "start" | "end",
): FixedPointBinding | null => {
if (!binding) {
return null;
}
// ---------------------------------------------------------------------------
// elbow arrows
// ---------------------------------------------------------------------------
if (isElbowArrow(element)) {
const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"]
@@ -150,24 +156,41 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
return fixedPointBinding;
}
// Fallback if the bound element is missing but the binding is at least
// looking like a valid one shape-wise
if (binding.mode && binding.fixedPoint && binding.elementId) {
return {
elementId: binding.elementId,
mode: binding.mode,
fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]),
} as FixedPointBinding | null;
// ---------------------------------------------------------------------------
// simple arrows
// ---------------------------------------------------------------------------
// binding schema v2
// ---------------------------------------------------------------------------
if (binding.mode) {
// if latest binding schema, don't check if binding.elementId exists
// (it's done in a separate pass)
if (binding.elementId) {
return {
elementId: binding.elementId,
mode: binding.mode,
fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]),
} as FixedPointBinding | null;
}
return null;
}
// binding schema v1 (legacy) -> attempt to migrate to v2
// ---------------------------------------------------------------------------
const targetBoundElement =
(targetElementsMap.get(binding.elementId) as ExcalidrawBindableElement) ||
undefined;
const boundElement =
targetBoundElement ||
(localElementsMap?.get(binding.elementId) as ExcalidrawBindableElement) ||
(existingElementsMap?.get(
binding.elementId,
) as ExcalidrawBindableElement) ||
undefined;
const elementsMap = targetBoundElement ? targetElementsMap : localElementsMap;
const elementsMap = targetBoundElement
? targetElementsMap
: existingElementsMap;
// migrating legacy focus point bindings
if (boundElement && elementsMap) {
@@ -296,9 +319,12 @@ const restoreElementWithProperties = <
};
export const restoreElement = (
/** element to be restored */
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
/** all elements to be restored */
targetElementsMap: Readonly<ElementsMap>,
localElementsMap: Readonly<ElementsMap> | null | undefined,
/** used for additional context */
existingElementsMap: Readonly<ElementsMap> | null | undefined,
opts?: {
deleteInvisibleElements?: boolean;
},
@@ -420,14 +446,14 @@ export const restoreElement = (
element as ExcalidrawArrowElement,
element.startBinding,
targetElementsMap,
localElementsMap,
existingElementsMap,
"start",
),
endBinding: repairBinding(
element as ExcalidrawArrowElement,
element.endBinding,
targetElementsMap,
localElementsMap,
existingElementsMap,
"end",
),
startArrowhead,
@@ -588,10 +614,10 @@ const repairFrameMembership = (
}
};
export const restoreElements = (
targetElements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: Readonly<ElementsMapOrArray> | null | undefined,
export const restoreElements = <T extends ExcalidrawElement>(
targetElements: readonly T[] | undefined | null,
/** used for additional context (e.g. repairing arrow bindings) */
existingElements: Readonly<ElementsMapOrArray> | null | undefined,
opts?:
| {
refreshDimensions?: boolean;
@@ -599,11 +625,13 @@ export const restoreElements = (
deleteInvisibleElements?: boolean;
}
| undefined,
): OrderedExcalidrawElement[] => {
): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
const targetElementsMap = arrayToMap(targetElements || []);
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const existingElementsMap = existingElements
? arrayToMap(existingElements)
: null;
const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
@@ -615,21 +643,18 @@ export const restoreElements = (
let migratedElement: ExcalidrawElement | null = restoreElement(
element,
targetElementsMap,
localElementsMap,
existingElementsMap,
{
deleteInvisibleElements: opts?.deleteInvisibleElements,
},
);
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
const localElement = existingElementsMap?.get(element.id);
const shouldMarkAsDeleted =
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
if (
shouldMarkAsDeleted ||
(localElement && localElement.version > migratedElement.version)
) {
if (shouldMarkAsDeleted) {
migratedElement = bumpVersion(migratedElement, localElement?.version);
}
@@ -650,7 +675,10 @@ export const restoreElements = (
);
if (!opts?.repairBindings) {
return restoredElements;
return restoredElements as CombineBrandsIfNeeded<
T,
OrderedExcalidrawElement
>;
}
// repair binding. Mutates elements.
@@ -759,6 +787,41 @@ export const restoreElements = (
};
}
return element;
}) as CombineBrandsIfNeeded<T, OrderedExcalidrawElement>;
};
/**
* When replacing elements that may exist locally, this bumps their versions
* to the local version + 1. Mainly for later reconciliation to work properly.
*
* See https://github.com/excalidraw/excalidraw/issues/3795
*
* Generally use this on editor boundaries (importing from file etc.), though
* it does not apply universally (e.g. we don't want to do this for collab
* updates).
*/
export const bumpElementVersions = <T extends ExcalidrawElement>(
targetElements: readonly T[],
localElements: Readonly<ElementsMapOrArray> | null | undefined,
) => {
const localElementsMap = localElements ? arrayToMap(localElements) : null;
return targetElements.map((element) => {
const localElement = localElementsMap?.get(element.id);
if (
localElement &&
(localElement.version > element.version ||
// same versions but different versionNonce means different edits
// (this often means the element was bumped during restore e.g. due
// to re-indexing, and the original element was modified elsewhere
// and supplied as localElements)
(localElement.version === element.version &&
localElement.versionNonce !== element.versionNonce))
) {
return bumpVersion(element, localElement.version);
}
return element;
});
};
@@ -875,29 +938,6 @@ export const restoreAppState = (
};
};
export const restore = (
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
/**
* Local AppState (`this.state` or initial state from localStorage) so that we
* don't overwrite local state with default values (when values not
* explicitly specified).
* Supply `null` if you can't get access to it.
*/
localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
elementsConfig?: {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteInvisibleElements?: boolean;
},
): RestoredDataState => {
return {
elements: restoreElements(data?.elements, localElements, elementsConfig),
appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {},
};
};
const restoreLibraryItem = (libraryItem: LibraryItem) => {
const elements = restoreElements(
getNonDeletedElements(libraryItem.elements),
-1
View File
@@ -229,7 +229,6 @@ export { isInvisiblySmallElement } from "@excalidraw/element";
export { defaultLang, useI18n, languages } from "./i18n";
export {
restore,
restoreAppState,
restoreElement,
restoreElements,
+83 -82
View File
@@ -34,6 +34,20 @@ describe("restoreElements", () => {
mockSizeHelper.mockRestore();
});
it("basic restoreElements", () => {
const textElement = API.createElement({ type: "text" });
const rectElement = API.createElement({ type: "rectangle" });
const elements = [textElement, rectElement];
const restoredElements = restore.restoreElements(elements, null);
expect(restoredElements.length).toBe(elements.length);
});
it("when imported data state is null it should return an empty array of elements", () => {
const restoredElements = restore.restoreElements(null, null);
expect(restoredElements.length).toBe(0);
});
it("should return empty array when element is null", () => {
expect(restore.restoreElements(null, null)).toStrictEqual([]);
});
@@ -434,12 +448,12 @@ describe("restoreElements", () => {
});
it("bump versions of local duplicate elements when supplied", () => {
const rectangle = API.createElement({ type: "rectangle" });
const rectangle = API.createElement({ type: "rectangle" }); // version=1
const ellipse = API.createElement({ type: "ellipse" });
const rectangle_modified = newElementWith(rectangle, { isDeleted: true });
const rectangle_modified = newElementWith(rectangle, { isDeleted: true }); // version=2
const restoredElements = restore.restoreElements(
[rectangle, ellipse],
const restoredElements = restore.bumpElementVersions(
restore.restoreElements([rectangle, ellipse], null),
[rectangle_modified],
);
@@ -448,7 +462,7 @@ describe("restoreElements", () => {
expect(restoredElements).toEqual([
expect.objectContaining({
id: rectangle.id,
version: rectangle_modified.version + 2,
version: rectangle_modified.version + 1,
}),
expect.objectContaining({
id: ellipse.id,
@@ -456,9 +470,73 @@ describe("restoreElements", () => {
}),
]);
});
it("bump versions of local duplicate elements when supplied even if both have same version", () => {
const rectangle = API.createElement({ type: "rectangle" });
const restored_rectangle_1 = restore.restoreElements([rectangle], null)[0];
const restored_rectangle_2 = restore.restoreElements(
[restored_rectangle_1],
null,
)[0];
// restored rectangle version should be +1 because of re-index
expect(rectangle.version).not.toBe(restored_rectangle_1.version);
// restoring it again shouldn't re-index again
expect(restored_rectangle_1.version).toBe(restored_rectangle_2.version);
expect(restored_rectangle_1.versionNonce).toBe(
restored_rectangle_2.versionNonce,
);
const modified_rectangle_1 = newElementWith(restored_rectangle_1, {
width: 500,
});
const modified_rectangle_2 = newElementWith(restored_rectangle_2, {
width: 600,
});
const restoredElements = restore.bumpElementVersions(
restore.restoreElements([modified_rectangle_1], null),
[modified_rectangle_2],
);
expect(restoredElements[0].id).toBe(rectangle.id);
expect(restoredElements[0].id).toBe(modified_rectangle_1.id);
expect(restoredElements[0].versionNonce).not.toBe(
modified_rectangle_1.versionNonce,
);
expect(restoredElements[0].version).toBe(modified_rectangle_2.version + 1);
});
});
describe("restoreAppState", () => {
it("when appState is null it should return the local app state property", () => {
const stubLocalAppState = getDefaultAppState();
stubLocalAppState.cursorButton = "down";
stubLocalAppState.name = "local app state";
const restoredAppState = restore.restoreAppState(null, stubLocalAppState);
expect(restoredAppState.cursorButton).toBe(stubLocalAppState.cursorButton);
expect(restoredAppState.name).toBe(stubLocalAppState.name);
});
it("when local appState is null but imported app state is supplied", () => {
const stubImportedAppState = getDefaultAppState();
stubImportedAppState.cursorButton = "down";
stubImportedAppState.name = "imported app state";
const importedDataState = {} as ImportedDataState;
importedDataState.appState = stubImportedAppState;
const restoredAppState = restore.restoreAppState(
importedDataState.appState,
null,
);
expect(restoredAppState.cursorButton).toBe("up");
expect(restoredAppState.name).toBe(stubImportedAppState.name);
});
it("should restore with imported data", () => {
const stubImportedAppState = getDefaultAppState();
stubImportedAppState.activeTool.type = "selection";
@@ -638,83 +716,6 @@ describe("restoreAppState", () => {
});
});
describe("restore", () => {
it("when imported data state is null it should return an empty array of elements", () => {
const stubLocalAppState = getDefaultAppState();
const restoredData = restore.restore(null, stubLocalAppState, null);
expect(restoredData.elements.length).toBe(0);
});
it("when imported data state is null it should return the local app state property", () => {
const stubLocalAppState = getDefaultAppState();
stubLocalAppState.cursorButton = "down";
stubLocalAppState.name = "local app state";
const restoredData = restore.restore(null, stubLocalAppState, null);
expect(restoredData.appState.cursorButton).toBe(
stubLocalAppState.cursorButton,
);
expect(restoredData.appState.name).toBe(stubLocalAppState.name);
});
it("when imported data state has elements", () => {
const stubLocalAppState = getDefaultAppState();
const textElement = API.createElement({ type: "text" });
const rectElement = API.createElement({ type: "rectangle" });
const elements = [textElement, rectElement];
const importedDataState = {} as ImportedDataState;
importedDataState.elements = elements;
const restoredData = restore.restore(
importedDataState,
stubLocalAppState,
null,
);
expect(restoredData.elements.length).toBe(elements.length);
});
it("when local app state is null but imported app state is supplied", () => {
const stubImportedAppState = getDefaultAppState();
stubImportedAppState.cursorButton = "down";
stubImportedAppState.name = "imported app state";
const importedDataState = {} as ImportedDataState;
importedDataState.appState = stubImportedAppState;
const restoredData = restore.restore(importedDataState, null, null);
expect(restoredData.appState.cursorButton).toBe("up");
expect(restoredData.appState.name).toBe(stubImportedAppState.name);
});
it("bump versions of local duplicate elements when supplied", () => {
const rectangle = API.createElement({ type: "rectangle" });
const ellipse = API.createElement({ type: "ellipse" });
const rectangle_modified = newElementWith(rectangle, { isDeleted: true });
const restoredData = restore.restore(
{ elements: [rectangle, ellipse] },
null,
[rectangle_modified],
);
expect(restoredData.elements[0].id).toBe(rectangle.id);
expect(restoredData.elements[0].versionNonce).not.toBe(
rectangle.versionNonce,
);
expect(restoredData.elements).toEqual([
expect.objectContaining({ version: rectangle_modified.version + 2 }),
expect.objectContaining({
id: ellipse.id,
version: ellipse.version + 1,
}),
]);
});
});
describe("repairing bindings", () => {
it("should repair container boundElements when repair is true", () => {
const container = API.createElement({
+13 -13
View File
@@ -7,7 +7,10 @@ import {
} from "@excalidraw/excalidraw/clipboard";
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import {
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import {
exportToCanvas as _exportToCanvas,
exportToSvg as _exportToSvg,
@@ -45,12 +48,11 @@ export const exportToCanvas = ({
}: ExportOpts & {
exportPadding?: number;
}) => {
const { elements: restoredElements, appState: restoredAppState } = restore(
{ elements, appState },
null,
null,
{ deleteInvisibleElements: true },
);
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas(
restoredElements,
@@ -176,12 +178,10 @@ export const exportToSvg = async ({
skipInliningFonts?: true;
reuseImages?: boolean;
}): Promise<SVGSVGElement> => {
const { elements: restoredElements, appState: restoredAppState } = restore(
{ elements, appState },
null,
null,
{ deleteInvisibleElements: true },
);
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const exportAppState = {
...restoredAppState,