feat: pass localElements to restore and restoreElement API's and bump versions of duplicate elements on import (#3797)

This commit is contained in:
David Luzar 2021-07-04 22:23:35 +02:00 committed by GitHub
parent 038e9c13dd
commit 097362662d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 164 additions and 61 deletions

View File

@ -201,7 +201,7 @@ export const actionLoadScene = register({
const { const {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
} = await loadFromJSON(appState); } = await loadFromJSON(appState, elements);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,

View File

@ -654,7 +654,7 @@ class App extends React.Component<AppProps, AppState> {
const fileHandle = launchParams.files[0]; const fileHandle = launchParams.files[0];
const blob: Blob = await fileHandle.getFile(); const blob: Blob = await fileHandle.getFile();
blob.handle = fileHandle; blob.handle = fileHandle;
loadFromBlob(blob, this.state) loadFromBlob(blob, this.state, this.scene.getElements())
.then(({ elements, appState }) => .then(({ elements, appState }) =>
this.syncActionResult({ this.syncActionResult({
elements, elements,
@ -692,7 +692,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
} }
const scene = restore(initialData, null); const scene = restore(initialData, null, null);
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
isLoading: false, isLoading: false,
@ -1201,7 +1201,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
} else if (data.elements) { } else if (data.elements) {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements: restoreElements(data.elements), elements: data.elements,
position: "cursor", position: "cursor",
}); });
} else if (data.text) { } else if (data.text) {
@ -1216,7 +1216,7 @@ class App extends React.Component<AppProps, AppState> {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
position: { clientX: number; clientY: number } | "cursor" | "center"; position: { clientX: number; clientY: number } | "cursor" | "center";
}) => { }) => {
const elements = restoreElements(opts.elements); const elements = restoreElements(opts.elements, null);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@ -3805,7 +3805,11 @@ class App extends React.Component<AppProps, AppState> {
try { try {
const file = event.dataTransfer.files[0]; const file = event.dataTransfer.files[0];
if (file?.type === "image/png" || file?.type === "image/svg+xml") { if (file?.type === "image/png" || file?.type === "image/svg+xml") {
const { elements, appState } = await loadFromBlob(file, this.state); const { elements, appState } = await loadFromBlob(
file,
this.state,
this.scene.getElements(),
);
this.syncActionResult({ this.syncActionResult({
elements, elements,
appState: { appState: {
@ -3865,7 +3869,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
loadFileToCanvas = (file: Blob) => { loadFileToCanvas = (file: Blob) => {
loadFromBlob(file, this.state) loadFromBlob(file, this.state, this.scene.getElements())
.then(({ elements, appState }) => .then(({ elements, appState }) =>
this.syncActionResult({ this.syncActionResult({
elements, elements,

View File

@ -1,6 +1,7 @@
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES } from "../constants"; import { EXPORT_DATA_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
@ -83,6 +84,7 @@ export const loadFromBlob = async (
blob: Blob, blob: Blob,
/** @see restore.localAppState */ /** @see restore.localAppState */
localAppState: AppState | null, localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
) => { ) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
try { try {
@ -103,6 +105,7 @@ export const loadFromBlob = async (
}, },
}, },
localAppState, localAppState,
localElements,
); );
return result; return result;

View File

@ -49,7 +49,10 @@ export const saveAsJSON = async (
return { fileHandle }; return { fileHandle };
}; };
export const loadFromJSON = async (localAppState: AppState) => { export const loadFromJSON = async (
localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null,
) => {
const blob = await fileOpen({ const blob = await fileOpen({
description: "Excalidraw files", description: "Excalidraw files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
@ -64,7 +67,7 @@ export const loadFromJSON = async (localAppState: AppState) => {
], ],
*/ */
}); });
return loadFromBlob(blob, localAppState); return loadFromBlob(blob, localAppState, localElements);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {

View File

@ -18,7 +18,7 @@ class Library {
}; };
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => { restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
const elements = getNonDeletedElements(restoreElements(libraryItem)); const elements = getNonDeletedElements(restoreElements(libraryItem, null));
return elements.length ? elements : null; return elements.length ? elements : null;
}; };

View File

@ -5,7 +5,11 @@ import {
} from "../element/types"; } from "../element/types";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { ImportedDataState } from "./types"; import { ImportedDataState } from "./types";
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element"; import {
getElementMap,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isLinearElementType } from "../element/typeChecks"; import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random"; import { randomId } from "../random";
import { import {
@ -16,6 +20,7 @@ import {
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@ -181,13 +186,20 @@ const restoreElement = (
export const restoreElements = ( export const restoreElements = (
elements: ImportedDataState["elements"], elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const localElementsMap = localElements ? getElementMap(localElements) : null;
return (elements || []).reduce((elements, element) => { return (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)) {
const migratedElement = restoreElement(element); let migratedElement: ExcalidrawElement = restoreElement(element);
if (migratedElement) { if (migratedElement) {
const localElement = localElementsMap?.[element.id];
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version);
}
elements.push(migratedElement); elements.push(migratedElement);
} }
} }
@ -197,25 +209,25 @@ export const restoreElements = (
export const restoreAppState = ( export const restoreAppState = (
appState: ImportedDataState["appState"], appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null, localAppState: Partial<AppState> | null | undefined,
): RestoredAppState => { ): RestoredAppState => {
appState = appState || {}; appState = appState || {};
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState; const nextAppState = {} as typeof defaultAppState;
for (const [key, val] of Object.entries(defaultAppState) as [ for (const [key, defaultValue] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState, keyof typeof defaultAppState,
any, any,
][]) { ][]) {
const restoredValue = appState[key]; const suppliedValue = appState[key];
const localValue = localAppState ? localAppState[key] : undefined; const localValue = localAppState ? localAppState[key] : undefined;
(nextAppState as any)[key] = (nextAppState as any)[key] =
restoredValue !== undefined suppliedValue !== undefined
? restoredValue ? suppliedValue
: localValue !== undefined : localValue !== undefined
? localValue ? localValue
: val; : defaultValue;
} }
return { return {
@ -243,9 +255,10 @@ export const restore = (
* Supply `null` if you can't get access to it. * Supply `null` if you can't get access to it.
*/ */
localAppState: Partial<AppState> | null | undefined, localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
): RestoredDataState => { ): RestoredDataState => {
return { return {
elements: restoreElements(data?.elements), elements: restoreElements(data?.elements, localElements),
appState: restoreAppState(data?.appState, localAppState || null), appState: restoreAppState(data?.appState, localAppState || null),
}; };
}; };

View File

@ -120,8 +120,11 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
* *
* NOTE: does not trigger re-render. * NOTE: does not trigger re-render.
*/ */
export const bumpVersion = (element: Mutable<ExcalidrawElement>) => { export const bumpVersion = (
element.version = element.version + 1; element: Mutable<ExcalidrawElement>,
version?: ExcalidrawElement["version"],
) => {
element.version = (version ?? element.version) + 1;
element.versionNonce = randomInteger(); element.versionNonce = randomInteger();
return element; return element;
}; };

View File

@ -196,5 +196,5 @@ export const loadFromFirebase = async (
firebaseSceneVersionCache.set(socket, getSceneVersion(elements)); firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
} }
return restoreElements(elements); return restoreElements(elements, null);
}; };

View File

@ -257,9 +257,10 @@ export const loadScene = async (
data = restore( data = restore(
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements,
); );
} else { } else {
data = restore(localDataState || null, null); data = restore(localDataState || null, null, null);
} }
return { return {

View File

@ -141,7 +141,7 @@ const initializeScene = async (opts: {
const url = externalUrlMatch[1]; const url = externalUrlMatch[1];
try { try {
const request = await fetch(window.decodeURIComponent(url)); const request = await fetch(window.decodeURIComponent(url));
const data = await loadFromBlob(await request.blob(), null); const data = await loadFromBlob(await request.blob(), null, null);
if ( if (
!scene.elements.length || !scene.elements.length ||
window.confirm(t("alerts.loadSceneOverridePrompt")) window.confirm(t("alerts.loadSceneOverridePrompt"))

View File

@ -19,6 +19,12 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- [`restore(data, localAppState, localElements)`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restore) and [`restoreElements(elements, localElements)`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreElements) now take `localElements` argument which will be used to ensure existing elements' versions are used and incremented. This fixes an issue where importing the same file would resolve to elements with older versions, potentially causing issues when reconciling [#3797](https://github.com/excalidraw/excalidraw/pull/3797).
#### BREAKING CHANGE
- `localElements` argument is mandatory (can be `null`/`undefined`) if using TypeScript.
- Support `appState.exportEmbedScene` attribute in [`exportToSvg`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportToSvg) which allows to embed the scene data. - Support `appState.exportEmbedScene` attribute in [`exportToSvg`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportToSvg) which allows to embed the scene data.
#### BREAKING CHANGE #### BREAKING CHANGE

View File

@ -701,7 +701,7 @@ This function returns an object where each element is mapped to its id.
**_Signature_** **_Signature_**
<pre> <pre>
restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L17">ImportedDataState["appState"]</a>, localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>> | null): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a> restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L17">ImportedDataState["appState"]</a>, localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>> | null): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>
</pre> </pre>
**_How to use_** **_How to use_**
@ -710,14 +710,16 @@ restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blo
import { restoreAppState } from "@excalidraw/excalidraw-next"; import { restoreAppState } from "@excalidraw/excalidraw-next";
``` ```
This function will make sure all the keys have appropriate values in [appState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) and if any key is missing, it will be set to default value. If you pass `localAppState`, `localAppState` value will be preferred over the `appState` passed in params. This function will make sure all the keys have appropriate values in [appState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) and if any key is missing, it will be set to default value.
When `localAppState` is supplied, it's used in place of values that are missing (`undefined`) in `appState` instead of defaults. Use this as a way to not override user's defaults if you persist them. Required: supply `null`/`undefined` if not applicable.
#### `restoreElements` #### `restoreElements`
**_Signature_** **_Signature_**
<pre> <pre>
restoreElements(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ImportedDataState["elements"]</a>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a> restoreElements(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ImportedDataState["elements"]</a>, localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>
</pre> </pre>
**_How to use_** **_How to use_**
@ -728,21 +730,25 @@ import { restoreElements } from "@excalidraw/excalidraw-next";
This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to default value. This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to default value.
When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. Use this when you import elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the updates.
#### `restore` #### `restore`
**_Signature_** **_Signature_**
<pre> <pre>
restoreElements(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L12">ImportedDataState</a>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a> restoreElements(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L12">ImportedDataState</a>, localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>> | null | undefined, localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a>
</pre> </pre>
See [`restoreAppState()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreAppState) about `localAppState`, and [`restoreElements()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreElements) about `localElements`.
**_How to use_** **_How to use_**
```js ```js
import { restore } from "@excalidraw/excalidraw-next"; import { restore } from "@excalidraw/excalidraw-next";
``` ```
This function makes sure elements and state is set to appropriate values and set to default value if not present. It is combination of [restoreElements](#restoreElements) and [restoreAppState](#restoreAppState) This function makes sure elements and state is set to appropriate values and set to default value if not present. It is a combination of [restoreElements](#restoreElements) and [restoreAppState](#restoreAppState).
#### `serializeAsJSON` #### `serializeAsJSON`

View File

@ -25,6 +25,7 @@ export const exportToCanvas = ({
const { elements: restoredElements, appState: restoredAppState } = restore( const { elements: restoredElements, appState: restoredAppState } = restore(
{ elements, appState }, { elements, appState },
null, null,
null,
); );
const { exportBackground, viewBackgroundColor } = restoredAppState; const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas( return _exportToCanvas(
@ -84,6 +85,7 @@ export const exportToSvg = async ({
const { elements: restoredElements, appState: restoredAppState } = restore( const { elements: restoredElements, appState: restoredAppState } = restore(
{ elements, appState }, { elements, appState },
null, null,
null,
); );
return _exportToSvg(getNonDeletedElements(restoredElements), { return _exportToSvg(getNonDeletedElements(restoredElements), {
...restoredAppState, ...restoredAppState,

View File

@ -11,6 +11,7 @@ import { getDefaultAppState } from "../../appState";
import { ImportedDataState } from "../../data/types"; import { ImportedDataState } from "../../data/types";
import { NormalizedZoomValue } from "../../types"; import { NormalizedZoomValue } from "../../types";
import { FONT_FAMILY } from "../../constants"; import { FONT_FAMILY } from "../../constants";
import { newElementWith } from "../../element/mutateElement";
const mockSizeHelper = jest.spyOn(sizeHelpers, "isInvisiblySmallElement"); const mockSizeHelper = jest.spyOn(sizeHelpers, "isInvisiblySmallElement");
@ -20,12 +21,12 @@ beforeEach(() => {
describe("restoreElements", () => { describe("restoreElements", () => {
it("should return empty array when element is null", () => { it("should return empty array when element is null", () => {
expect(restore.restoreElements(null)).toStrictEqual([]); expect(restore.restoreElements(null, null)).toStrictEqual([]);
}); });
it("should not call isInvisiblySmallElement when element is a selection element", () => { it("should not call isInvisiblySmallElement when element is a selection element", () => {
const selectionEl = { type: "selection" } as ExcalidrawElement; const selectionEl = { type: "selection" } as ExcalidrawElement;
const restoreElements = restore.restoreElements([selectionEl]); const restoreElements = restore.restoreElements([selectionEl], null);
expect(restoreElements.length).toBe(0); expect(restoreElements.length).toBe(0);
expect(sizeHelpers.isInvisiblySmallElement).toBeCalledTimes(0); expect(sizeHelpers.isInvisiblySmallElement).toBeCalledTimes(0);
}); });
@ -36,14 +37,16 @@ describe("restoreElements", () => {
}); });
dummyNotSupportedElement.type = "not supported"; dummyNotSupportedElement.type = "not supported";
expect(restore.restoreElements([dummyNotSupportedElement]).length).toBe(0); expect(
restore.restoreElements([dummyNotSupportedElement], null).length,
).toBe(0);
}); });
it("should return empty array when isInvisiblySmallElement is true", () => { it("should return empty array when isInvisiblySmallElement is true", () => {
const rectElement = API.createElement({ type: "rectangle" }); const rectElement = API.createElement({ type: "rectangle" });
mockSizeHelper.mockImplementation(() => true); mockSizeHelper.mockImplementation(() => true);
expect(restore.restoreElements([rectElement]).length).toBe(0); expect(restore.restoreElements([rectElement], null).length).toBe(0);
}); });
it("should restore text element correctly passing value for each attribute", () => { it("should restore text element correctly passing value for each attribute", () => {
@ -57,9 +60,10 @@ describe("restoreElements", () => {
id: "id-text01", id: "id-text01",
}); });
const restoredText = restore.restoreElements([ const restoredText = restore.restoreElements(
textElement, [textElement],
])[0] as ExcalidrawTextElement; null,
)[0] as ExcalidrawTextElement;
expect(restoredText).toMatchSnapshot({ expect(restoredText).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),
@ -77,9 +81,10 @@ describe("restoreElements", () => {
textElement.text = null; textElement.text = null;
textElement.font = "10 unknown"; textElement.font = "10 unknown";
const restoredText = restore.restoreElements([ const restoredText = restore.restoreElements(
textElement, [textElement],
])[0] as ExcalidrawTextElement; null,
)[0] as ExcalidrawTextElement;
expect(restoredText).toMatchSnapshot({ expect(restoredText).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),
}); });
@ -91,9 +96,10 @@ describe("restoreElements", () => {
id: "id-freedraw01", id: "id-freedraw01",
}); });
const restoredFreedraw = restore.restoreElements([ const restoredFreedraw = restore.restoreElements(
freedrawElement, [freedrawElement],
])[0] as ExcalidrawFreeDrawElement; null,
)[0] as ExcalidrawFreeDrawElement;
expect(restoredFreedraw).toMatchSnapshot({ seed: expect.any(Number) }); expect(restoredFreedraw).toMatchSnapshot({ seed: expect.any(Number) });
}); });
@ -107,10 +113,10 @@ describe("restoreElements", () => {
}); });
drawElement.type = "draw"; drawElement.type = "draw";
const restoredElements = restore.restoreElements([ const restoredElements = restore.restoreElements(
lineElement, [lineElement, drawElement],
drawElement, null,
]); );
const restoredLine = restoredElements[0] as ExcalidrawLinearElement; const restoredLine = restoredElements[0] as ExcalidrawLinearElement;
const restoredDraw = restoredElements[1] as ExcalidrawLinearElement; const restoredDraw = restoredElements[1] as ExcalidrawLinearElement;
@ -122,7 +128,7 @@ describe("restoreElements", () => {
it("should restore arrow element correctly", () => { it("should restore arrow element correctly", () => {
const arrowElement = API.createElement({ type: "arrow", id: "id-arrow01" }); const arrowElement = API.createElement({ type: "arrow", id: "id-arrow01" });
const restoredElements = restore.restoreElements([arrowElement]); const restoredElements = restore.restoreElements([arrowElement], null);
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement; const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
@ -132,7 +138,7 @@ describe("restoreElements", () => {
it("when arrow element has defined endArrowHead", () => { it("when arrow element has defined endArrowHead", () => {
const arrowElement = API.createElement({ type: "arrow" }); const arrowElement = API.createElement({ type: "arrow" });
const restoredElements = restore.restoreElements([arrowElement]); const restoredElements = restore.restoreElements([arrowElement], null);
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement; const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
@ -145,7 +151,7 @@ describe("restoreElements", () => {
get: jest.fn(() => undefined), get: jest.fn(() => undefined),
}); });
const restoredElements = restore.restoreElements([arrowElement]); const restoredElements = restore.restoreElements([arrowElement], null);
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement; const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
@ -166,9 +172,10 @@ describe("restoreElements", () => {
[lineElement.width, lineElement.height], [lineElement.width, lineElement.height],
]; ];
const restoredLine = restore.restoreElements([ const restoredLine = restore.restoreElements(
lineElement, [lineElement],
])[0] as ExcalidrawLinearElement; null,
)[0] as ExcalidrawLinearElement;
expect(restoredLine.points).toMatchObject(expectedLinePoints); expect(restoredLine.points).toMatchObject(expectedLinePoints);
}); });
@ -205,10 +212,10 @@ describe("restoreElements", () => {
get: jest.fn(() => pointsEl_1), get: jest.fn(() => pointsEl_1),
}); });
const restoredElements = restore.restoreElements([ const restoredElements = restore.restoreElements(
lineElement_0, [lineElement_0, lineElement_1],
lineElement_1, null,
]); );
const restoredLine_0 = restoredElements[0] as ExcalidrawLinearElement; const restoredLine_0 = restoredElements[0] as ExcalidrawLinearElement;
const restoredLine_1 = restoredElements[1] as ExcalidrawLinearElement; const restoredLine_1 = restoredElements[1] as ExcalidrawLinearElement;
@ -254,12 +261,37 @@ describe("restoreElements", () => {
elements.push(element); elements.push(element);
}); });
const restoredElements = restore.restoreElements(elements); const restoredElements = restore.restoreElements(elements, null);
expect(restoredElements[0]).toMatchSnapshot({ seed: expect.any(Number) }); expect(restoredElements[0]).toMatchSnapshot({ seed: expect.any(Number) });
expect(restoredElements[1]).toMatchSnapshot({ seed: expect.any(Number) }); expect(restoredElements[1]).toMatchSnapshot({ seed: expect.any(Number) });
expect(restoredElements[2]).toMatchSnapshot({ seed: expect.any(Number) }); expect(restoredElements[2]).toMatchSnapshot({ seed: expect.any(Number) });
}); });
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 restoredElements = restore.restoreElements(
[rectangle, ellipse],
[rectangle_modified],
);
expect(restoredElements[0].id).toBe(rectangle.id);
expect(restoredElements[0].versionNonce).not.toBe(rectangle.versionNonce);
expect(restoredElements).toEqual([
expect.objectContaining({
id: rectangle.id,
version: rectangle_modified.version + 1,
}),
expect.objectContaining({
id: ellipse.id,
version: ellipse.version,
versionNonce: ellipse.versionNonce,
}),
]);
});
}); });
describe("restoreAppState", () => { describe("restoreAppState", () => {
@ -429,7 +461,7 @@ describe("restore", () => {
it("when imported data state is null it should return an empty array of elements", () => { it("when imported data state is null it should return an empty array of elements", () => {
const stubLocalAppState = getDefaultAppState(); const stubLocalAppState = getDefaultAppState();
const restoredData = restore.restore(null, stubLocalAppState); const restoredData = restore.restore(null, stubLocalAppState, null);
expect(restoredData.elements.length).toBe(0); expect(restoredData.elements.length).toBe(0);
}); });
@ -438,7 +470,7 @@ describe("restore", () => {
stubLocalAppState.cursorButton = "down"; stubLocalAppState.cursorButton = "down";
stubLocalAppState.name = "local app state"; stubLocalAppState.name = "local app state";
const restoredData = restore.restore(null, stubLocalAppState); const restoredData = restore.restore(null, stubLocalAppState, null);
expect(restoredData.appState.cursorButton).toBe( expect(restoredData.appState.cursorButton).toBe(
stubLocalAppState.cursorButton, stubLocalAppState.cursorButton,
); );
@ -455,7 +487,11 @@ describe("restore", () => {
const importedDataState = {} as ImportedDataState; const importedDataState = {} as ImportedDataState;
importedDataState.elements = elements; importedDataState.elements = elements;
const restoredData = restore.restore(importedDataState, stubLocalAppState); const restoredData = restore.restore(
importedDataState,
stubLocalAppState,
null,
);
expect(restoredData.elements.length).toBe(elements.length); expect(restoredData.elements.length).toBe(elements.length);
}); });
@ -467,10 +503,36 @@ describe("restore", () => {
const importedDataState = {} as ImportedDataState; const importedDataState = {} as ImportedDataState;
importedDataState.appState = stubImportedAppState; importedDataState.appState = stubImportedAppState;
const restoredData = restore.restore(importedDataState, null); const restoredData = restore.restore(importedDataState, null, null);
expect(restoredData.appState.cursorButton).toBe( expect(restoredData.appState.cursorButton).toBe(
stubImportedAppState.cursorButton, stubImportedAppState.cursorButton,
); );
expect(restoredData.appState.name).toBe(stubImportedAppState.name); 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 + 1 }),
expect.objectContaining({
id: ellipse.id,
version: ellipse.version,
versionNonce: ellipse.versionNonce,
}),
]);
});
}); });