瀏覽代碼

Fix library dnd (#2314)

David Luzar 4 年之前
父節點
當前提交
ba3f548b91

+ 1 - 0
.gitignore

@@ -14,3 +14,4 @@ yarn-error.log*
 yarn.lock
 .idea
 dist/
+.eslintcache

+ 3 - 3
src/actions/actionAddToLibrary.ts

@@ -2,7 +2,7 @@ import { register } from "./register";
 import { getSelectedElements } from "../scene";
 import { getNonDeletedElements } from "../element";
 import { deepCopyElement } from "../element/newElement";
-import { loadLibrary, saveLibrary } from "../data/localStorage";
+import { Library } from "../data/library";
 
 export const actionAddToLibrary = register({
   name: "addToLibrary",
@@ -12,8 +12,8 @@ export const actionAddToLibrary = register({
       appState,
     );
 
-    loadLibrary().then((items) => {
-      saveLibrary([...items, selectedElements.map(deepCopyElement)]);
+    Library.loadLibrary().then((items) => {
+      Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
     });
 
     return false;

+ 4 - 5
src/components/App.tsx

@@ -145,7 +145,6 @@ import {
   isBindingElementType,
 } from "../element/typeChecks";
 import { actionFinalize, actionDeleteSelected } from "../actions";
-import { loadLibrary } from "../data/localStorage";
 
 import throttle from "lodash.throttle";
 import { LinearElementEditor } from "../element/linearElementEditor";
@@ -1266,7 +1265,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     history.resumeRecording();
     this.scene.replaceAllElements(this.scene.getElements());
 
-    this.initializeSocketClient({ showLoadingState: false });
+    await this.initializeSocketClient({ showLoadingState: false });
   };
 
   closePortal = () => {
@@ -3729,7 +3728,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       });
     }
 
-    const libraryShapes = event.dataTransfer.getData(MIME_TYPES.excalidraw);
+    const libraryShapes = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
     if (libraryShapes !== "") {
       this.addElementsFromPasteOrLibrary(
         JSON.parse(libraryShapes),
@@ -4040,7 +4039,7 @@ declare global {
       setState: React.Component<any, AppState>["setState"];
       history: SceneHistory;
       app: InstanceType<typeof App>;
-      library: ReturnType<typeof loadLibrary>;
+      library: typeof Library;
     };
   }
 }
@@ -4064,7 +4063,7 @@ if (
       get: () => history,
     },
     library: {
-      get: () => loadLibrary(),
+      value: Library,
     },
   });
 }

+ 6 - 6
src/components/LayerUI.tsx

@@ -39,12 +39,12 @@ import { Tooltip } from "./Tooltip";
 
 import "./LayerUI.scss";
 import { LibraryUnit } from "./LibraryUnit";
-import { loadLibrary, saveLibrary } from "../data/localStorage";
 import { ToolButton } from "./ToolButton";
 import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
 import { muteFSAbortError } from "../utils";
 import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 import clsx from "clsx";
+import { Library } from "../data/library";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -223,7 +223,7 @@ const LibraryMenu = ({
           resolve("loading");
         }, 100);
       }),
-      loadLibrary().then((items) => {
+      Library.loadLibrary().then((items) => {
         setLibraryItems(items);
         setIsLoading("ready");
       }),
@@ -238,18 +238,18 @@ const LibraryMenu = ({
   }, []);
 
   const removeFromLibrary = useCallback(async (indexToRemove) => {
-    const items = await loadLibrary();
+    const items = await Library.loadLibrary();
     const nextItems = items.filter((_, index) => index !== indexToRemove);
-    saveLibrary(nextItems);
+    Library.saveLibrary(nextItems);
     setLibraryItems(nextItems);
   }, []);
 
   const addToLibrary = useCallback(
     async (elements: LibraryItem) => {
-      const items = await loadLibrary();
+      const items = await Library.loadLibrary();
       const nextItems = [...items, elements];
       onAddToLibrary();
-      saveLibrary(nextItems);
+      Library.saveLibrary(nextItems);
       setLibraryItems(nextItems);
     },
     [onAddToLibrary],

+ 7 - 0
src/constants.ts

@@ -89,3 +89,10 @@ export const MIME_TYPES = {
   excalidraw: "application/vnd.excalidraw+json",
   excalidrawlib: "application/vnd.excalidrawlib+json",
 };
+
+export const STORAGE_KEYS = {
+  LOCAL_STORAGE_ELEMENTS: "excalidraw",
+  LOCAL_STORAGE_APP_STATE: "excalidraw-state",
+  LOCAL_STORAGE_COLLAB: "excalidraw-collab",
+  LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+};

+ 15 - 4
src/data/blob.ts

@@ -55,13 +55,24 @@ export const parseFileContents = async (blob: Blob | File) => {
   return contents;
 };
 
-const getMimeType = (blob: Blob): string => {
-  if (blob.type) {
-    return blob.type;
+export const getMimeType = (blob: Blob | string): string => {
+  let name: string;
+  if (typeof blob === "string") {
+    name = blob;
+  } else {
+    if (blob.type) {
+      return blob.type;
+    }
+    name = blob.name || "";
   }
-  const name = blob.name || "";
   if (/\.(excalidraw|json)$/.test(name)) {
     return "application/json";
+  } else if (/\.png$/.test(name)) {
+    return "image/png";
+  } else if (/\.jpe?g$/.test(name)) {
+    return "image/jpeg";
+  } else if (/\.svg$/.test(name)) {
+    return "image/svg+xml";
   }
   return "";
 };

+ 1 - 2
src/data/json.ts

@@ -4,7 +4,6 @@ import { cleanAppStateForExport } from "../appState";
 
 import { fileOpen, fileSave } from "browser-nativefs";
 import { loadFromBlob } from "./blob";
-import { loadLibrary } from "./localStorage";
 import { Library } from "./library";
 import { MIME_TYPES } from "../constants";
 
@@ -65,7 +64,7 @@ export const isValidLibrary = (json: any) => {
 };
 
 export const saveLibraryAsJSON = async () => {
-  const library = await loadLibrary();
+  const library = await Library.loadLibrary();
   const serialized = JSON.stringify(
     {
       type: "excalidrawlib",

+ 52 - 3
src/data/library.ts

@@ -1,8 +1,16 @@
 import { loadLibraryFromBlob } from "./blob";
 import { LibraryItems, LibraryItem } from "../types";
-import { loadLibrary, saveLibrary } from "./localStorage";
+import { restoreElements } from "./restore";
+import { STORAGE_KEYS } from "../constants";
 
 export class Library {
+  private static libraryCache: LibraryItems | null = null;
+
+  static resetLibrary = () => {
+    Library.libraryCache = null;
+    localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
+  };
+
   /** imports library (currently merges, removing duplicates) */
   static async importLibrary(blob: Blob) {
     const libraryFile = await loadLibraryFromBlob(blob);
@@ -34,10 +42,51 @@ export class Library {
       });
     };
 
-    const existingLibraryItems = await loadLibrary();
+    const existingLibraryItems = await Library.loadLibrary();
     const filtered = libraryFile.library!.filter((libraryItem) =>
       isUniqueitem(existingLibraryItems, libraryItem),
     );
-    saveLibrary([...existingLibraryItems, ...filtered]);
+    Library.saveLibrary([...existingLibraryItems, ...filtered]);
   }
+
+  static loadLibrary = (): Promise<LibraryItems> => {
+    return new Promise(async (resolve) => {
+      if (Library.libraryCache) {
+        return resolve(JSON.parse(JSON.stringify(Library.libraryCache)));
+      }
+
+      try {
+        const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
+        if (!data) {
+          return resolve([]);
+        }
+
+        const items = (JSON.parse(data) as LibraryItems).map((elements) =>
+          restoreElements(elements),
+        ) as Mutable<LibraryItems>;
+
+        // clone to ensure we don't mutate the cached library elements in the app
+        Library.libraryCache = JSON.parse(JSON.stringify(items));
+
+        resolve(items);
+      } catch (e) {
+        console.error(e);
+        resolve([]);
+      }
+    });
+  };
+
+  static saveLibrary = (items: LibraryItems) => {
+    const prevLibraryItems = Library.libraryCache;
+    try {
+      const serializedItems = JSON.stringify(items);
+      // cache optimistically so that consumers have access to the latest
+      //  immediately
+      Library.libraryCache = JSON.parse(serializedItems);
+      localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
+    } catch (e) {
+      Library.libraryCache = prevLibraryItems;
+      console.error(e);
+    }
+  };
 }

+ 8 - 55
src/data/localStorage.ts

@@ -1,59 +1,12 @@
 import { ExcalidrawElement } from "../element/types";
-import { AppState, LibraryItems } from "../types";
+import { AppState } from "../types";
 import { clearAppStateForLocalStorage, getDefaultAppState } from "../appState";
-import { restoreElements } from "./restore";
-
-const LOCAL_STORAGE_KEY = "excalidraw";
-const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
-const LOCAL_STORAGE_KEY_COLLAB = "excalidraw-collab";
-const LOCAL_STORAGE_KEY_LIBRARY = "excalidraw-library";
-
-let _LATEST_LIBRARY_ITEMS: LibraryItems | null = null;
-export const loadLibrary = (): Promise<LibraryItems> => {
-  return new Promise(async (resolve) => {
-    if (_LATEST_LIBRARY_ITEMS) {
-      return resolve(JSON.parse(JSON.stringify(_LATEST_LIBRARY_ITEMS)));
-    }
-
-    try {
-      const data = localStorage.getItem(LOCAL_STORAGE_KEY_LIBRARY);
-      if (!data) {
-        return resolve([]);
-      }
-
-      const items = (JSON.parse(data) as LibraryItems).map((elements) =>
-        restoreElements(elements),
-      ) as Mutable<LibraryItems>;
-
-      // clone to ensure we don't mutate the cached library elements in the app
-      _LATEST_LIBRARY_ITEMS = JSON.parse(JSON.stringify(items));
-
-      resolve(items);
-    } catch (e) {
-      console.error(e);
-      resolve([]);
-    }
-  });
-};
-
-export const saveLibrary = (items: LibraryItems) => {
-  const prevLibraryItems = _LATEST_LIBRARY_ITEMS;
-  try {
-    const serializedItems = JSON.stringify(items);
-    // cache optimistically so that consumers have access to the latest
-    //  immediately
-    _LATEST_LIBRARY_ITEMS = JSON.parse(serializedItems);
-    localStorage.setItem(LOCAL_STORAGE_KEY_LIBRARY, serializedItems);
-  } catch (e) {
-    _LATEST_LIBRARY_ITEMS = prevLibraryItems;
-    console.error(e);
-  }
-};
+import { STORAGE_KEYS } from "../constants";
 
 export const saveUsernameToLocalStorage = (username: string) => {
   try {
     localStorage.setItem(
-      LOCAL_STORAGE_KEY_COLLAB,
+      STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
       JSON.stringify({ username }),
     );
   } catch (error) {
@@ -64,7 +17,7 @@ export const saveUsernameToLocalStorage = (username: string) => {
 
 export const importUsernameFromLocalStorage = (): string | null => {
   try {
-    const data = localStorage.getItem(LOCAL_STORAGE_KEY_COLLAB);
+    const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
     if (data) {
       return JSON.parse(data).username;
     }
@@ -82,11 +35,11 @@ export const saveToLocalStorage = (
 ) => {
   try {
     localStorage.setItem(
-      LOCAL_STORAGE_KEY,
+      STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
       JSON.stringify(elements.filter((element) => !element.isDeleted)),
     );
     localStorage.setItem(
-      LOCAL_STORAGE_KEY_STATE,
+      STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
       JSON.stringify(clearAppStateForLocalStorage(appState)),
     );
   } catch (error) {
@@ -100,8 +53,8 @@ export const importFromLocalStorage = () => {
   let savedState = null;
 
   try {
-    savedElements = localStorage.getItem(LOCAL_STORAGE_KEY);
-    savedState = localStorage.getItem(LOCAL_STORAGE_KEY_STATE);
+    savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
+    savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
   } catch (error) {
     // Unable to access localStorage
     console.error(error);

+ 14 - 6
src/tests/appState.test.tsx

@@ -28,12 +28,20 @@ describe("appState", () => {
       expect(h.state.viewBackgroundColor).toBe("#F00");
     });
 
-    API.dropFile({
-      appState: {
-        viewBackgroundColor: "#000",
-      },
-      elements: [API.createElement({ type: "rectangle", id: "A" })],
-    });
+    API.drop(
+      new Blob(
+        [
+          JSON.stringify({
+            type: "excalidraw",
+            appState: {
+              viewBackgroundColor: "#000",
+            },
+            elements: [API.createElement({ type: "rectangle", id: "A" })],
+          }),
+        ],
+        { type: "application/json" },
+      ),
+    );
 
     await waitFor(() => {
       expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);

+ 12 - 1
src/tests/collab.test.tsx

@@ -29,6 +29,17 @@ jest.mock("../data/firebase.ts", () => {
   };
 });
 
+jest.mock("socket.io-client", () => {
+  return () => {
+    return {
+      close: () => {},
+      on: () => {},
+      off: () => {},
+      emit: () => {},
+    };
+  };
+});
+
 describe("collaboration", () => {
   it("creating room should reset deleted elements", async () => {
     render(
@@ -50,7 +61,7 @@ describe("collaboration", () => {
       expect(API.getStateHistory().length).toBe(1);
     });
 
-    h.app.openPortal();
+    await h.app.openPortal();
     await waitFor(() => {
       expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
       expect(API.getStateHistory().length).toBe(1);

+ 7 - 57
src/tests/export.test.tsx

@@ -9,12 +9,6 @@ import {
 } from "../data/image";
 import { serializeAsJSON } from "../data/json";
 
-import fs from "fs";
-import util from "util";
-import path from "path";
-
-const readFile = util.promisify(fs.readFile);
-
 const { h } = window;
 
 const testElements = [
@@ -43,22 +37,18 @@ Object.defineProperty(window, "TextDecoder", {
   },
 });
 
-describe("appState", () => {
+describe("export", () => {
   beforeEach(() => {
     render(<App />);
   });
 
   it("export embedded png and reimport", async () => {
-    const pngBlob = new Blob(
-      [await readFile(path.resolve(__dirname, "./fixtures/smiley.png"))],
-      { type: "image/png" },
-    );
-
+    const pngBlob = await API.loadFile("./fixtures/smiley.png");
     const pngBlobEmbedded = await encodePngMetadata({
       blob: pngBlob,
       metadata: serializeAsJSON(testElements, h.state),
     });
-    API.dropFile(pngBlobEmbedded);
+    API.drop(pngBlobEmbedded);
 
     await waitFor(() => {
       expect(h.elements).toEqual([
@@ -78,17 +68,7 @@ describe("appState", () => {
   });
 
   it("import embedded png (legacy v1)", async () => {
-    const pngBlob = new Blob(
-      [
-        await readFile(
-          path.resolve(__dirname, "./fixtures/test_embedded_v1.png"),
-        ),
-      ],
-      { type: "image/png" },
-    );
-
-    API.dropFile(pngBlob);
-
+    API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ type: "text", text: "test" }),
@@ -97,17 +77,7 @@ describe("appState", () => {
   });
 
   it("import embedded png (v2)", async () => {
-    const pngBlob = new Blob(
-      [
-        await readFile(
-          path.resolve(__dirname, "./fixtures/smiley_embedded_v2.png"),
-        ),
-      ],
-      { type: "image/png" },
-    );
-
-    API.dropFile(pngBlob);
-
+    API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ type: "text", text: "😀" }),
@@ -116,17 +86,7 @@ describe("appState", () => {
   });
 
   it("import embedded svg (legacy v1)", async () => {
-    const svgBlob = new Blob(
-      [
-        await readFile(
-          path.resolve(__dirname, "./fixtures/test_embedded_v1.svg"),
-        ),
-      ],
-      { type: "image/svg+xml" },
-    );
-
-    API.dropFile(svgBlob);
-
+    API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ type: "text", text: "test" }),
@@ -135,17 +95,7 @@ describe("appState", () => {
   });
 
   it("import embedded svg (v2)", async () => {
-    const svgBlob = new Blob(
-      [
-        await readFile(
-          path.resolve(__dirname, "./fixtures/smiley_embedded_v2.svg"),
-        ),
-      ],
-      { type: "image/svg+xml" },
-    );
-
-    API.dropFile(svgBlob);
-
+    API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ type: "text", text: "😀" }),

+ 31 - 0
src/tests/fixtures/fixture_library.excalidrawlib

@@ -0,0 +1,31 @@
+{
+  "type": "excalidrawlib",
+  "version": 1,
+  "library": [
+    [
+      {
+        "type": "rectangle",
+        "version": 38,
+        "versionNonce": 1046419680,
+        "isDeleted": false,
+        "id": "A",
+        "fillStyle": "hachure",
+        "strokeWidth": 1,
+        "strokeStyle": "solid",
+        "roughness": 1,
+        "opacity": 100,
+        "angle": 0,
+        "x": 21801,
+        "y": 719.5,
+        "strokeColor": "#c92a2a",
+        "backgroundColor": "#e64980",
+        "width": 50,
+        "height": 30,
+        "seed": 117297479,
+        "groupIds": [],
+        "strokeSharpness": "sharp",
+        "boundElementIds": []
+      }
+    ]
+  ]
+}

+ 42 - 19
src/tests/helpers/api.ts

@@ -8,7 +8,12 @@ import { newElement, newTextElement, newLinearElement } from "../../element";
 import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
 import { getDefaultAppState } from "../../appState";
 import { GlobalTestState, createEvent, fireEvent } from "../test-utils";
-import { ImportedDataState } from "../../data/types";
+import fs from "fs";
+import util from "util";
+import path from "path";
+import { getMimeType } from "../../data/blob";
+
+const readFile = util.promisify(fs.readFile);
 
 const { h } = window;
 
@@ -138,30 +143,48 @@ export class API {
     return element as any;
   };
 
-  static dropFile(data: ImportedDataState | Blob) {
+  static readFile = async <T extends "utf8" | null>(
+    filepath: string,
+    encoding?: T,
+  ): Promise<T extends "utf8" ? string : Buffer> => {
+    filepath = path.isAbsolute(filepath)
+      ? filepath
+      : path.resolve(path.join(__dirname, "../", filepath));
+    return readFile(filepath, { encoding }) as any;
+  };
+
+  static loadFile = async (filepath: string) => {
+    const { base, ext } = path.parse(filepath);
+    return new File([await API.readFile(filepath, null)], base, {
+      type: getMimeType(ext),
+    });
+  };
+
+  static drop = async (blob: Blob) => {
     const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
-    const file =
-      data instanceof Blob
-        ? data
-        : new Blob(
-            [
-              JSON.stringify({
-                type: "excalidraw",
-                ...data,
-              }),
-            ],
-            {
-              type: "application/json",
-            },
-          );
+    const text = await new Promise<string>((resolve, reject) => {
+      try {
+        const reader = new FileReader();
+        reader.onload = () => {
+          resolve(reader.result as string);
+        };
+        reader.readAsText(blob);
+      } catch (error) {
+        reject(error);
+      }
+    });
+
     Object.defineProperty(fileDropEvent, "dataTransfer", {
       value: {
-        files: [file],
-        getData: (_type: string) => {
+        files: [blob],
+        getData: (type: string) => {
+          if (type === blob.type) {
+            return text;
+          }
           return "";
         },
       },
     });
     fireEvent(GlobalTestState.canvas, fileDropEvent);
-  }
+  };
 }

+ 15 - 7
src/tests/history.test.tsx

@@ -78,13 +78,21 @@ describe("history", () => {
       expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
     );
 
-    API.dropFile({
-      appState: {
-        ...getDefaultAppState(),
-        viewBackgroundColor: "#000",
-      },
-      elements: [API.createElement({ type: "rectangle", id: "B" })],
-    });
+    API.drop(
+      new Blob(
+        [
+          JSON.stringify({
+            type: "excalidraw",
+            appState: {
+              ...getDefaultAppState(),
+              viewBackgroundColor: "#000",
+            },
+            elements: [API.createElement({ type: "rectangle", id: "B" })],
+          }),
+        ],
+        { type: "application/json" },
+      ),
+    );
 
     await waitFor(() => expect(API.getStateHistory().length).toBe(2));
     expect(h.state.viewBackgroundColor).toBe("#000");

+ 43 - 0
src/tests/library.test.tsx

@@ -0,0 +1,43 @@
+import React from "react";
+import { render, waitFor } from "./test-utils";
+import App from "../components/App";
+import { API } from "./helpers/api";
+import { MIME_TYPES } from "../constants";
+import { LibraryItem } from "../types";
+
+const { h } = window;
+
+describe("library", () => {
+  beforeEach(() => {
+    h.library.resetLibrary();
+    render(<App />);
+  });
+
+  it("import library via drag&drop", async () => {
+    expect(await h.library.loadLibrary()).toEqual([]);
+    await API.drop(
+      await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
+    );
+    await waitFor(async () => {
+      expect(await h.library.loadLibrary()).toEqual([
+        [expect.objectContaining({ id: "A" })],
+      ]);
+    });
+  });
+
+  // NOTE: mocked to test logic, not actual drag&drop via UI
+  it("drop library item onto canvas", async () => {
+    expect(h.elements).toEqual([]);
+    const libraryItems: LibraryItem = JSON.parse(
+      await API.readFile("./fixtures/fixture_library.excalidrawlib", "utf8"),
+    ).library[0];
+    await API.drop(
+      new Blob([JSON.stringify(libraryItems)], {
+        type: MIME_TYPES.excalidrawlib,
+      }),
+    );
+    await waitFor(() => {
+      expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
+    });
+  });
+});