library.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. import { loadLibraryFromBlob } from "./blob";
  2. import {
  3. LibraryItems,
  4. LibraryItem,
  5. ExcalidrawImperativeAPI,
  6. LibraryItemsSource,
  7. } from "../types";
  8. import { restoreLibraryItems } from "./restore";
  9. import type App from "../components/App";
  10. import { atom } from "jotai";
  11. import { jotaiStore } from "../jotai";
  12. import { ExcalidrawElement } from "../element/types";
  13. import { getCommonBoundingBox } from "../element/bounds";
  14. import { AbortError } from "../errors";
  15. import { t } from "../i18n";
  16. import { useEffect, useRef } from "react";
  17. import {
  18. URL_HASH_KEYS,
  19. URL_QUERY_KEYS,
  20. APP_NAME,
  21. EVENT,
  22. DEFAULT_SIDEBAR,
  23. LIBRARY_SIDEBAR_TAB,
  24. } from "../constants";
  25. export const libraryItemsAtom = atom<{
  26. status: "loading" | "loaded";
  27. isInitialized: boolean;
  28. libraryItems: LibraryItems;
  29. }>({ status: "loaded", isInitialized: true, libraryItems: [] });
  30. const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
  31. JSON.parse(JSON.stringify(libraryItems));
  32. /**
  33. * checks if library item does not exist already in current library
  34. */
  35. const isUniqueItem = (
  36. existingLibraryItems: LibraryItems,
  37. targetLibraryItem: LibraryItem,
  38. ) => {
  39. return !existingLibraryItems.find((libraryItem) => {
  40. if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
  41. return false;
  42. }
  43. // detect z-index difference by checking the excalidraw elements
  44. // are in order
  45. return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
  46. return (
  47. libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
  48. libItemExcalidrawItem.versionNonce ===
  49. targetLibraryItem.elements[idx].versionNonce
  50. );
  51. });
  52. });
  53. };
  54. /** Merges otherItems into localItems. Unique items in otherItems array are
  55. sorted first. */
  56. export const mergeLibraryItems = (
  57. localItems: LibraryItems,
  58. otherItems: LibraryItems,
  59. ): LibraryItems => {
  60. const newItems = [];
  61. for (const item of otherItems) {
  62. if (isUniqueItem(localItems, item)) {
  63. newItems.push(item);
  64. }
  65. }
  66. return [...newItems, ...localItems];
  67. };
  68. class Library {
  69. /** latest libraryItems */
  70. private lastLibraryItems: LibraryItems = [];
  71. /** indicates whether library is initialized with library items (has gone
  72. * though at least one update) */
  73. private isInitialized = false;
  74. private app: App;
  75. constructor(app: App) {
  76. this.app = app;
  77. }
  78. private updateQueue: Promise<LibraryItems>[] = [];
  79. private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
  80. return this.updateQueue[this.updateQueue.length - 1];
  81. };
  82. private notifyListeners = () => {
  83. if (this.updateQueue.length > 0) {
  84. jotaiStore.set(libraryItemsAtom, {
  85. status: "loading",
  86. libraryItems: this.lastLibraryItems,
  87. isInitialized: this.isInitialized,
  88. });
  89. } else {
  90. this.isInitialized = true;
  91. jotaiStore.set(libraryItemsAtom, {
  92. status: "loaded",
  93. libraryItems: this.lastLibraryItems,
  94. isInitialized: this.isInitialized,
  95. });
  96. try {
  97. this.app.props.onLibraryChange?.(
  98. cloneLibraryItems(this.lastLibraryItems),
  99. );
  100. } catch (error) {
  101. console.error(error);
  102. }
  103. }
  104. };
  105. resetLibrary = () => {
  106. return this.setLibrary([]);
  107. };
  108. /**
  109. * @returns latest cloned libraryItems. Awaits all in-progress updates first.
  110. */
  111. getLatestLibrary = (): Promise<LibraryItems> => {
  112. return new Promise(async (resolve) => {
  113. try {
  114. const libraryItems = await (this.getLastUpdateTask() ||
  115. this.lastLibraryItems);
  116. if (this.updateQueue.length > 0) {
  117. resolve(this.getLatestLibrary());
  118. } else {
  119. resolve(cloneLibraryItems(libraryItems));
  120. }
  121. } catch (error) {
  122. return resolve(this.lastLibraryItems);
  123. }
  124. });
  125. };
  126. // NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
  127. // a slight overhead (always restoring library items). For internal use
  128. // where merging isn't needed, use `library.setLibrary()` directly.
  129. updateLibrary = async ({
  130. libraryItems,
  131. prompt = false,
  132. merge = false,
  133. openLibraryMenu = false,
  134. defaultStatus = "unpublished",
  135. }: {
  136. libraryItems: LibraryItemsSource;
  137. merge?: boolean;
  138. prompt?: boolean;
  139. openLibraryMenu?: boolean;
  140. defaultStatus?: "unpublished" | "published";
  141. }): Promise<LibraryItems> => {
  142. if (openLibraryMenu) {
  143. this.app.setState({
  144. openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
  145. });
  146. }
  147. return this.setLibrary(() => {
  148. return new Promise<LibraryItems>(async (resolve, reject) => {
  149. try {
  150. const source = await (typeof libraryItems === "function" &&
  151. !(libraryItems instanceof Blob)
  152. ? libraryItems(this.lastLibraryItems)
  153. : libraryItems);
  154. let nextItems;
  155. if (source instanceof Blob) {
  156. nextItems = await loadLibraryFromBlob(source, defaultStatus);
  157. } else {
  158. nextItems = restoreLibraryItems(source, defaultStatus);
  159. }
  160. if (
  161. !prompt ||
  162. window.confirm(
  163. t("alerts.confirmAddLibrary", {
  164. numShapes: nextItems.length,
  165. }),
  166. )
  167. ) {
  168. if (prompt) {
  169. // focus container if we've prompted. We focus conditionally
  170. // lest `props.autoFocus` is disabled (in which case we should
  171. // focus only on user action such as prompt confirm)
  172. this.app.focusContainer();
  173. }
  174. if (merge) {
  175. resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
  176. } else {
  177. resolve(nextItems);
  178. }
  179. } else {
  180. reject(new AbortError());
  181. }
  182. } catch (error: any) {
  183. reject(error);
  184. }
  185. });
  186. });
  187. };
  188. setLibrary = (
  189. /**
  190. * LibraryItems that will replace current items. Can be a function which
  191. * will be invoked after all previous tasks are resolved
  192. * (this is the prefered way to update the library to avoid race conditions,
  193. * but you'll want to manually merge the library items in the callback
  194. * - which is what we're doing in Library.importLibrary()).
  195. *
  196. * If supplied promise is rejected with AbortError, we swallow it and
  197. * do not update the library.
  198. */
  199. libraryItems:
  200. | LibraryItems
  201. | Promise<LibraryItems>
  202. | ((
  203. latestLibraryItems: LibraryItems,
  204. ) => LibraryItems | Promise<LibraryItems>),
  205. ): Promise<LibraryItems> => {
  206. const task = new Promise<LibraryItems>(async (resolve, reject) => {
  207. try {
  208. await this.getLastUpdateTask();
  209. if (typeof libraryItems === "function") {
  210. libraryItems = libraryItems(this.lastLibraryItems);
  211. }
  212. this.lastLibraryItems = cloneLibraryItems(await libraryItems);
  213. resolve(this.lastLibraryItems);
  214. } catch (error: any) {
  215. reject(error);
  216. }
  217. })
  218. .catch((error) => {
  219. if (error.name === "AbortError") {
  220. console.warn("Library update aborted by user");
  221. return this.lastLibraryItems;
  222. }
  223. throw error;
  224. })
  225. .finally(() => {
  226. this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
  227. this.notifyListeners();
  228. });
  229. this.updateQueue.push(task);
  230. this.notifyListeners();
  231. return task;
  232. };
  233. }
  234. export default Library;
  235. export const distributeLibraryItemsOnSquareGrid = (
  236. libraryItems: LibraryItems,
  237. ) => {
  238. const PADDING = 50;
  239. const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length));
  240. const resElements: ExcalidrawElement[] = [];
  241. const getMaxHeightPerRow = (row: number) => {
  242. const maxHeight = libraryItems
  243. .slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW)
  244. .reduce((acc, item) => {
  245. const { height } = getCommonBoundingBox(item.elements);
  246. return Math.max(acc, height);
  247. }, 0);
  248. return maxHeight;
  249. };
  250. const getMaxWidthPerCol = (targetCol: number) => {
  251. let index = 0;
  252. let currCol = 0;
  253. let maxWidth = 0;
  254. for (const item of libraryItems) {
  255. if (index % ITEMS_PER_ROW === 0) {
  256. currCol = 0;
  257. }
  258. if (currCol === targetCol) {
  259. const { width } = getCommonBoundingBox(item.elements);
  260. maxWidth = Math.max(maxWidth, width);
  261. }
  262. index++;
  263. currCol++;
  264. }
  265. return maxWidth;
  266. };
  267. let colOffsetX = 0;
  268. let rowOffsetY = 0;
  269. let maxHeightCurrRow = 0;
  270. let maxWidthCurrCol = 0;
  271. let index = 0;
  272. let col = 0;
  273. let row = 0;
  274. for (const item of libraryItems) {
  275. if (index && index % ITEMS_PER_ROW === 0) {
  276. rowOffsetY += maxHeightCurrRow + PADDING;
  277. colOffsetX = 0;
  278. col = 0;
  279. row++;
  280. }
  281. if (col === 0) {
  282. maxHeightCurrRow = getMaxHeightPerRow(row);
  283. }
  284. maxWidthCurrCol = getMaxWidthPerCol(col);
  285. const { minX, minY, width, height } = getCommonBoundingBox(item.elements);
  286. const offsetCenterX = (maxWidthCurrCol - width) / 2;
  287. const offsetCenterY = (maxHeightCurrRow - height) / 2;
  288. resElements.push(
  289. // eslint-disable-next-line no-loop-func
  290. ...item.elements.map((element) => ({
  291. ...element,
  292. x:
  293. element.x +
  294. // offset for column
  295. colOffsetX +
  296. // offset to center in given square grid
  297. offsetCenterX -
  298. // subtract minX so that given item starts at 0 coord
  299. minX,
  300. y:
  301. element.y +
  302. // offset for row
  303. rowOffsetY +
  304. // offset to center in given square grid
  305. offsetCenterY -
  306. // subtract minY so that given item starts at 0 coord
  307. minY,
  308. })),
  309. );
  310. colOffsetX += maxWidthCurrCol + PADDING;
  311. index++;
  312. col++;
  313. }
  314. return resElements;
  315. };
  316. export const parseLibraryTokensFromUrl = () => {
  317. const libraryUrl =
  318. // current
  319. new URLSearchParams(window.location.hash.slice(1)).get(
  320. URL_HASH_KEYS.addLibrary,
  321. ) ||
  322. // legacy, kept for compat reasons
  323. new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary);
  324. const idToken = libraryUrl
  325. ? new URLSearchParams(window.location.hash.slice(1)).get("token")
  326. : null;
  327. return libraryUrl ? { libraryUrl, idToken } : null;
  328. };
  329. export const useHandleLibrary = ({
  330. excalidrawAPI,
  331. getInitialLibraryItems,
  332. }: {
  333. excalidrawAPI: ExcalidrawImperativeAPI | null;
  334. getInitialLibraryItems?: () => LibraryItemsSource;
  335. }) => {
  336. const getInitialLibraryRef = useRef(getInitialLibraryItems);
  337. useEffect(() => {
  338. if (!excalidrawAPI) {
  339. return;
  340. }
  341. const importLibraryFromURL = async ({
  342. libraryUrl,
  343. idToken,
  344. }: {
  345. libraryUrl: string;
  346. idToken: string | null;
  347. }) => {
  348. const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
  349. try {
  350. const request = await fetch(decodeURIComponent(libraryUrl));
  351. const blob = await request.blob();
  352. resolve(blob);
  353. } catch (error: any) {
  354. reject(error);
  355. }
  356. });
  357. const shouldPrompt = idToken !== excalidrawAPI.id;
  358. // wait for the tab to be focused before continuing in case we'll prompt
  359. // for confirmation
  360. await (shouldPrompt && document.hidden
  361. ? new Promise<void>((resolve) => {
  362. window.addEventListener("focus", () => resolve(), {
  363. once: true,
  364. });
  365. })
  366. : null);
  367. try {
  368. await excalidrawAPI.updateLibrary({
  369. libraryItems: libraryPromise,
  370. prompt: shouldPrompt,
  371. merge: true,
  372. defaultStatus: "published",
  373. openLibraryMenu: true,
  374. });
  375. } catch (error) {
  376. throw error;
  377. } finally {
  378. if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
  379. const hash = new URLSearchParams(window.location.hash.slice(1));
  380. hash.delete(URL_HASH_KEYS.addLibrary);
  381. window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
  382. } else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
  383. const query = new URLSearchParams(window.location.search);
  384. query.delete(URL_QUERY_KEYS.addLibrary);
  385. window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
  386. }
  387. }
  388. };
  389. const onHashChange = (event: HashChangeEvent) => {
  390. event.preventDefault();
  391. const libraryUrlTokens = parseLibraryTokensFromUrl();
  392. if (libraryUrlTokens) {
  393. event.stopImmediatePropagation();
  394. // If hash changed and it contains library url, import it and replace
  395. // the url to its previous state (important in case of collaboration
  396. // and similar).
  397. // Using history API won't trigger another hashchange.
  398. window.history.replaceState({}, "", event.oldURL);
  399. importLibraryFromURL(libraryUrlTokens);
  400. }
  401. };
  402. // -------------------------------------------------------------------------
  403. // ------ init load --------------------------------------------------------
  404. if (getInitialLibraryRef.current) {
  405. excalidrawAPI.updateLibrary({
  406. libraryItems: getInitialLibraryRef.current(),
  407. });
  408. }
  409. const libraryUrlTokens = parseLibraryTokensFromUrl();
  410. if (libraryUrlTokens) {
  411. importLibraryFromURL(libraryUrlTokens);
  412. }
  413. // --------------------------------------------------------- init load -----
  414. window.addEventListener(EVENT.HASHCHANGE, onHashChange);
  415. return () => {
  416. window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
  417. };
  418. }, [excalidrawAPI]);
  419. };