retain local appState props on restore (#2224)

Co-authored-by: Lipis <lipiridis@gmail.com>
This commit is contained in:
David Luzar 2020-10-13 13:46:52 +02:00 committed by GitHub
parent b91f929503
commit 7618ca48d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 153 additions and 69 deletions

View File

@ -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);

View File

@ -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"));
} }

View File

@ -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 {

View File

@ -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) => {

View File

@ -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

View File

@ -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),
}; };
}; };

View 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");
});
});
});

View File

@ -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);
}
} }

View File

@ -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");