|
@@ -4,6 +4,7 @@ import {
|
|
|
LibraryItem,
|
|
|
ExcalidrawImperativeAPI,
|
|
|
LibraryItemsSource,
|
|
|
+ LibraryItems_anyVersion,
|
|
|
} from "../types";
|
|
|
import { restoreLibraryItems } from "./restore";
|
|
|
import type App from "../components/App";
|
|
@@ -23,13 +24,72 @@ import {
|
|
|
LIBRARY_SIDEBAR_TAB,
|
|
|
} from "../constants";
|
|
|
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
|
|
-import { cloneJSON } from "../utils";
|
|
|
+import {
|
|
|
+ arrayToMap,
|
|
|
+ cloneJSON,
|
|
|
+ preventUnload,
|
|
|
+ promiseTry,
|
|
|
+ resolvablePromise,
|
|
|
+} from "../utils";
|
|
|
+import { MaybePromise } from "../utility-types";
|
|
|
+import { Emitter } from "../emitter";
|
|
|
+import { Queue } from "../queue";
|
|
|
+import { hashElementsVersion, hashString } from "../element";
|
|
|
+
|
|
|
+type LibraryUpdate = {
|
|
|
+ /** deleted library items since last onLibraryChange event */
|
|
|
+ deletedItems: Map<LibraryItem["id"], LibraryItem>;
|
|
|
+ /** newly added items in the library */
|
|
|
+ addedItems: Map<LibraryItem["id"], LibraryItem>;
|
|
|
+};
|
|
|
+
|
|
|
+// an object so that we can later add more properties to it without breaking,
|
|
|
+// such as schema version
|
|
|
+export type LibraryPersistedData = { libraryItems: LibraryItems };
|
|
|
+
|
|
|
+const onLibraryUpdateEmitter = new Emitter<
|
|
|
+ [update: LibraryUpdate, libraryItems: LibraryItems]
|
|
|
+>();
|
|
|
+
|
|
|
+export interface LibraryPersistenceAdapter {
|
|
|
+ /**
|
|
|
+ * Should load data that were previously saved into the database using the
|
|
|
+ * `save` method. Should throw if saving fails.
|
|
|
+ *
|
|
|
+ * Will be used internally in multiple places, such as during save to
|
|
|
+ * in order to reconcile changes with latest store data.
|
|
|
+ */
|
|
|
+ load(metadata: {
|
|
|
+ /**
|
|
|
+ * Priority 1 indicates we're loading latest data with intent
|
|
|
+ * to reconcile with before save.
|
|
|
+ * Priority 2 indicates we're loading for read-only purposes, so
|
|
|
+ * host app can implement more aggressive caching strategy.
|
|
|
+ */
|
|
|
+ priority: 1 | 2;
|
|
|
+ }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
|
|
|
+ /** Should persist to the database as is (do no change the data structure). */
|
|
|
+ save(libraryData: LibraryPersistedData): MaybePromise<void>;
|
|
|
+}
|
|
|
+
|
|
|
+export interface LibraryMigrationAdapter {
|
|
|
+ /**
|
|
|
+ * loads data from legacy data source. Returns `null` if no data is
|
|
|
+ * to be migrated.
|
|
|
+ */
|
|
|
+ load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
|
|
|
+
|
|
|
+ /** clears entire storage afterwards */
|
|
|
+ clear(): MaybePromise<void>;
|
|
|
+}
|
|
|
|
|
|
export const libraryItemsAtom = atom<{
|
|
|
status: "loading" | "loaded";
|
|
|
+ /** indicates whether library is initialized with library items (has gone
|
|
|
+ * through at least one update). Used in UI. Specific to this atom only. */
|
|
|
isInitialized: boolean;
|
|
|
libraryItems: LibraryItems;
|
|
|
-}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
|
|
+}>({ status: "loaded", isInitialized: false, libraryItems: [] });
|
|
|
|
|
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
|
|
cloneJSON(libraryItems);
|
|
@@ -74,12 +134,45 @@ export const mergeLibraryItems = (
|
|
|
return [...newItems, ...localItems];
|
|
|
};
|
|
|
|
|
|
+/**
|
|
|
+ * Returns { deletedItems, addedItems } maps of all added and deleted items
|
|
|
+ * since last onLibraryChange event.
|
|
|
+ *
|
|
|
+ * Host apps are recommended to diff with the latest state they have.
|
|
|
+ */
|
|
|
+const createLibraryUpdate = (
|
|
|
+ prevLibraryItems: LibraryItems,
|
|
|
+ nextLibraryItems: LibraryItems,
|
|
|
+): LibraryUpdate => {
|
|
|
+ const nextItemsMap = arrayToMap(nextLibraryItems);
|
|
|
+
|
|
|
+ const update: LibraryUpdate = {
|
|
|
+ deletedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
|
|
+ addedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
|
|
+ };
|
|
|
+
|
|
|
+ for (const item of prevLibraryItems) {
|
|
|
+ if (!nextItemsMap.has(item.id)) {
|
|
|
+ update.deletedItems.set(item.id, item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const prevItemsMap = arrayToMap(prevLibraryItems);
|
|
|
+
|
|
|
+ for (const item of nextLibraryItems) {
|
|
|
+ if (!prevItemsMap.has(item.id)) {
|
|
|
+ update.addedItems.set(item.id, item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return update;
|
|
|
+};
|
|
|
+
|
|
|
class Library {
|
|
|
/** latest libraryItems */
|
|
|
- private lastLibraryItems: LibraryItems = [];
|
|
|
- /** indicates whether library is initialized with library items (has gone
|
|
|
- * though at least one update) */
|
|
|
- private isInitialized = false;
|
|
|
+ private currLibraryItems: LibraryItems = [];
|
|
|
+ /** snapshot of library items since last onLibraryChange call */
|
|
|
+ private prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
|
|
|
|
|
|
private app: App;
|
|
|
|
|
@@ -95,21 +188,29 @@ class Library {
|
|
|
|
|
|
private notifyListeners = () => {
|
|
|
if (this.updateQueue.length > 0) {
|
|
|
- jotaiStore.set(libraryItemsAtom, {
|
|
|
+ jotaiStore.set(libraryItemsAtom, (s) => ({
|
|
|
status: "loading",
|
|
|
- libraryItems: this.lastLibraryItems,
|
|
|
- isInitialized: this.isInitialized,
|
|
|
- });
|
|
|
+ libraryItems: this.currLibraryItems,
|
|
|
+ isInitialized: s.isInitialized,
|
|
|
+ }));
|
|
|
} else {
|
|
|
- this.isInitialized = true;
|
|
|
jotaiStore.set(libraryItemsAtom, {
|
|
|
status: "loaded",
|
|
|
- libraryItems: this.lastLibraryItems,
|
|
|
- isInitialized: this.isInitialized,
|
|
|
+ libraryItems: this.currLibraryItems,
|
|
|
+ isInitialized: true,
|
|
|
});
|
|
|
try {
|
|
|
- this.app.props.onLibraryChange?.(
|
|
|
- cloneLibraryItems(this.lastLibraryItems),
|
|
|
+ const prevLibraryItems = this.prevLibraryItems;
|
|
|
+ this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
|
|
|
+
|
|
|
+ const nextLibraryItems = cloneLibraryItems(this.currLibraryItems);
|
|
|
+
|
|
|
+ this.app.props.onLibraryChange?.(nextLibraryItems);
|
|
|
+
|
|
|
+ // for internal use in `useHandleLibrary` hook
|
|
|
+ onLibraryUpdateEmitter.trigger(
|
|
|
+ createLibraryUpdate(prevLibraryItems, nextLibraryItems),
|
|
|
+ nextLibraryItems,
|
|
|
);
|
|
|
} catch (error) {
|
|
|
console.error(error);
|
|
@@ -119,9 +220,8 @@ class Library {
|
|
|
|
|
|
/** call on excalidraw instance unmount */
|
|
|
destroy = () => {
|
|
|
- this.isInitialized = false;
|
|
|
this.updateQueue = [];
|
|
|
- this.lastLibraryItems = [];
|
|
|
+ this.currLibraryItems = [];
|
|
|
jotaiStore.set(libraryItemSvgsCache, new Map());
|
|
|
// TODO uncomment after/if we make jotai store scoped to each excal instance
|
|
|
// jotaiStore.set(libraryItemsAtom, {
|
|
@@ -142,14 +242,14 @@ class Library {
|
|
|
return new Promise(async (resolve) => {
|
|
|
try {
|
|
|
const libraryItems = await (this.getLastUpdateTask() ||
|
|
|
- this.lastLibraryItems);
|
|
|
+ this.currLibraryItems);
|
|
|
if (this.updateQueue.length > 0) {
|
|
|
resolve(this.getLatestLibrary());
|
|
|
} else {
|
|
|
resolve(cloneLibraryItems(libraryItems));
|
|
|
}
|
|
|
} catch (error) {
|
|
|
- return resolve(this.lastLibraryItems);
|
|
|
+ return resolve(this.currLibraryItems);
|
|
|
}
|
|
|
});
|
|
|
};
|
|
@@ -181,7 +281,7 @@ class Library {
|
|
|
try {
|
|
|
const source = await (typeof libraryItems === "function" &&
|
|
|
!(libraryItems instanceof Blob)
|
|
|
- ? libraryItems(this.lastLibraryItems)
|
|
|
+ ? libraryItems(this.currLibraryItems)
|
|
|
: libraryItems);
|
|
|
|
|
|
let nextItems;
|
|
@@ -207,7 +307,7 @@ class Library {
|
|
|
}
|
|
|
|
|
|
if (merge) {
|
|
|
- resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
|
|
|
+ resolve(mergeLibraryItems(this.currLibraryItems, nextItems));
|
|
|
} else {
|
|
|
resolve(nextItems);
|
|
|
}
|
|
@@ -244,12 +344,12 @@ class Library {
|
|
|
await this.getLastUpdateTask();
|
|
|
|
|
|
if (typeof libraryItems === "function") {
|
|
|
- libraryItems = libraryItems(this.lastLibraryItems);
|
|
|
+ libraryItems = libraryItems(this.currLibraryItems);
|
|
|
}
|
|
|
|
|
|
- this.lastLibraryItems = cloneLibraryItems(await libraryItems);
|
|
|
+ this.currLibraryItems = cloneLibraryItems(await libraryItems);
|
|
|
|
|
|
- resolve(this.lastLibraryItems);
|
|
|
+ resolve(this.currLibraryItems);
|
|
|
} catch (error: any) {
|
|
|
reject(error);
|
|
|
}
|
|
@@ -257,7 +357,7 @@ class Library {
|
|
|
.catch((error) => {
|
|
|
if (error.name === "AbortError") {
|
|
|
console.warn("Library update aborted by user");
|
|
|
- return this.lastLibraryItems;
|
|
|
+ return this.currLibraryItems;
|
|
|
}
|
|
|
throw error;
|
|
|
})
|
|
@@ -382,20 +482,165 @@ export const parseLibraryTokensFromUrl = () => {
|
|
|
return libraryUrl ? { libraryUrl, idToken } : null;
|
|
|
};
|
|
|
|
|
|
-export const useHandleLibrary = ({
|
|
|
- excalidrawAPI,
|
|
|
- getInitialLibraryItems,
|
|
|
-}: {
|
|
|
- excalidrawAPI: ExcalidrawImperativeAPI | null;
|
|
|
- getInitialLibraryItems?: () => LibraryItemsSource;
|
|
|
-}) => {
|
|
|
- const getInitialLibraryRef = useRef(getInitialLibraryItems);
|
|
|
+class AdapterTransaction {
|
|
|
+ static queue = new Queue();
|
|
|
+
|
|
|
+ static async getLibraryItems(
|
|
|
+ adapter: LibraryPersistenceAdapter,
|
|
|
+ priority: 1 | 2,
|
|
|
+ _queue = true,
|
|
|
+ ): Promise<LibraryItems> {
|
|
|
+ const task = () =>
|
|
|
+ new Promise<LibraryItems>(async (resolve, reject) => {
|
|
|
+ try {
|
|
|
+ const data = await adapter.load({ priority });
|
|
|
+ resolve(restoreLibraryItems(data?.libraryItems || [], "published"));
|
|
|
+ } catch (error: any) {
|
|
|
+ reject(error);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (_queue) {
|
|
|
+ return AdapterTransaction.queue.push(task);
|
|
|
+ }
|
|
|
+
|
|
|
+ return task();
|
|
|
+ }
|
|
|
+
|
|
|
+ static run = async <T>(
|
|
|
+ adapter: LibraryPersistenceAdapter,
|
|
|
+ fn: (transaction: AdapterTransaction) => Promise<T>,
|
|
|
+ ) => {
|
|
|
+ const transaction = new AdapterTransaction(adapter);
|
|
|
+ return AdapterTransaction.queue.push(() => fn(transaction));
|
|
|
+ };
|
|
|
+
|
|
|
+ // ------------------
|
|
|
+
|
|
|
+ private adapter: LibraryPersistenceAdapter;
|
|
|
+
|
|
|
+ constructor(adapter: LibraryPersistenceAdapter) {
|
|
|
+ this.adapter = adapter;
|
|
|
+ }
|
|
|
+
|
|
|
+ getLibraryItems(priority: 1 | 2) {
|
|
|
+ return AdapterTransaction.getLibraryItems(this.adapter, priority, false);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+let lastSavedLibraryItemsHash = 0;
|
|
|
+let librarySaveCounter = 0;
|
|
|
+
|
|
|
+export const getLibraryItemsHash = (items: LibraryItems) => {
|
|
|
+ return hashString(
|
|
|
+ items
|
|
|
+ .map((item) => {
|
|
|
+ return `${item.id}:${hashElementsVersion(item.elements)}`;
|
|
|
+ })
|
|
|
+ .sort()
|
|
|
+ .join(),
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const persistLibraryUpdate = async (
|
|
|
+ adapter: LibraryPersistenceAdapter,
|
|
|
+ update: LibraryUpdate,
|
|
|
+): Promise<LibraryItems> => {
|
|
|
+ try {
|
|
|
+ librarySaveCounter++;
|
|
|
+
|
|
|
+ return await AdapterTransaction.run(adapter, async (transaction) => {
|
|
|
+ const nextLibraryItemsMap = arrayToMap(
|
|
|
+ await transaction.getLibraryItems(1),
|
|
|
+ );
|
|
|
+
|
|
|
+ for (const [id] of update.deletedItems) {
|
|
|
+ nextLibraryItemsMap.delete(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ const addedItems: LibraryItem[] = [];
|
|
|
+
|
|
|
+ // we want to merge current library items with the ones stored in the
|
|
|
+ // DB so that we don't lose any elements that for some reason aren't
|
|
|
+ // in the current editor library, which could happen when:
|
|
|
+ //
|
|
|
+ // 1. we haven't received an update deleting some elements
|
|
|
+ // (in which case it's still better to keep them in the DB lest
|
|
|
+ // it was due to a different reason)
|
|
|
+ // 2. we keep a single DB for all active editors, but the editors'
|
|
|
+ // libraries aren't synced or there's a race conditions during
|
|
|
+ // syncing
|
|
|
+ // 3. some other race condition, e.g. during init where emit updates
|
|
|
+ // for partial updates (e.g. you install a 3rd party library and
|
|
|
+ // init from DB only after — we emit events for both updates)
|
|
|
+ for (const [id, item] of update.addedItems) {
|
|
|
+ if (nextLibraryItemsMap.has(id)) {
|
|
|
+ // replace item with latest version
|
|
|
+ // TODO we could prefer the newer item instead
|
|
|
+ nextLibraryItemsMap.set(id, item);
|
|
|
+ } else {
|
|
|
+ // we want to prepend the new items with the ones that are already
|
|
|
+ // in DB to preserve the ordering we do in editor (newly added
|
|
|
+ // items are added to the beginning)
|
|
|
+ addedItems.push(item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const nextLibraryItems = addedItems.concat(
|
|
|
+ Array.from(nextLibraryItemsMap.values()),
|
|
|
+ );
|
|
|
+
|
|
|
+ const version = getLibraryItemsHash(nextLibraryItems);
|
|
|
+
|
|
|
+ if (version !== lastSavedLibraryItemsHash) {
|
|
|
+ await adapter.save({ libraryItems: nextLibraryItems });
|
|
|
+ }
|
|
|
+
|
|
|
+ lastSavedLibraryItemsHash = version;
|
|
|
+
|
|
|
+ return nextLibraryItems;
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ librarySaveCounter--;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+export const useHandleLibrary = (
|
|
|
+ opts: {
|
|
|
+ excalidrawAPI: ExcalidrawImperativeAPI | null;
|
|
|
+ } & (
|
|
|
+ | {
|
|
|
+ /** @deprecated we recommend using `opts.adapter` instead */
|
|
|
+ getInitialLibraryItems?: () => MaybePromise<LibraryItemsSource>;
|
|
|
+ }
|
|
|
+ | {
|
|
|
+ adapter: LibraryPersistenceAdapter;
|
|
|
+ /**
|
|
|
+ * Adapter that takes care of loading data from legacy data store.
|
|
|
+ * Supply this if you want to migrate data on initial load from legacy
|
|
|
+ * data store.
|
|
|
+ *
|
|
|
+ * Can be a different LibraryPersistenceAdapter.
|
|
|
+ */
|
|
|
+ migrationAdapter?: LibraryMigrationAdapter;
|
|
|
+ }
|
|
|
+ ),
|
|
|
+) => {
|
|
|
+ const { excalidrawAPI } = opts;
|
|
|
+
|
|
|
+ const optsRef = useRef(opts);
|
|
|
+ optsRef.current = opts;
|
|
|
+
|
|
|
+ const isLibraryLoadedRef = useRef(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (!excalidrawAPI) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ // reset on editor remount (excalidrawAPI changed)
|
|
|
+ isLibraryLoadedRef.current = false;
|
|
|
+
|
|
|
const importLibraryFromURL = async ({
|
|
|
libraryUrl,
|
|
|
idToken,
|
|
@@ -463,23 +708,209 @@ export const useHandleLibrary = ({
|
|
|
};
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
- // ------ init load --------------------------------------------------------
|
|
|
- if (getInitialLibraryRef.current) {
|
|
|
- excalidrawAPI.updateLibrary({
|
|
|
- libraryItems: getInitialLibraryRef.current(),
|
|
|
- });
|
|
|
- }
|
|
|
+ // ---------------------------------- init ---------------------------------
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
|
|
|
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
|
|
|
|
|
if (libraryUrlTokens) {
|
|
|
importLibraryFromURL(libraryUrlTokens);
|
|
|
}
|
|
|
+
|
|
|
+ // ------ (A) init load (legacy) -------------------------------------------
|
|
|
+ if (
|
|
|
+ "getInitialLibraryItems" in optsRef.current &&
|
|
|
+ optsRef.current.getInitialLibraryItems
|
|
|
+ ) {
|
|
|
+ console.warn(
|
|
|
+ "useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.",
|
|
|
+ );
|
|
|
+
|
|
|
+ Promise.resolve(optsRef.current.getInitialLibraryItems())
|
|
|
+ .then((libraryItems) => {
|
|
|
+ excalidrawAPI.updateLibrary({
|
|
|
+ libraryItems,
|
|
|
+ // merge with current library items because we may have already
|
|
|
+ // populated it (e.g. by installing 3rd party library which can
|
|
|
+ // happen before the DB data is loaded)
|
|
|
+ merge: true,
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .catch((error: any) => {
|
|
|
+ console.error(
|
|
|
+ `UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`,
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
// --------------------------------------------------------- init load -----
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
+
|
|
|
+ // ------ (B) data source adapter ------------------------------------------
|
|
|
+
|
|
|
+ if ("adapter" in optsRef.current && optsRef.current.adapter) {
|
|
|
+ const adapter = optsRef.current.adapter;
|
|
|
+ const migrationAdapter = optsRef.current.migrationAdapter;
|
|
|
+
|
|
|
+ const initDataPromise = resolvablePromise<LibraryItems | null>();
|
|
|
+
|
|
|
+ // migrate from old data source if needed
|
|
|
+ // (note, if `migrate` function is defined, we always migrate even
|
|
|
+ // if the data has already been migrated. In that case it'll be a no-op,
|
|
|
+ // though with several unnecessary steps — we will still load latest
|
|
|
+ // DB data during the `persistLibraryChange()` step)
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+ if (migrationAdapter) {
|
|
|
+ initDataPromise.resolve(
|
|
|
+ promiseTry(migrationAdapter.load)
|
|
|
+ .then(async (libraryData) => {
|
|
|
+ try {
|
|
|
+ // if no library data to migrate, assume no migration needed
|
|
|
+ // and skip persisting to new data store, as well as well
|
|
|
+ // clearing the old store via `migrationAdapter.clear()`
|
|
|
+ if (!libraryData) {
|
|
|
+ return AdapterTransaction.getLibraryItems(adapter, 2);
|
|
|
+ }
|
|
|
+
|
|
|
+ // we don't queue this operation because it's running inside
|
|
|
+ // a promise that's running inside Library update queue itself
|
|
|
+ const nextItems = await persistLibraryUpdate(
|
|
|
+ adapter,
|
|
|
+ createLibraryUpdate(
|
|
|
+ [],
|
|
|
+ restoreLibraryItems(
|
|
|
+ libraryData.libraryItems || [],
|
|
|
+ "published",
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ try {
|
|
|
+ await migrationAdapter.clear();
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error(
|
|
|
+ `couldn't delete legacy library data: ${error.message}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ // migration suceeded, load migrated data
|
|
|
+ return nextItems;
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error(
|
|
|
+ `couldn't migrate legacy library data: ${error.message}`,
|
|
|
+ );
|
|
|
+ // migration failed, load empty library
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ })
|
|
|
+ // errors caught during `migrationAdapter.load()`
|
|
|
+ .catch((error: any) => {
|
|
|
+ console.error(`error during library migration: ${error.message}`);
|
|
|
+ // as a default, load latest library from current data source
|
|
|
+ return AdapterTransaction.getLibraryItems(adapter, 2);
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ initDataPromise.resolve(
|
|
|
+ promiseTry(AdapterTransaction.getLibraryItems, adapter, 2),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // load initial (or migrated) library
|
|
|
+ excalidrawAPI
|
|
|
+ .updateLibrary({
|
|
|
+ libraryItems: initDataPromise.then((libraryItems) => {
|
|
|
+ const _libraryItems = libraryItems || [];
|
|
|
+ lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems);
|
|
|
+ return _libraryItems;
|
|
|
+ }),
|
|
|
+ // merge with current library items because we may have already
|
|
|
+ // populated it (e.g. by installing 3rd party library which can
|
|
|
+ // happen before the DB data is loaded)
|
|
|
+ merge: true,
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ isLibraryLoadedRef.current = true;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ // ---------------------------------------------- data source datapter -----
|
|
|
|
|
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
|
|
|
return () => {
|
|
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
|
|
|
};
|
|
|
- }, [excalidrawAPI]);
|
|
|
+ }, [
|
|
|
+ // important this useEffect only depends on excalidrawAPI so it only reruns
|
|
|
+ // on editor remounts (the excalidrawAPI changes)
|
|
|
+ excalidrawAPI,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // This effect is run without excalidrawAPI dependency so that host apps
|
|
|
+ // can run this hook outside of an active editor instance and the library
|
|
|
+ // update queue/loop survives editor remounts
|
|
|
+ //
|
|
|
+ // This effect is still only meant to be run if host apps supply an persitence
|
|
|
+ // adapter. If we don't have access to it, it the update listener doesn't
|
|
|
+ // do anything.
|
|
|
+ useEffect(
|
|
|
+ () => {
|
|
|
+ // on update, merge with current library items and persist
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+ const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on(
|
|
|
+ async (update, nextLibraryItems) => {
|
|
|
+ const isLoaded = isLibraryLoadedRef.current;
|
|
|
+ // we want to operate with the latest adapter, but we don't want this
|
|
|
+ // effect to rerun on every adapter change in case host apps' adapter
|
|
|
+ // isn't stable
|
|
|
+ const adapter =
|
|
|
+ ("adapter" in optsRef.current && optsRef.current.adapter) || null;
|
|
|
+ try {
|
|
|
+ if (adapter) {
|
|
|
+ if (
|
|
|
+ // if nextLibraryItems hash identical to previously saved hash,
|
|
|
+ // exit early, even if actual upstream state ends up being
|
|
|
+ // different (e.g. has more data than we have locally), as it'd
|
|
|
+ // be low-impact scenario.
|
|
|
+ lastSavedLibraryItemsHash !==
|
|
|
+ getLibraryItemsHash(nextLibraryItems)
|
|
|
+ ) {
|
|
|
+ await persistLibraryUpdate(adapter, update);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error(
|
|
|
+ `couldn't persist library update: ${error.message}`,
|
|
|
+ update,
|
|
|
+ );
|
|
|
+
|
|
|
+ // currently we only show error if an editor is loaded
|
|
|
+ if (isLoaded && optsRef.current.excalidrawAPI) {
|
|
|
+ optsRef.current.excalidrawAPI.updateScene({
|
|
|
+ appState: {
|
|
|
+ errorMessage: t("errors.saveLibraryError"),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ const onUnload = (event: Event) => {
|
|
|
+ if (librarySaveCounter) {
|
|
|
+ preventUnload(event);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload);
|
|
|
+ unsubOnLibraryUpdate();
|
|
|
+ lastSavedLibraryItemsHash = 0;
|
|
|
+ librarySaveCounter = 0;
|
|
|
+ };
|
|
|
+ },
|
|
|
+ [
|
|
|
+ // this effect must not have any deps so it doesn't rerun
|
|
|
+ ],
|
|
|
+ );
|
|
|
};
|