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"; export const libraryItemsAtom = atom<{ status: "loading" | "loaded"; isInitialized: boolean; libraryItems: LibraryItems; }>({ status: "loaded", isInitialized: true, 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 ); }); }); }; /** Merges otherItems into localItems. Unique items in otherItems array are sorted first. */ export const mergeLibraryItems = ( localItems: LibraryItems, otherItems: LibraryItems, ): LibraryItems => { const newItems = []; for (const item of otherItems) { if (isUniqueItem(localItems, item)) { newItems.push(item); } } return [...newItems, ...localItems]; }; class Library { /** latest libraryItems */ private lastLibraryItems: LibraryItems = []; /** indicates whether library is initialized with library items (has gone * though at least one update) */ private isInitialized = false; private app: App; constructor(app: App) { this.app = app; } private updateQueue: Promise[] = []; private getLastUpdateTask = (): Promise | undefined => { return this.updateQueue[this.updateQueue.length - 1]; }; private notifyListeners = () => { if (this.updateQueue.length > 0) { jotaiStore.set(libraryItemsAtom, { status: "loading", libraryItems: this.lastLibraryItems, isInitialized: this.isInitialized, }); } else { this.isInitialized = true; jotaiStore.set(libraryItemsAtom, { status: "loaded", libraryItems: this.lastLibraryItems, isInitialized: this.isInitialized, }); try { this.app.props.onLibraryChange?.( cloneLibraryItems(this.lastLibraryItems), ); } catch (error) { console.error(error); } } }; resetLibrary = () => { return this.setLibrary([]); }; /** * imports library (from blob or libraryItems), merging with current library * (attempting to remove duplicates) */ importLibrary( library: | Blob | Required["libraryItems"] | Promise["libraryItems"]>, defaultStatus: LibraryItem["status"] = "unpublished", ): Promise { return this.setLibrary( () => new Promise(async (resolve, reject) => { try { let libraryItems: LibraryItems; if (library instanceof Blob) { libraryItems = await loadLibraryFromBlob(library, defaultStatus); } else { libraryItems = restoreLibraryItems(await library, defaultStatus); } resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems)); } catch (error) { reject(error); } }), ); } /** * @returns latest cloned libraryItems. Awaits all in-progress updates first. */ getLatestLibrary = (): Promise => { return new Promise(async (resolve) => { try { const libraryItems = await (this.getLastUpdateTask() || this.lastLibraryItems); if (this.updateQueue.length > 0) { resolve(this.getLatestLibrary()); } else { resolve(cloneLibraryItems(libraryItems)); } } catch (error) { return resolve(this.lastLibraryItems); } }); }; setLibrary = ( /** * LibraryItems that will replace current items. Can be a function which * will be invoked after all previous tasks are resolved * (this is the prefered way to update the library to avoid race conditions, * but you'll want to manually merge the library items in the callback * - which is what we're doing in Library.importLibrary()). * * If supplied promise is rejected with AbortError, we swallow it and * do not update the library. */ libraryItems: | LibraryItems | Promise | (( latestLibraryItems: LibraryItems, ) => LibraryItems | Promise), ): Promise => { const task = new Promise(async (resolve, reject) => { try { await this.getLastUpdateTask(); if (typeof libraryItems === "function") { libraryItems = libraryItems(this.lastLibraryItems); } this.lastLibraryItems = cloneLibraryItems(await libraryItems); resolve(this.lastLibraryItems); } catch (error: any) { reject(error); } }) .catch((error) => { if (error.name === "AbortError") { console.warn("Library update aborted by user"); return this.lastLibraryItems; } throw error; }) .finally(() => { this.updateQueue = this.updateQueue.filter((_task) => _task !== task); this.notifyListeners(); }); this.updateQueue.push(task); this.notifyListeners(); return task; }; } export default Library;