retain local appState props on restore (#2224)
Co-authored-by: Lipis <lipiridis@gmail.com>
This commit is contained in:
parent
b91f929503
commit
7618ca48d7
@ -599,9 +599,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
) {
|
) {
|
||||||
// Backwards compatibility with legacy url format
|
// Backwards compatibility with legacy url format
|
||||||
if (id) {
|
if (id) {
|
||||||
scene = await loadScene(id);
|
scene = await loadScene(id, null, this.props.initialData);
|
||||||
} else if (jsonMatch) {
|
} else if (jsonMatch) {
|
||||||
scene = await loadScene(jsonMatch[1], jsonMatch[2]);
|
scene = await loadScene(
|
||||||
|
jsonMatch[1],
|
||||||
|
jsonMatch[2],
|
||||||
|
this.props.initialData,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!isCollaborationScene) {
|
if (!isCollaborationScene) {
|
||||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||||
|
@ -23,11 +23,11 @@ const loadFileContents = async (blob: any) => {
|
|||||||
return contents;
|
return contents;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const loadFromBlob = async (
|
||||||
* @param blob
|
blob: any,
|
||||||
* @param appState if provided, used for centering scroll to restored scene
|
/** @see restore.localAppState */
|
||||||
*/
|
localAppState: AppState | null,
|
||||||
export const loadFromBlob = async (blob: any, appState?: AppState) => {
|
) => {
|
||||||
if (blob.handle) {
|
if (blob.handle) {
|
||||||
// TODO: Make this part of `AppState`.
|
// TODO: Make this part of `AppState`.
|
||||||
(window as any).handle = blob.handle;
|
(window as any).handle = blob.handle;
|
||||||
@ -39,16 +39,19 @@ export const loadFromBlob = async (blob: any, appState?: AppState) => {
|
|||||||
if (data.type !== "excalidraw") {
|
if (data.type !== "excalidraw") {
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||||
}
|
}
|
||||||
return restore({
|
return restore(
|
||||||
|
{
|
||||||
elements: data.elements,
|
elements: data.elements,
|
||||||
appState: {
|
appState: {
|
||||||
appearance: appState?.appearance,
|
appearance: localAppState?.appearance,
|
||||||
...cleanAppStateForExport(data.appState || {}),
|
...cleanAppStateForExport(data.appState || {}),
|
||||||
...(appState
|
...(localAppState
|
||||||
? calculateScrollCenter(data.elements || [], appState, null)
|
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
localAppState,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||||
}
|
}
|
||||||
|
@ -233,18 +233,15 @@ const importFromBackend = async (
|
|||||||
id: string | null,
|
id: string | null,
|
||||||
privateKey?: string | null,
|
privateKey?: string | null,
|
||||||
): Promise<ImportedDataState> => {
|
): Promise<ImportedDataState> => {
|
||||||
let elements: readonly ExcalidrawElement[] = [];
|
|
||||||
let appState = getDefaultAppState();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
|
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
window.alert(t("alerts.importBackendFailed"));
|
window.alert(t("alerts.importBackendFailed"));
|
||||||
return { elements, appState };
|
return {};
|
||||||
}
|
}
|
||||||
let data;
|
let data: ImportedDataState;
|
||||||
if (privateKey) {
|
if (privateKey) {
|
||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
const key = await getImportedKey(privateKey, "decrypt");
|
const key = await getImportedKey(privateKey, "decrypt");
|
||||||
@ -267,13 +264,14 @@ const importFromBackend = async (
|
|||||||
data = await response.json();
|
data = await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
elements = data.elements || elements;
|
return {
|
||||||
appState = { ...appState, ...data.appState };
|
elements: data.elements || null,
|
||||||
|
appState: data.appState || null,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(t("alerts.importBackendFailed"));
|
window.alert(t("alerts.importBackendFailed"));
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
return {};
|
||||||
return { elements, appState };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -363,16 +361,22 @@ export const exportCanvas = async (
|
|||||||
|
|
||||||
export const loadScene = async (
|
export const loadScene = async (
|
||||||
id: string | null,
|
id: string | null,
|
||||||
privateKey?: string | null,
|
privateKey: string | null,
|
||||||
initialData?: ImportedDataState,
|
// Supply initialData 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`.
|
||||||
|
initialData: ImportedDataState | undefined | null,
|
||||||
) => {
|
) => {
|
||||||
let data;
|
let data;
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
// the private key is used to decrypt the content from the server, take
|
// the private key is used to decrypt the content from the server, take
|
||||||
// extra care not to leak it
|
// extra care not to leak it
|
||||||
data = restore(await importFromBackend(id, privateKey));
|
data = restore(
|
||||||
|
await importFromBackend(id, privateKey),
|
||||||
|
initialData?.appState,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(initialData || {});
|
data = restore(initialData || {}, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -45,13 +45,13 @@ export const saveAsJSON = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromJSON = async (appState: AppState) => {
|
export const loadFromJSON = async (localAppState: AppState) => {
|
||||||
const blob = await fileOpen({
|
const blob = await fileOpen({
|
||||||
description: "Excalidraw files",
|
description: "Excalidraw files",
|
||||||
extensions: [".json", ".excalidraw"],
|
extensions: [".json", ".excalidraw"],
|
||||||
mimeTypes: ["application/json"],
|
mimeTypes: ["application/json"],
|
||||||
});
|
});
|
||||||
return loadFromBlob(blob, appState);
|
return loadFromBlob(blob, localAppState);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidLibrary = (json: any) => {
|
export const isValidLibrary = (json: any) => {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, LibraryItems } from "../types";
|
import { AppState, LibraryItems } from "../types";
|
||||||
import { clearAppStateForLocalStorage, getDefaultAppState } from "../appState";
|
import { clearAppStateForLocalStorage, getDefaultAppState } from "../appState";
|
||||||
import { restore } from "./restore";
|
import { restoreElements } from "./restore";
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = "excalidraw";
|
const LOCAL_STORAGE_KEY = "excalidraw";
|
||||||
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
||||||
@ -21,8 +21,8 @@ export const loadLibrary = (): Promise<LibraryItems> => {
|
|||||||
return resolve([]);
|
return resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = (JSON.parse(data) as LibraryItems).map(
|
const items = (JSON.parse(data) as LibraryItems).map((elements) =>
|
||||||
(elements) => restore({ elements, appState: null }).elements,
|
restoreElements(elements),
|
||||||
) as Mutable<LibraryItems>;
|
) as Mutable<LibraryItems>;
|
||||||
|
|
||||||
// clone to ensure we don't mutate the cached library elements in the app
|
// clone to ensure we don't mutate the cached library elements in the app
|
||||||
|
@ -118,7 +118,7 @@ const restoreElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreElements = (
|
export const restoreElements = (
|
||||||
elements: ImportedDataState["elements"],
|
elements: ImportedDataState["elements"],
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
return (elements || []).reduce((elements, element) => {
|
return (elements || []).reduce((elements, element) => {
|
||||||
@ -134,18 +134,27 @@ const restoreElements = (
|
|||||||
}, [] as ExcalidrawElement[]);
|
}, [] as ExcalidrawElement[]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreAppState = (appState: ImportedDataState["appState"]): AppState => {
|
const restoreAppState = (
|
||||||
|
appState: ImportedDataState["appState"],
|
||||||
|
localAppState: Partial<AppState> | null,
|
||||||
|
): AppState => {
|
||||||
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)) {
|
for (const [key, val] of Object.entries(defaultAppState) as [
|
||||||
if ((appState as any)[key] !== undefined) {
|
keyof typeof defaultAppState,
|
||||||
(nextAppState as any)[key] = (appState as any)[key];
|
any,
|
||||||
} else {
|
][]) {
|
||||||
(nextAppState as any)[key] = val;
|
const restoredValue = appState[key];
|
||||||
}
|
const localValue = localAppState ? localAppState[key] : undefined;
|
||||||
|
(nextAppState as any)[key] =
|
||||||
|
restoredValue !== undefined
|
||||||
|
? restoredValue
|
||||||
|
: localValue !== undefined
|
||||||
|
? localValue
|
||||||
|
: val;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -155,9 +164,18 @@ const restoreAppState = (appState: ImportedDataState["appState"]): AppState => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const restore = (data: ImportedDataState): DataState => {
|
export const restore = (
|
||||||
|
data: ImportedDataState,
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
): DataState => {
|
||||||
return {
|
return {
|
||||||
elements: restoreElements(data.elements),
|
elements: restoreElements(data.elements),
|
||||||
appState: restoreAppState(data.appState),
|
appState: restoreAppState(data.appState, localAppState || null),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
46
src/tests/appState.test.tsx
Normal file
46
src/tests/appState.test.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, waitFor } from "./test-utils";
|
||||||
|
import App from "../components/App";
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import { getDefaultAppState } from "../appState";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe("appState", () => {
|
||||||
|
it("drag&drop file doesn't reset non-persisted appState", async () => {
|
||||||
|
const defaultAppState = getDefaultAppState();
|
||||||
|
const exportBackground = !defaultAppState.exportBackground;
|
||||||
|
render(
|
||||||
|
<App
|
||||||
|
initialData={{
|
||||||
|
appState: {
|
||||||
|
...defaultAppState,
|
||||||
|
exportBackground,
|
||||||
|
viewBackgroundColor: "#F00",
|
||||||
|
},
|
||||||
|
elements: [],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.exportBackground).toBe(exportBackground);
|
||||||
|
expect(h.state.viewBackgroundColor).toBe("#F00");
|
||||||
|
});
|
||||||
|
|
||||||
|
API.dropFile({
|
||||||
|
appState: {
|
||||||
|
viewBackgroundColor: "#000",
|
||||||
|
},
|
||||||
|
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
|
// non-imported prop → retain
|
||||||
|
expect(h.state.exportBackground).toBe(exportBackground);
|
||||||
|
// imported prop → overwrite
|
||||||
|
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -7,6 +7,8 @@ import {
|
|||||||
import { newElement, newTextElement, newLinearElement } from "../../element";
|
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||||
import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
|
import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
|
||||||
import { getDefaultAppState } from "../../appState";
|
import { getDefaultAppState } from "../../appState";
|
||||||
|
import { GlobalTestState, createEvent, fireEvent } from "../test-utils";
|
||||||
|
import { ImportedDataState } from "../../data/types";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@ -135,4 +137,28 @@ export class API {
|
|||||||
}
|
}
|
||||||
return element as any;
|
return element as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static dropFile(sceneData: ImportedDataState) {
|
||||||
|
const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
|
||||||
|
const file = new Blob(
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
type: "excalidraw",
|
||||||
|
...sceneData,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
type: "application/json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
||||||
|
value: {
|
||||||
|
files: [file],
|
||||||
|
getData: (_type: string) => {
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fireEvent(GlobalTestState.canvas, fileDropEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, GlobalTestState } from "./test-utils";
|
import { render } from "./test-utils";
|
||||||
import App from "../components/App";
|
import App from "../components/App";
|
||||||
import { UI } from "./helpers/ui";
|
import { UI } from "./helpers/ui";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { waitFor, fireEvent, createEvent } from "@testing-library/react";
|
import { waitFor } from "@testing-library/react";
|
||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@ -77,31 +77,14 @@ describe("history", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
||||||
);
|
);
|
||||||
const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
|
|
||||||
const file = new Blob(
|
API.dropFile({
|
||||||
[
|
|
||||||
JSON.stringify({
|
|
||||||
type: "excalidraw",
|
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
viewBackgroundColor: "#000",
|
viewBackgroundColor: "#000",
|
||||||
},
|
},
|
||||||
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
||||||
}),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
type: "application/json",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
|
||||||
value: {
|
|
||||||
files: [file],
|
|
||||||
getData: (_type: string) => {
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
fireEvent(GlobalTestState.canvas, fileDropEvent);
|
|
||||||
|
|
||||||
await waitFor(() => expect(API.getStateHistory().length).toBe(2));
|
await waitFor(() => expect(API.getStateHistory().length).toBe(2));
|
||||||
expect(h.state.viewBackgroundColor).toBe("#000");
|
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||||
|
Loading…
x
Reference in New Issue
Block a user