diff options
Diffstat (limited to 'packages/excalidraw/data/library.ts')
| -rw-r--r-- | packages/excalidraw/data/library.ts | 978 |
1 files changed, 978 insertions, 0 deletions
diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts new file mode 100644 index 0000000..1c23edc --- /dev/null +++ b/packages/excalidraw/data/library.ts @@ -0,0 +1,978 @@ +import { loadLibraryFromBlob } from "./blob"; +import type { + LibraryItems, + LibraryItem, + ExcalidrawImperativeAPI, + LibraryItemsSource, + LibraryItems_anyVersion, +} from "../types"; +import { restoreLibraryItems } from "./restore"; +import type App from "../components/App"; +import { atom, editorJotaiStore } from "../editor-jotai"; +import type { ExcalidrawElement } from "../element/types"; +import { getCommonBoundingBox } from "../element/bounds"; +import { AbortError } from "../errors"; +import { t } from "../i18n"; +import { useEffect, useRef } from "react"; +import { + URL_HASH_KEYS, + URL_QUERY_KEYS, + APP_NAME, + EVENT, + DEFAULT_SIDEBAR, + LIBRARY_SIDEBAR_TAB, +} from "../constants"; +import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; +import { + arrayToMap, + cloneJSON, + preventUnload, + promiseTry, + resolvablePromise, +} from "../utils"; +import type { MaybePromise } from "../utility-types"; +import { Emitter } from "../emitter"; +import { Queue } from "../queue"; +import { hashElementsVersion, hashString } from "../element"; +import { toValidURL } from "./url"; + +/** + * format: hostname or hostname/pathname + * + * Both hostname and pathname are matched partially, + * hostname from the end, pathname from the start, with subdomain/path + * boundaries + **/ +const ALLOWED_LIBRARY_URLS = [ + "excalidraw.com", + // when installing from github PRs + "raw.githubusercontent.com/excalidraw/excalidraw-libraries", +]; + +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 type LibraryAdatapterSource = "load" | "save"; + +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: { + /** + * Indicates whether we're loading data for save purposes, or reading + * purposes, in which case host app can implement more aggressive caching. + */ + source: LibraryAdatapterSource; + }): 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: false, libraryItems: [] }); + +const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems => + cloneJSON(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 + ); + }); + }); +}; + +/** 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]; +}; + +/** + * 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 currLibraryItems: LibraryItems = []; + /** snapshot of library items since last onLibraryChange call */ + private prevLibraryItems = cloneLibraryItems(this.currLibraryItems); + + private app: App; + + constructor(app: App) { + this.app = app; + } + + private updateQueue: Promise<LibraryItems>[] = []; + + private getLastUpdateTask = (): Promise<LibraryItems> | undefined => { + return this.updateQueue[this.updateQueue.length - 1]; + }; + + private notifyListeners = () => { + if (this.updateQueue.length > 0) { + editorJotaiStore.set(libraryItemsAtom, (s) => ({ + status: "loading", + libraryItems: this.currLibraryItems, + isInitialized: s.isInitialized, + })); + } else { + editorJotaiStore.set(libraryItemsAtom, { + status: "loaded", + libraryItems: this.currLibraryItems, + isInitialized: true, + }); + try { + 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); + } + } + }; + + /** call on excalidraw instance unmount */ + destroy = () => { + this.updateQueue = []; + this.currLibraryItems = []; + editorJotaiStore.set(libraryItemSvgsCache, new Map()); + // TODO uncomment after/if we make jotai store scoped to each excal instance + // jotaiStore.set(libraryItemsAtom, { + // status: "loading", + // isInitialized: false, + // libraryItems: [], + // }); + }; + + resetLibrary = () => { + return this.setLibrary([]); + }; + + /** + * @returns latest cloned libraryItems. Awaits all in-progress updates first. + */ + getLatestLibrary = (): Promise<LibraryItems> => { + return new Promise(async (resolve) => { + try { + const libraryItems = await (this.getLastUpdateTask() || + this.currLibraryItems); + if (this.updateQueue.length > 0) { + resolve(this.getLatestLibrary()); + } else { + resolve(cloneLibraryItems(libraryItems)); + } + } catch (error) { + return resolve(this.currLibraryItems); + } + }); + }; + + // NOTE this is a high-level public API (exposed on ExcalidrawAPI) with + // a slight overhead (always restoring library items). For internal use + // where merging isn't needed, use `library.setLibrary()` directly. + updateLibrary = async ({ + libraryItems, + prompt = false, + merge = false, + openLibraryMenu = false, + defaultStatus = "unpublished", + }: { + libraryItems: LibraryItemsSource; + merge?: boolean; + prompt?: boolean; + openLibraryMenu?: boolean; + defaultStatus?: "unpublished" | "published"; + }): Promise<LibraryItems> => { + if (openLibraryMenu) { + this.app.setState({ + openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB }, + }); + } + + return this.setLibrary(() => { + return new Promise<LibraryItems>(async (resolve, reject) => { + try { + const source = await (typeof libraryItems === "function" && + !(libraryItems instanceof Blob) + ? libraryItems(this.currLibraryItems) + : libraryItems); + + let nextItems; + + if (source instanceof Blob) { + nextItems = await loadLibraryFromBlob(source, defaultStatus); + } else { + nextItems = restoreLibraryItems(source, defaultStatus); + } + if ( + !prompt || + window.confirm( + t("alerts.confirmAddLibrary", { + numShapes: nextItems.length, + }), + ) + ) { + if (prompt) { + // focus container if we've prompted. We focus conditionally + // lest `props.autoFocus` is disabled (in which case we should + // focus only on user action such as prompt confirm) + this.app.focusContainer(); + } + + if (merge) { + resolve(mergeLibraryItems(this.currLibraryItems, nextItems)); + } else { + resolve(nextItems); + } + } else { + reject(new AbortError()); + } + } catch (error: any) { + reject(error); + } + }); + }); + }; + + setLibrary = ( + /** + * 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 { + await this.getLastUpdateTask(); + + if (typeof libraryItems === "function") { + libraryItems = libraryItems(this.currLibraryItems); + } + + this.currLibraryItems = cloneLibraryItems(await libraryItems); + + resolve(this.currLibraryItems); + } catch (error: any) { + reject(error); + } + }) + .catch((error) => { + if (error.name === "AbortError") { + console.warn("Library update aborted by user"); + return this.currLibraryItems; + } + throw error; + }) + .finally(() => { + this.updateQueue = this.updateQueue.filter((_task) => _task !== task); + this.notifyListeners(); + }); + + this.updateQueue.push(task); + this.notifyListeners(); + + return task; + }; +} + +export default Library; + +export const distributeLibraryItemsOnSquareGrid = ( + libraryItems: LibraryItems, +) => { + const PADDING = 50; + const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length)); + + const resElements: ExcalidrawElement[] = []; + + const getMaxHeightPerRow = (row: number) => { + const maxHeight = libraryItems + .slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW) + .reduce((acc, item) => { + const { height } = getCommonBoundingBox(item.elements); + return Math.max(acc, height); + }, 0); + return maxHeight; + }; + + const getMaxWidthPerCol = (targetCol: number) => { + let index = 0; + let currCol = 0; + let maxWidth = 0; + for (const item of libraryItems) { + if (index % ITEMS_PER_ROW === 0) { + currCol = 0; + } + if (currCol === targetCol) { + const { width } = getCommonBoundingBox(item.elements); + maxWidth = Math.max(maxWidth, width); + } + index++; + currCol++; + } + return maxWidth; + }; + + let colOffsetX = 0; + let rowOffsetY = 0; + + let maxHeightCurrRow = 0; + let maxWidthCurrCol = 0; + + let index = 0; + let col = 0; + let row = 0; + + for (const item of libraryItems) { + if (index && index % ITEMS_PER_ROW === 0) { + rowOffsetY += maxHeightCurrRow + PADDING; + colOffsetX = 0; + col = 0; + row++; + } + + if (col === 0) { + maxHeightCurrRow = getMaxHeightPerRow(row); + } + maxWidthCurrCol = getMaxWidthPerCol(col); + + const { minX, minY, width, height } = getCommonBoundingBox(item.elements); + const offsetCenterX = (maxWidthCurrCol - width) / 2; + const offsetCenterY = (maxHeightCurrRow - height) / 2; + resElements.push( + // eslint-disable-next-line no-loop-func + ...item.elements.map((element) => ({ + ...element, + x: + element.x + + // offset for column + colOffsetX + + // offset to center in given square grid + offsetCenterX - + // subtract minX so that given item starts at 0 coord + minX, + y: + element.y + + // offset for row + rowOffsetY + + // offset to center in given square grid + offsetCenterY - + // subtract minY so that given item starts at 0 coord + minY, + })), + ); + colOffsetX += maxWidthCurrCol + PADDING; + index++; + col++; + } + + return resElements; +}; + +export const validateLibraryUrl = ( + libraryUrl: string, + /** + * @returns `true` if the URL is valid, throws otherwise. + */ + validator: + | ((libraryUrl: string) => boolean) + | string[] = ALLOWED_LIBRARY_URLS, +): true => { + if ( + typeof validator === "function" + ? validator(libraryUrl) + : validator.some((allowedUrlDef) => { + const allowedUrl = new URL( + `https://${allowedUrlDef.replace(/^https?:\/\//, "")}`, + ); + + const { hostname, pathname } = new URL(libraryUrl); + + return ( + new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) && + new RegExp( + `^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`, + ).test(pathname) + ); + }) + ) { + return true; + } + + throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`); +}; + +export const parseLibraryTokensFromUrl = () => { + const libraryUrl = + // current + new URLSearchParams(window.location.hash.slice(1)).get( + URL_HASH_KEYS.addLibrary, + ) || + // legacy, kept for compat reasons + new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary); + const idToken = libraryUrl + ? new URLSearchParams(window.location.hash.slice(1)).get("token") + : null; + + return libraryUrl ? { libraryUrl, idToken } : null; +}; + +class AdapterTransaction { + static queue = new Queue(); + + static async getLibraryItems( + adapter: LibraryPersistenceAdapter, + source: LibraryAdatapterSource, + _queue = true, + ): Promise<LibraryItems> { + const task = () => + new Promise<LibraryItems>(async (resolve, reject) => { + try { + const data = await adapter.load({ source }); + 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(source: LibraryAdatapterSource) { + return AdapterTransaction.getLibraryItems(this.adapter, source, 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("save"), + ); + + 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; + /** + * Return `true` if the library install url should be allowed. + * If not supplied, only the excalidraw.com base domain is allowed. + */ + validateLibraryUrl?: (libraryUrl: string) => boolean; + } & ( + | { + /** @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, + }: { + libraryUrl: string; + idToken: string | null; + }) => { + const libraryPromise = new Promise<Blob>(async (resolve, reject) => { + try { + libraryUrl = decodeURIComponent(libraryUrl); + + libraryUrl = toValidURL(libraryUrl); + + validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl); + + const request = await fetch(libraryUrl); + const blob = await request.blob(); + resolve(blob); + } catch (error: any) { + reject(error); + } + }); + + const shouldPrompt = idToken !== excalidrawAPI.id; + + // wait for the tab to be focused before continuing in case we'll prompt + // for confirmation + await (shouldPrompt && document.hidden + ? new Promise<void>((resolve) => { + window.addEventListener("focus", () => resolve(), { + once: true, + }); + }) + : null); + + try { + await excalidrawAPI.updateLibrary({ + libraryItems: libraryPromise, + prompt: shouldPrompt, + merge: true, + defaultStatus: "published", + openLibraryMenu: true, + }); + } catch (error: any) { + excalidrawAPI.updateScene({ + appState: { + errorMessage: error.message, + }, + }); + throw error; + } finally { + if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { + const hash = new URLSearchParams(window.location.hash.slice(1)); + hash.delete(URL_HASH_KEYS.addLibrary); + window.history.replaceState({}, APP_NAME, `#${hash.toString()}`); + } else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) { + const query = new URLSearchParams(window.location.search); + query.delete(URL_QUERY_KEYS.addLibrary); + window.history.replaceState({}, APP_NAME, `?${query.toString()}`); + } + } + }; + const onHashChange = (event: HashChangeEvent) => { + event.preventDefault(); + const libraryUrlTokens = parseLibraryTokensFromUrl(); + if (libraryUrlTokens) { + event.stopImmediatePropagation(); + // If hash changed and it contains library url, import it and replace + // the url to its previous state (important in case of collaboration + // and similar). + // Using history API won't trigger another hashchange. + window.history.replaceState({}, "", event.oldURL); + + importLibraryFromURL(libraryUrlTokens); + } + }; + + // ------------------------------------------------------------------------- + // ---------------------------------- 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) => { + let restoredData: LibraryItems | null = null; + 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, "load"); + } + + restoredData = restoreLibraryItems( + libraryData.libraryItems || [], + "published", + ); + + // 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([], restoredData), + ); + 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 data from previous store, if any + return restoredData; + } + }) + // 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, "load"); + }), + ); + } else { + initDataPromise.resolve( + promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"), + ); + } + + // 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); + }; + }, [ + // 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 + ], + ); +}; |
