123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- import { loadLibraryFromBlob } from "./blob";
- import {
- LibraryItems,
- LibraryItem,
- ExcalidrawImperativeAPI,
- LibraryItemsSource,
- } from "../types";
- import { restoreLibraryItems } from "./restore";
- import type App from "../components/App";
- import { atom } from "jotai";
- import { jotaiStore } from "../jotai";
- import { 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 } from "../constants";
- export const libraryItemsAtom = atom<{
- status: "loading" | "loaded";
- isInitialized: boolean;
- libraryItems: LibraryItems;
- }>({ status: "loaded", isInitialized: true, libraryItems: [] });
- 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
- );
- });
- });
- };
- /** 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];
- };
- class Library {
- /** latest libraryItems */
- private lastLibraryItems: LibraryItems = [];
- /** indicates whether library is initialized with library items (has gone
- * though at least one update) */
- private isInitialized = false;
- private 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, {
- 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([]);
- };
- /**
- * @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.lastLibraryItems);
- if (this.updateQueue.length > 0) {
- resolve(this.getLatestLibrary());
- } else {
- resolve(cloneLibraryItems(libraryItems));
- }
- } catch (error) {
- return resolve(this.lastLibraryItems);
- }
- });
- };
- // 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();
- });
- };
- 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.lastLibraryItems);
- }
- 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();
- });
- 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;
- };
- export const useHandleLibrary = ({
- excalidrawAPI,
- getInitialLibraryItems,
- }: {
- excalidrawAPI: ExcalidrawImperativeAPI | null;
- getInitialLibraryItems?: () => LibraryItemsSource;
- }) => {
- const getInitialLibraryRef = useRef(getInitialLibraryItems);
- useEffect(() => {
- if (!excalidrawAPI) {
- return;
- }
- 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 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]);
- };
|