mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-02 03:25:53 +01:00
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:
parent
55ccd5b79b
commit
cd942c3e3b
@ -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",
|
||||
|
@ -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 }),
|
||||
);
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 [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 : (
|
||||
<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 = 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}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
@ -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 });
|
||||
|
@ -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 (
|
||||
|
@ -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 &&
|
||||
|
@ -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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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;
|
||||
}
|
||||
};
|
||||
|
@ -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[] = [];
|
||||
|
@ -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 [];
|
||||
}
|
||||
};
|
||||
|
@ -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
src/global.d.ts
vendored
2
src/global.d.ts
vendored
@ -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
src/jotai.ts
Normal file
4
src/jotai.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { unstable_createStore } from "jotai";
|
||||
|
||||
export const jotaiScope = Symbol();
|
||||
export const jotaiStore = unstable_createStore();
|
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
14
src/types.ts
14
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;
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user