You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1001 lines
31 KiB
1001 lines
31 KiB
import { useEffect, useRef } from "react"; |
|
|
|
import { |
|
URL_HASH_KEYS, |
|
URL_QUERY_KEYS, |
|
APP_NAME, |
|
EVENT, |
|
DEFAULT_SIDEBAR, |
|
LIBRARY_SIDEBAR_TAB, |
|
arrayToMap, |
|
cloneJSON, |
|
preventUnload, |
|
promiseTry, |
|
resolvablePromise, |
|
toValidURL, |
|
Queue, |
|
Emitter, |
|
} from "@excalidraw/common"; |
|
|
|
import { hashElementsVersion, hashString } from "@excalidraw/element"; |
|
|
|
import { getCommonBoundingBox } from "@excalidraw/element"; |
|
|
|
import type { ExcalidrawElement } from "@excalidraw/element/types"; |
|
|
|
import type { MaybePromise } from "@excalidraw/common/utility-types"; |
|
|
|
import { atom, editorJotaiStore } from "../editor-jotai"; |
|
|
|
import { AbortError } from "../errors"; |
|
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; |
|
import { t } from "../i18n"; |
|
|
|
import { loadLibraryFromBlob } from "./blob"; |
|
import { restoreLibraryItems } from "./restore"; |
|
|
|
import type App from "../components/App"; |
|
|
|
import type { |
|
LibraryItems, |
|
LibraryItem, |
|
ExcalidrawImperativeAPI, |
|
LibraryItemsSource, |
|
LibraryItems_anyVersion, |
|
} from "../types"; |
|
|
|
/** |
|
* 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>; |
|
updatedItems: 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>(), |
|
updatedItems: 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) { |
|
const prevItem = prevItemsMap.get(item.id); |
|
if (!prevItem) { |
|
update.addedItems.set(item.id, item); |
|
} else if (getLibraryItemHash(prevItem) !== getLibraryItemHash(item)) { |
|
update.updatedItems.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; |
|
|
|
const getLibraryItemHash = (item: LibraryItem) => { |
|
return `${item.id}:${item.name || ""}:${hashElementsVersion(item.elements)}`; |
|
}; |
|
|
|
export const getLibraryItemsHash = (items: LibraryItems) => { |
|
return hashString( |
|
items |
|
.map((item) => getLibraryItemHash(item)) |
|
.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); |
|
} |
|
} |
|
|
|
// replace existing items with their updated versions |
|
if (update.updatedItems) { |
|
for (const [id, item] of update.updatedItems) { |
|
nextLibraryItemsMap.set(id, 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 |
|
], |
|
); |
|
};
|
|
|