You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
917 lines
29 KiB
TypeScript
917 lines
29 KiB
TypeScript
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 } from "jotai";
|
|
import { jotaiStore } from "../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";
|
|
|
|
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) {
|
|
jotaiStore.set(libraryItemsAtom, (s) => ({
|
|
status: "loading",
|
|
libraryItems: this.currLibraryItems,
|
|
isInitialized: s.isInitialized,
|
|
}));
|
|
} else {
|
|
jotaiStore.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 = [];
|
|
jotaiStore.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 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;
|
|
} & (
|
|
| {
|
|
/** @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 {
|
|
const request = await fetch(decodeURIComponent(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) {
|
|
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
|
|
],
|
|
);
|
|
};
|