Procházet zdrojové kódy

feat: rewrite library state management & related refactor (#5067)

* support libraryItems promise for `updateScene()` and use `importLibrary`

* fix typing for `getLibraryItemsFromStorage()`

* remove `libraryItemsFromStorage` hack

if there was a point to it then I'm missing it, but this part will be rewritten anyway

* rewrite state handling

(temporarily removed loading states)

* add async support

* refactor and deduplicate library importing logic

* hide hints when library open

* fix snaps

* support promise in `initialData.libraryItems`

* add default to params instead
David Luzar před 3 roky
rodič
revize
cd942c3e3b

+ 1 - 0
package.json

@@ -36,6 +36,7 @@
     "i18next-browser-languagedetector": "6.1.2",
     "idb-keyval": "6.0.3",
     "image-blob-reduce": "3.0.1",
+    "jotai": "1.6.4",
     "lodash.throttle": "4.1.1",
     "nanoid": "3.1.32",
     "open-color": "1.9.1",

+ 12 - 32
src/components/App.tsx

@@ -76,9 +76,8 @@ import {
   ZOOM_STEP,
 } from "../constants";
 import { loadFromBlob } from "../data";
-import { isValidLibrary } from "../data/json";
 import Library from "../data/library";
-import { restore, restoreElements, restoreLibraryItems } from "../data/restore";
+import { restore, restoreElements } from "../data/restore";
 import {
   dragNewElement,
   dragSelectedElements,
@@ -231,6 +230,7 @@ import {
   generateIdFromFile,
   getDataURL,
   isSupportedImageFile,
+  loadLibraryFromBlob,
   resizeImageFile,
   SVGStringToFile,
 } from "../data/blob";
@@ -706,28 +706,21 @@ class App extends React.Component<AppProps, AppState> {
     try {
       const request = await fetch(decodeURIComponent(url));
       const blob = await request.blob();
-      const json = JSON.parse(await blob.text());
-      if (!isValidLibrary(json)) {
-        throw new Error();
-      }
+      const defaultStatus = "published";
+      const libraryItems = await loadLibraryFromBlob(blob, defaultStatus);
       if (
         token === this.id ||
         window.confirm(
           t("alerts.confirmAddLibrary", {
-            numShapes: (json.libraryItems || json.library || []).length,
+            numShapes: libraryItems.length,
           }),
         )
       ) {
-        await this.library.importLibrary(blob, "published");
-        // hack to rerender the library items after import
-        if (this.state.isLibraryOpen) {
-          this.setState({ isLibraryOpen: false });
-        }
-        this.setState({ isLibraryOpen: true });
+        await this.library.importLibrary(libraryItems, defaultStatus);
       }
     } catch (error: any) {
-      window.alert(t("alerts.errorLoadingLibrary"));
       console.error(error);
+      this.setState({ errorMessage: t("errors.importLibraryError") });
     } finally {
       this.focusContainer();
     }
@@ -792,10 +785,7 @@ class App extends React.Component<AppProps, AppState> {
     try {
       initialData = (await this.props.initialData) || null;
       if (initialData?.libraryItems) {
-        this.libraryItemsFromStorage = restoreLibraryItems(
-          initialData.libraryItems,
-          "unpublished",
-        ) as LibraryItems;
+        this.library.importLibrary(initialData.libraryItems, "unpublished");
       }
     } catch (error: any) {
       console.error(error);
@@ -1681,7 +1671,9 @@ class App extends React.Component<AppProps, AppState> {
       appState?: Pick<AppState, K> | null;
       collaborators?: SceneData["collaborators"];
       commitToHistory?: SceneData["commitToHistory"];
-      libraryItems?: SceneData["libraryItems"];
+      libraryItems?:
+        | Required<SceneData>["libraryItems"]
+        | Promise<Required<SceneData>["libraryItems"]>;
     }) => {
       if (sceneData.commitToHistory) {
         this.history.resumeRecording();
@@ -1700,14 +1692,7 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (sceneData.libraryItems) {
-        this.library.saveLibrary(
-          restoreLibraryItems(sceneData.libraryItems, "unpublished"),
-        );
-        if (this.state.isLibraryOpen) {
-          this.setState({ isLibraryOpen: false }, () => {
-            this.setState({ isLibraryOpen: true });
-          });
-        }
+        this.library.importLibrary(sceneData.libraryItems, "unpublished");
       }
     },
   );
@@ -5275,11 +5260,6 @@ class App extends React.Component<AppProps, AppState> {
     ) {
       this.library
         .importLibrary(file)
-        .then(() => {
-          // Close and then open to get the libraries updated
-          this.setState({ isLibraryOpen: false });
-          this.setState({ isLibraryOpen: true });
-        })
         .catch((error) =>
           this.setState({ isLoading: false, errorMessage: error.message }),
         );

+ 4 - 0
src/components/HintViewer.tsx

@@ -23,6 +23,10 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
 
+  if (appState.isLibraryOpen) {
+    return null;
+  }
+
   if (isEraserActive(appState)) {
     return t("hints.eraserRevert");
   }

+ 11 - 2
src/components/LibraryMenu.scss

@@ -28,8 +28,17 @@
   }
 
   .layer-ui__library-message {
-    padding: 10px 20px;
-    max-width: 200px;
+    padding: 2em 4em;
+    min-width: 200px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    .Spinner {
+      margin-bottom: 1em;
+    }
+    span {
+      font-size: 0.8em;
+    }
   }
 
   .publish-library-success {

+ 120 - 119
src/components/LibraryMenu.tsx

@@ -1,5 +1,12 @@
-import { useRef, useState, useEffect, useCallback, RefObject } from "react";
-import Library from "../data/library";
+import {
+  useRef,
+  useState,
+  useEffect,
+  useCallback,
+  RefObject,
+  forwardRef,
+} from "react";
+import Library, { libraryItemsAtom } from "../data/library";
 import { t } from "../i18n";
 import { randomId } from "../random";
 import {
@@ -20,6 +27,9 @@ import { EVENT } from "../constants";
 import { KEYS } from "../keys";
 import { arrayToMap } from "../utils";
 import { trackEvent } from "../analytics";
+import { useAtom } from "jotai";
+import { jotaiScope } from "../jotai";
+import Spinner from "./Spinner";
 
 const useOnClickOutside = (
   ref: RefObject<HTMLElement>,
@@ -54,6 +64,17 @@ const getSelectedItems = (
   selectedItems: LibraryItem["id"][],
 ) => libraryItems.filter((item) => selectedItems.includes(item.id));
 
+const LibraryMenuWrapper = forwardRef<
+  HTMLDivElement,
+  { children: React.ReactNode }
+>(({ children }, ref) => {
+  return (
+    <Island padding={1} ref={ref} className="layer-ui__library">
+      {children}
+    </Island>
+  );
+});
+
 export const LibraryMenu = ({
   onClose,
   onInsertShape,
@@ -103,11 +124,6 @@ export const LibraryMenu = ({
     };
   }, [onClose]);
 
-  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
-
-  const [loadingState, setIsLoading] = useState<
-    "preloading" | "loading" | "ready"
-  >("preloading");
   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
   const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
     useState(false);
@@ -115,56 +131,35 @@ export const LibraryMenu = ({
     url: string;
     authorName: string;
   }>(null);
-  const loadingTimerRef = useRef<number | null>(null);
-
-  useEffect(() => {
-    Promise.race([
-      new Promise((resolve) => {
-        loadingTimerRef.current = window.setTimeout(() => {
-          resolve("loading");
-        }, 100);
-      }),
-      library.loadLibrary().then((items) => {
-        setLibraryItems(items);
-        setIsLoading("ready");
-      }),
-    ]).then((data) => {
-      if (data === "loading") {
-        setIsLoading("loading");
-      }
-    });
-    return () => {
-      clearTimeout(loadingTimerRef.current!);
-    };
-  }, [library]);
 
-  const removeFromLibrary = useCallback(async () => {
-    const items = await library.loadLibrary();
+  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
 
-    const nextItems = items.filter((item) => !selectedItems.includes(item.id));
-    library.saveLibrary(nextItems).catch((error) => {
-      setLibraryItems(items);
-      setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
-    });
-    setSelectedItems([]);
-    setLibraryItems(nextItems);
-  }, [library, setAppState, selectedItems, setSelectedItems]);
+  const removeFromLibrary = useCallback(
+    async (libraryItems: LibraryItems) => {
+      const nextItems = libraryItems.filter(
+        (item) => !selectedItems.includes(item.id),
+      );
+      library.saveLibrary(nextItems).catch(() => {
+        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
+      });
+      setSelectedItems([]);
+    },
+    [library, setAppState, selectedItems, setSelectedItems],
+  );
 
   const resetLibrary = useCallback(() => {
     library.resetLibrary();
-    setLibraryItems([]);
     focusContainer();
   }, [library, focusContainer]);
 
   const addToLibrary = useCallback(
-    async (elements: LibraryItem["elements"]) => {
+    async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
       trackEvent("element", "addToLibrary", "ui");
       if (elements.some((element) => element.type === "image")) {
         return setAppState({
           errorMessage: "Support for adding images to the library coming soon!",
         });
       }
-      const items = await library.loadLibrary();
       const nextItems: LibraryItems = [
         {
           status: "unpublished",
@@ -172,14 +167,12 @@ export const LibraryMenu = ({
           id: randomId(),
           created: Date.now(),
         },
-        ...items,
+        ...libraryItems,
       ];
       onAddToLibrary();
-      library.saveLibrary(nextItems).catch((error) => {
-        setLibraryItems(items);
+      library.saveLibrary(nextItems).catch(() => {
         setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
       });
-      setLibraryItems(nextItems);
     },
     [onAddToLibrary, library, setAppState],
   );
@@ -218,7 +211,7 @@ export const LibraryMenu = ({
   }, [setPublishLibSuccess, publishLibSuccess]);
 
   const onPublishLibSuccess = useCallback(
-    (data) => {
+    (data, libraryItems: LibraryItems) => {
       setShowPublishLibraryDialog(false);
       setPublishLibSuccess({ url: data.url, authorName: data.authorName });
       const nextLibItems = libraryItems.slice();
@@ -228,101 +221,109 @@ export const LibraryMenu = ({
         }
       });
       library.saveLibrary(nextLibItems);
-      setLibraryItems(nextLibItems);
     },
-    [
-      setShowPublishLibraryDialog,
-      setPublishLibSuccess,
-      libraryItems,
-      selectedItems,
-      library,
-    ],
+    [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
   );
 
   const [lastSelectedItem, setLastSelectedItem] = useState<
     LibraryItem["id"] | null
   >(null);
 
-  return loadingState === "preloading" ? null : (
-    <Island padding={1} ref={ref} className="layer-ui__library">
+  if (libraryItemsData.status === "loading") {
+    return (
+      <LibraryMenuWrapper ref={ref}>
+        <div className="layer-ui__library-message">
+          <Spinner size="2em" />
+          <span>{t("labels.libraryLoadingMessage")}</span>
+        </div>
+      </LibraryMenuWrapper>
+    );
+  }
+
+  return (
+    <LibraryMenuWrapper ref={ref}>
       {showPublishLibraryDialog && (
         <PublishLibrary
           onClose={() => setShowPublishLibraryDialog(false)}
-          libraryItems={getSelectedItems(libraryItems, selectedItems)}
+          libraryItems={getSelectedItems(
+            libraryItemsData.libraryItems,
+            selectedItems,
+          )}
           appState={appState}
-          onSuccess={onPublishLibSuccess}
+          onSuccess={(data) =>
+            onPublishLibSuccess(data, libraryItemsData.libraryItems)
+          }
           onError={(error) => window.alert(error)}
-          updateItemsInStorage={() => library.saveLibrary(libraryItems)}
+          updateItemsInStorage={() =>
+            library.saveLibrary(libraryItemsData.libraryItems)
+          }
           onRemove={(id: string) =>
             setSelectedItems(selectedItems.filter((_id) => _id !== id))
           }
         />
       )}
       {publishLibSuccess && renderPublishSuccess()}
+      <LibraryMenuItems
+        libraryItems={libraryItemsData.libraryItems}
+        onRemoveFromLibrary={() =>
+          removeFromLibrary(libraryItemsData.libraryItems)
+        }
+        onAddToLibrary={(elements) =>
+          addToLibrary(elements, libraryItemsData.libraryItems)
+        }
+        onInsertShape={onInsertShape}
+        pendingElements={pendingElements}
+        setAppState={setAppState}
+        libraryReturnUrl={libraryReturnUrl}
+        library={library}
+        theme={theme}
+        files={files}
+        id={id}
+        selectedItems={selectedItems}
+        onToggle={(id, event) => {
+          const shouldSelect = !selectedItems.includes(id);
 
-      {loadingState === "loading" ? (
-        <div className="layer-ui__library-message">
-          {t("labels.libraryLoadingMessage")}
-        </div>
-      ) : (
-        <LibraryMenuItems
-          libraryItems={libraryItems}
-          onRemoveFromLibrary={removeFromLibrary}
-          onAddToLibrary={addToLibrary}
-          onInsertShape={onInsertShape}
-          pendingElements={pendingElements}
-          setAppState={setAppState}
-          libraryReturnUrl={libraryReturnUrl}
-          library={library}
-          theme={theme}
-          files={files}
-          id={id}
-          selectedItems={selectedItems}
-          onToggle={(id, event) => {
-            const shouldSelect = !selectedItems.includes(id);
-
-            if (shouldSelect) {
-              if (event.shiftKey && lastSelectedItem) {
-                const rangeStart = libraryItems.findIndex(
-                  (item) => item.id === lastSelectedItem,
-                );
-                const rangeEnd = libraryItems.findIndex(
-                  (item) => item.id === id,
-                );
-
-                if (rangeStart === -1 || rangeEnd === -1) {
-                  setSelectedItems([...selectedItems, id]);
-                  return;
-                }
-
-                const selectedItemsMap = arrayToMap(selectedItems);
-                const nextSelectedIds = libraryItems.reduce(
-                  (acc: LibraryItem["id"][], item, idx) => {
-                    if (
-                      (idx >= rangeStart && idx <= rangeEnd) ||
-                      selectedItemsMap.has(item.id)
-                    ) {
-                      acc.push(item.id);
-                    }
-                    return acc;
-                  },
-                  [],
-                );
+          if (shouldSelect) {
+            if (event.shiftKey && lastSelectedItem) {
+              const rangeStart = libraryItemsData.libraryItems.findIndex(
+                (item) => item.id === lastSelectedItem,
+              );
+              const rangeEnd = libraryItemsData.libraryItems.findIndex(
+                (item) => item.id === id,
+              );
 
-                setSelectedItems(nextSelectedIds);
-              } else {
+              if (rangeStart === -1 || rangeEnd === -1) {
                 setSelectedItems([...selectedItems, id]);
+                return;
               }
-              setLastSelectedItem(id);
+
+              const selectedItemsMap = arrayToMap(selectedItems);
+              const nextSelectedIds = libraryItemsData.libraryItems.reduce(
+                (acc: LibraryItem["id"][], item, idx) => {
+                  if (
+                    (idx >= rangeStart && idx <= rangeEnd) ||
+                    selectedItemsMap.has(item.id)
+                  ) {
+                    acc.push(item.id);
+                  }
+                  return acc;
+                },
+                [],
+              );
+
+              setSelectedItems(nextSelectedIds);
             } else {
-              setLastSelectedItem(null);
-              setSelectedItems(selectedItems.filter((_id) => _id !== id));
+              setSelectedItems([...selectedItems, id]);
             }
-          }}
-          onPublish={() => setShowPublishLibraryDialog(true)}
-          resetLibrary={resetLibrary}
-        />
-      )}
-    </Island>
+            setLastSelectedItem(id);
+          } else {
+            setLastSelectedItem(null);
+            setSelectedItems(selectedItems.filter((_id) => _id !== id));
+          }
+        }}
+        onPublish={() => setShowPublishLibraryDialog(true)}
+        resetLibrary={resetLibrary}
+      />
+    </LibraryMenuWrapper>
   );
 };

+ 0 - 5
src/components/LibraryMenuItems.tsx

@@ -106,11 +106,6 @@ const LibraryMenuItems = ({
             icon={load}
             onClick={() => {
               importLibraryFromJSON(library)
-                .then(() => {
-                  // Close and then open to get the libraries updated
-                  setAppState({ isLibraryOpen: false });
-                  setAppState({ isLibraryOpen: true });
-                })
                 .catch(muteFSAbortError)
                 .catch((error) => {
                   setAppState({ errorMessage: error.message });

+ 13 - 13
src/data/blob.ts

@@ -1,20 +1,16 @@
 import { nanoid } from "nanoid";
 import { cleanAppStateForExport } from "../appState";
-import {
-  ALLOWED_IMAGE_MIME_TYPES,
-  EXPORT_DATA_TYPES,
-  MIME_TYPES,
-} from "../constants";
+import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
 import { clearElementsForExport } from "../element";
 import { ExcalidrawElement, FileId } from "../element/types";
 import { CanvasError } from "../errors";
 import { t } from "../i18n";
 import { calculateScrollCenter } from "../scene";
-import { AppState, DataURL } from "../types";
+import { AppState, DataURL, LibraryItem } from "../types";
 import { bytesToHexString } from "../utils";
 import { FileSystemHandle } from "./filesystem";
-import { isValidExcalidrawData } from "./json";
-import { restore } from "./restore";
+import { isValidExcalidrawData, isValidLibrary } from "./json";
+import { restore, restoreLibraryItems } from "./restore";
 import { ImportedLibraryData } from "./types";
 
 const parseFileContents = async (blob: Blob | File) => {
@@ -163,13 +159,17 @@ export const loadFromBlob = async (
   }
 };
 
-export const loadLibraryFromBlob = async (blob: Blob) => {
+export const loadLibraryFromBlob = async (
+  blob: Blob,
+  defaultStatus: LibraryItem["status"] = "unpublished",
+) => {
   const contents = await parseFileContents(blob);
-  const data: ImportedLibraryData = JSON.parse(contents);
-  if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
-    throw new Error(t("alerts.couldNotLoadInvalidFile"));
+  const data: ImportedLibraryData | undefined = JSON.parse(contents);
+  if (!isValidLibrary(data)) {
+    throw new Error("Invalid library");
   }
-  return data;
+  const libraryItems = data.libraryItems || data.library;
+  return restoreLibraryItems(libraryItems, defaultStatus);
 };
 
 export const canvasToBlob = async (

+ 2 - 1
src/data/json.ts

@@ -15,6 +15,7 @@ import {
   ExportedDataState,
   ImportedDataState,
   ExportedLibraryData,
+  ImportedLibraryData,
 } from "./types";
 import Library from "./library";
 
@@ -114,7 +115,7 @@ export const isValidExcalidrawData = (data?: {
   );
 };
 
-export const isValidLibrary = (json: any) => {
+export const isValidLibrary = (json: any): json is ImportedLibraryData => {
   return (
     typeof json === "object" &&
     json &&

+ 111 - 70
src/data/library.ts

@@ -2,9 +2,51 @@ import { loadLibraryFromBlob } from "./blob";
 import { LibraryItems, LibraryItem } from "../types";
 import { restoreLibraryItems } from "./restore";
 import type App from "../components/App";
+import { ImportedDataState } from "./types";
+import { atom } from "jotai";
+import { jotaiStore } from "../jotai";
+import { isPromiseLike } from "../utils";
+import { t } from "../i18n";
+
+export const libraryItemsAtom = atom<
+  | { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
+  | { status: "loaded"; libraryItems: LibraryItems }
+>({ status: "loaded", libraryItems: [] });
+
+const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
+  JSON.parse(JSON.stringify(libraryItems));
+
+/**
+ * checks if library item does not exist already in current library
+ */
+const isUniqueItem = (
+  existingLibraryItems: LibraryItems,
+  targetLibraryItem: LibraryItem,
+) => {
+  return !existingLibraryItems.find((libraryItem) => {
+    if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
+      return false;
+    }
+
+    // detect z-index difference by checking the excalidraw elements
+    // are in order
+    return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
+      return (
+        libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
+        libItemExcalidrawItem.versionNonce ===
+          targetLibraryItem.elements[idx].versionNonce
+      );
+    });
+  });
+};
 
 class Library {
-  private libraryCache: LibraryItems | null = null;
+  /** cache for currently active promise when initializing/updating libaries
+   asynchronously */
+  private libraryItemsPromise: Promise<LibraryItems> | null = null;
+  /** last resolved libraryItems */
+  private lastLibraryItems: LibraryItems = [];
+
   private app: App;
 
   constructor(app: App) {
@@ -12,93 +54,92 @@ class Library {
   }
 
   resetLibrary = async () => {
-    await this.app.props.onLibraryChange?.([]);
-    this.libraryCache = [];
+    this.saveLibrary([]);
   };
 
   /** imports library (currently merges, removing duplicates) */
   async importLibrary(
-    blob: Blob,
+    library:
+      | Blob
+      | Required<ImportedDataState>["libraryItems"]
+      | Promise<Required<ImportedDataState>["libraryItems"]>,
     defaultStatus: LibraryItem["status"] = "unpublished",
   ) {
-    const libraryFile = await loadLibraryFromBlob(blob);
-    if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
-      return;
-    }
-
-    /**
-     * checks if library item does not exist already in current library
-     */
-    const isUniqueitem = (
-      existingLibraryItems: LibraryItems,
-      targetLibraryItem: LibraryItem,
-    ) => {
-      return !existingLibraryItems.find((libraryItem) => {
-        if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
-          return false;
+    return this.saveLibrary(
+      new Promise<LibraryItems>(async (resolve, reject) => {
+        try {
+          let libraryItems: LibraryItems;
+          if (library instanceof Blob) {
+            libraryItems = await loadLibraryFromBlob(library, defaultStatus);
+          } else {
+            libraryItems = restoreLibraryItems(await library, defaultStatus);
+          }
+
+          const existingLibraryItems = this.lastLibraryItems;
+
+          const filteredItems = [];
+          for (const item of libraryItems) {
+            if (isUniqueItem(existingLibraryItems, item)) {
+              filteredItems.push(item);
+            }
+          }
+
+          resolve([...filteredItems, ...existingLibraryItems]);
+        } catch (error) {
+          reject(new Error(t("errors.importLibraryError")));
         }
-
-        // detect z-index difference by checking the excalidraw elements
-        // are in order
-        return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
-          return (
-            libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
-            libItemExcalidrawItem.versionNonce ===
-              targetLibraryItem.elements[idx].versionNonce
-          );
-        });
-      });
-    };
-
-    const existingLibraryItems = await this.loadLibrary();
-
-    const library = libraryFile.libraryItems || libraryFile.library || [];
-    const restoredLibItems = restoreLibraryItems(library, defaultStatus);
-    const filteredItems = [];
-    for (const item of restoredLibItems) {
-      if (isUniqueitem(existingLibraryItems, item)) {
-        filteredItems.push(item);
-      }
-    }
-
-    await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
+      }),
+    );
   }
 
   loadLibrary = (): Promise<LibraryItems> => {
     return new Promise(async (resolve) => {
-      if (this.libraryCache) {
-        return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
-      }
-
       try {
-        const libraryItems = this.app.libraryItemsFromStorage;
-        if (!libraryItems) {
-          return resolve([]);
-        }
-
-        const items = restoreLibraryItems(libraryItems, "unpublished");
-
-        // clone to ensure we don't mutate the cached library elements in the app
-        this.libraryCache = JSON.parse(JSON.stringify(items));
-
-        resolve(items);
-      } catch (error: any) {
-        console.error(error);
-        resolve([]);
+        resolve(
+          cloneLibraryItems(
+            await (this.libraryItemsPromise || this.lastLibraryItems),
+          ),
+        );
+      } catch (error) {
+        return resolve(this.lastLibraryItems);
       }
     });
   };
 
-  saveLibrary = async (items: LibraryItems) => {
-    const prevLibraryItems = this.libraryCache;
+  saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
+    const prevLibraryItems = this.lastLibraryItems;
     try {
-      const serializedItems = JSON.stringify(items);
-      // cache optimistically so that the app has access to the latest
-      // immediately
-      this.libraryCache = JSON.parse(serializedItems);
-      await this.app.props.onLibraryChange?.(items);
+      let nextLibraryItems;
+      if (isPromiseLike(items)) {
+        const promise = items.then((items) => cloneLibraryItems(items));
+        this.libraryItemsPromise = promise;
+        jotaiStore.set(libraryItemsAtom, {
+          status: "loading",
+          promise,
+          libraryItems: null,
+        });
+        nextLibraryItems = await promise;
+      } else {
+        nextLibraryItems = cloneLibraryItems(items);
+      }
+
+      this.lastLibraryItems = nextLibraryItems;
+      this.libraryItemsPromise = null;
+
+      jotaiStore.set(libraryItemsAtom, {
+        status: "loaded",
+        libraryItems: nextLibraryItems,
+      });
+      await this.app.props.onLibraryChange?.(
+        cloneLibraryItems(nextLibraryItems),
+      );
     } catch (error: any) {
-      this.libraryCache = prevLibraryItems;
+      this.lastLibraryItems = prevLibraryItems;
+      this.libraryItemsPromise = null;
+      jotaiStore.set(libraryItemsAtom, {
+        status: "loaded",
+        libraryItems: prevLibraryItems,
+      });
       throw error;
     }
   };

+ 2 - 2
src/data/restore.ts

@@ -280,7 +280,7 @@ export const restoreAppState = (
 };
 
 export const restore = (
-  data: ImportedDataState | null,
+  data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
   /**
    * Local AppState (`this.state` or initial state from localStorage) so that we
    * don't overwrite local state with default values (when values not
@@ -306,7 +306,7 @@ const restoreLibraryItem = (libraryItem: LibraryItem) => {
 };
 
 export const restoreLibraryItems = (
-  libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
+  libraryItems: ImportedDataState["libraryItems"] = [],
   defaultStatus: LibraryItem["status"],
 ) => {
   const restoredItems: LibraryItem[] = [];

+ 7 - 7
src/excalidraw-app/data/localStorage.ts

@@ -6,6 +6,7 @@ import {
 } from "../../appState";
 import { clearElementsForLocalStorage } from "../../element";
 import { STORAGE_KEYS } from "../app_constants";
+import { ImportedDataState } from "../../data/types";
 
 export const saveUsernameToLocalStorage = (username: string) => {
   try {
@@ -102,14 +103,13 @@ export const getTotalStorageSize = () => {
 
 export const getLibraryItemsFromStorage = () => {
   try {
-    const libraryItems =
-      JSON.parse(
-        localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
-      ) || [];
+    const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
+      localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
+    );
 
-    return libraryItems;
-  } catch (e) {
-    console.error(e);
+    return libraryItems || [];
+  } catch (error) {
+    console.error(error);
     return [];
   }
 };

+ 4 - 4
src/excalidraw-app/index.tsx

@@ -12,7 +12,6 @@ import {
   VERSION_TIMEOUT,
 } from "../constants";
 import { loadFromBlob } from "../data/blob";
-import { ImportedDataState } from "../data/types";
 import {
   ExcalidrawElement,
   FileId,
@@ -29,6 +28,7 @@ import {
   LibraryItems,
   ExcalidrawImperativeAPI,
   BinaryFiles,
+  ExcalidrawInitialDataState,
 } from "../types";
 import {
   debounce,
@@ -84,7 +84,7 @@ languageDetector.init({
 const initializeScene = async (opts: {
   collabAPI: CollabAPI;
 }): Promise<
-  { scene: ImportedDataState | null } & (
+  { scene: ExcalidrawInitialDataState | null } & (
     | { isExternalScene: true; id: string; key: string }
     | { isExternalScene: false; id?: null; key?: null }
   )
@@ -211,11 +211,11 @@ const ExcalidrawWrapper = () => {
   // ---------------------------------------------------------------------------
 
   const initialStatePromiseRef = useRef<{
-    promise: ResolvablePromise<ImportedDataState | null>;
+    promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
   }>({ promise: null! });
   if (!initialStatePromiseRef.current.promise) {
     initialStatePromiseRef.current.promise =
-      resolvablePromise<ImportedDataState | null>();
+      resolvablePromise<ExcalidrawInitialDataState | null>();
   }
 
   useEffect(() => {

+ 2 - 0
src/global.d.ts

@@ -34,6 +34,8 @@ type Mutable<T> = {
   -readonly [P in keyof T]: T[P];
 };
 
+type Merge<M, N> = Omit<M, keyof N> & N;
+
 /** utility type to assert that the second type is a subtype of the first type.
  * Returns the subtype. */
 type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;

+ 4 - 0
src/jotai.ts

@@ -0,0 +1,4 @@
+import { unstable_createStore } from "jotai";
+
+export const jotaiScope = Symbol();
+export const jotaiStore = unstable_createStore();

+ 2 - 2
src/locales/en.json

@@ -172,7 +172,6 @@
     "uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
     "loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
     "collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
-    "errorLoadingLibrary": "There was an error loading the third party library.",
     "errorAddingToLibrary": "Couldn't add item to the library",
     "errorRemovingFromLibrary": "Couldn't remove item from the library",
     "confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
@@ -189,7 +188,8 @@
     "fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
     "svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
     "invalidSVGString": "Invalid SVG.",
-    "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again."
+    "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
+    "importLibraryError": "Couldn't load library"
   },
   "toolBar": {
     "selection": "Selection",

+ 30 - 26
src/packages/excalidraw/index.tsx

@@ -10,6 +10,8 @@ import "../../css/styles.scss";
 import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
 import { defaultLang } from "../../i18n";
 import { DEFAULT_UI_OPTIONS } from "../../constants";
+import { Provider } from "jotai";
+import { jotaiScope, jotaiStore } from "../../jotai";
 
 const Excalidraw = (props: ExcalidrawProps) => {
   const {
@@ -73,32 +75,34 @@ const Excalidraw = (props: ExcalidrawProps) => {
 
   return (
     <InitializeApp langCode={langCode}>
-      <App
-        onChange={onChange}
-        initialData={initialData}
-        excalidrawRef={excalidrawRef}
-        onCollabButtonClick={onCollabButtonClick}
-        isCollaborating={isCollaborating}
-        onPointerUpdate={onPointerUpdate}
-        renderTopRightUI={renderTopRightUI}
-        renderFooter={renderFooter}
-        langCode={langCode}
-        viewModeEnabled={viewModeEnabled}
-        zenModeEnabled={zenModeEnabled}
-        gridModeEnabled={gridModeEnabled}
-        libraryReturnUrl={libraryReturnUrl}
-        theme={theme}
-        name={name}
-        renderCustomStats={renderCustomStats}
-        UIOptions={UIOptions}
-        onPaste={onPaste}
-        detectScroll={detectScroll}
-        handleKeyboardGlobally={handleKeyboardGlobally}
-        onLibraryChange={onLibraryChange}
-        autoFocus={autoFocus}
-        generateIdForFile={generateIdForFile}
-        onLinkOpen={onLinkOpen}
-      />
+      <Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
+        <App
+          onChange={onChange}
+          initialData={initialData}
+          excalidrawRef={excalidrawRef}
+          onCollabButtonClick={onCollabButtonClick}
+          isCollaborating={isCollaborating}
+          onPointerUpdate={onPointerUpdate}
+          renderTopRightUI={renderTopRightUI}
+          renderFooter={renderFooter}
+          langCode={langCode}
+          viewModeEnabled={viewModeEnabled}
+          zenModeEnabled={zenModeEnabled}
+          gridModeEnabled={gridModeEnabled}
+          libraryReturnUrl={libraryReturnUrl}
+          theme={theme}
+          name={name}
+          renderCustomStats={renderCustomStats}
+          UIOptions={UIOptions}
+          onPaste={onPaste}
+          detectScroll={detectScroll}
+          handleKeyboardGlobally={handleKeyboardGlobally}
+          onLibraryChange={onLibraryChange}
+          autoFocus={autoFocus}
+          generateIdForFile={generateIdForFile}
+          onLinkOpen={onLinkOpen}
+        />
+      </Provider>
     </InitializeApp>
   );
 };

+ 13 - 1
src/types.ts

@@ -209,13 +209,25 @@ export type ExcalidrawAPIRefValue =
       ready?: false;
     };
 
+export type ExcalidrawInitialDataState = Merge<
+  ImportedDataState,
+  {
+    libraryItems?:
+      | Required<ImportedDataState>["libraryItems"]
+      | Promise<Required<ImportedDataState>["libraryItems"]>;
+  }
+>;
+
 export interface ExcalidrawProps {
   onChange?: (
     elements: readonly ExcalidrawElement[],
     appState: AppState,
     files: BinaryFiles,
   ) => void;
-  initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
+  initialData?:
+    | ExcalidrawInitialDataState
+    | null
+    | Promise<ExcalidrawInitialDataState | null>;
   excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
   onCollabButtonClick?: () => void;
   isCollaborating?: boolean;

+ 5 - 0
yarn.lock

@@ -7678,6 +7678,11 @@ jest@26.6.0:
     import-local "^3.0.2"
     jest-cli "^26.6.0"
 
+jotai@1.6.4:
+  version "1.6.4"
+  resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.6.4.tgz#4d9904362c53c4293d32e21fb358d3de34b82912"
+  integrity sha512-XC0ExLhdE6FEBdIjKTe6kMlHaAUV/QiwN7vZond76gNr/WdcdonJOEW79+5t8u38sR41bJXi26B1dRi7cCRz9A==
+
 "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"