fix: library init/import race conditions (#5101)

This commit is contained in:
David Luzar 2022-04-29 16:45:02 +02:00 committed by GitHub
parent 6a0f800716
commit d53ac2a61e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 248 additions and 133 deletions

View File

@ -25,9 +25,9 @@ export const actionAddToLibrary = register({
} }
return app.library return app.library
.loadLibrary() .getLatestLibrary()
.then((items) => { .then((items) => {
return app.library.saveLibrary([ return app.library.setLibrary([
{ {
id: randomId(), id: randomId(),
status: "unpublished", status: "unpublished",

View File

@ -257,6 +257,7 @@ import {
isPointHittingLinkIcon, isPointHittingLinkIcon,
isLocalLink, isLocalLink,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { AbortError } from "../errors";
const defaultDeviceTypeContext: DeviceType = { const defaultDeviceTypeContext: DeviceType = {
isMobile: false, isMobile: false,
@ -703,11 +704,18 @@ class App extends React.Component<AppProps, AppState> {
window.history.replaceState({}, APP_NAME, `?${query.toString()}`); window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
} }
const defaultStatus = "published";
this.setState({ isLibraryOpen: true });
try {
await this.library.importLibrary(
new Promise<LibraryItems>(async (resolve, reject) => {
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 defaultStatus = "published";
const libraryItems = await loadLibraryFromBlob(blob, defaultStatus); const libraryItems = await loadLibraryFromBlob(blob, defaultStatus);
if ( if (
token === this.id || token === this.id ||
window.confirm( window.confirm(
@ -716,8 +724,15 @@ class App extends React.Component<AppProps, AppState> {
}), }),
) )
) { ) {
await this.library.importLibrary(libraryItems, defaultStatus); resolve(libraryItems);
} else {
reject(new AbortError());
} }
} catch (error: any) {
reject(error);
}
}),
);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
this.setState({ errorMessage: t("errors.importLibraryError") }); this.setState({ errorMessage: t("errors.importLibraryError") });
@ -1674,6 +1689,11 @@ class App extends React.Component<AppProps, AppState> {
collaborators?: SceneData["collaborators"]; collaborators?: SceneData["collaborators"];
commitToHistory?: SceneData["commitToHistory"]; commitToHistory?: SceneData["commitToHistory"];
libraryItems?: libraryItems?:
| ((
currentLibraryItems: LibraryItems,
) =>
| Required<SceneData>["libraryItems"]
| Promise<Required<SceneData>["libraryItems"]>)
| Required<SceneData>["libraryItems"] | Required<SceneData>["libraryItems"]
| Promise<Required<SceneData>["libraryItems"]>; | Promise<Required<SceneData>["libraryItems"]>;
}) => { }) => {
@ -1694,20 +1714,20 @@ class App extends React.Component<AppProps, AppState> {
} }
if (sceneData.libraryItems) { if (sceneData.libraryItems) {
this.library.saveLibrary( this.library.setLibrary((currentLibraryItems) => {
new Promise<LibraryItems>(async (resolve, reject) => { const nextItems =
typeof sceneData.libraryItems === "function"
? sceneData.libraryItems(currentLibraryItems)
: sceneData.libraryItems;
return new Promise<LibraryItems>(async (resolve, reject) => {
try { try {
resolve( resolve(restoreLibraryItems(await nextItems, "unpublished"));
restoreLibraryItems( } catch (error: any) {
await sceneData.libraryItems, reject(error);
"unpublished",
),
);
} catch {
reject(new Error(t("errors.importLibraryError")));
} }
}), });
); });
} }
}, },
); );
@ -5280,11 +5300,14 @@ class App extends React.Component<AppProps, AppState> {
file?.type === MIME_TYPES.excalidrawlib || file?.type === MIME_TYPES.excalidrawlib ||
file?.name?.endsWith(".excalidrawlib") file?.name?.endsWith(".excalidrawlib")
) { ) {
this.library this.setState({ isLibraryOpen: true });
.importLibrary(file) this.library.importLibrary(file).catch((error) => {
.catch((error) => console.error(error);
this.setState({ isLoading: false, errorMessage: error.message }), this.setState({
); isLoading: false,
errorMessage: t("errors.importLibraryError"),
});
});
// default: assume an Excalidraw file regardless of extension/MimeType // default: assume an Excalidraw file regardless of extension/MimeType
} else if (file) { } else if (file) {
this.setState({ isLoading: true }); this.setState({ isLoading: true });

View File

@ -13,6 +13,10 @@
width: 100%; width: 100%;
margin: 2px 0; margin: 2px 0;
.Spinner {
margin-right: 1rem;
}
button { button {
// 2px from the left to account for focus border of left-most button // 2px from the left to account for focus border of left-most button
margin: 0 2px; margin: 0 2px;

View File

@ -139,7 +139,7 @@ export const LibraryMenu = ({
const nextItems = libraryItems.filter( const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id), (item) => !selectedItems.includes(item.id),
); );
library.saveLibrary(nextItems).catch(() => { library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
}); });
setSelectedItems([]); setSelectedItems([]);
@ -170,7 +170,7 @@ export const LibraryMenu = ({
...libraryItems, ...libraryItems,
]; ];
onAddToLibrary(); onAddToLibrary();
library.saveLibrary(nextItems).catch(() => { library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
}); });
}, },
@ -220,7 +220,7 @@ export const LibraryMenu = ({
libItem.status = "published"; libItem.status = "published";
} }
}); });
library.saveLibrary(nextLibItems); library.setLibrary(nextLibItems);
}, },
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library], [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
); );
@ -229,7 +229,10 @@ export const LibraryMenu = ({
LibraryItem["id"] | null LibraryItem["id"] | null
>(null); >(null);
if (libraryItemsData.status === "loading") { if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return ( return (
<LibraryMenuWrapper ref={ref}> <LibraryMenuWrapper ref={ref}>
<div className="layer-ui__library-message"> <div className="layer-ui__library-message">
@ -255,7 +258,7 @@ export const LibraryMenu = ({
} }
onError={(error) => window.alert(error)} onError={(error) => window.alert(error)}
updateItemsInStorage={() => updateItemsInStorage={() =>
library.saveLibrary(libraryItemsData.libraryItems) library.setLibrary(libraryItemsData.libraryItems)
} }
onRemove={(id: string) => onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id)) setSelectedItems(selectedItems.filter((_id) => _id !== id))
@ -264,6 +267,7 @@ export const LibraryMenu = ({
)} )}
{publishLibSuccess && renderPublishSuccess()} {publishLibSuccess && renderPublishSuccess()}
<LibraryMenuItems <LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItemsData.libraryItems} libraryItems={libraryItemsData.libraryItems}
onRemoveFromLibrary={() => onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems) removeFromLibrary(libraryItemsData.libraryItems)

View File

@ -22,8 +22,10 @@ import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
import { VERSIONS } from "../constants"; import { VERSIONS } from "../constants";
import Spinner from "./Spinner";
const LibraryMenuItems = ({ const LibraryMenuItems = ({
isLoading,
libraryItems, libraryItems,
onRemoveFromLibrary, onRemoveFromLibrary,
onAddToLibrary, onAddToLibrary,
@ -40,6 +42,7 @@ const LibraryMenuItems = ({
onPublish, onPublish,
resetLibrary, resetLibrary,
}: { }: {
isLoading: boolean;
libraryItems: LibraryItems; libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void; onRemoveFromLibrary: () => void;
@ -108,7 +111,8 @@ const LibraryMenuItems = ({
importLibraryFromJSON(library) importLibraryFromJSON(library)
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
setAppState({ errorMessage: error.message }); console.error(error);
setAppState({ errorMessage: t("errors.importLibraryError") });
}); });
}} }}
className="library-actions--load" className="library-actions--load"
@ -125,7 +129,7 @@ const LibraryMenuItems = ({
onClick={async () => { onClick={async () => {
const libraryItems = itemsSelected const libraryItems = itemsSelected
? items ? items
: await library.loadLibrary(); : await library.getLatestLibrary();
saveLibraryAsJSON(libraryItems) saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
@ -284,6 +288,9 @@ const LibraryMenuItems = ({
{showRemoveLibAlert && renderRemoveLibAlert()} {showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header"> <div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()} {renderLibraryActions()}
{isLoading ? (
<Spinner />
) : (
<a <a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank" window.name || "_blank"
@ -294,6 +301,7 @@ const LibraryMenuItems = ({
> >
{t("labels.libraries")} {t("labels.libraries")}
</a> </a>
)}
</div> </div>
<Stack.Col <Stack.Col
className="library-menu-items-container__items" className="library-menu-items-container__items"

View File

@ -5,13 +5,12 @@ import type App from "../components/App";
import { ImportedDataState } from "./types"; import { ImportedDataState } from "./types";
import { atom } from "jotai"; import { atom } from "jotai";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { isPromiseLike } from "../utils";
import { t } from "../i18n";
export const libraryItemsAtom = atom< export const libraryItemsAtom = atom<{
| { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> } status: "loading" | "loaded";
| { status: "loaded"; libraryItems: LibraryItems } isInitialized: boolean;
>({ status: "loaded", libraryItems: [] }); libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems => const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
JSON.parse(JSON.stringify(libraryItems)); JSON.parse(JSON.stringify(libraryItems));
@ -40,12 +39,28 @@ const isUniqueItem = (
}); });
}; };
/** Merges otherItems into localItems. Unique items in otherItems array are
sorted first. */
export const mergeLibraryItems = (
localItems: LibraryItems,
otherItems: LibraryItems,
): LibraryItems => {
const newItems = [];
for (const item of otherItems) {
if (isUniqueItem(localItems, item)) {
newItems.push(item);
}
}
return [...newItems, ...localItems];
};
class Library { class Library {
/** cache for currently active promise when initializing/updating libaries /** latest libraryItems */
asynchronously */
private libraryItemsPromise: Promise<LibraryItems> | null = null;
/** last resolved libraryItems */
private lastLibraryItems: LibraryItems = []; private lastLibraryItems: LibraryItems = [];
/** indicates whether library is initialized with library items (has gone
* though at least one update) */
private isInitialized = false;
private app: App; private app: App;
@ -53,19 +68,53 @@ class Library {
this.app = app; this.app = app;
} }
resetLibrary = async () => { private updateQueue: Promise<LibraryItems>[] = [];
this.saveLibrary([]);
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
return this.updateQueue[this.updateQueue.length - 1];
}; };
/** imports library (currently merges, removing duplicates) */ private notifyListeners = () => {
async importLibrary( if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, {
status: "loading",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
} else {
this.isInitialized = true;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
try {
this.app.props.onLibraryChange?.(
cloneLibraryItems(this.lastLibraryItems),
);
} catch (error) {
console.error(error);
}
}
};
resetLibrary = () => {
return this.setLibrary([]);
};
/**
* imports library (from blob or libraryItems), merging with current library
* (attempting to remove duplicates)
*/
importLibrary(
library: library:
| Blob | Blob
| Required<ImportedDataState>["libraryItems"] | Required<ImportedDataState>["libraryItems"]
| Promise<Required<ImportedDataState>["libraryItems"]>, | Promise<Required<ImportedDataState>["libraryItems"]>,
defaultStatus: LibraryItem["status"] = "unpublished", defaultStatus: LibraryItem["status"] = "unpublished",
) { ): Promise<LibraryItems> {
return this.saveLibrary( return this.setLibrary(
() =>
new Promise<LibraryItems>(async (resolve, reject) => { new Promise<LibraryItems>(async (resolve, reject) => {
try { try {
let libraryItems: LibraryItems; let libraryItems: LibraryItems;
@ -75,73 +124,82 @@ class Library {
libraryItems = restoreLibraryItems(await library, defaultStatus); libraryItems = restoreLibraryItems(await library, defaultStatus);
} }
const existingLibraryItems = this.lastLibraryItems; resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems));
const filteredItems = [];
for (const item of libraryItems) {
if (isUniqueItem(existingLibraryItems, item)) {
filteredItems.push(item);
}
}
resolve([...filteredItems, ...existingLibraryItems]);
} catch (error) { } catch (error) {
reject(new Error(t("errors.importLibraryError"))); reject(error);
} }
}), }),
); );
} }
loadLibrary = (): Promise<LibraryItems> => { /**
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
getLatestLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
try { try {
resolve( const libraryItems = await (this.getLastUpdateTask() ||
cloneLibraryItems( this.lastLibraryItems);
await (this.libraryItemsPromise || this.lastLibraryItems), if (this.updateQueue.length > 0) {
), resolve(this.getLatestLibrary());
); } else {
resolve(cloneLibraryItems(libraryItems));
}
} catch (error) { } catch (error) {
return resolve(this.lastLibraryItems); return resolve(this.lastLibraryItems);
} }
}); });
}; };
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => { setLibrary = (
const prevLibraryItems = this.lastLibraryItems; /**
* LibraryItems that will replace current items. Can be a function which
* will be invoked after all previous tasks are resolved
* (this is the prefered way to update the library to avoid race conditions,
* but you'll want to manually merge the library items in the callback
* - which is what we're doing in Library.importLibrary()).
*
* If supplied promise is rejected with AbortError, we swallow it and
* do not update the library.
*/
libraryItems:
| LibraryItems
| Promise<LibraryItems>
| ((
latestLibraryItems: LibraryItems,
) => LibraryItems | Promise<LibraryItems>),
): Promise<LibraryItems> => {
const task = new Promise<LibraryItems>(async (resolve, reject) => {
try { try {
let nextLibraryItems; await this.getLastUpdateTask();
if (isPromiseLike(items)) {
const promise = items.then((items) => cloneLibraryItems(items)); if (typeof libraryItems === "function") {
this.libraryItemsPromise = promise; libraryItems = libraryItems(this.lastLibraryItems);
jotaiStore.set(libraryItemsAtom, {
status: "loading",
promise,
libraryItems: null,
});
nextLibraryItems = await promise;
} else {
nextLibraryItems = cloneLibraryItems(items);
} }
this.lastLibraryItems = nextLibraryItems; this.lastLibraryItems = cloneLibraryItems(await libraryItems);
this.libraryItemsPromise = null;
jotaiStore.set(libraryItemsAtom, { resolve(this.lastLibraryItems);
status: "loaded",
libraryItems: nextLibraryItems,
});
await this.app.props.onLibraryChange?.(
cloneLibraryItems(nextLibraryItems),
);
} catch (error: any) { } catch (error: any) {
this.lastLibraryItems = prevLibraryItems; reject(error);
this.libraryItemsPromise = null;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: prevLibraryItems,
});
throw error;
} }
})
.catch((error) => {
if (error.name === "AbortError") {
console.warn("Library update aborted by user");
return this.lastLibraryItems;
}
throw error;
})
.finally(() => {
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
this.notifyListeners();
});
this.updateQueue.push(task);
this.notifyListeners();
return task;
}; };
} }

View File

@ -17,7 +17,9 @@ Please add the latest change on the top under the correct section.
#### Features #### Features
- Expose util `exportToClipboard`[https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportToClipboard] which allows to copy the scene contents to clipboard as `svg`, `png` or `json` [#5103](https://github.com/excalidraw/excalidraw/pull/5103). - Support `libraryItems` argument in `initialData.libraryItems` and `updateScene({ libraryItems })` to be a Promise resolving to `LibraryItems`, and support functional update of `libraryItems` in [`updateScene({ libraryItems })`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateScene). [#5101](https://github.com/excalidraw/excalidraw/pull/5101).
- Expose util [`mergeLibraryItems`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#mergeLibraryItems) [#5101](https://github.com/excalidraw/excalidraw/pull/5101).
- Expose util [`exportToClipboard`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportToClipboard) which allows to copy the scene contents to clipboard as `svg`, `png` or `json` [#5103](https://github.com/excalidraw/excalidraw/pull/5103).
- Expose `window.EXCALIDRAW_EXPORT_SOURCE` which you can use to overwrite the `source` field in exported data [#5095](https://github.com/excalidraw/excalidraw/pull/5095). - Expose `window.EXCALIDRAW_EXPORT_SOURCE` which you can use to overwrite the `source` field in exported data [#5095](https://github.com/excalidraw/excalidraw/pull/5095).
- The `exportToBlob` utility now supports the `exportEmbedScene` option when generating a png image [#5047](https://github.com/excalidraw/excalidraw/pull/5047). - The `exportToBlob` utility now supports the `exportEmbedScene` option when generating a png image [#5047](https://github.com/excalidraw/excalidraw/pull/5047).
- Exported [`restoreLibraryItems`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreLibraryItems) API [#4995](https://github.com/excalidraw/excalidraw/pull/4995). - Exported [`restoreLibraryItems`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreLibraryItems) API [#4995](https://github.com/excalidraw/excalidraw/pull/4995).

View File

@ -436,7 +436,7 @@ This helps to load Excalidraw with `initialData`. It must be an object or a [pro
| `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78) | The elements with which Excalidraw should be mounted. | | `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78) | The elements with which Excalidraw should be mounted. |
| `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) | The App state with which Excalidraw should be mounted. | | `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) | The App state with which Excalidraw should be mounted. |
| `scrollToContent` | boolean | This attribute implies whether to scroll to the nearest element to center once Excalidraw is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained | | `scrollToContent` | boolean | This attribute implies whether to scroll to the nearest element to center once Excalidraw is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained |
| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L151) | This library items with which Excalidraw should be mounted. | | `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) &#124; Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)> | This library items with which Excalidraw should be mounted. |
| `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) | The files added to the scene. | | `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) | The files added to the scene. |
```json ```json
@ -514,7 +514,7 @@ You can use this function to update the scene with the sceneData. It accepts the
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L18) | The `appState` to be updated in the scene. | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L18) | The `appState` to be updated in the scene. |
| `collaborators` | <pre>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L29">Collaborator></a></pre> | The list of collaborators to be updated in the scene. | | `collaborators` | <pre>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L29">Collaborator></a></pre> | The list of collaborators to be updated in the scene. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | | `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L258) | The `libraryItems` to be update in the scene. | | `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) &#124; Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)> &#124; ((currentItems: [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)>) => [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) &#124; Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)>) | The `libraryItems` to be update in the scene. |
### `addFiles` ### `addFiles`
@ -960,7 +960,7 @@ If you want to overwrite the source field in the JSON string, you can set `windo
<pre> <pre>
serializeLibraryAsJSON({ serializeLibraryAsJSON({
libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L191">LibraryItems[]</a>, libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems[]</a>,
</pre> </pre>
Takes the library items and returns a JSON string. Takes the library items and returns a JSON string.
@ -1072,6 +1072,20 @@ getNonDeletedElements(elements: <a href="https://github.com/excalidraw/excalidra
This function returns an array of deleted elements. This function returns an array of deleted elements.
#### `mergeLibraryItems`
```js
import { mergeLibraryItems } from "@excalidraw/excalidraw-next";
```
**_Signature_**
<pre>
mergeLibraryItems(localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>, otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>
</pre>
This function merges two `LibraryItems` arrays, where unique items from `otherItems` are sorted first in the returned array.
### Exported constants ### Exported constants
#### `FONT_FAMILY` #### `FONT_FAMILY`

View File

@ -198,6 +198,7 @@ export {
loadFromBlob, loadFromBlob,
getFreeDrawSvgPath, getFreeDrawSvgPath,
exportToClipboard, exportToClipboard,
mergeLibraryItems,
} from "../../packages/utils"; } from "../../packages/utils";
export { isLinearElement } from "../../element/typeChecks"; export { isLinearElement } from "../../element/typeChecks";

View File

@ -194,3 +194,4 @@ export const exportToClipboard = async (
export { serializeAsJSON, serializeLibraryAsJSON } from "../data/json"; export { serializeAsJSON, serializeLibraryAsJSON } from "../data/json";
export { loadFromBlob, loadLibraryFromBlob } from "../data/blob"; export { loadFromBlob, loadLibraryFromBlob } from "../data/blob";
export { getFreeDrawSvgPath } from "../renderer/renderElement"; export { getFreeDrawSvgPath } from "../renderer/renderElement";
export { mergeLibraryItems } from "../data/library";

View File

@ -14,12 +14,12 @@ describe("library", () => {
}); });
it("import library via drag&drop", async () => { it("import library via drag&drop", async () => {
expect(await h.app.library.loadLibrary()).toEqual([]); expect(await h.app.library.getLatestLibrary()).toEqual([]);
await API.drop( await API.drop(
await API.loadFile("./fixtures/fixture_library.excalidrawlib"), await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
); );
await waitFor(async () => { await waitFor(async () => {
expect(await h.app.library.loadLibrary()).toEqual([ expect(await h.app.library.getLatestLibrary()).toEqual([
{ {
status: "unpublished", status: "unpublished",
elements: [expect.objectContaining({ id: "A" })], elements: [expect.objectContaining({ id: "A" })],