From cd942c3e3b9f4ca109b9d46f9074d74536f131ba Mon Sep 17 00:00:00 2001 From: David Luzar Date: Wed, 20 Apr 2022 14:40:03 +0200 Subject: [PATCH] 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 --- package.json | 1 + src/components/App.tsx | 44 ++--- src/components/HintViewer.tsx | 4 + src/components/LibraryMenu.scss | 13 +- src/components/LibraryMenu.tsx | 239 ++++++++++++------------ src/components/LibraryMenuItems.tsx | 5 - src/data/blob.ts | 26 +-- src/data/json.ts | 3 +- src/data/library.ts | 179 +++++++++++------- src/data/restore.ts | 4 +- src/excalidraw-app/data/localStorage.ts | 14 +- src/excalidraw-app/index.tsx | 8 +- src/global.d.ts | 2 + src/jotai.ts | 4 + src/locales/en.json | 4 +- src/packages/excalidraw/index.tsx | 56 +++--- src/types.ts | 14 +- yarn.lock | 5 + 18 files changed, 342 insertions(+), 283 deletions(-) create mode 100644 src/jotai.ts diff --git a/package.json b/package.json index f837eaa62..9d0d3178e 100644 --- a/package.json +++ b/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", diff --git a/src/components/App.tsx b/src/components/App.tsx index ec9271793..d542c3a4d 100644 --- a/src/components/App.tsx +++ b/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 { 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 { 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 { appState?: Pick | null; collaborators?: SceneData["collaborators"]; commitToHistory?: SceneData["commitToHistory"]; - libraryItems?: SceneData["libraryItems"]; + libraryItems?: + | Required["libraryItems"] + | Promise["libraryItems"]>; }) => { if (sceneData.commitToHistory) { this.history.resumeRecording(); @@ -1700,14 +1692,7 @@ class App extends React.Component { } 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 { ) { 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 }), ); diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index 983938a85..1809c2af3 100644 --- a/src/components/HintViewer.tsx +++ b/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"); } diff --git a/src/components/LibraryMenu.scss b/src/components/LibraryMenu.scss index fbd784f7e..803c18480 100644 --- a/src/components/LibraryMenu.scss +++ b/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 { diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index 508683d51..8ecced2cf 100644 --- a/src/components/LibraryMenu.tsx +++ b/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, @@ -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 ( + + {children} + + ); +}); + export const LibraryMenu = ({ onClose, onInsertShape, @@ -103,11 +124,6 @@ export const LibraryMenu = ({ }; }, [onClose]); - const [libraryItems, setLibraryItems] = useState([]); - - const [loadingState, setIsLoading] = useState< - "preloading" | "loading" | "ready" - >("preloading"); const [selectedItems, setSelectedItems] = useState([]); const [showPublishLibraryDialog, setShowPublishLibraryDialog] = useState(false); @@ -115,56 +131,35 @@ export const LibraryMenu = ({ url: string; authorName: string; }>(null); - const loadingTimerRef = useRef(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 [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); - const removeFromLibrary = useCallback(async () => { - const items = await library.loadLibrary(); - - 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 : ( - + if (libraryItemsData.status === "loading") { + return ( + +
+ + {t("labels.libraryLoadingMessage")} +
+
+ ); + } + + return ( + {showPublishLibraryDialog && ( 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()} + + 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" ? ( -
- {t("labels.libraryLoadingMessage")} -
- ) : ( - { - const shouldSelect = !selectedItems.includes(id); + if (shouldSelect) { + if (event.shiftKey && lastSelectedItem) { + const rangeStart = libraryItemsData.libraryItems.findIndex( + (item) => item.id === lastSelectedItem, + ); + const rangeEnd = libraryItemsData.libraryItems.findIndex( + (item) => item.id === 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; - }, - [], - ); - - 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} - /> - )} -
+ setLastSelectedItem(id); + } else { + setLastSelectedItem(null); + setSelectedItems(selectedItems.filter((_id) => _id !== id)); + } + }} + onPublish={() => setShowPublishLibraryDialog(true)} + resetLibrary={resetLibrary} + /> + ); }; diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index c059028d9..b236e1023 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/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 }); diff --git a/src/data/blob.ts b/src/data/blob.ts index 2418f5ace..c22fc0efa 100644 --- a/src/data/blob.ts +++ b/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 ( diff --git a/src/data/json.ts b/src/data/json.ts index fc52b8741..f58e59063 100644 --- a/src/data/json.ts +++ b/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 && diff --git a/src/data/library.ts b/src/data/library.ts index 2710b430f..eb4df4fd6 100644 --- a/src/data/library.ts +++ b/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 } + | { 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 | 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["libraryItems"] + | Promise["libraryItems"]>, defaultStatus: LibraryItem["status"] = "unpublished", ) { - const libraryFile = await loadLibraryFromBlob(blob); - if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) { - return; - } + return this.saveLibrary( + new Promise(async (resolve, reject) => { + try { + let libraryItems: LibraryItems; + if (library instanceof Blob) { + libraryItems = await loadLibraryFromBlob(library, defaultStatus); + } else { + libraryItems = restoreLibraryItems(await library, defaultStatus); + } - /** - * 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; + 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 => { 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) => { + 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; } }; diff --git a/src/data/restore.ts b/src/data/restore.ts index 7c18da293..cc6596077 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -280,7 +280,7 @@ export const restoreAppState = ( }; export const restore = ( - data: ImportedDataState | null, + data: Pick | 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, + libraryItems: ImportedDataState["libraryItems"] = [], defaultStatus: LibraryItem["status"], ) => { const restoredItems: LibraryItem[] = []; diff --git a/src/excalidraw-app/data/localStorage.ts b/src/excalidraw-app/data/localStorage.ts index d9e843681..6902052bd 100644 --- a/src/excalidraw-app/data/localStorage.ts +++ b/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 []; } }; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 787efbda5..5451a9c82 100644 --- a/src/excalidraw-app/index.tsx +++ b/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; + promise: ResolvablePromise; }>({ promise: null! }); if (!initialStatePromiseRef.current.promise) { initialStatePromiseRef.current.promise = - resolvablePromise(); + resolvablePromise(); } useEffect(() => { diff --git a/src/global.d.ts b/src/global.d.ts index 337a5f0d7..4d607b9a7 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -34,6 +34,8 @@ type Mutable = { -readonly [P in keyof T]: T[P]; }; +type Merge = Omit & N; + /** utility type to assert that the second type is a subtype of the first type. * Returns the subtype. */ type SubtypeOf = Subtype; diff --git a/src/jotai.ts b/src/jotai.ts new file mode 100644 index 000000000..e26bab1d3 --- /dev/null +++ b/src/jotai.ts @@ -0,0 +1,4 @@ +import { unstable_createStore } from "jotai"; + +export const jotaiScope = Symbol(); +export const jotaiStore = unstable_createStore(); diff --git a/src/locales/en.json b/src/locales/en.json index 7e69365c1..168d1ce67 100644 --- a/src/locales/en.json +++ b/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", diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index bee1e605e..05bb3a34b 100644 --- a/src/packages/excalidraw/index.tsx +++ b/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 ( - + jotaiStore} scope={jotaiScope}> + + ); }; diff --git a/src/types.ts b/src/types.ts index 43f9212ee..6aefedf9f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -209,13 +209,25 @@ export type ExcalidrawAPIRefValue = ready?: false; }; +export type ExcalidrawInitialDataState = Merge< + ImportedDataState, + { + libraryItems?: + | Required["libraryItems"] + | Promise["libraryItems"]>; + } +>; + export interface ExcalidrawProps { onChange?: ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, ) => void; - initialData?: ImportedDataState | null | Promise; + initialData?: + | ExcalidrawInitialDataState + | null + | Promise; excalidrawRef?: ForwardRef; onCollabButtonClick?: () => void; isCollaborating?: boolean; diff --git a/yarn.lock b/yarn.lock index 73b69d8c1..c34301cc9 100644 --- a/yarn.lock +++ b/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"