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
This commit is contained in:
David Luzar 2022-04-20 14:40:03 +02:00 committed by GitHub
parent 55ccd5b79b
commit cd942c3e3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 342 additions and 283 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ import {
ExportedDataState, ExportedDataState,
ImportedDataState, ImportedDataState,
ExportedLibraryData, ExportedLibraryData,
ImportedLibraryData,
} from "./types"; } from "./types";
import Library from "./library"; 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 ( return (
typeof json === "object" && typeof json === "object" &&
json && json &&

View File

@ -2,37 +2,27 @@ import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem } from "../types";
import { restoreLibraryItems } from "./restore"; import { restoreLibraryItems } from "./restore";
import type App from "../components/App"; 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";
class Library { export const libraryItemsAtom = atom<
private libraryCache: LibraryItems | null = null; | { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
private app: App; | { status: "loaded"; libraryItems: LibraryItems }
>({ status: "loaded", libraryItems: [] });
constructor(app: App) { const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
this.app = app; JSON.parse(JSON.stringify(libraryItems));
}
resetLibrary = async () => { /**
await this.app.props.onLibraryChange?.([]);
this.libraryCache = [];
};
/** imports library (currently merges, removing duplicates) */
async importLibrary(
blob: Blob,
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 * checks if library item does not exist already in current library
*/ */
const isUniqueitem = ( const isUniqueItem = (
existingLibraryItems: LibraryItems, existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem, targetLibraryItem: LibraryItem,
) => { ) => {
return !existingLibraryItems.find((libraryItem) => { return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) { if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false; return false;
@ -48,57 +38,108 @@ class Library {
); );
}); });
}); });
};
class Library {
/** 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) {
this.app = app;
}
resetLibrary = async () => {
this.saveLibrary([]);
}; };
const existingLibraryItems = await this.loadLibrary(); /** imports library (currently merges, removing duplicates) */
async importLibrary(
library:
| Blob
| Required<ImportedDataState>["libraryItems"]
| Promise<Required<ImportedDataState>["libraryItems"]>,
defaultStatus: LibraryItem["status"] = "unpublished",
) {
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 library = libraryFile.libraryItems || libraryFile.library || [];
const restoredLibItems = restoreLibraryItems(library, defaultStatus);
const filteredItems = []; const filteredItems = [];
for (const item of restoredLibItems) { for (const item of libraryItems) {
if (isUniqueitem(existingLibraryItems, item)) { if (isUniqueItem(existingLibraryItems, item)) {
filteredItems.push(item); filteredItems.push(item);
} }
} }
await this.saveLibrary([...filteredItems, ...existingLibraryItems]); resolve([...filteredItems, ...existingLibraryItems]);
} catch (error) {
reject(new Error(t("errors.importLibraryError")));
}
}),
);
} }
loadLibrary = (): Promise<LibraryItems> => { loadLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (this.libraryCache) {
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
}
try { try {
const libraryItems = this.app.libraryItemsFromStorage; resolve(
if (!libraryItems) { cloneLibraryItems(
return resolve([]); await (this.libraryItemsPromise || this.lastLibraryItems),
} ),
);
const items = restoreLibraryItems(libraryItems, "unpublished"); } catch (error) {
return resolve(this.lastLibraryItems);
// 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([]);
} }
}); });
}; };
saveLibrary = async (items: LibraryItems) => { saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
const prevLibraryItems = this.libraryCache; const prevLibraryItems = this.lastLibraryItems;
try { try {
const serializedItems = JSON.stringify(items); let nextLibraryItems;
// cache optimistically so that the app has access to the latest if (isPromiseLike(items)) {
// immediately const promise = items.then((items) => cloneLibraryItems(items));
this.libraryCache = JSON.parse(serializedItems); this.libraryItemsPromise = promise;
await this.app.props.onLibraryChange?.(items); 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) { } catch (error: any) {
this.libraryCache = prevLibraryItems; this.lastLibraryItems = prevLibraryItems;
this.libraryItemsPromise = null;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: prevLibraryItems,
});
throw error; throw error;
} }
}; };

View File

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

View File

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

View File

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

2
src/global.d.ts vendored
View File

@ -34,6 +34,8 @@ type Mutable<T> = {
-readonly [P in keyof T]: T[P]; -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. /** utility type to assert that the second type is a subtype of the first type.
* Returns the subtype. */ * Returns the subtype. */
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype; type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;

4
src/jotai.ts Normal file
View File

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

View File

@ -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.", "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?", "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.)", "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", "errorAddingToLibrary": "Couldn't add item to the library",
"errorRemovingFromLibrary": "Couldn't remove item from the library", "errorRemovingFromLibrary": "Couldn't remove item from the library",
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?", "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}}.", "fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.", "svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
"invalidSVGString": "Invalid SVG.", "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": { "toolBar": {
"selection": "Selection", "selection": "Selection",

View File

@ -10,6 +10,8 @@ import "../../css/styles.scss";
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types"; import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { defaultLang } from "../../i18n"; import { defaultLang } from "../../i18n";
import { DEFAULT_UI_OPTIONS } from "../../constants"; import { DEFAULT_UI_OPTIONS } from "../../constants";
import { Provider } from "jotai";
import { jotaiScope, jotaiStore } from "../../jotai";
const Excalidraw = (props: ExcalidrawProps) => { const Excalidraw = (props: ExcalidrawProps) => {
const { const {
@ -73,6 +75,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
return ( return (
<InitializeApp langCode={langCode}> <InitializeApp langCode={langCode}>
<Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
<App <App
onChange={onChange} onChange={onChange}
initialData={initialData} initialData={initialData}
@ -99,6 +102,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
generateIdForFile={generateIdForFile} generateIdForFile={generateIdForFile}
onLinkOpen={onLinkOpen} onLinkOpen={onLinkOpen}
/> />
</Provider>
</InitializeApp> </InitializeApp>
); );
}; };

View File

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

View File

@ -7678,6 +7678,11 @@ jest@26.6.0:
import-local "^3.0.2" import-local "^3.0.2"
jest-cli "^26.6.0" 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: "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"