2020-07-27 15:29:19 +03:00
|
|
|
import { loadLibraryFromBlob } from "./blob";
|
2022-05-11 15:08:54 +02:00
|
|
|
import {
|
|
|
|
LibraryItems,
|
|
|
|
LibraryItem,
|
|
|
|
ExcalidrawImperativeAPI,
|
|
|
|
LibraryItemsSource,
|
|
|
|
} from "../types";
|
2022-04-14 16:20:35 +02:00
|
|
|
import { restoreLibraryItems } from "./restore";
|
2021-06-01 23:52:13 +05:30
|
|
|
import type App from "../components/App";
|
2022-04-20 14:40:03 +02:00
|
|
|
import { atom } from "jotai";
|
|
|
|
import { jotaiStore } from "../jotai";
|
2022-05-11 15:51:02 +02:00
|
|
|
import { ExcalidrawElement } from "../element/types";
|
|
|
|
import { getCommonBoundingBox } from "../element/bounds";
|
2022-05-11 15:08:54 +02:00
|
|
|
import { AbortError } from "../errors";
|
|
|
|
import { t } from "../i18n";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
|
2022-04-20 14:40:03 +02:00
|
|
|
|
2022-04-29 16:45:02 +02:00
|
|
|
export const libraryItemsAtom = atom<{
|
|
|
|
status: "loading" | "loaded";
|
|
|
|
isInitialized: boolean;
|
|
|
|
libraryItems: LibraryItems;
|
|
|
|
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
2022-04-20 14:40:03 +02:00
|
|
|
|
|
|
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
|
|
|
JSON.parse(JSON.stringify(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
|
|
|
|
);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
2020-07-27 15:29:19 +03:00
|
|
|
|
2022-04-29 16:45:02 +02:00
|
|
|
/** 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];
|
|
|
|
};
|
|
|
|
|
2021-04-21 23:38:24 +05:30
|
|
|
class Library {
|
2022-04-29 16:45:02 +02:00
|
|
|
/** latest libraryItems */
|
2022-04-20 14:40:03 +02:00
|
|
|
private lastLibraryItems: LibraryItems = [];
|
2022-04-29 16:45:02 +02:00
|
|
|
/** indicates whether library is initialized with library items (has gone
|
|
|
|
* though at least one update) */
|
|
|
|
private isInitialized = false;
|
2022-04-20 14:40:03 +02:00
|
|
|
|
2021-04-21 23:38:24 +05:30
|
|
|
private app: App;
|
2020-10-30 21:01:41 +01:00
|
|
|
|
2021-04-21 23:38:24 +05:30
|
|
|
constructor(app: App) {
|
|
|
|
this.app = app;
|
|
|
|
}
|
|
|
|
|
2022-04-29 16:45:02 +02:00
|
|
|
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, {
|
|
|
|
status: "loading",
|
|
|
|
libraryItems: this.lastLibraryItems,
|
|
|
|
isInitialized: this.isInitialized,
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
this.isInitialized = true;
|
|
|
|
jotaiStore.set(libraryItemsAtom, {
|
|
|
|
status: "loaded",
|
|
|
|
libraryItems: this.lastLibraryItems,
|
|
|
|
isInitialized: this.isInitialized,
|
|
|
|
});
|
|
|
|
try {
|
|
|
|
this.app.props.onLibraryChange?.(
|
|
|
|
cloneLibraryItems(this.lastLibraryItems),
|
|
|
|
);
|
|
|
|
} catch (error) {
|
|
|
|
console.error(error);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
resetLibrary = () => {
|
|
|
|
return this.setLibrary([]);
|
2020-10-30 21:01:41 +01:00
|
|
|
};
|
|
|
|
|
2022-04-29 16:45:02 +02:00
|
|
|
/**
|
|
|
|
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
|
|
|
|
*/
|
|
|
|
getLatestLibrary = (): Promise<LibraryItems> => {
|
2020-10-30 21:01:41 +01:00
|
|
|
return new Promise(async (resolve) => {
|
|
|
|
try {
|
2022-04-29 16:45:02 +02:00
|
|
|
const libraryItems = await (this.getLastUpdateTask() ||
|
|
|
|
this.lastLibraryItems);
|
|
|
|
if (this.updateQueue.length > 0) {
|
|
|
|
resolve(this.getLatestLibrary());
|
|
|
|
} else {
|
|
|
|
resolve(cloneLibraryItems(libraryItems));
|
|
|
|
}
|
2022-04-20 14:40:03 +02:00
|
|
|
} catch (error) {
|
|
|
|
return resolve(this.lastLibraryItems);
|
2020-10-30 21:01:41 +01:00
|
|
|
}
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2022-05-11 15:08:54 +02:00
|
|
|
// 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({ isLibraryOpen: true });
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.setLibrary(() => {
|
|
|
|
return new Promise<LibraryItems>(async (resolve, reject) => {
|
|
|
|
try {
|
|
|
|
const source = await (typeof libraryItems === "function"
|
|
|
|
? libraryItems(this.lastLibraryItems)
|
|
|
|
: 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 (merge) {
|
|
|
|
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
|
|
|
|
} else {
|
|
|
|
resolve(nextItems);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
reject(new AbortError());
|
|
|
|
}
|
|
|
|
} catch (error: any) {
|
|
|
|
reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}).finally(() => {
|
|
|
|
this.app.focusContainer();
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2022-04-29 16:45:02 +02:00
|
|
|
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();
|
2022-04-20 14:40:03 +02:00
|
|
|
|
2022-04-29 16:45:02 +02:00
|
|
|
if (typeof libraryItems === "function") {
|
|
|
|
libraryItems = libraryItems(this.lastLibraryItems);
|
|
|
|
}
|
2022-04-20 14:40:03 +02:00
|
|
|
|
2022-04-29 16:45:02 +02:00
|
|
|
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
|
|
|
|
|
|
|
|
resolve(this.lastLibraryItems);
|
|
|
|
} catch (error: any) {
|
|
|
|
reject(error);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.catch((error) => {
|
|
|
|
if (error.name === "AbortError") {
|
|
|
|
console.warn("Library update aborted by user");
|
|
|
|
return this.lastLibraryItems;
|
|
|
|
}
|
|
|
|
throw error;
|
|
|
|
})
|
|
|
|
.finally(() => {
|
|
|
|
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
|
|
|
|
this.notifyListeners();
|
2022-04-20 14:40:03 +02:00
|
|
|
});
|
2022-04-29 16:45:02 +02:00
|
|
|
|
|
|
|
this.updateQueue.push(task);
|
|
|
|
this.notifyListeners();
|
|
|
|
|
|
|
|
return task;
|
2020-10-30 21:01:41 +01:00
|
|
|
};
|
2020-07-27 15:29:19 +03:00
|
|
|
}
|
2021-04-21 23:38:24 +05:30
|
|
|
|
|
|
|
export default Library;
|
2022-05-11 15:08:54 +02:00
|
|
|
|
2022-05-11 15:51:02 +02:00
|
|
|
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;
|
|
|
|
};
|
|
|
|
|
2022-05-11 15:08:54 +02:00
|
|
|
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;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const useHandleLibrary = ({
|
|
|
|
excalidrawAPI,
|
|
|
|
getInitialLibraryItems,
|
|
|
|
}: {
|
|
|
|
excalidrawAPI: ExcalidrawImperativeAPI | null;
|
|
|
|
getInitialLibraryItems?: () => LibraryItemsSource;
|
|
|
|
}) => {
|
|
|
|
const getInitialLibraryRef = useRef(getInitialLibraryItems);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!excalidrawAPI) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const importLibraryFromURL = ({
|
|
|
|
libraryUrl,
|
|
|
|
idToken,
|
|
|
|
}: {
|
|
|
|
libraryUrl: string;
|
|
|
|
idToken: string | null;
|
|
|
|
}) => {
|
|
|
|
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()}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
excalidrawAPI.updateLibrary({
|
|
|
|
libraryItems: 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);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
prompt: idToken !== excalidrawAPI.id,
|
|
|
|
merge: true,
|
|
|
|
defaultStatus: "published",
|
|
|
|
openLibraryMenu: true,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
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 load --------------------------------------------------------
|
|
|
|
if (getInitialLibraryRef.current) {
|
|
|
|
excalidrawAPI.updateLibrary({
|
|
|
|
libraryItems: getInitialLibraryRef.current(),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
|
|
|
|
|
|
|
if (libraryUrlTokens) {
|
|
|
|
importLibraryFromURL(libraryUrlTokens);
|
|
|
|
}
|
|
|
|
// --------------------------------------------------------- init load -----
|
|
|
|
|
|
|
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
|
|
|
|
return () => {
|
|
|
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
|
|
|
|
};
|
|
|
|
}, [excalidrawAPI]);
|
|
|
|
};
|