From ba3f548b914ff77b659a5d3456fe59677ddcb434 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 30 Oct 2020 21:01:41 +0100 Subject: [PATCH] Fix library dnd (#2314) --- .gitignore | 1 + src/actions/actionAddToLibrary.ts | 6 +- src/components/App.tsx | 9 ++- src/components/LayerUI.tsx | 12 ++-- src/constants.ts | 7 ++ src/data/blob.ts | 19 ++++-- src/data/json.ts | 3 +- src/data/library.ts | 55 +++++++++++++++- src/data/localStorage.ts | 63 +++--------------- src/tests/appState.test.tsx | 20 ++++-- src/tests/collab.test.tsx | 13 +++- src/tests/export.test.tsx | 64 ++----------------- .../fixtures/fixture_library.excalidrawlib | 31 +++++++++ src/tests/helpers/api.ts | 61 ++++++++++++------ src/tests/history.test.tsx | 22 +++++-- src/tests/library.test.tsx | 43 +++++++++++++ 16 files changed, 261 insertions(+), 168 deletions(-) create mode 100644 src/tests/fixtures/fixture_library.excalidrawlib create mode 100644 src/tests/library.test.tsx diff --git a/.gitignore b/.gitignore index 4da8849a..29877e18 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ yarn-error.log* yarn.lock .idea dist/ +.eslintcache diff --git a/src/actions/actionAddToLibrary.ts b/src/actions/actionAddToLibrary.ts index 87780fcb..71527a0b 100644 --- a/src/actions/actionAddToLibrary.ts +++ b/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; diff --git a/src/components/App.tsx b/src/components/App.tsx index 8c057c35..6f86e1a6 100644 --- a/src/components/App.tsx +++ b/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 { 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 { }); } - 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["setState"]; history: SceneHistory; app: InstanceType; - library: ReturnType; + library: typeof Library; }; } } @@ -4064,7 +4063,7 @@ if ( get: () => history, }, library: { - get: () => loadLibrary(), + value: Library, }, }); } diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 7a7c50ea..e9afd2b1 100644 --- a/src/components/LayerUI.tsx +++ b/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], diff --git a/src/constants.ts b/src/constants.ts index e85b6e23..17859204 100644 --- a/src/constants.ts +++ b/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", +}; diff --git a/src/data/blob.ts b/src/data/blob.ts index dcbf431c..a7a02395 100644 --- a/src/data/blob.ts +++ b/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 ""; }; diff --git a/src/data/json.ts b/src/data/json.ts index df3cead8..0613730e 100644 --- a/src/data/json.ts +++ b/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", diff --git a/src/data/library.ts b/src/data/library.ts index b813ef7c..dab121a5 100644 --- a/src/data/library.ts +++ b/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 => { + 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; + + // 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); + } + }; } diff --git a/src/data/localStorage.ts b/src/data/localStorage.ts index 0f43578b..15967863 100644 --- a/src/data/localStorage.ts +++ b/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 => { - 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; - - // 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); diff --git a/src/tests/appState.test.tsx b/src/tests/appState.test.tsx index 6f8a304f..15d2644d 100644 --- a/src/tests/appState.test.tsx +++ b/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" })]); diff --git a/src/tests/collab.test.tsx b/src/tests/collab.test.tsx index ea952b55..d6aa4c40 100644 --- a/src/tests/collab.test.tsx +++ b/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); diff --git a/src/tests/export.test.tsx b/src/tests/export.test.tsx index 353af51f..157e5968 100644 --- a/src/tests/export.test.tsx +++ b/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(); }); 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: "😀" }), diff --git a/src/tests/fixtures/fixture_library.excalidrawlib b/src/tests/fixtures/fixture_library.excalidrawlib new file mode 100644 index 00000000..10779886 --- /dev/null +++ b/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": [] + } + ] + ] +} diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 47872662..c0563296 100644 --- a/src/tests/helpers/api.ts +++ b/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 ( + filepath: string, + encoding?: T, + ): Promise => { + 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((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); - } + }; } diff --git a/src/tests/history.test.tsx b/src/tests/history.test.tsx index fc6dd41b..a5bbdf7e 100644 --- a/src/tests/history.test.tsx +++ b/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"); diff --git a/src/tests/library.test.tsx b/src/tests/library.test.tsx new file mode 100644 index 00000000..b8ff3b5e --- /dev/null +++ b/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(); + }); + + 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" })]); + }); + }); +});