소스 검색

retain local appState props on restore (#2224)

Co-authored-by: Lipis <lipiridis@gmail.com>
vanilla_orig
David Luzar 4 년 전
부모
커밋
7618ca48d7
No account linked to committer's email address
9개의 변경된 파일153개의 추가작업 그리고 69개의 파일을 삭제
  1. 6
    2
      src/components/App.tsx
  2. 17
    14
      src/data/blob.ts
  3. 17
    13
      src/data/index.ts
  4. 2
    2
      src/data/json.ts
  5. 3
    3
      src/data/localStorage.ts
  6. 28
    10
      src/data/restore.ts
  7. 46
    0
      src/tests/appState.test.tsx
  8. 26
    0
      src/tests/helpers/api.ts
  9. 8
    25
      src/tests/history.test.tsx

+ 6
- 2
src/components/App.tsx 파일 보기

@@ -599,9 +599,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
599 599
       ) {
600 600
         // Backwards compatibility with legacy url format
601 601
         if (id) {
602
-          scene = await loadScene(id);
602
+          scene = await loadScene(id, null, this.props.initialData);
603 603
         } else if (jsonMatch) {
604
-          scene = await loadScene(jsonMatch[1], jsonMatch[2]);
604
+          scene = await loadScene(
605
+            jsonMatch[1],
606
+            jsonMatch[2],
607
+            this.props.initialData,
608
+          );
605 609
         }
606 610
         if (!isCollaborationScene) {
607 611
           window.history.replaceState({}, "Excalidraw", window.location.origin);

+ 17
- 14
src/data/blob.ts 파일 보기

@@ -23,11 +23,11 @@ const loadFileContents = async (blob: any) => {
23 23
   return contents;
24 24
 };
25 25
 
26
-/**
27
- * @param blob
28
- * @param appState if provided, used for centering scroll to restored scene
29
- */
30
-export const loadFromBlob = async (blob: any, appState?: AppState) => {
26
+export const loadFromBlob = async (
27
+  blob: any,
28
+  /** @see restore.localAppState */
29
+  localAppState: AppState | null,
30
+) => {
31 31
   if (blob.handle) {
32 32
     // TODO: Make this part of `AppState`.
33 33
     (window as any).handle = blob.handle;
@@ -39,16 +39,19 @@ export const loadFromBlob = async (blob: any, appState?: AppState) => {
39 39
     if (data.type !== "excalidraw") {
40 40
       throw new Error(t("alerts.couldNotLoadInvalidFile"));
41 41
     }
42
-    return restore({
43
-      elements: data.elements,
44
-      appState: {
45
-        appearance: appState?.appearance,
46
-        ...cleanAppStateForExport(data.appState || {}),
47
-        ...(appState
48
-          ? calculateScrollCenter(data.elements || [], appState, null)
49
-          : {}),
42
+    return restore(
43
+      {
44
+        elements: data.elements,
45
+        appState: {
46
+          appearance: localAppState?.appearance,
47
+          ...cleanAppStateForExport(data.appState || {}),
48
+          ...(localAppState
49
+            ? calculateScrollCenter(data.elements || [], localAppState, null)
50
+            : {}),
51
+        },
50 52
       },
51
-    });
53
+      localAppState,
54
+    );
52 55
   } catch {
53 56
     throw new Error(t("alerts.couldNotLoadInvalidFile"));
54 57
   }

+ 17
- 13
src/data/index.ts 파일 보기

@@ -233,18 +233,15 @@ const importFromBackend = async (
233 233
   id: string | null,
234 234
   privateKey?: string | null,
235 235
 ): Promise<ImportedDataState> => {
236
-  let elements: readonly ExcalidrawElement[] = [];
237
-  let appState = getDefaultAppState();
238
-
239 236
   try {
240 237
     const response = await fetch(
241 238
       privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
242 239
     );
243 240
     if (!response.ok) {
244 241
       window.alert(t("alerts.importBackendFailed"));
245
-      return { elements, appState };
242
+      return {};
246 243
     }
247
-    let data;
244
+    let data: ImportedDataState;
248 245
     if (privateKey) {
249 246
       const buffer = await response.arrayBuffer();
250 247
       const key = await getImportedKey(privateKey, "decrypt");
@@ -267,13 +264,14 @@ const importFromBackend = async (
267 264
       data = await response.json();
268 265
     }
269 266
 
270
-    elements = data.elements || elements;
271
-    appState = { ...appState, ...data.appState };
267
+    return {
268
+      elements: data.elements || null,
269
+      appState: data.appState || null,
270
+    };
272 271
   } catch (error) {
273 272
     window.alert(t("alerts.importBackendFailed"));
274 273
     console.error(error);
275
-  } finally {
276
-    return { elements, appState };
274
+    return {};
277 275
   }
278 276
 };
279 277
 
@@ -363,16 +361,22 @@ export const exportCanvas = async (
363 361
 
364 362
 export const loadScene = async (
365 363
   id: string | null,
366
-  privateKey?: string | null,
367
-  initialData?: ImportedDataState,
364
+  privateKey: string | null,
365
+  // Supply initialData even if importing from backend to ensure we restore
366
+  // localStorage user settings which we do not persist on server.
367
+  // Non-optional so we don't forget to pass it even if `undefined`.
368
+  initialData: ImportedDataState | undefined | null,
368 369
 ) => {
369 370
   let data;
370 371
   if (id != null) {
371 372
     // the private key is used to decrypt the content from the server, take
372 373
     // extra care not to leak it
373
-    data = restore(await importFromBackend(id, privateKey));
374
+    data = restore(
375
+      await importFromBackend(id, privateKey),
376
+      initialData?.appState,
377
+    );
374 378
   } else {
375
-    data = restore(initialData || {});
379
+    data = restore(initialData || {}, null);
376 380
   }
377 381
 
378 382
   return {

+ 2
- 2
src/data/json.ts 파일 보기

@@ -45,13 +45,13 @@ export const saveAsJSON = async (
45 45
   );
46 46
 };
47 47
 
48
-export const loadFromJSON = async (appState: AppState) => {
48
+export const loadFromJSON = async (localAppState: AppState) => {
49 49
   const blob = await fileOpen({
50 50
     description: "Excalidraw files",
51 51
     extensions: [".json", ".excalidraw"],
52 52
     mimeTypes: ["application/json"],
53 53
   });
54
-  return loadFromBlob(blob, appState);
54
+  return loadFromBlob(blob, localAppState);
55 55
 };
56 56
 
57 57
 export const isValidLibrary = (json: any) => {

+ 3
- 3
src/data/localStorage.ts 파일 보기

@@ -1,7 +1,7 @@
1 1
 import { ExcalidrawElement } from "../element/types";
2 2
 import { AppState, LibraryItems } from "../types";
3 3
 import { clearAppStateForLocalStorage, getDefaultAppState } from "../appState";
4
-import { restore } from "./restore";
4
+import { restoreElements } from "./restore";
5 5
 
6 6
 const LOCAL_STORAGE_KEY = "excalidraw";
7 7
 const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
@@ -21,8 +21,8 @@ export const loadLibrary = (): Promise<LibraryItems> => {
21 21
         return resolve([]);
22 22
       }
23 23
 
24
-      const items = (JSON.parse(data) as LibraryItems).map(
25
-        (elements) => restore({ elements, appState: null }).elements,
24
+      const items = (JSON.parse(data) as LibraryItems).map((elements) =>
25
+        restoreElements(elements),
26 26
       ) as Mutable<LibraryItems>;
27 27
 
28 28
       // clone to ensure we don't mutate the cached library elements in the app

+ 28
- 10
src/data/restore.ts 파일 보기

@@ -118,7 +118,7 @@ const restoreElement = (
118 118
   }
119 119
 };
120 120
 
121
-const restoreElements = (
121
+export const restoreElements = (
122 122
   elements: ImportedDataState["elements"],
123 123
 ): ExcalidrawElement[] => {
124 124
   return (elements || []).reduce((elements, element) => {
@@ -134,18 +134,27 @@ const restoreElements = (
134 134
   }, [] as ExcalidrawElement[]);
135 135
 };
136 136
 
137
-const restoreAppState = (appState: ImportedDataState["appState"]): AppState => {
137
+const restoreAppState = (
138
+  appState: ImportedDataState["appState"],
139
+  localAppState: Partial<AppState> | null,
140
+): AppState => {
138 141
   appState = appState || {};
139 142
 
140 143
   const defaultAppState = getDefaultAppState();
141 144
   const nextAppState = {} as typeof defaultAppState;
142 145
 
143
-  for (const [key, val] of Object.entries(defaultAppState)) {
144
-    if ((appState as any)[key] !== undefined) {
145
-      (nextAppState as any)[key] = (appState as any)[key];
146
-    } else {
147
-      (nextAppState as any)[key] = val;
148
-    }
146
+  for (const [key, val] of Object.entries(defaultAppState) as [
147
+    keyof typeof defaultAppState,
148
+    any,
149
+  ][]) {
150
+    const restoredValue = appState[key];
151
+    const localValue = localAppState ? localAppState[key] : undefined;
152
+    (nextAppState as any)[key] =
153
+      restoredValue !== undefined
154
+        ? restoredValue
155
+        : localValue !== undefined
156
+        ? localValue
157
+        : val;
149 158
   }
150 159
 
151 160
   return {
@@ -155,9 +164,18 @@ const restoreAppState = (appState: ImportedDataState["appState"]): AppState => {
155 164
   };
156 165
 };
157 166
 
158
-export const restore = (data: ImportedDataState): DataState => {
167
+export const restore = (
168
+  data: ImportedDataState,
169
+  /**
170
+   * Local AppState (`this.state` or initial state from localStorage) so that we
171
+   * don't overwrite local state with default values (when values not
172
+   * explicitly specified).
173
+   * Supply `null` if you can't get access to it.
174
+   */
175
+  localAppState: Partial<AppState> | null | undefined,
176
+): DataState => {
159 177
   return {
160 178
     elements: restoreElements(data.elements),
161
-    appState: restoreAppState(data.appState),
179
+    appState: restoreAppState(data.appState, localAppState || null),
162 180
   };
163 181
 };

+ 46
- 0
src/tests/appState.test.tsx 파일 보기

@@ -0,0 +1,46 @@
1
+import React from "react";
2
+import { render, waitFor } from "./test-utils";
3
+import App from "../components/App";
4
+import { API } from "./helpers/api";
5
+import { getDefaultAppState } from "../appState";
6
+
7
+const { h } = window;
8
+
9
+describe("appState", () => {
10
+  it("drag&drop file doesn't reset non-persisted appState", async () => {
11
+    const defaultAppState = getDefaultAppState();
12
+    const exportBackground = !defaultAppState.exportBackground;
13
+    render(
14
+      <App
15
+        initialData={{
16
+          appState: {
17
+            ...defaultAppState,
18
+            exportBackground,
19
+            viewBackgroundColor: "#F00",
20
+          },
21
+          elements: [],
22
+        }}
23
+      />,
24
+    );
25
+
26
+    await waitFor(() => {
27
+      expect(h.state.exportBackground).toBe(exportBackground);
28
+      expect(h.state.viewBackgroundColor).toBe("#F00");
29
+    });
30
+
31
+    API.dropFile({
32
+      appState: {
33
+        viewBackgroundColor: "#000",
34
+      },
35
+      elements: [API.createElement({ type: "rectangle", id: "A" })],
36
+    });
37
+
38
+    await waitFor(() => {
39
+      expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
40
+      // non-imported prop → retain
41
+      expect(h.state.exportBackground).toBe(exportBackground);
42
+      // imported prop → overwrite
43
+      expect(h.state.viewBackgroundColor).toBe("#000");
44
+    });
45
+  });
46
+});

+ 26
- 0
src/tests/helpers/api.ts 파일 보기

@@ -7,6 +7,8 @@ import {
7 7
 import { newElement, newTextElement, newLinearElement } from "../../element";
8 8
 import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
9 9
 import { getDefaultAppState } from "../../appState";
10
+import { GlobalTestState, createEvent, fireEvent } from "../test-utils";
11
+import { ImportedDataState } from "../../data/types";
10 12
 
11 13
 const { h } = window;
12 14
 
@@ -135,4 +137,28 @@ export class API {
135 137
     }
136 138
     return element as any;
137 139
   };
140
+
141
+  static dropFile(sceneData: ImportedDataState) {
142
+    const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
143
+    const file = new Blob(
144
+      [
145
+        JSON.stringify({
146
+          type: "excalidraw",
147
+          ...sceneData,
148
+        }),
149
+      ],
150
+      {
151
+        type: "application/json",
152
+      },
153
+    );
154
+    Object.defineProperty(fileDropEvent, "dataTransfer", {
155
+      value: {
156
+        files: [file],
157
+        getData: (_type: string) => {
158
+          return "";
159
+        },
160
+      },
161
+    });
162
+    fireEvent(GlobalTestState.canvas, fileDropEvent);
163
+  }
138 164
 }

+ 8
- 25
src/tests/history.test.tsx 파일 보기

@@ -1,10 +1,10 @@
1 1
 import React from "react";
2
-import { render, GlobalTestState } from "./test-utils";
2
+import { render } from "./test-utils";
3 3
 import App from "../components/App";
4 4
 import { UI } from "./helpers/ui";
5 5
 import { API } from "./helpers/api";
6 6
 import { getDefaultAppState } from "../appState";
7
-import { waitFor, fireEvent, createEvent } from "@testing-library/react";
7
+import { waitFor } from "@testing-library/react";
8 8
 import { createUndoAction, createRedoAction } from "../actions/actionHistory";
9 9
 
10 10
 const { h } = window;
@@ -77,31 +77,14 @@ describe("history", () => {
77 77
     await waitFor(() =>
78 78
       expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
79 79
     );
80
-    const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
81
-    const file = new Blob(
82
-      [
83
-        JSON.stringify({
84
-          type: "excalidraw",
85
-          appState: {
86
-            ...getDefaultAppState(),
87
-            viewBackgroundColor: "#000",
88
-          },
89
-          elements: [API.createElement({ type: "rectangle", id: "B" })],
90
-        }),
91
-      ],
92
-      {
93
-        type: "application/json",
94
-      },
95
-    );
96
-    Object.defineProperty(fileDropEvent, "dataTransfer", {
97
-      value: {
98
-        files: [file],
99
-        getData: (_type: string) => {
100
-          return "";
101
-        },
80
+
81
+    API.dropFile({
82
+      appState: {
83
+        ...getDefaultAppState(),
84
+        viewBackgroundColor: "#000",
102 85
       },
86
+      elements: [API.createElement({ type: "rectangle", id: "B" })],
103 87
     });
104
-    fireEvent(GlobalTestState.canvas, fileDropEvent);
105 88
 
106 89
     await waitFor(() => expect(API.getStateHistory().length).toBe(2));
107 90
     expect(h.state.viewBackgroundColor).toBe("#000");

Loading…
취소
저장