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,21 +704,35 @@ 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 { try {
const request = await fetch(decodeURIComponent(url)); await this.library.importLibrary(
const blob = await request.blob(); new Promise<LibraryItems>(async (resolve, reject) => {
const defaultStatus = "published"; try {
const libraryItems = await loadLibraryFromBlob(blob, defaultStatus); const request = await fetch(decodeURIComponent(url));
if ( const blob = await request.blob();
token === this.id || const libraryItems = await loadLibraryFromBlob(blob, defaultStatus);
window.confirm(
t("alerts.confirmAddLibrary", { if (
numShapes: libraryItems.length, token === this.id ||
}), window.confirm(
) t("alerts.confirmAddLibrary", {
) { numShapes: libraryItems.length,
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,16 +288,20 @@ 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()}
<a {isLoading ? (
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ <Spinner />
window.name || "_blank" ) : (
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${ <a
VERSIONS.excalidrawLibrary href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
}`} window.name || "_blank"
target="_excalidraw_libraries" }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
> VERSIONS.excalidrawLibrary
{t("labels.libraries")} }`}
</a> target="_excalidraw_libraries"
>
{t("labels.libraries")}
</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,95 +68,138 @@ 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) => { () =>
try { new Promise<LibraryItems>(async (resolve, reject) => {
let libraryItems: LibraryItems; try {
if (library instanceof Blob) { let libraryItems: LibraryItems;
libraryItems = await loadLibraryFromBlob(library, defaultStatus); if (library instanceof Blob) {
} else { libraryItems = await loadLibraryFromBlob(library, defaultStatus);
libraryItems = restoreLibraryItems(await library, defaultStatus); } else {
} libraryItems = restoreLibraryItems(await library, defaultStatus);
const existingLibraryItems = this.lastLibraryItems;
const filteredItems = [];
for (const item of libraryItems) {
if (isUniqueItem(existingLibraryItems, item)) {
filteredItems.push(item);
} }
}
resolve([...filteredItems, ...existingLibraryItems]); resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems));
} 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; /**
try { * LibraryItems that will replace current items. Can be a function which
let nextLibraryItems; * will be invoked after all previous tasks are resolved
if (isPromiseLike(items)) { * (this is the prefered way to update the library to avoid race conditions,
const promise = items.then((items) => cloneLibraryItems(items)); * but you'll want to manually merge the library items in the callback
this.libraryItemsPromise = promise; * - which is what we're doing in Library.importLibrary()).
jotaiStore.set(libraryItemsAtom, { *
status: "loading", * If supplied promise is rejected with AbortError, we swallow it and
promise, * do not update the library.
libraryItems: null, */
}); libraryItems:
nextLibraryItems = await promise; | LibraryItems
} else { | Promise<LibraryItems>
nextLibraryItems = cloneLibraryItems(items); | ((
latestLibraryItems: LibraryItems,
) => LibraryItems | Promise<LibraryItems>),
): Promise<LibraryItems> => {
const task = new Promise<LibraryItems>(async (resolve, reject) => {
try {
await this.getLastUpdateTask();
if (typeof libraryItems === "function") {
libraryItems = libraryItems(this.lastLibraryItems);
}
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
resolve(this.lastLibraryItems);
} catch (error: any) {
reject(error);
} }
})
this.lastLibraryItems = nextLibraryItems; .catch((error) => {
this.libraryItemsPromise = null; if (error.name === "AbortError") {
console.warn("Library update aborted by user");
jotaiStore.set(libraryItemsAtom, { return this.lastLibraryItems;
status: "loaded", }
libraryItems: nextLibraryItems, throw error;
})
.finally(() => {
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
this.notifyListeners();
}); });
await this.app.props.onLibraryChange?.(
cloneLibraryItems(nextLibraryItems), this.updateQueue.push(task);
); this.notifyListeners();
} catch (error: any) {
this.lastLibraryItems = prevLibraryItems; return task;
this.libraryItemsPromise = null;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: prevLibraryItems,
});
throw error;
}
}; };
} }

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" })],